Compare commits

..
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 b878663b2d CI: bump infra pin to ecd3864 (admin/goodwe#60, UX-08)
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 8m55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:42:01 +02:00
admin 32b9e56357 Merge pull request 'UX-08 2d: plain log lines, the rest of the status page, and DOCS' (#136) from UX-08-2d-logs-docs into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 08:40:33 +02:00
Claude review 569f16aa91 UX-08 2d review: action log reachable in status JSON, engaged peak row pinned, imported keys expanded
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m54s
- status_payload() also returns the action log as `events`. The page
  does not render it. Some entries (a dropped inverter's reason, the peak
  guard's projection, unplaced watts) are written nowhere else, and DOCS
  still points installers at the action log.
- test_peakguard pins the engaged row's ordered pair (the battery's share,
  then the limit).
- test_messages expands setting keys named by constants imported from
  another app module, so the shown-text 30-word scan counts them.
- p1.py: 'A meter message could not be read'. DOCS glossary: control
  board in plain words, with the hardware in an installer note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:29:14 +02:00
adminandClaude Opus 5.5 6a95447248 UX-08 2d: plain log lines, the rest of the status page, and DOCS
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 8m55s
Every WARNING and ERROR line is one plain line of at most 25 words, with
no card ids, capitals or semicolons; the reasoning of the ten longest is
on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0).

The status page: the month-peak and peak-guard rows, the stale-reading
row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the
Stopped banner and the line under the buttons, which now shows the plain
hint of each event instead of the raw action log. The Supervisor question
at start logs one line with a real reason.

Refusals are counted as shown (labels expanded, long cases driven), seven
were shortened, the card and parse errors lost their internals, a blank
value reads "empty", and the panel no longer files inverters.max_w under
the flat max_w. Option names lose "(JSON)", the NL texts are checked too.

DOCS gets a glossary (control board, isolator, Developer Tools) and card
ids move from headings to a "For installers" line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 08:01:16 +02:00
admin a02cd4b713 Merge pull request 'UX-08 2c: short refusals, error texts and option texts' (#135) from UX-08-2c-refusals into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 07:01:25 +02:00
goodwe-review c84d5fd14f UX-08 2c review: unit settings anchor to inverters, plainer texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
- messages.setting() for one inverter in the list reads
  “Inverter rating” for inverter-b (inverters.max_w), so the config panel
  files per-unit reasons under `inverters`, where the value is changed.
- The tariff card and plausibility reasons name
  “Distribution, levies and VAT” (price_taxes_eur_kwh) again.
- The wrong-direction reasons say "safety stop", the sign_disagree_w
  option says what it protects, and the roster, price-entity,
  integrator_max_w and capacity_kwh texts read more plainly.
- NL: stuurprint -> stuurmodule, "De piekbeperking".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 06:51:57 +02:00
glenn schrooyenandClaude Opus 5.5 2c516a032c UX-08 2c: short refusals, error texts and option texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
Validator refusals name the setting by its label and key, say what is
wrong and what to change, and keep the rejected value and limit. Option
names and descriptions are short in en and nl; citations moved to DOCS
"For installers" and the test_translations citation check now pins the
instruction on the tab and the record in DOCS. No rule, key, schema,
default or exit code changed. Allowlist 167 -> 43 (log lines only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 05:55:45 +02:00
admin e1d0970b23 Merge pull request 'UX-09: Details link for current HA, shadow wording, plain terms' (#134) from UX-09-2a-fixes into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 04:54:42 +02:00
adminandClaude Opus 5.5 63dee6db1e UX-09 review: short start-up timeout, latch rows pinned in shadow, plainer rows
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 1s
The Supervisor questions for the Details link are asked together with a
3 s budget instead of one after the other at 10 s each, so a hung
Supervisor cannot hold control start for 20 s. In shadow mode the
circulating and site-direction latches are pinned to keep their own red
row that says how they clear. Plainer inverter and battery wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 04:45:06 +02:00
adminandClaude Opus 5.5 00e3ee9de8 UX-09: Details link for current HA, "would" in shadow mode, plain terms
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Successful in 8m50s
- Details link: /config/app/<slug>/documentation on HA 2026.2+ (the old
  /hassio/addon path is a 404 from 2026.5), /hassio/addon on 2026.1 and
  older. Slug from GET /addons/self/info, version from GET /info, hostname
  as fallback.
- Shadow mode: the over-rating alarm and row say what would have been sent,
  the peak alarm no longer says the battery is giving all it can.
- "controller" -> "the add-on" / "control". Settings named by their labels;
  four labels changed to match (Peak reserve, Battery level entity, Solar
  production ..., Reversed-sign solar entities).
- Shadow banner and inverter link notes moved to messages.py and checked.
- DOCS Notifications and Status page rewritten for home owners, with a
  "For installers" part each. Allowlist 168 -> 167.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 04:10:39 +02:00
admin d57767fe38 Merge pull request 'UX-08 phase 2b: short, plain status rows and page banners' (#133) from UX-08-status-rows into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 03:19:54 +02:00
glenn schrooyenandClaude Opus 5.5 571b884315 UX-08 phase 2b review: keep over-rating evidence, pin latch clearing, plainer rows
checks / unit (pull_request) Successful in 8m48s
infra-pin / fresh (pull_request) Successful in 1s
- During a bound episode an earlier over-rating count stays on the page (amber,
  under its own title); test_clamp pins it.
- test_messages pins that every latch row says how it clears.
- pv_entities description no longer claims the page names the entity (en, nl);
  allowlist hash updated for that option-desc line.
- Plainer texts: multi-inverter direction banner, "fine now" after an episode,
  and the e-stop, peak-forecast and peak-guard-waiting rows move to messages.py.
- TEL-12 DECISIONS points to UX-08 item 3 for the retired row rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 03:10:25 +02:00
glenn schrooyenandClaude Opus 5.5 696142bccc Merge branch 'release/1.0' into UX-08-status-rows
Brings in the infra pin bump (#132) so infra-pin / fresh runs against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 02:41:05 +02:00
adminandClaude Opus 5.5 d017fc67bf UX-08 phase 2b: short, plain status rows and page banners
checks / unit (pull_request) Successful in 8m47s
infra-pin / fresh (pull_request) Failing after 1s
Every Commissioning row and every page banner is now worded in
app/messages.py (row_*, banner_*): one sentence of at most 15 words, a red
row up to three sentences so a latch still says how it clears, banners at
most 12 words. No row names a Home Assistant entity: sensors by their
setting, solar sources by their place in the list. Reason codes and limits
stay on the log line and event list; rows keep the numbers a reader acts on.

UX-07 item 1: ClampWatchdog counts over-rating and integrator-bound
engagements separately, and after an episode each count shows under its own
alarm's title instead of both under "output clamp".

No row colour, visibility or banner order changed. Rows carry an "id" so the
tests pin meaning rather than wording. The row and banner kinds leave the
allowlist (200 -> 168, cap equal). DOCS.md gains "Status page"; choices are in
DECISIONS/UX-08-short-notifications.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 02:12:10 +02:00
admin 5c151a3458 Merge pull request 'CI: bump infra pin to 7081df4 (admin/goodwe#59, UX-08)' (#132) from pin-bump-7081df4 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 01:41:37 +02:00
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
53 changed files with 5142 additions and 2469 deletions
+1 -1
View File
@@ -1,2 +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").
2d026dfa7c425f462c6969b2f7cdfddc17b17a81
ecd3864d9376a990c267dfb399eb2dae4e56c76b
+5 -3
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
@@ -498,7 +499,8 @@ jobs:
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
run: |
set -eu
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]')
# `|| 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"
+10 -6
View File
@@ -23,16 +23,20 @@ jobs:
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
run: |
set -eu
D=$(mktemp -d) && cd "$D"
git init -q . && git remote add origin "$(echo "$SELF" | sed "s#://#://x-access-token:${TOKEN}@#")"
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).
git fetch -q --depth 1 origin "$GITHUB_SHA"
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]')
# 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)
cd / && rm -rf "$D"
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."
+63
View File
@@ -0,0 +1,63 @@
# STYLE.md - how user-facing text is written (UX-08)
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Its allowlist is empty since phase 2d and its cap is 0: a new string that breaks the rule fails the test.
## 1. Shape
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
- **Log line** (WARNING/ERROR): one line, at most 25 words, with the numbers that matter. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated. A reason text a line quotes (`%s`) keeps its own codes, because that is the evidence an installer searches for.
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
## 2. Numbers
Use a number only if it helps the reader act, for example the peak limit, or what was asked and what was sent in a fault report. Write it the way `app/messages.py` does: `5 000 W`, `20 %`, `10 minutes`, `14:05`.
## 3. Severity
Severity is shown by the title and the colour, never by CAPITALS or by words like "BUG SIGNAL".
| Level | Colour | When |
|---|---|---|
| alarm | red | Control stopped, money or energy lost now, a fault, or a person must act now. Up to 3 sentences. |
| warning | orange | Control is still running, but something needs a look. 1 sentence. |
| info | grey | No action needed. 1 sentence. |
| resolved | green | An earlier alarm or warning has cleared. 1 sentence. |
A rewrite never changes an alarm's severity, `notification_id` or cadence.
## 4. Words - the same word for the same thing everywhere
A setting is named by its label in `translations/en.yaml`, in quotes when it sits mid-sentence (“Inverter rating”). The words below match those labels. If a label changes, change it here and in `app/messages.py` in the same change.
| Say | Not |
|---|---|
| the add-on (who decides), control (what it does: "start control", "Control: last ran …") | controller, loop, planner |
| battery | pack, BMS pack |
| inverter, or its name | unit, device, actuator |
| meter | P1 feed, telegram |
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
| 15-minute period | quarter |
| battery level (setting: “Battery level entity”) | SoC, state of charge, charge level |
| battery cut-out level, or cut-out level next to "battery" (setting: “Battery cut-out level”) | BMS floor, `bms_soc_floor` |
| peak reserve (setting: “Peak reserve”) | `peak_soc_floor`, EMS floor, "peak guard will not discharge below" |
| “Inverter rating” (the setting) | `max_w`, rail, inverter rating as a fact about the hardware |
| solar production, solar entity (settings: “Solar production entity”, “Solar production entities, per inverter”, “Reversed-sign solar entities”) | PV, reversed-sign list, single sensor setting |
| “Where the meter reading comes from” | meter source |
| “Grid power entity”; entity for any Home Assistant sensor a setting points at | grid power sensor, sensor setting |
| draw the battery down, give power | discharge, discharging |
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
| safety stop (a stop that only starting control clears) | failsafe latch |
| software fault | BUG SIGNAL |
| control board | master, ESP32 |
| power request, what the add-on asks | command, setpoint (on the page) |
| the power went the wrong way | sign disagreement, command and meter point opposite ways |
| check for inverters working against each other | circulating power check |
| month peak, 15-minute periods measured | billed quarter, banked, soft limit, open block |
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. In shadow mode a text says what the add-on **would** send, never that it sent it. |
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
**Glossary.** DOCS.md "Words used on this page" explains, once, the words a home owner meets without context: control board, isolator, Developer Tools, shadow mode, safety stop, peak limit, peak guard, peak reserve and cut-out level. A home-owner text may use them without explaining them again. Card ids stay out of DOCS.md headings: they go on a *For installers* line under the heading.
@@ -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.
@@ -357,3 +357,5 @@ the bump is PKG's call. The material an entry needs is this file.
The option surface moves **76 keys → 77**, one new optional key, `pv_entities`,
empty on every install that does not set it.
**Superseded in part (UX-08, 2026-09-27):** the PV status rows no longer read 0.11.0's sentences at one entity. The owner ruled out entity ids on status rows, so 2/5 now holds for the control document only. See `UX-08-short-notifications.md`, phase 2b item 3.
@@ -0,0 +1,63 @@
# 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.
# Phase 2b: status rows and page banners
Every row of the Commissioning list and every banner is now in `app/messages.py` (`row_*`, `banner_*`), and `test_messages.py` checks a rendering of each. The row and banner kinds are gone from `ux08_allowlist.txt` (200 → 168 entries). DOCS.md "Status page" explains the rows. **Unchanged:** every row's colour (ok / warn / bad), which rows show when, and the banner cascade order.
## Where a choice was made
1. **A red row may use 3 sentences (30 words).** STYLE.md gave rows 15 words; the owner allows red safety states 3 sentences. A red row needs room for what happened, the state (0 W) and how it clears. STYLE.md now says so, and the test applies it by colour.
2. **Figures left the rows, not the product.** The reason texts (freeze, sign, circulating, delivery, peak guard) carry reason codes and limits; they stay on the ERROR/WARNING log line and the event list, as before. Rows keep the numbers a reader acts on: the stall age and the 0 W deadline, the meter age and limit, asked and sent for an over-rating, the peak limit, and "opposite for 12 of 60 seconds" while a check counts. So 2a item 2 ("also on the status row") no longer holds, and DOCS.md says "in the add-on log".
3. **No entity id on any row**, even the green ones that used to print `sensor.x = 54`. The sensor rows name the setting (*Battery power for inverter-b*). Solar sources are numbered in the order of the settings list (*source 2 of 3*, *entry 1 of the reversed-sign list*). This retires TEL-12's rule that a one-entity site reads 0.11.0's sentence byte for byte. A `tools/tel11_pv_trace.py --compare` against a pre-UX-08 tree therefore shows the PV rows as changed telemetry, and the control documents stay equal. The per-source watts and units left the multi-source row. The total, the count and which source is missing remain.
4. **UX-07 item 1.** `ClampWatchdog` now also counts `over_rating` and `bound` separately. After an episode, each count shows under its own alarm's title ("Power request too high: 2 times since start, not now", "Battery is not keeping up: …"). A site with both gets two amber rows where it had one.
5. **The "Not ready" banner** is the first red row's own `banner`, or else its first sentence. The peak-reserve and over-rating rows set `banner` to their notification title. The three trips keep their own banner branches, reworded (e.g. "Inverters worked against each other. Every inverter is set to 0 W.").
6. **How a latch clears stays on the row**, shortened: "Only starting control clears this", "A restart does not clear this", "only stopping and starting control clears this" (not delivering). SAFETY-25's sentence stays on the peak-reserve row.
7. **Rows got an `id`** (`meter_frozen`, `direction`, `pv`, …), so tests find a row by what it is, not by its words. The page ignores it.
8. **Left for later:** texts that rows embed from other modules and that were not flagged as rows (the month-peak line, `peakguard.explain()`, the liveness and stale notes, `shadow.banner()`). The month-peak row only lost its semicolons.
# UX-09: fixes after the phase 2a test
1. **The Details link follows the Home Assistant version.** HA 2026.2 added the built-in `app` panel (core #157554, frontend #28245), whose add-on pages are `/config/app/<slug>/documentation`. 2026.5 stopped registering the `hassio` panel, so `/hassio/addon/<slug>/documentation` is a 404 there (TEST-23, HA 2026.9.1). 2026.1 and older have only the old path. The one path valid on both, `/_my_redirect/supervisor_addon?addon=<slug>`, opens the Info tab, not Documentation. So the add-on reads its slug (`GET /addons/self/info`) and the HA version (`GET /info`) at start, and picks the path. An unknown version gets the current path. The hostname stays as a fallback for the slug. Phase 2a item 7 is replaced by this.
2. **Shadow mode says "would".** The over-rating alarm and row said "sent X W" while 0 W was on the wire, and the peak alarm said "the battery is giving all it can". Both now take `shadow=`, and in shadow mode say what would have been sent and that the inverters stay at 0 W. Severity, id and cadence do not change with the mode.
3. **"Controller" is gone from texts.** "The add-on" names who decides, and "control" names what it does, as in the existing "start control". The add-on is what the reader sees in Home Assistant, and "controller" was easy to confuse with the control board.
4. **One term per setting, matching its label.** Four labels changed instead of the texts, because the texts' words were plainer: “Peak reserve (%)” (was "Peak guard will not discharge below"), “Battery level entity (%)” (was "Battery charge level"), and “Solar production entity / entities, per inverter / Reversed-sign solar entities” (was "PV …"). The Dutch labels already said "Zonneproductie". Option descriptions are phase 2c and were not changed, so some still say "PV".
5. **The shadow banner and the inverter notes** are now `messages.banner_shadow()` and `row_no_setpoint` / `row_not_controllable` / `row_commands_refused`, so `test_messages.py` checks them. The red shadow banner no longer lists the latches, because 12 words cannot carry them. It says a safety stop stays on, and each latch keeps its own red row, which names it and says how it clears. The colour of a liveness row now comes from `is_live()`, not from matching "NOT COMMANDABLE" in the text.
6. **DOCS "Notifications" and "Status page"** are written for a home owner, and the card ids, keys and log detail moved to a "For installers" part of each.
# UX-08 phase 2c: refusals, error texts and option texts
Validator refusals, the parse errors they carry, and every option name and description are now short. No rule, key, schema, default or exit code changed. Over 36 spoiled configurations the old and new validators refuse the same number of times. The allowlist went from 167 entries to 43, and only log lines (phase 2d) remain on it.
1. **A refusal names the setting by its label and then its key**, as `messages.setting()` writes it: “Inverter rating” (max_w). The key has to stay, because `configui.problems_by_field()` shows a reason beside every key its text names. A test checks each label against en.yaml. A few reasons no longer mention keys they only named in passing, such as `price_markup_eur_kwh` in the withdrawn-provider reason, so the panel shows those reasons beside fewer settings. Each reason still names the setting that fixes it. A setting of one inverter in the list is written “Inverter rating” for inverter-b (inverters.max_w), so the panel shows it beside `inverters`, where that value is changed, and not only beside the flat `max_w` that a list does not read.
2. **Citations moved to DOCS.** Option descriptions say "Measured value, leave it unchanged" or similar, and the record behind the value is under "For installers: where the numbers come from". The UX-01 citation check in `test_translations.py` now checks the tab for that instruction and DOCS for the record.
3. **What left the refusals:** the shipped default in the peak-reserve reason, the superseded-default note in the levy-table reason (DOCS already covers it), the endpoints of the withdrawn providers (DOCS "Energy price providers"), the plant measurement behind `slew_w` (DOCS, beside the setting), and the leg-specific paragraphs of the tariff-formula errors. Each reason keeps the rejected value, the limit and what to change.
4. **Not rewritten:** errors that only a code defect can raise, such as plugin-contract and argument-type checks in `price.py`, `price_forecast.py`, `static_tariff.py`, `arbitrage.py` and `main.py`, which a home owner never reads. INFO lines such as the tariff-formula preview were also left.
# UX-08 phase 2d: log lines, the rest of the status page, and DOCS
The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons (the semicolon rule now covers log lines too). **Unchanged:** every log level, every trigger and cadence, every notification id, every row's colour and when it shows, the banner cascade, and every refusal count.
1. **No code prefixes on log lines.** Nothing outside the repositories greps them, except `/home/homelab/clampwatch.sh`, a one-off site watch from 2026-09-05 that greps `OUTPUT_CLAMP` and `Engagements so far`. `OUTPUT_CLAMP` is still in the reason the line quotes; "Engagements so far" is now "Cut back N times so far". The tests that found a line by its card id (`SAFETY-37`, `SAFETY-44`, `CONTROL LOOP DIED`, `UNGRADED`) now find it by its words.
2. **Reason texts keep their codes.** A line quotes a watchdog's reason (`%s`), such as `STALE_READING: …` or `OUTPUT_CLAMP: …`. Those are the evidence an installer searches for and many tests pin them, so they were not rewritten. The line around them is plain.
3. **Reasoning moved to DEBUG, not deleted,** for the 10 lines marked DROP-to-DEBUG (for example the 14 768 W history behind the over-rating alarm, why SAFETY-42 reports and does not act, and the Eneco card advice). The 25-word line keeps the action and the numbers. SAFETY-57's promise stays on the ERROR line, as an ordered pair: "until it changes, or 17 s after the command drops below 250 W".
4. **The line under the buttons** showed the newest raw action-log entry (`OUTPUT_CLAMP …`, `FAILSAFE …: SIGN_DISAGREEMENT`, `ALLOCATOR …`). Each `log_event()` call now also names a plain hint from `messages.HINTS`, and the page shows that. The action log itself is unchanged, because tests and later analysis read it, and the page no longer shows it. It stays reachable as `events` in the `status` JSON (review of #136), because some entries are written nowhere else: a dropped inverter's reason, the unplaced watts, the peak guard's projection and the shadow decision.
5. **The top table.** *Why* is `messages.why()`: the arbiter's winner and limit in words ("Keeping the grid near zero: 440 W, held back by the peak limit"). `self.reason` keeps `Resolution.explain()` for the log and the checks. *Tariff*, *Price history*, *Arbitrage* and *Shadow mode* keep every figure TARIFF-11 and SAFETY-47 put there, each in its own slot, in words. Labels: *Battery level*, *Solar production*, *Power request*.
6. **The month-peak row became two rows**, because one sentence of 15 words cannot hold the peak, its time, its share of the limit and TARIFF-13's coverage figure. The store's state at start (`restore_state`) left the row: it is the INFO line at start.
7. **The peak guard rows** name the projection first and the limit second, as before (SAFETY-38). The blind row says what it means for the owner: the battery does not charge until the period can be measured again.
8. **Wording that changed meaning-carrying words**: "command and meter point opposite ways" is now "the power went the wrong way" (banner) and "…went against what the add-on asked" (row). The N=1 banner still names the meter, the N≥2 texts still say "own power". The inverter range reads "up to 5 000 W each way" instead of "-5 000 W to 5 000 W".
9. **Refusals are counted as shown.** Setting placeholders are expanded to label and key, and the long cases the 2c test found are driven through the validator. Seven refusals were shortened. The card refusals no longer show `c_eur_per_kwh`, `vat_pct` or "its own card", and a text that does not parse says "not valid near character N" instead of the parser's words. A missing value shows as *empty*, not `None`.
10. **The panel's key match** no longer files `inverters.max_w` under the flat `max_w`.
11. **The Supervisor question at start** is one INFO line with a real reason (a timeout's own text is empty, so the error type is named).
12. **DOCS**: a glossary ("Words used on this page") explains control board, isolator and Developer Tools once. Card ids left the headings and sit on a *For installers* line under each. The links to the renamed headings were updated.
+550 -88
View File
@@ -7,45 +7,73 @@ balance cells and recalibrate its coulomb counter.
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.
## Words used on this page
- **Control board**: the small computer on the inverter's meter cable. It
passes the add-on's power request to the inverter. If the add-on goes quiet
for about 30 seconds, it sets the inverter to 0 W by itself. (For
installers: a T-CAN485 board with an ESP32 chip on the RS485 bus.)
- **Isolator**: the switch that cuts an inverter off from the mains. Switching
it off stops the inverter, whatever the add-on or the control board asks.
- **Developer Tools**: the Home Assistant page that lists every entity and its
current value, on its *States* tab. Use it to check that a sensor the add-on
reads is really there and updating.
- **Shadow mode**: the add-on makes every decision but sends nothing, so every
inverter stays at 0 W. Pages and notifications say what it *would* have done.
- **Safety stop**: the add-on has set one inverter, or all of them, to 0 W and
keeps it there until you start control on the add-on page.
- **Peak limit**: the 15-minute average grid use you want to stay under this
month, because your capacity tariff bills the highest one.
- **Peak guard**: the part of the add-on that uses the battery to keep each
15-minute period under the peak limit.
- **Peak reserve** and **battery cut-out level**: the charge the peak guard
keeps back, and the level where the battery switches itself off.
## Before you start
You need:
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
- The vendor's meter-emulating controller **disconnected** from the bus
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml`
- A control board: 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`.
After installing, turn on **Watchdog** on the add-on's Info tab. If control
stops by accident, the add-on sets the inverters to 0 W and exits, and Home
Assistant starts it again only when Watchdog is on. It is off by default.
## The safety model, in short
The inverter **holds its last command forever** — it has no meter-timeout. So:
The inverter **keeps its last power request forever**. It has no timeout of its
own. So:
- The ESP32 commands 0 W if this add-on stops refreshing for ~30 s, and keeps
commanding it.
- This add-on commands 0 W when its inputs go missing, when you stop control,
and when it shuts down.
- This add-on commands 0 W **and latches there** if what it is commanding and
what the meter reads point opposite ways for three minutes — see "The
- The control board sets the inverter to 0 W if this add-on stops sending for
about 30 seconds, and keeps it there.
- This add-on sets the inverters to 0 W when a reading goes missing, when you
stop control, and when it shuts down.
- This add-on sets the inverters to 0 W **and keeps them there** if the power
goes the opposite way to what it asks for three minutes. See "The
sign-disagreement failsafe" below.
- The **optional RS485 e-stop** is the only thing that covers this machine
dying. Without it, a failed host leaves the battery latched at its last
command until someone intervenes.
- This add-on **raises an alarm — and nothing more —** when the meter keeps
answering with a reading that never changes while the command sits at the
rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff
exposure of the grid-following loop".
- The **optional emergency stop** is the only thing that covers this computer
failing. Without it, the battery keeps its last power request until someone
steps in.
- This add-on **raises an alarm, and does nothing more,** when the meter keeps
answering with a value that never changes while the battery is asked for
full power. See "The frozen-meter alarm" and, before you rely on it,
"Capacity-tariff exposure of the grid-following loop".
**If anything looks wrong: stop the add-on.** That commands 0 W and the
hardware holds it there.
**If anything looks wrong: stop the add-on.** That sets the inverters to 0 W,
and the control board keeps them there.
*For installers: a crashed control task is SAFETY-61, see
`DECISIONS/SAFETY-61-control-task-death.md`.*
## Configuration
### ⚠️ There are TWO places to configure this add-on (UX-02)
### ⚠️ There are TWO places to configure this add-on
*For installers: card UX-02.*
Since **UX-02** the ingress panel has a **Configuration** page of its own,
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
@@ -108,7 +136,9 @@ One column, big targets, sections collapsed until you open them, and the
refusals pinned where you cannot scroll past them. It is the screen for
standing in front of the inverter.
### What the Configuration tab actually shows you (UX-01)
### What the Configuration tab actually shows you
*For installers: card UX-01.*
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
so Home Assistant's *Configuration* tab renders human labels and a description
@@ -125,7 +155,7 @@ whole rendered list, in both languages.
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
| **The description says whether to touch it** | A number that was **derived** names the campaign it came from and says not to change it without re-deriving; a number that is genuinely yours (`max_w`, `peak_cap_w`) says **"THIS IS YOUR NUMBER"**; and `cycle_cost_threshold_eur`, which is neither, says it is an open product question |
| **The description says whether to touch it** | One or two plain sentences. A measured or derived value says "leave it unchanged", a number that is genuinely yours (`max_w`, `peak_cap_w`) says "Your own number", and `cycle_cost_threshold_eur` says it is a product choice. Where each number comes from is under [For installers](#for-installers-where-the-numbers-come-from) |
| **Dutch falls back per key** | `translations[your language]?.name \|\| translations["en"]?.name \|\| the raw key`. A key missing from `nl.yaml` degrades to English; a key present but wrong does not degrade at all |
| **⚠️ The per-entry keys inside `inverters:` still render raw** | Measured, and negative. A list-of-objects becomes an `object` selector with `multiple: true`, and `ha-selector-object` labels its fields `fields[name].label \|\| name` — the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do **not** reach the screen. The `inverters` label and description themselves do |
@@ -133,6 +163,148 @@ whole rendered list, in both languages.
option that does not exist, or an option with no translation, fails CI — in
either direction, and the check is mutation-tested both ways.
### Settings at a glance
The label and description each setting shows on the Configuration tab, in tab order. `translations/en.yaml` is the source, and `test_translations.py` keeps this table in step with it.
| Group | Setting | Key | What it does |
|---|---|---|---|
| Hardware | Inverter setpoint entity | `setpoint_entity` | The ESPHome number entity the add-on writes the inverter's power to. Leave it empty when you fill in the inverter list. |
| Hardware | Battery level entity (%) | `soc_entity` | The sensor with the battery level in percent. Use the control board's own reading. |
| Hardware | Battery power entity (W) | `batt_entity` | The sensor with this inverter's battery power. Positive must mean the battery is giving power. |
| Hardware | Battery power sign is reversed | `batt_invert` | Switch this on only if your battery power sensor shows giving power as a negative number. |
| Hardware | Inverter list | `inverters` | Leave it empty if you have one inverter. With two or more, add one entry per inverter, which replaces the inverter settings above. |
| Hardware · Inverter list | Name | `inverters.name` | A short, unique name for this inverter, for example inverter-a. |
| Hardware · Inverter list | Setpoint entity | `inverters.setpoint_entity` | This inverter's own ESPHome setpoint entity. Two entries may not use the same one. |
| Hardware · Inverter list | Inverter rating (W) | `inverters.max_w` | The most power the add-on may ask of this inverter. The site total is the sum of the list. |
| Hardware · Inverter list | Battery cut-out level (%) | `inverters.bms_soc_floor` | The level at which this battery's own management system stops giving power. Read it off the battery that is installed. |
| Hardware · Inverter list | Battery level entity (%) (optional) | `inverters.soc_entity` | This battery's own level sensor. Leave it empty if this inverter has none. |
| Hardware · Inverter list | Battery power entity (W) (optional) | `inverters.batt_entity` | This inverter's own battery power sensor. Leave it empty if it has none. |
| Hardware · Inverter list | Battery power sign is reversed (optional) | `inverters.batt_invert` | Switch this on if this inverter's sensor shows giving power as a negative number. |
| Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left. |
| Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. |
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
| Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. |
| Meter | Where the meter reading comes from | `meter_source` | How the add-on reads your meter. 'off' uses “Grid power entity” below, and only homewizard_local can spot a stuck meter. |
| Meter | Grid power entity (W) | `meter_entity` | Used when the meter source is 'off'. Positive must mean drawing from the grid. |
| Meter | Grid power sign is reversed | `meter_invert` | Switch this on if your grid sensor shows drawing from the grid as a negative number. |
| Meter | Number of phases (1 or 3) | `meter_phases` | Must match your grid connection. Meter readings with a different number of phases are rejected. |
| Meter | HomeWizard P1 address | `p1_host` | For homewizard_local only. The meter's address on your network, as host or host:port. |
| Meter | Time between meter reads (s) | `meter_poll_s` | For homewizard_local only. The meter updates every 5 s, so lower gains nothing. Keep it well below “Reading too old after”. |
| Meter | Reading too old after (s) | `meter_max_age_s` | After this a meter reading counts as missing. The “Missing inputs time limit” starts only after this one runs out. |
| Meter | MQTT topic | `meter_mqtt_topic` | For mqtt_p1 only. The topic your meter readings are published on. |
| Meter | Grid import entity (W) | `p1_import_entity` | The sensor for power drawn from the grid, never negative. The add-on works out the net value itself. |
| Meter | Grid export entity (W) | `p1_export_entity` | The sensor for power fed into the grid, never negative. It pairs with “Grid import entity”. |
| Meter | Signed net power entity (W) | `p1_net_entity` | For ha_signed only. One sensor that is positive when drawing from the grid and negative when feeding in. |
| Meter | Import entities per phase (L1-L3) | `p1_phase_import_entities` | Optional, in L1, L2, L3 order. Needed for the peak limit on a three-phase connection, one entry per phase. |
| Meter | Export entities per phase (L1-L3) | `p1_phase_export_entities` | Optional, in L1, L2, L3 order, next to the import entities per phase. One entry per phase. |
| Meter | Net entities per phase (L1-L3) | `p1_phase_net_entities` | For ha_signed only. Optional, in L1, L2, L3 order, signed like “Signed net power entity”, one entry per phase. |
| Limits | Inverter rating (W) | `max_w` | Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning. |
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
| Tariff | Distribution, levies and VAT (EUR/kWh) | `price_taxes_eur_kwh` | Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates. |
| Tariff | Injection price (EUR/kWh) | `export_price_eur_kwh` | What you are paid per kWh fed into the grid, all in. It may be negative. |
| Tariff | Fixed or day/night tariff | `static_profile` | Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback. |
| Tariff | Day/night levies and VAT | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. |
| Tariff | Your own tariff formula | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card. |
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
| Tariff | Average price entity (older) | `price_avg_entity` | The average the current price is compared with. Use it only with the current price entity, not with a dynamic price source. |
| Tariff | Trade on the price spread | `arbitrage_enabled` | Charge in the cheapest hour and draw the battery down in the dearest, when the spread covers the cost. Turning it off does not turn the price feed off. |
| Tariff | Battery cycle cost (EUR/kWh) | `cycle_cost_threshold_eur` | What one full battery cycle costs per kWh. A smaller price spread is not traded, and the value is a product choice, not a measured one. |
| Tariff | Trading keeps at least (%) | `arbitrage_soc_floor` | Price trading will not draw the battery below this level. It is separate from the maintenance settings. |
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
| Maintenance | Recharge up to (%) | `maintenance_soc_target` | The level the recharge aims for before the hold starts. |
| Maintenance | Hold at full for (minutes) | `maintenance_hold_min` | How long the battery stays full so its cells can balance. |
| Safety | Wrong-direction threshold (W) | `sign_disagree_w` | Part of the safety stop for an inverter pushing the wrong way. It counts only when command and meter reading both exceed this. Measured value, leave it unchanged. |
| Safety | Wrong-direction time (s) | `sign_disagree_s` | How long a wrong direction may last before a safety stop sets every inverter to 0 W. Measured value, leave it unchanged. |
| Safety | Per-inverter wrong-direction threshold (W) | `unit_sign_disagree_w` | For two or more inverters: the wrong-direction threshold for each inverter. Derived value, leave it unchanged. |
| Safety | Per-inverter wrong-direction time (s) | `unit_sign_disagree_s` | For two or more inverters: how long one inverter may push the wrong way before it is set to 0 W. Derived value, leave it unchanged. |
| Safety | Site wrong-direction threshold (W) | `site_sign_disagree_w` | For two or more inverters: the wrong-direction threshold for the whole site. Measured value, leave it unchanged. |
| Safety | Site wrong-direction time (s) | `site_sign_disagree_s` | For two or more inverters: how long the whole site may push the wrong way before a safety stop. Derived value, leave it unchanged. |
| Safety | Stuck meter alarm after (s) | `meter_freeze_s` | An alarm when the meter repeats one reading this long while the command is at full power, with homewizard_local only. Measured value, leave it unchanged. |
| Safety | Opposing inverters threshold (W) | `circulation_w` | For two or more inverters: how much power one may push against another before it counts. Measured value, leave it unchanged. |
| Safety | Opposing inverters time (s) | `circulation_hold_s` | For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged. |
| Safety | Not delivering alarm after (s) | `delivery_hold_s` | For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged. |
| Safety | Shadow mode | `shadow_mode` | The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never. |
| Advanced | Start control on boot | `auto_start` | Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart. |
| Advanced | Control gain | `gain` | How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it. |
| Advanced | Largest change per cycle (W) | `slew_w` | How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended. |
| Advanced | Ignore errors below (W) | `deadband_w` | Errors smaller than this are ignored. Keep it small, since any resting point inside it holds. |
| Advanced | Meter resting point (W) | `target_grid_w` | What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day. |
| Advanced | Command rounding (W) | `step_w` | The command is rounded to a multiple of this, so noise does not change it every cycle. |
| Advanced | At-limit shortfall (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. |
| Advanced | Cycles at limit before freezing | `saturation_cycles` | How many cycles in a row at the limit before control stops building up error. Do not set it to 1. |
| Advanced | Catch-up limit (W) | `integrator_max_w` | Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating. |
| Advanced | Command refresh interval (s) | `heartbeat_s` | How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit. |
| Advanced | Missing inputs time limit (s) | `stale_input_s` | How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”. |
| Advanced | Longest time between cycles (s) | `cycle_floor_s` | A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s. |
| Advanced | Log level | `log_level` | How much detail the add-on writes to its log. 'debug' and 'trace' are for finding a problem. |
### For installers: where the numbers come from
The Configuration tab keeps each description to one or two sentences. The detail that used to sit there is here, one entry per setting. A value marked *measured* or *derived* in its description came from the record named below; change it only after re-deriving it from a new measurement.
When the add-on refuses to start, each reason names the setting by its label and then its key, for example “Inverter rating” (max_w), and adds the inverter's name when there is a list. The configuration panel shows a reason beside every setting it names.
- **Battery power entity** (`batt_entity`): on the reference installation this is holding register 0x0518, read by the control board. The wrong-direction checks and the opposing-inverters alarm both grade each inverter against it.
- **Inverter list** (`inverters`): empty means the single-inverter settings describe the one inverter you have. Filled in, the list replaces them and nothing is inherited: each entry names its own setpoint entity, rating and cut-out level, so no inverter is judged by another's numbers. “Inverter setpoint entity” must then be empty, and the add-on refuses to start when both are set. Each entry's name keys its own safety stop and records in the add-on's state, so two entries may not share one.
- **Usable battery capacity** (`inverters.capacity_kwh`): read only to share the site target by the energy each battery has left rather than by its rating. The reference batteries are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so the two ways of sharing give the work to different batteries. Stated on some entries and not others, the add-on refuses to start.
- **Solar production entities** (`pv_entity`, `pv_entities`, `pv_invert_entities`): measurement only, no control behaviour changes. The unit is read off each entity, so a sensor without one is refused rather than guessed. With several entities, one that stops reporting gives no site total at all, because a partial sum looks like a real one. Setting `pv_entities` ignores `pv_entity`.
- **Where the meter reading comes from** (`meter_source`): 'off' keeps “Grid power entity”. ha_dsmr follows the DSMR integration, mqtt_p1 reads an MQTT topic, ha_signed reads one signed entity (what a HomeWizard P1 publishes), and homewizard_local polls the meter's own local API. Only homewizard_local can tell "the meter did not answer" from "the value did not change", so the stuck-meter alarm only means something there.
- **Grid import and export entities** (`p1_import_entity`, `p1_export_entity`, `p1_net_entity`): the add-on computes net power itself, so the sign convention lives in tested code rather than in a template sensor.
- **Number of phases** (`meter_phases`): a reading whose phase count disagrees is rejected and logged as it arrives, so a wrong value shows up at once. The range accepts 2, which is not a real Belgian connection.
- **Time between meter reads, Reading too old after** (`meter_poll_s`, `meter_max_age_s`): the add-on refuses to start if the poll interval is at or above the age limit. 30 s is six polls of headroom at the default 5 s. The reading-age limit on its own does not command 0 W. “Missing inputs time limit” starts after it, so the two add up.
- **Inverter rating** (`max_w`): your own number, not a derived one. It is not read when the inverter list is filled in, since each entry carries its own and the site limit is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not.
- **Battery cut-out level** (`bms_soc_floor`, `inverters.bms_soc_floor`): measured, not chosen. Register 0x0560 reads 10 % on the reference GW5000S-BP. It exists so the add-on can refuse a “Peak reserve” at or below it, which could never take effect. Each inverter entry states its own, because one default for two different batteries protects the shallower one with the deeper one's number.
- **Peak limit** (`peak_cap_w`): your own number, from your grid contract. In Belgium it is the capacity-tariff peak your distribution charge is billed on.
- **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
- **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
- **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
- **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different).
- **Day/night levies and VAT** (`price_taxes_profile`): for Wallonia and Brussels, where distribution has a day and a night rate (ORES 13,27 against 7,39 c/kWh, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10). The figures are levies alone, added to the market price. The add-on refuses to start if it and the flat figure disagree. See [Levies and VAT by time of day](#levies-and-vat-by-time-of-day-price_taxes_profile).
- **Injection price** (`export_price_eur_kwh`): one number because no supplier here publishes a dynamic injection curve. In Belgium it is often near zero, and at a sunny midday it can be negative.
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
- **Trading keeps at least, Trading charges at most** (`arbitrage_soc_floor`, `arbitrage_soc_ceiling`): kept apart from the maintenance levels, so one setting does not couple two unrelated policies.
- **Draw-down and recharge rate** (`maintenance_discharge_w`, `maintenance_charge_w`): the draw-down rate above the rating is cut back, not refused. A recharge rate above the rating would ask for more than the limit allows and resolve to 0 W, so maintenance would never charge. That is why the add-on refuses it.
- **Draw down to** (`maintenance_soc_floor`): the monthly cycle goes this deep on purpose, while “Peak reserve” is a depth peak limiting never reaches. 11 % against 20 % is why they are two settings.
- **Wrong-direction threshold and time** (`sign_disagree_w`, `sign_disagree_s`): measured, not chosen (SAFETY-02). On the captured healthy day (2026-08-23) signs agree on 93.4 % of gated samples, on the fault day (2026-08-20) 0.0-10.5 %, with opposed runs up to 1752 s. The longest legitimate opposed run is 30 s, and the fault day still trips 13 times at 300 s, so 180 s sits in a wide gap. The threshold must stay below “Inverter rating”.
- **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit shortfall. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
- **Site wrong-direction threshold** (`site_sign_disagree_w`): the single-inverter 300 W carried across deliberately (SAFETY-30), because near zero the sign is noise whatever is behind the one meter.
- **Site wrong-direction time** (`site_sign_disagree_s`): derived from the same 1890 runs (SAFETY-30). The worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, so 180 s would false-trip. 325 s is 1.51 times that and inside the 900 s billing period.
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode).
- **Control gain** (`gain`): measured on real hardware and already at the limit.
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
- **Ignore errors below, Meter resting point** (`deadband_w`, `target_grid_w`): the meter's import counter bills every positive watt, so a resting point at 0 W leaks about half the deadband as billed import, roughly 0.2-0.35 kWh a day at 15 W. -10 W moves that leak to export.
- **At-limit shortfall** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
- **Catch-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
- **Command refresh interval** (`heartbeat_s`): the control board refuses a link that has been quiet for 30 s.
- **Longest time between cycles** (`cycle_floor_s`): keep it under “Reading too old after”, since past that there is no reading left to act on.
### Sources
| option | required | meaning |
@@ -144,9 +316,9 @@ either direction, and the check is mutation-tested both ways.
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
| `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) |
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity) |
| `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) |
| `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) |
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
@@ -202,7 +374,7 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
| `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) |
| `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) |
⚠️ **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
@@ -245,7 +417,9 @@ in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.**
#### How the site target is divided between the packs (OPT-09)
#### How the site target is divided between the packs
*For installers: card OPT-09.*
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
version before it the split is a fixed 50/50 regardless of what either pack can
@@ -366,8 +540,9 @@ accumulator is doing:
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
often disagree.** `Why` is the *arbiter's* sentence — `loop -> -1200 W` — which
names the strategy that won and the watts it won with. It has never carried the
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
near zero: -1 200 W* — which names the strategy that won and the watts it won
with. The log keeps the arbiter's own `loop -> -1200 W`. It has never carried the
strategy's own reason, so a loop that is slew-limited or frozen says so **only**
here.
@@ -425,10 +600,12 @@ and `Inverter B` — are two names to the validator and one id once slugged. The
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
get then depend on roster order. Give your inverters plainly different names.
### PV production (`pv_entity`, TEL-11)
### PV production (`pv_entity`)
*For installers: card TEL-11.*
> **More than one solar inverter?** Use `pv_entities` instead — see
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The
> [Several PV inverters](#several-pv-inverters-pv_entities) below. The
> rest of this section applies to both.
@@ -462,7 +639,9 @@ 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 `pv_invert_entities` (TEL-14)
#### The sign, and `pv_invert_entities`
*For installers: card TEL-14.*
**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
@@ -477,7 +656,7 @@ History recorded before you set it keeps the old sign. See
The add-on reads your entity's own `unit_of_measurement` and converts: `mW`,
`W`, `kW`, `MW`, `GW`, `TW` — Home Assistant's own power units. A sensor with
**no** unit, or with a unit that is not one of those, is **refused**, and the
status page says which entity and which unit. That is deliberate: there is no
status page says so (*the sensor has no unit*). That is deliberate: there is no
magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess
here would be a silent factor of 1000 in the one channel that exists to be
believed a week later.
@@ -500,7 +679,9 @@ pictures, which is exactly the distinction the whole card exists to preserve.
A failed read also **clears** the previous good one: the value never outlives
its own sensor.
### Several PV inverters (`pv_entities`, TEL-12)
### Several PV inverters (`pv_entities`)
*For installers: card TEL-12.*
A multi-string or multi-orientation roof routinely carries **two or three PV
inverters**, each publishing its own power sensor into Home Assistant. List them
@@ -534,7 +715,7 @@ about:
|---|---|---|
| all three reporting 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 W` |
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which entity, why, and what the others reported |
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which source is missing |
| all three gone | `unknown` | as above |
**It does not publish the sum of the ones that answered.** Two inverters out of
@@ -546,8 +727,8 @@ it say something false. So the total goes to `unknown` and the recorder stores a
gap, exactly as it does for a single dead sensor.
The diagnostic is not thrown away — it is put where a person will see it. The
status page names the entity that is missing, the reason, **and** what the
others reported. That page is read live by a human who wants to fix something;
status page names the missing source by its place in the `pv_entities` list
(*source 2 of 3*). That page is read live by a human who wants to fix something;
the recorded number is read cold by an analysis that would believe it.
Note the **third row against the second**: a member honestly reporting `0 W` is
@@ -569,7 +750,7 @@ It is **instrumentation**. It is not an input to the control law, the
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog;
it is not part of `inputs_ok`, so a PV integration that breaks cannot stop the
battery being regulated; and its status row is a **caution**, never a
`NOT READY`. Wiring production into the control law would be a behaviour change
*Not ready*. Wiring production into the control law would be a behaviour change
on a system driving a real battery — and it would destroy the measurement,
because the loop would then be reacting to the thing being measured.
@@ -791,8 +972,8 @@ repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at
the exact operating point the controller aims for. Freeze detection is a
separate problem and needs the low-power case solved first.
You read it yourself instead: the add-on's status page shows it on the P1 line,
as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
You read it yourself instead: the add-on's status page shows it on the Meter
row, as `… 4 readings refused, unchanged for 5 minutes`. 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.
@@ -883,7 +1064,9 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
#### Shadow mode (`shadow_mode`, SAFETY-10)
#### Shadow mode (`shadow_mode`)
*For installers: card SAFETY-10.*
Shadow mode runs the **whole** decision path — the price strategy, the capacity
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
@@ -1050,11 +1233,13 @@ If the setpoint this add-on is emitting and the grid figure it is regulating on
point **opposite ways** — charging the battery while the house imports, or
discharging it while the house exports — with both over `sign_disagree_w`, for
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
there. The banner reads `FAILSAFE`, the commissioning list says which two
numbers disagreed and for how long, and the `Controller status` entity reports
`failsafe`.
there. The banner reads *The meter saw power go the wrong way*, the add-on
log says which two numbers disagreed and for how long, and the `Controller
status` entity reports `failsafe`.
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
#### ⚠️ At two or more inverters this is TWO checks, not one
*For installers: card SAFETY-30.*
The paragraph above describes what a **one-inverter** install does, and it is
unchanged: there is one meter, one actuator, and comparing the command against
@@ -1143,7 +1328,9 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
says so.
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
#### ⚠️ A unit that stops DELIVERING is a third fault again
*For installers: card SAFETY-42.*
The two checks above both key on **direction**. A unit sitting at **0 W against
a non-zero share** has no direction: it is not pushing the wrong way, it is
@@ -1194,7 +1381,8 @@ start, because a `persistent_notification` is in memory and that is precisely ho
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
other latches.
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
The banner reads **`<unit> is not delivering. Control keeps running.`** as a
*warning*, not a failure,
and it **displaces** `Running — holding grid at zero`. That last sentence is a
claim the site cannot make while a unit is absent, and a page that goes on
reading healthy while the fault runs is the whole shape this check exists to
@@ -1250,7 +1438,9 @@ correct toward that wrong value instead of holding a stale command. It is bounde
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
safety property.
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
#### When the freeze lets go, and when it deliberately never does
*For installers: card SAFETY-13.*
While the loop is frozen the command's **magnitude may fall but never rise**. That is
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
@@ -1428,7 +1618,7 @@ What exists today, and what does not:
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
quarter-hour average to guard, and the status page says **NOT COVERED**. There
quarter-hour average to guard, and the status page says *Peak guard: off*. There
the mitigation is still operational: set `max_w` no higher than the loop actually
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
there), fit the e-stop, and watch for the alarm.
@@ -1525,8 +1715,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
capacity-peak guard will not spend the pack for money below 20 %"*, and
**nothing more**. See below for what that costs on a real evening.
- **When it binds you are told**, once per episode, with notification
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
peak reserve* (`PEAK GUARD FLOOR-LIMITED` in the log): the quarter **will**
go over the ceiling, so shedding load is
the only thing left that changes the bill. Failing to shave is never silent —
the euro figure is the whole point of the guard.
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A
@@ -1542,7 +1733,9 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
smaller, or your quarters are worse, raise it.
#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25)
#### ⚠️ What `peak_soc_floor` does **not** protect
*For installers: card SAFETY-25.*
**On a sustained-load evening this add-on will take your battery to the BMS
cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed
@@ -1630,7 +1823,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
@@ -1639,9 +1834,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*
@@ -1650,6 +1847,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
@@ -1697,13 +1905,13 @@ the add-on UI.
⚠️ **Only `meter_source: homewizard_local` is covered.** It is the only transport
that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
`off` there is no signal to threshold and the status page says **NOT COVERED**
rather than pretending the guard is armed.
`off` there is no signal to threshold and the status page says *Stuck-meter
check: off* rather than pretending the guard is armed.
The same row appears for a second reason: a setpoint device that reports a range
from which no command magnitude is available in both directions leaves no rail
to measure at all, and a 0 W rail would be satisfied by *every* command. That is
reported as **NOT COVERED** too, and the detector stays silent rather than
reported as *off* too, and the detector stays silent rather than
degenerating into the bare "unchanged for N seconds" rule this design exists to
avoid.
@@ -1720,7 +1928,9 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
parked at 0 W and the two halves of the rule cannot both be true. At the schema
floor of 60 s that figure is 60 W and only the margin is left.
### The circulating-power alarm (SAFETY-31)
### The circulating-power alarm
*For installers: card SAFETY-31.*
> **Only at two or more inverters.** At the shipped roster of one this quantity
> is identically zero for every reading, no claim is ever filed, and the status
@@ -2187,7 +2397,9 @@ distinct state from "prices, all of them zero": the plan is marked provisional
and arbitrage is disabled, per the degradation matrix. It never falls back to a
price of zero, which would read as free electricity.
#### The hours the feed has not published yet (FCST-04)
#### The hours the feed has not published yet
*For installers: card FCST-04.*
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
@@ -2230,29 +2442,28 @@ firm/provisional split whenever that split is what refused a trade. And
since TARIFF-11 that row carries the provider plan itself:
```
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead,
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing,
provisional
Tariff: No fixed tariff set. Price plan from 412 s ago, for 48 hours ahead:
day-ahead prices from the price feed, 60 prices over 33 hours,
36 published and 24 estimated, 900+3600 s apart, not final yet
```
⚠️ **The two halves of that row are not computed at the same moment, and the
row says so.** Everything before the semicolon is the static-profile or Home
row says so.** Everything before "Price plan" is the static-profile or Home
Assistant price-entity sentence, regenerated on the **30 s tick** from options
alone — no network. Everything after it is the **background refresh's**
resolution, which runs every 1800 s and is served for up to 5400 s before it is
retired; `fetched N s ago` is the age of the fetch that produced it and is the
retired; `Price plan from N s ago` is the age of the fetch that produced it and is the
only figure in the row that tells you whether the feed is still answering. On a
site with a provider configured and no usable plan the row says that too,
rather than falling silently back to the static half.
Read the plan half as five separate facts: which *product* the feed publishes
and which *plugin* read it, how many **points** it carries and how many **whole
hours** those points cover, how many of the points are **firm** (published for
their own interval) against **carried** (filled from yesterday under §8.3), the
**spacing** of the points in seconds — `900+3600` is the normal mixed steady
state, quarter-hourly firm points beside hourly carried ones — and `provisional`
whenever any point is carried.
and whether it came from the feed or from your fixed tariff, how many
**prices** it carries and how many **whole hours** they cover, how many are
**published** (firm, for their own interval) against **estimated** (carried
from yesterday under §8.3), how far **apart** they are in seconds — `900+3600`
is the normal mixed steady state, quarter-hourly firm points beside hourly
carried ones — and `not final yet` (provisional) whenever any is estimated.
⚠️ **Points and hours are two different numbers and this row used to print one
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
@@ -2427,7 +2638,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
— configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one.
### The three buckets — which price components can move a decision (TARIFF-17)
### The three buckets — which price components can move a decision
*For installers: card TARIFF-17.*
**Read this before adding a price component of any kind.** The optimiser reads
exactly two quantities off a price series, and nothing else:
@@ -2483,7 +2696,9 @@ Three consequences worth having in mind:
is the only ORDER component the add-on has. Anything added next belongs in one
of the three, and saying which is part of adding it.
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18)
### What `price_taxes_eur_kwh` is made of, and why yours is different
*For installers: card TARIFF-18.*
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
Flemish residential bill — everything on your invoice that is *not* the energy
@@ -2787,18 +3002,261 @@ it.
| `estop_fitted` | Whether the RS485 e-stop is installed. Drives the warning banner |
| `log_level` | `trace`/`debug`/`info`/`warning`/`error` |
## Notifications
The add-on tells you about a problem with a Home Assistant notification. Each
one has a short title, one to three sentences, and a **Details** link to this
page. The link opens the add-on's Documentation tab. Look for the section with
the same name as the notification title below. A repeat of the same problem
replaces the notification rather than adding a new one.
In **shadow mode** the add-on makes every decision but sends nothing: every
inverter stays at 0 W. Notifications then say what the add-on *would* have
sent.
### Battery is not keeping up
The battery gives less power than the add-on asks for. This is normal for a
short time, for example just after a large load switches on or the sun comes
out. It is not a fault, and control keeps running. If it keeps coming back,
check that the battery is switched on, and that it is not empty (when it should
give power) or full (when it should charge).
### Power request too high
The add-on worked out a power request above what is allowed, and cut it back
before sending it. The notification shows what was asked and what was sent,
and which limit cut it: the “Inverter rating” setting, or the inverter's own
limit. In shadow mode it shows what *would* have been sent. Control keeps
running. This should never happen, so it is a software fault: please send the
notification and the add-on log to your installer.
### Load too high for battery
Your home uses more power than the battery can supply, so this 15-minute
period will go over your peak limit and raise this month's peak. The battery
is already giving all it can. This is not a fault. Switch off large loads,
such as an oven, a car charger or a heat pump, for the next few minutes. In
shadow mode the battery gives nothing, so this can happen with a much smaller
load.
### Battery stopped limiting the peak
The add-on stopped using the battery to keep your peak down, so this
15-minute period will go over your peak limit and raise this month's peak.
Switch off large loads for the next few minutes. The notification says why,
and what to do:
- **The battery is at its peak reserve.** The “Peak reserve” setting keeps
that much charge back from peak limiting. Lower it if you accept a lower
battery level.
- **The battery level cannot be read.** Check the “Battery level entity”
setting and that sensor in Home Assistant.
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
for each inverter in the settings.
The peak reserve only applies to keeping the peak down. Normal home use can
still draw the battery below it, down to the battery cut-out level, where the
battery switches itself off.
### Inverters working against each other
With several inverters, one was charging its battery from another. Your meter
cannot see this, because the two flows cancel out, but it wastes energy. The
add-on has stopped control and set every inverter to 0 W. If the grid power
does not change after that, switch the inverters off at the isolator. A
restart does not clear this. Find out why, then start control on the add-on
page.
### An inverter is not delivering
With several inverters, one gives much less power than it is asked for, while
the others do. Nothing is changed: control keeps running, and the other
inverters carry on. Check, in this order:
1. Is the inverter on and connected?
2. Is its battery empty or cold? If it is emptier than its “Battery cut-out
level” says, that setting does not match this battery.
3. Is anything else controlling it?
A restart does not clear this. Once you know why, stop and then start control
on the add-on page.
### Meter reading may be stuck
The meter keeps answering, but its value has not changed for a long time while
the add-on asks the battery for full power. That usually means the meter
reading is stuck. Control keeps running and keeps following the reading, so a
stuck reading can raise this month's peak. Check the meter. If it is stuck,
stop control on the add-on page.
### For installers: notifications
This part is for installers. The texts live in `app/messages.py` and follow
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
replaces the card. The full figures and reason codes are on the ERROR or
WARNING line of the add-on log.
| Title | Severity | id | Card |
|---|---|---|---|
| Battery is not keeping up | warning | `goodwe_integrator_bound` | SAFETY-40 |
| Power request too high | alarm | `goodwe_output_clamp` | SAFETY-03 |
| Load too high for battery | alarm | `goodwe_peak_saturated` | SAFETY-07 |
| Battery stopped limiting the peak | alarm | `goodwe_peak_soc_floor` | SAFETY-24/25/32 |
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
`/hassio/addon/<slug>/documentation`, which 2026.1 and older still use. At
start the add-on reads its slug from `GET /addons/self/info` and the Home
Assistant version from `GET /info`, and builds the link for that version. If
the version cannot be read it uses the new path. If the slug cannot be read it
falls back to the container hostname, and outside an add-on the line names the
section without a link. The documentation tab adds no heading anchors, so the
link cannot jump to the section.
**Battery is not keeping up.** The grid-following loop's integrator reached its
anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the
rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
winding-loop alarm*.
**Power request too high.** A computed command above a limit was cut before it
was written (SAFETY-03). The limits are `max_w` (label “Inverter rating”), the
setpoint device's own range, or the rating on the loop side. Asked always comes
first. 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. In
shadow mode the clamp still grades the decision (SAFETY-10), so the alarm can
fire while 0 W is on the wire, and the text says "would have sent". ERROR line,
reason code `OUTPUT_CLAMP`.
**Load too high for battery.** The capacity-peak guard (SAFETY-07) asks for
everything the inverter can discharge and the running 15-minute average is
still projected over the peak limit, so the month's billed peak
(capaciteitstarief) goes up. In shadow mode the guard's projection is made on a
plant that did not respond (SAFETY-47), and the text says so. See *The
capacity-peak guard*.
**Battery stopped limiting the peak.** The peak guard may not discharge below
`peak_soc_floor` (label “Peak reserve”, SAFETY-24). It also stops when the
state of charge is unread, or when an inverter's `bms_soc_floor` (label
“Battery cut-out level”) is missing, because the reserve is graded against it.
Charging from the grid stays blocked meanwhile. With several inverters the
notification names the battery at its limit (SAFETY-32), each graded against
its own cut-out level. ⚠️ **The reserve binds the peak guard only
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
does not protect*.
**Inverters working against each other.** SAFETY-31. The flows cancel at the
P1 meter, so every other guard, which reads that net figure, stays quiet. This
has happened on this hardware once for 6 h 25 min. The alarm is latched,
survives restarts, and is raised again every 5 min until control is started.
The watts and the time window are in the log. The *Check for inverters working
against each other* row shows the live watts. See *The circulating-power alarm*.
**An inverter is not delivering.** SAFETY-42. The unit stood well short of its
share of the site target for longer than `delivery_hold_s`. 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. The guard only reports: commanding an inverter
that has stopped listening achieves nothing. Check first whether it answers on
RS485. The alarm is latched and survives restarts; stop then start clears it.
See *A unit that stops DELIVERING is a third fault again*.
**Meter reading may be stuck.** SAFETY-18. The value has not changed for
longer than `meter_freeze_s` while the command stands at the power limit, and
the age is still fresh, so no other guard stops it. Only `meter_source:
homewizard_local` can tell a silent meter from an unchanged value. The limit
and command figures are in the log. See *The frozen-meter alarm*.
## The Web UI
The ingress panel shows live values, why the controller is commanding what it
is, and a **Commissioning** checklist that names any problem in words. It also
carries the three buttons: start/stop control, force a maintenance cycle, and
abort one.
The add-on page shows live values, why the battery does what it does, and a
**Commissioning** checklist that names any problem in words. It also has three
buttons: start or stop control, force a maintenance cycle, and abort one.
Under the buttons is the last thing that happened, in a few words.
Since **UX-02** it has a second page, **Configuration** — see *There are TWO
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
inverter.** The configuration page reads and writes the add-on's own settings
and can restart the add-on; the set of commands that reach the hardware is
exactly the three buttons it always had.
It has a second page, **Configuration**. See *There are TWO places to configure
this add-on*, above. **It adds nothing you can do to the inverter.** It reads
and writes the add-on's own settings and can restart the add-on. Only the three
buttons reach the inverter.
*For installers: the Configuration page is UX-02. The line under the buttons is
`messages.hint()`, the plain form of the newest entry of the add-on's own
action log (`Controller.events`). The page no longer shows that log, but the
`status` JSON still carries its last 40 entries as `events`: open the add-on
page and add `status` to its address. The action-log tables in this document
refer to it.*
## Status page
The add-on page shows a banner at the top and a **Commissioning** list below
it. The banner says in one line what matters most now. Each row of the list is
one check: ✓ green is fine, ! amber needs a look, ✗ red needs action. Rows name
settings by their label, never by a Home Assistant entity id.
In **shadow mode** the banner says so first, whatever else is going on: the
add-on makes every decision but the battery is not controlled, and every
inverter stays at 0 W. If the banner is red and says a safety stop stays on,
one of the red rows below is a stop that will still be there when shadow mode
ends. That row says how to clear it.
| Row starts with | What it tells you |
|---|---|
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. |
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
| *name*: … cannot be controlled, commands refused, “Setpoint entity” is not set | Whether each inverter still takes commands. |
| Power request too high, Battery is not keeping up | See those titles under Notifications. Each shows how often it happened since the add-on started, and *fine now* once it has cleared. In shadow mode the row says what *would* have been sent. |
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
### For installers: status page
The texts are in `app/messages.py`. The full figures of any alarm are on its
ERROR or WARNING line in the add-on log.
- **Stuck-meter check: off** needs `meter_source: homewizard_local` (label
“Where the meter reading comes from”), a `max_w` (“Inverter rating”) and a
power range the inverter can reach. *The inverter's own limit* means the
setpoint device's range sets the threshold, below `max_w`.
- **Peak guard: off** means `meter_source` is `off`, so there is no 15-minute
average to guard.
- **Battery level / Battery power / Grid power** are `soc_entity`,
`batt_entity` and `meter_entity`. *No usable reading* is SAFETY-56: missing
**or** held back as stale, and a separate stale-reading row then says which.
- **Inverter control** is the `setpoint_entity` and the range it accepts.
- ***name* cannot be controlled** (red): every write was rejected for
`LINK_STALE_S`, so the unit is dropped and the others carry its share
(DEV-02). ***name*: commands refused** (amber) is the same before that limit.
***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can
write, so the site refuses to run at part strength and holds 0 W.
- **Direction check, Site direction check** are the sign checks (SAFETY-02/30).
**Check for inverters working against each other** is SAFETY-31, **Delivery
check** SAFETY-42.
- **Month peak** and **15-minute periods measured** are TARIFF-01 and
TARIFF-13. The store's state at start is the INFO line `capacity-tariff peak
store: …`, and the peak guard's full reason is on its log line.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line carries the full `STALE_READING` reason and when it clears.
- The table at the top: **Why** is `messages.why()`, the arbiter's answer in
words (`Resolution.explain()` stays on the log). **Tariff**, **Price
history**, **Arbitrage** and **Shadow mode** keep every figure they had
(TARIFF-11), in words.
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
(circulating power, or a site or per-inverter sign trip), and that latch keeps
its own red row.
## Status entities
@@ -2807,7 +3265,9 @@ battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control.
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
## Before you commission inverter B
*For installers: card ARCH-02. The tool is `tools/rehearse_roster.py`.*
The day the `inverters:` roster is declared, two things change that are
**one-way on a live box and invisible until after the restart that applies
@@ -2988,7 +3448,9 @@ maintenance bookkeeping — and if a cycle ran during it, correct
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
decision, not a discovery made at the inverter.
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
## Before you deploy
*For installers: card PKG-09. The tool is `tools/predeploy_check.py`.*
Two ways a deploy has gone wrong on this system, both of which the Supervisor
reports as success:
+2 -4
View File
@@ -262,10 +262,8 @@ def threshold_eur_kwh(opts: dict) -> float:
v = float(price_option(opts, "cycle_cost_threshold_eur"))
if not math.isfinite(v) or v < 0:
raise ValueError(
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
f"in EUR/kWh, so it is non-negative and finite - a negative "
f"threshold is one every spread clears, which is arbitrage with the "
f"brake removed.")
f"“Battery cycle cost” (cycle_cost_threshold_eur) is {v!r}. Set "
f"it to 0 or more, in EUR/kWh.")
return v
+79 -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.
@@ -333,32 +358,72 @@ class ClampWatchdog:
def __init__(self):
self.active = False
self.engagements = 0 # every engagement, never deduplicated
# UX-08: the same count split by alarm type, so the status page names
# each with its own alarm's title after the episode clears.
self.over_rating = 0 # bug=True: "Power request too high"
self.bound = 0 # bug=False: "Battery is not keeping up"
self.episodes = 0 # rising edges
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
if bug:
self.over_rating += 1
else:
self.bound += 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 +449,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 +458,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 +477,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
+3 -4
View File
@@ -413,10 +413,9 @@ function showProblems(payload){
save.disabled = total > 0;
if(total){
note(banner, 'bad', 'This configuration would not start.',
'The add-on refuses to run on it and would stop with exit 78. '
+ 'Saving is blocked until these are fixed; nothing has been written. '
+ 'Each message below is the add-on\\u2019s own words, from the same '
+ 'check that runs at startup.');
'The add-on would refuse to start on it. Saving is blocked until these '
+ 'are fixed, and nothing has been written. Each message below comes '
+ 'from the same check that runs at start.');
banner.insertBefore(banner.lastChild, banner.firstChild);
msg(total + (total === 1 ? ' problem' : ' problems')
+ ' - saving is blocked', 'bad');
+4 -1
View File
@@ -289,7 +289,10 @@ def problems_by_field(problems, keys):
cross-field rule is about all of its fields, and hiding it from one of them
would be this card's own defect in miniature.
"""
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
# ⚠️ `.` BEFORE THE KEY DOES NOT COUNT (UX-08 2d): `inverters.max_w`
# names a setting of one inverter in the list, which is changed under
# `inverters`, and it must not also land beside the flat `max_w`.
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_.])" + re.escape(key)
+ r"(?![A-Za-z0-9_])")) for key in keys]
by_key, unanchored = {}, []
for problem in problems:
+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(
+64 -212
View File
@@ -175,6 +175,8 @@ import math
from dataclasses import dataclass
from datetime import date, datetime, timezone
from . import messages
class FormulaInvalid(Exception):
"""`price_formula` cannot be used. Raised by the parser, refused by
@@ -199,7 +201,7 @@ RESOLUTIONS: tuple = ("hourly", "quarter_hourly")
# index id -> the human name of the series it refers to. One entry today; a
# second index is a new row here plus a fetch path, not a new grammar.
INDEXES: dict = {
"epex_be": "EPEX BE day-ahead, EUR/MWh",
"epex_be": "Belgian day-ahead price, EUR/MWh",
}
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
@@ -285,87 +287,28 @@ _LEGS = {
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}'),
when="Leave it empty unless price_provider is 'custom_formula'.",
zero_factor=("every spread is exactly 0, and arbitrage can never "
"clear any threshold - a strategy that silently never "
"fires rather than one that is off"),
flat=("that is `static_profile` with a single period covering "
"00:00-24:00 on all days and price_provider 'off'; the add-on "
"then says so at startup instead of leaving you to find out "
"from a year of unchanged bills"),
neg_factor=("No consumption tariff is indexed negatively; if your "
"card shows a minus sign it is on the OFFSET (`A`), "
"which is allowed here and common on injection"),
when="Leave it empty unless “Dynamic price source” is 'custom_formula'.",
zero_factor=" and trading never finds a price difference",
flat="use “Fixed or day/night tariff” (static_profile) instead",
neg_factor="Put the minus sign on the offset, not on the factor",
card_required=True,
card_note=("It is the only thing that can tell a correct pair of "
"coefficients from a plausible-looking wrong one"),
card_causes=(
"or - the expensive one - network charges, levies or excise "
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
"card's Tariefformule line is the ENERGY component alone (Eneco "
"tabulates network tariffs and levies separately; ENGIE says in "
"so many words that the formula excludes network, distribution, "
"transport, levies and excise), the add-on adds "
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
"network charge into `offset` bills you for it twice. That is "
"the error this check exists to catch, and it is why it cannot "
"be switched off."),
card_note="The add-on needs it to check your two numbers",
card_causes=("the units, the VAT, and that network charges are not "
"in the offset"),
),
EXPORT_FORMULA_OPTION: _Leg(
option=EXPORT_FORMULA_OPTION,
what="injection",
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
# none to copy - the formula is printed with no price beside it. An
# example carrying one would invite an operator to compute the figure
# themselves, which is the one thing this leg must not encourage.
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
when=("Leave it empty unless your supplier pays you an injection "
"price that moves with the market; a flat one is "
"export_price_eur_kwh. ⚠️ This option is read whatever "
"price_provider is set to - it is the leg you are PAID on, not "
"the one you buy on."),
zero_factor=("every hour pays the same, which is a FLAT injection "
"price written as a formula. ⚠️ Note this is NOT the "
"arbitrage argument the consumption leg gives: no "
"strategy in this add-on reads an injection price at "
"all, so a flat one here fires nothing and declines "
"nothing - it is simply the wrong option"),
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
"which is what every site used before this option existed and "
"which still answers when this one is empty"),
neg_factor=("No Belgian injection tariff is indexed negatively - you "
"are paid MORE when the market is dearer, not less. The "
"minus sign on an injection card is on the OFFSET (`A`): "
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
"`0,07 x Belpex - 2`, and a negative offset is accepted "
"here"),
when="Fill it in only if your injection price follows the market.",
zero_factor="",
flat="use “Injection price” (export_price_eur_kwh) instead",
neg_factor="Put the minus sign on the offset, not on the factor",
card_required=False,
card_note=(
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
"formula with the reference index and the resulting price beside "
"it, which is what makes `price_formula`'s self-check possible. "
"Eneco's injection line has NO printed price beside it, so on "
"that card there is nothing to grade against and requiring a "
"`card` block would make the market's own published formula "
"inexpressible - or, worse, invite an operator to compute the "
"estimate themselves, at which point the check grades the "
"coefficients against arithmetic done with the same coefficients. "
"Supply it if YOUR card prints one; when it is absent the add-on "
"says at startup that this leg is UNGRADED and the two "
"coefficient bounds below are all there is"),
card_causes=(
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
"Belgian card the research found, and a 6 typed here because it "
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
"is added to this leg by the framework: price_taxes_eur_kwh is a "
"CONSUMPTION component and is not applied to what you are paid "
"for export, so an offset carrying network charges is a "
"different error here than it is on price_formula and this "
"check cannot tell you which one you made."),
card_note=("It is optional here. Without it the add-on only checks "
"the size of your two numbers"),
card_causes="the units, and that the VAT is 0 for injection",
),
}
@@ -488,16 +431,12 @@ def _finite(value, field: str, option: str = "price_formula") -> float:
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise FormulaInvalid(
f"{option}.{field} is {value!r}, which is not a number. "
f"Write the coefficient as a bare JSON number - 0.102, not "
f"\"0.102\" and not an expression: this provider takes FIXED "
f"coefficients and evaluates nothing.")
f"{option}.{field} is {value!r}, which is not a number. Write a "
f"plain number, for example 0.102.")
if not math.isfinite(float(value)):
raise FormulaInvalid(
f"{option}.{field} is {value!r}, which is not finite. A NaN "
f"or an infinity here makes every price NaN, every price "
f"comparison False, and an arbitrage strategy that never fires "
f"while reporting that it is armed.")
f"{option}.{field} is {value!r}, which is not finite. Write a "
f"plain number, for example 0.102.")
return float(value)
@@ -517,11 +456,8 @@ def _card(raw, leg: _Leg = None) -> tuple:
if not leg.card_required:
return ("", None, None)
raise FormulaInvalid(
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
f"{leg.card_note}: the add-on recomputes your "
"supplier's own printed example from the numbers you typed and "
"refuses to start if it does not come out. Copy the three figures "
"off the same tariff card as the coefficients: "
f"{leg.option} has no \"card\" block. {leg.card_note}. Copy "
"the three figures from your tariff card, for example "
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
"\"expect_incl_vat\": 15.55}.")
if not isinstance(raw, dict):
@@ -531,16 +467,13 @@ def _card(raw, leg: _Leg = None) -> tuple:
unknown = sorted(set(raw) - set(_CARD_KEYS))
if unknown:
raise FormulaInvalid(
f"{leg.option}.card has no field(s) {unknown}. The fields it "
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
f"number the operator believes took effect.")
f"{leg.option}.card has unknown field(s) {unknown}. It takes "
f"only {list(_CARD_KEYS)}.")
month = str(raw.get("month", "") or "").strip()
if not _month_date(month):
raise FormulaInvalid(
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
f"not used in any price - it is what lets the add-on tell you "
f"your card has gone stale.")
f"{leg.option}.card.month is {raw.get('month')!r}. Write the "
f"month of the card as \"YYYY-MM\", for example \"2026-09\".")
return (month,
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
leg.option),
@@ -594,10 +527,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
value = json.loads(text)
except ValueError as e:
raise FormulaInvalid(
f"{option} is not valid JSON ({e}). It is a JSON object "
f"on one line, e.g.\n"
f" {leg.example}\n"
f"{leg.when}")
f"{messages.not_valid(option, e)}. Copy the example from the "
f"documentation. {leg.when}")
if not isinstance(value, dict):
raise FormulaInvalid(
f"{option} is {type(value).__name__}, not a JSON object with "
@@ -612,34 +543,21 @@ def parse_price_formula(value, *, option: str = "price_formula"):
unknown = sorted(set(value) - set(_TOP_KEYS))
if unknown:
raise FormulaInvalid(
f"{option} has no field(s) {unknown}. The fields it takes "
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
f"takes an expression: this provider multiplies and adds two "
f"FIXED coefficients and evaluates nothing, because two "
f"coefficients can be graded against your card's printed price "
f"and an arbitrary expression cannot be graded against anything. "
f"If your contract needs a shape this cannot express, it needs a "
f"new named field here - say which shape.")
f"{option} has unknown field(s) {unknown}. It takes only "
f"{list(_TOP_KEYS)}.")
index = str(value.get("index", "") or "").strip()
if index not in INDEXES:
raise FormulaInvalid(
f"{option}.index is {value.get('index')!r}. The reference "
f"series this add-on can fetch are "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
f"If your card names a different index, the add-on cannot get it "
f"and guessing which series your supplier meant would price your "
f"battery against the wrong curve.")
f"{option}.index is {value.get('index')!r}. The add-on can only "
f"fetch "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}.")
resolution = str(value.get("resolution", "") or "").strip()
if resolution not in RESOLUTIONS:
raise FormulaInvalid(
f"{option}.resolution is {value.get('resolution')!r}; it "
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
f"a day; a contract written against the HOURLY series is priced "
f"on the mean of each hour's quarters. Which one your contract "
f"uses is on your card and cannot be inferred from the feed.")
f"{option}.resolution is {value.get('resolution')!r}. Choose one "
f"of {list(RESOLUTIONS)}, as your tariff card says.")
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
# factor-1000 class with a friendly face: the add-on would silently assume
@@ -648,12 +566,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
units = str(value.get("units", "") or "").strip()
if units not in UNITS:
raise FormulaInvalid(
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
f"there is no default, on purpose. It must be one of "
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
f"and two suppliers use two different conventions for the same "
f"shape, so assuming one would be a factor of 100 or 1000 on "
f"every hour with nothing able to notice.")
f"{option}.units is {value.get('units')!r}. Choose one of "
f"{sorted(UNITS)}, as printed beside the formula on your card.")
factor = _finite(value.get("factor"), "factor", option)
offset = _finite(value.get("offset"), "offset", option)
@@ -667,10 +581,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
# the operator at it rather than let them build it by accident.
if factor == 0.0:
raise FormulaInvalid(
f"{option}.factor is 0, so the index would not enter the "
"price at all: every hour of every day comes out at "
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
f"{option}.factor is 0, so every hour gets the same {leg.what} "
f"price{leg.zero_factor}. For one flat price, {leg.flat}.")
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
# the dearest hour becomes the cheapest one, so the battery is told to
@@ -678,22 +590,16 @@ def parse_price_formula(value, *, option: str = "price_formula"):
# mispriced site, it is a site actively trading against itself.
if factor < 0.0:
raise FormulaInvalid(
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
f"inverts the market: the dearest hour of the day comes out as "
f"the cheapest one, so the battery would charge at the evening "
f"peak and discharge at 03:00 - trading against itself on every "
f"cycle. {leg.neg_factor}.")
f"{option}.factor is {factor:g}, which turns the market upside "
f"down. {leg.neg_factor}.")
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
# and some cards are already quoted VAT-inclusive. The bound exists for the
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
if not 0.0 <= vat_pct <= 30.0:
raise FormulaInvalid(
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
f"injection is VAT-exempt, and a card that already quotes "
f"VAT-inclusive coefficients needs no second multiplication.")
f"{option}.vat_pct is {vat_pct:g}. Write a percentage between 0 "
f"and 30, for example 6.")
month, card_index, card_expect = _card(value.get("card"), leg)
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
@@ -771,33 +677,15 @@ def parse_export_formula(value):
def card_refusal(spec: FormulaSpec, delta: float) -> str:
"""The sentence an operator reads when their formula misses its own card.
⚠️ IT NAMES THE DISCREPANCY AND THE SIX WAYS IT HAPPENS. "Invalid
configuration" would be true and useless: every one of these errors looks
exactly like a working tariff from the status page, which is why the check
exists at all.
It names both numbers and what to check. The usual causes are in DOCS.md,
because each of these errors looks like a working tariff on the status page.
"""
got = in_card_units(spec, spec.card_index_eur_per_mwh)
ratio = (got / spec.card_expect_incl_vat
if spec.card_expect_incl_vat else float("inf"))
leg = _LEGS[spec.option]
return (
f"{spec.option} does not reproduce its own card, so the add-on will "
f"NOT start on it. Your contract's arithmetic is "
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
f"on the card's own index that is "
f"({spec.factor:g} x {spec.card_index_eur_per_mwh:g} + "
f"{spec.offset:g}) x (1 + {spec.vat_pct:g}/100) = {got:.4f} "
f"{spec.units}, but card.expect_incl_vat says "
f"{spec.card_expect_incl_vat:g} - OUT BY {delta:+.4f} {spec.units} "
f"(ratio {ratio:.4g}; the tolerance is {CARD_TOLERANCE}, which is the "
f"last digit a card prints). One of the two sides is wrong and the "
f"add-on cannot tell which, so it refuses rather than price a battery "
f"on it. The usual causes, in the order they happen: the coefficients "
f"and the expected price are in DIFFERENT units (ratio 10, 100 or "
f"1000 - `units` must be the one the formula line is printed in); VAT "
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
f"digit; the two sides copied off DIFFERENT cards or different "
f"months; {leg.card_causes}")
f"{spec.option} gives {got:.4f} "
f"{messages.UNIT_WORDS.get(spec.units, spec.units)}, the card says "
f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
# --------------------------------------------------------------------------- #
@@ -852,20 +740,10 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
return None
return (
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
f"price_taxes_eur_kwh is added, which is outside the plausible band "
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh for a Belgian "
f"retail tariff. The card self-check passed, so the coefficients and "
f"the printed price AGREE - they simply agree on a number no Belgian "
f"bill carries, which is what a whole card read in the wrong units "
f"looks like (a factor of 10, 100 or 1000 applied to BOTH sides at "
f"once cancels out of that check and cannot cancel out of this one). "
f"Check `units` against the unit printed beside your formula. "
f"⚠️ This bounds the CARD'S OWN POINT, not every hour: a live hour may "
f"legitimately fall outside the band, and a negative day-ahead hour - "
f"the best charging opportunity of the year - is never refused.")
f"{spec.option} gives {energy:.5f} EUR/kWh for energy and "
f"{all_in:.5f} EUR/kWh with “Distribution, levies and VAT” "
f"(price_taxes_eur_kwh), outside the plausible band of "
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh. Check \"units\" against the unit beside your formula.")
def implausible_injection(spec: FormulaSpec):
@@ -914,36 +792,16 @@ def implausible_injection(spec: FormulaSpec):
"""
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
return (
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
f"wholesale price - outside the plausible "
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
f"Belgian injection formula pays a share of the market price a "
f"little under or about equal to it: Eneco's "
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
f"factor typed in the wrong units looks like - check `units` "
f"({spec.units!r}) against the unit printed beside your formula, "
f"because the card self-check cannot see a factor of 10, 100 or "
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
f"COEFFICIENT and not the price: an injection price below zero is "
f"accepted and is the real market - Eneco's goes negative under "
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
f"{spec.option}.factor passes through {spec.passthrough:.4g}x the "
f"market price, outside {PASSTHROUGH_LO}-{PASSTHROUGH_HI}x. Check "
f"\"units\" ({spec.units!r}) against the unit beside your "
f"formula.")
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
return (
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
f"injection tariff in either direction. The published ones are "
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
f"cause is the offset typed in a different unit from `units` "
f"({spec.units!r}), or a consumption offset copied onto the "
f"injection leg: they are different numbers on the same card. "
f"⚠️ Network charges and levies do not belong here either - "
f"nothing is added to what you are PAID for export, so an offset "
f"carrying them is simply wrong rather than double-counted.")
f"{spec.option}.offset is {spec.offset:g} {spec.units}, which is "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh and larger than "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh either way. Check its "
f"unit.")
return None
@@ -957,15 +815,9 @@ def stale_card(spec: FormulaSpec, today=None):
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
return None
return (
f"{spec.option}'s card is dated {spec.card_month}, which is "
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
f"has been renewed or rolled over since then, the coefficients here "
f"are last year's and every price this add-on computes is wrong by "
f"however far they moved - and the card self-check cannot see it, "
f"because it grades your coefficients against the card you typed, not "
f"the card against your current contract. Not refused: ENGIE freezes "
f"coefficients for the contract's duration, so an old card is often "
f"exactly right. Worth re-reading your klantenzone once.")
f"{spec.option}'s card is dated {spec.card_month}, "
f"{(now - dated).days} days ago. The add-on still starts, but if your "
f"contract changed since, copy the new figures from your supplier.")
# --------------------------------------------------------------------------- #
+14 -10
View File
@@ -123,6 +123,7 @@ from datetime import datetime
from .clamp import ClampResult
from .delivery import DeliveryConfig, DeliveryWatchdog
from . import messages
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
@@ -407,21 +408,24 @@ class Inverter:
stop the site (`inputs_ok`). Folding one into the other is how a
measurement fault ends up handing a sibling extra power.
"""
return self.batt_fresh.note(self.name)
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
else "")
def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say."""
"""One line for the status page. Empty when there is nothing to say.
The words are `app/messages.py`'s (UX-09). main.py colours the row red
exactly when `is_live()` is False.
"""
if not self.has_writer():
return (f"{self.name}: NO SETPOINT ENTITY - this controller cannot "
f"command it, and the site refuses rather than running at "
f"part strength")
live, why = self.is_live(now)
return messages.row_no_setpoint(self.name)
live, _why = self.is_live(now)
held = (0.0 if self.rejected_since is None
else (now - self.rejected_since).total_seconds())
if not live:
return f"{self.name}: NOT COMMANDABLE - {why}"
return messages.row_not_controllable(self.name, held, LINK_STALE_S)
if self.rejected_since is not None:
held = (now - self.rejected_since).total_seconds()
return (f"{self.name}: writes rejected for {held:.0f} s - still "
f"commandable until {LINK_STALE_S:.0f} s")
return messages.row_commands_refused(self.name, held, LINK_STALE_S)
return ""
def __repr__(self) -> str:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+34 -27
View File
@@ -47,6 +47,10 @@ from datetime import datetime, timezone
import aiohttp
from . import messages
_S = messages.setting # a refusal names the setting by its label (UX-08)
try:
import paho.mqtt.client as mqtt
except ImportError: # pragma: no cover - container always has it
@@ -180,7 +184,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
# already applied a sign we are about to apply again - reject it rather
# than silently double-signing the control loop.
if imp < 0 or exp < 0:
raise P1Error(f"unsigned registers cannot be negative (import={imp:g} export={exp:g})")
raise P1Error(f"import and export readings cannot be negative "
f"(import={imp:g} export={exp:g})")
per_phase = per_phase_import = None
if phase_import_w is not None or phase_export_w is not None:
@@ -188,8 +193,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
pe = list(phase_export_w or [0.0] * len(pi))
if len(pi) != phases or len(pe) != phases:
raise P1Error(
f"phase count mismatch: telegram has {len(pi)} import / {len(pe)} export "
f"phases, meter_phases is {phases}")
f"the meter sent {len(pi)} import and {len(pe)} export phases, "
f"but {_S('meter_phases')} is {phases}")
vals = [_watts(a, f"L{i + 1} import") - _watts(b, f"L{i + 1} export")
for i, (a, b) in enumerate(zip(pi, pe))]
per_phase = tuple(vals)
@@ -467,7 +472,7 @@ class P1Ingest:
"""
self.parse_errors += 1
self.last_error = str(err)
_LOG.warning("P1 telegram rejected: %s", err)
_LOG.warning("A meter message could not be read: %s", err)
# -- what consumers read -------------------------------------------------
def age_s(self) -> float | None:
@@ -1160,37 +1165,37 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
exp = str(opts.get("p1_export_entity", "") or "").strip()
if not imp or not exp:
problems.append(
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
f"are {messages.shown(imp)} and {messages.shown(exp)}.")
elif source == SOURCE_HA_SIGNED:
net = str(opts.get("p1_net_entity", "") or "").strip()
phase_net = [str(e).strip() for e in
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
if not net:
problems.append(
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
"(sensor.p1_sample_age_s would otherwise be announced with "
"nothing feeding it)")
f"{_S('meter_source')} is {SOURCE_HA_SIGNED!r}, so it needs "
f"{_S('p1_net_entity')}.")
if phase_net and len(phase_net) != phases:
problems.append(
f"p1_phase_net_entities has {len(phase_net)} entities but "
f"meter_phases is {phases} - every telegram would be rejected "
"on the phase-count check")
f"{_S('p1_phase_net_entities')} has {len(phase_net)} entities, "
f"but {_S('meter_phases')} is {phases}. Make them match.")
elif source == SOURCE_MQTT:
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
problems.append(f"{_S('meter_source')} is {SOURCE_MQTT!r}, so it "
f"needs {_S('meter_mqtt_topic')}.")
elif source == SOURCE_HOMEWIZARD:
host, port_s = homewizard_host_port(opts)
if not host:
problems.append(
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
"own address, e.g. 192.168.2.250)")
f"{_S('meter_source')} is {SOURCE_HOMEWIZARD!r}, so it needs "
f"{_S('p1_host')}, the meter's address on your network.")
if port_s:
try:
int(port_s)
except ValueError:
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
"unparseable port")
problems.append(f"{_S('p1_host')} is {messages.shown(opts.get('p1_host'))}, and "
"its port is not a number.")
try:
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
# cadence of 0 is a misconfiguration that must be reported, not
@@ -1198,23 +1203,26 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
raw = opts.get("meter_poll_s", 5)
poll_s = float(5 if raw is None or raw == "" else raw)
except (TypeError, ValueError):
problems.append(f"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
problems.append(f"{_S('meter_poll_s')} is "
f"{messages.shown(opts.get('meter_poll_s'))}, which is not a "
"number.")
else:
if poll_s <= 0:
problems.append("meter_poll_s must be positive")
problems.append(f"{_S('meter_poll_s')} must be above 0.")
# ⚠️ The age can never be fresher than the poll interval. At or past
# max_age_s every reading is stale before its successor arrives, so
# the controller would sit permanently on missing inputs while the
# meter is perfectly healthy - refuse it rather than ship that.
elif poll_s >= max_age_s:
problems.append(
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
f"{max_age_s:.1f} - every reading would go stale before the "
"next poll")
f"{_S('meter_poll_s')} is {poll_s:.1f}, not below "
f"{_S('meter_max_age_s')} at {max_age_s:.1f}. Poll more "
"often, or every reading goes stale.")
else:
problems.append(
f"meter_source {source!r} is not one of "
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
f"{_S('meter_source')} is {source!r}. Choose one of: off, "
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD))
+ ".")
return problems
@@ -1261,9 +1269,8 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
# can NEVER be fresh.
if poll_s > ingest.max_age_s / 2:
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
"%.1f: one missed poll makes the reading stale. The meter "
"updates every ~5 s; 5 s against 30 s is the tested pair.",
_LOG.warning("“Time between meter reads” (%.1f s) is too close to "
"“Reading too old after” (%.1f s). Use 5 s and 30 s.",
poll_s, ingest.max_age_s)
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
if source == SOURCE_MQTT:
+17 -4
View File
@@ -395,6 +395,7 @@ not need re-testing. Nothing here indexes a phase.
from dataclasses import dataclass
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
@@ -485,17 +486,29 @@ class PeakDecision:
reason: str = ""
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
technical record with every figure, for the log and the action log."""
if self.floor_limited:
return self.reason
if not self.engaged:
if self.projected_w is None:
return f"peak guard idle - {self.reason}"
return (f"peak guard idle - quarter projects "
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
f"ceiling ({self.ceiling_from})")
return messages.row_peak_guard_quiet()
return messages.row_peak_guard_idle(
self.projected_w, self.ceiling_w,
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
return self.reason
def blind_kind(blind: str) -> str:
"""Which of the three kinds of blindness, as a key of
`messages.BLIND_WORDS`, for the status row."""
if blind == BLIND_FROZEN:
return "frozen"
if blind == BLIND_NO_BLOCK:
return "no_block"
return "gap"
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
"""The average this quarter may not exceed, and where the number came from.
+29 -39
View File
@@ -1314,8 +1314,8 @@ def code_leaks(cls) -> list:
src = dedent(inspect.getsource(cls))
tree = ast.parse(src)
except (OSError, TypeError, IndentationError, SyntaxError) as e:
_LOG.warning("could not read %s's source for the framework-key scan "
"(%s); the double-counting guard is NOT enforced for it",
_LOG.warning("Could not read the code of price source %s (%s), so its "
"double-counting check is skipped.",
getattr(cls, "id", cls), e)
return []
for node in ast.walk(tree):
@@ -1453,9 +1453,8 @@ def _validate_raw(points, caps):
if abs(p.eur_per_kwh) > MAX_PLAUSIBLE_EUR_KWH:
raise SourceUnavailable(
f"point at {p.start.isoformat()} is {p.eur_per_kwh} EUR/kWh, "
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH} - the usual cause is a feed "
f"in EUR/MWh being read as EUR/kWh, which is a factor of 1000 "
f"and would make every hour look like an emergency")
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH}, so the feed is probably in "
f"EUR/MWh")
u = p.start.astimezone(timezone.utc)
if seen is not None and u <= seen:
raise SourceUnavailable(
@@ -1708,25 +1707,19 @@ def _points_from_columnar_json(body, expect_unit, scale,
if len(times) != len(prices):
raise SourceUnavailable(
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
f"The short array would truncate the series silently, and a price "
f"series missing its last hours is one missing the evening peak - "
f"traded on as if complete, where no data at all would have "
f"switched arbitrage off (§11.4)")
f"{COLUMNAR_PRICE_KEY} has {len(prices)}, so the feed is cut short "
f"and none of it is used")
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
if unit != _columnar_unit(expect_unit):
raise SourceUnavailable(
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
f"{expect_unit!r}. This provider converts with a fixed factor of "
f"{scale}, so a changed unit is a factor of 1000 on every price "
f"with nothing downstream able to notice it")
f"{expect_unit!r}, so its prices cannot be converted")
if doc.get("deprecated"):
# Not a refusal: today's prices are still prices. But §11.4 makes a
# dead feed look exactly like a quiet one, so the warning that the
# source is going away has to be emitted while it still works.
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
"but this endpoint is scheduled to stop",
doc.get("deprecated"))
_LOG.warning("The price feed says it will stop (deprecated=%r), "
"though it still answers for now.", doc.get("deprecated"))
out = []
dropped = []
for i, (t, v) in enumerate(zip(times, prices)):
@@ -1745,9 +1738,8 @@ def _points_from_columnar_json(body, expect_unit, scale,
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
provisional=bool(provisional_default)))
if dropped:
_LOG.warning("the feed published no price for %d of %d instants "
"(index %s); those instants are left OUT of the series, "
"never zero-filled - a missing hour is not a free hour",
_LOG.warning("The price feed gave no price for %d of %d times (index "
"%s). Those times are left out, not counted as free.",
len(dropped), len(times),
", ".join(str(i) for i in dropped[:5])
+ (", ..." if len(dropped) > 5 else ""))
@@ -1796,16 +1788,16 @@ def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
override = str(opts.get("price_feed_url") or "").strip()
if override:
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
"price_feed_url is set, so %s asks for %s as given, and "
"tomorrow's prices may stay provisional. Clear it to ask for %s "
"to %s.",
plugin_id, override, *(d.isoformat()
for d in brussels_date_range(since, until)))
_LOG.debug(
"The public feed answers one Brussels day to a request that names "
"no range. Every later hour is then copied from yesterday and "
"marked provisional, and §8.3 leaves provisional hours out of "
"trading.")
return override
first, last = brussels_date_range(since, until)
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
@@ -2011,11 +2003,9 @@ class CustomFormula(PriceProvider):
raise SourceUnavailable(f"price_formula: {e}")
if spec is None:
raise SourceUnavailable(
"price_provider is 'custom_formula' but price_formula is "
"empty, so there are no coefficients to price anything with. "
"validate.py refuses this configuration at startup; reaching "
"it here means the options blob changed under a running "
"add-on")
"“Dynamic price source” (price_provider) is 'custom_formula', "
"but “Your own tariff formula” (price_formula) is empty. "
"Restart the add-on to check the settings again")
url = dayahead_feed_url(self.id, opts, since, until,
self.FEED_BASE, self.BZN)
check_egress(url, self.capabilities())
@@ -2091,8 +2081,8 @@ def _emit_startup_preview(plugin, pid: str, points) -> None:
try:
lines = list(plugin.startup_preview(points))
except Exception as e: # noqa: BLE001
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
"unaffected - this is a log line, not a decision",
_LOG.warning("%s could not show its start-up preview (%s: %s). Prices "
"are not affected.",
pid, type(e).__name__, e)
return
for line in lines:
@@ -2139,12 +2129,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
# Not silent, and not a traceback into the 30 s tick. validate.py
# should have refused this at startup; if the add-on is running with it
# anyway, the operator is told which key and what is wrong with it.
_LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch "
"has NO tax component to add - it is not falling back to "
"price_taxes_eur_kwh, because a silently different tax "
"basis is the defect this option exists to remove. Fix the "
"table or clear it; validate.py refuses it at startup",
_LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes "
"are added to this price update. Fix the table or clear it.",
type(e).__name__, e)
_LOG.debug("price_taxes_eur_kwh is not used instead, because a "
"silently different tax basis is the fault this setting "
"exists to remove.")
return PriceRead.no_data(
f"price_taxes_profile cannot be used: {e}")
if table is not None:
+7 -7
View File
@@ -345,8 +345,8 @@ def _row_of(day: str, points: int, hours: int) -> str:
whole day to carry, and that is the HOUR figure; the point count says how
finely it is priced. Neither answers for the other.
"""
return (f"{day} ({points} point{'' if points == 1 else 's'} "
f"over {hours} h)")
return (f"{day} ({points} price{'' if points == 1 else 's'} "
f"over {hours} hour{'' if hours == 1 else 's'})")
def _bad_rows(rec) -> int:
@@ -437,9 +437,9 @@ class PriceHistory:
# exactly what this handler says, and a third `ValueError` subclass
# nobody has thought of is how this defect arrived the first time.
self.last_error = f"unreadable ({err})"
_LOG.warning("price history at %s is %s - starting from empty. "
"§8.3 carry-forward has nothing to carry until the "
"next refresh has written a full day",
_LOG.warning("Price history at %s is %s, so it starts empty. "
"Missing hours cannot be filled in until a full day "
"is saved.",
self.path, self.last_error)
return
found = blob.get("version") if isinstance(blob, dict) else None
@@ -775,8 +775,8 @@ class PriceHistory:
f"which is not a PriceProduct. The optimiser switches on that "
f"enum, so a record that cannot name one cannot be a series")
if bad:
_LOG.warning("price history: %s in the %s record were dropped; "
"%d point(s) carried forward", bad, want, len(points))
_LOG.warning("Price history: %s in the %s record were dropped, "
"and %d points were kept.", bad, want, len(points))
return PriceRead.series(points, product,
str(rec.get("provider") or "price_history"),
stamp)
+4 -4
View File
@@ -381,8 +381,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
"""
problems = await problems_for(proposed, hass)
if problems:
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
"nothing written. First: %s", len(problems), problems[0])
_LOG.warning("The settings page did not save, because of %d "
"problems. The first: %s", len(problems), problems[0])
raise SaveRefused("invalid", problems=problems)
info = await selfcfg.info()
@@ -390,8 +390,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
_LOG.warning("panel REFUSED to save: the stored options changed "
"underneath this page (keys: %s); nothing written",
_LOG.warning("The settings page did not save, because the settings "
"changed while it was open (keys: %s).",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
+31 -43
View File
@@ -279,6 +279,8 @@ that write is issued rather than skipped as unchanged.
"""
from __future__ import annotations
from . import messages
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
# module-level name ending in OPTION_DEFAULTS and asserts each value
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
@@ -316,11 +318,8 @@ WIRE_W = 0.0
#: a cap that does not bind - and a number invented for the row would be a
#: second helping of the defect this constant exists to disclose.
SIZED_FROM_METER = (
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
"trade against a meter the battery is moving, so this is an UPPER BOUND "
"and not a prediction; how far under it a live run lands is a closed-loop "
"question and a shadow run cannot answer it")
"This is an upper limit, not a forecast, because the meter did not see "
"the battery move.")
def mode(opts: dict) -> str:
@@ -371,18 +370,18 @@ def why(opts: dict, *, control_started: bool) -> str:
# discount. What is true in every live state is that what this add-on
# decides reaches the inverter; whether control is RUNNING is the
# `Stopped` banner's question and it is answered there.
# UX-08: the same four rungs, in plain words. The setting is named by its
# value, "automatic" for `auto`, so each rung still reads differently.
m = mode(opts)
live = "what the add-on decides reaches the inverters"
inert = "the add-on decides but sends nothing"
if m == "off":
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
"add-on decides DOES reach the inverter")
return f"Off, {live}"
if m == "on":
return ("on (shadow_mode: on) - decisions are recorded and nothing "
"is commanded")
return f"On, {inert}"
if control_started:
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
"mode: what this add-on decides DOES reach the inverter")
return ("on (shadow_mode: auto, control is stopped) - decisions are "
"recorded and nothing is commanded")
return f"Off (automatic, control is started), {live}"
return f"On (automatic, control is stopped), {inert}"
def words(opts: dict, *, control_started: bool, would_w: float | None,
@@ -409,10 +408,10 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
that decided it did not apply, which is the shape of defect this card is
about. TEST-14's rule with a new name on it.
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
⚠️ AND THE PHRASE IS "THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
CAVEAT FIRES. Every number a shadow run records was reached from a plant
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
of a live run and no number here is one. The ⚠️ below is the STRONGER,
that did not respond; "would ask for -2 000 W" alone reads as a forecast
of a live run and no number here is one. UX-08 2d put it in plain words. The ⚠️ below is the STRONGER,
conditional statement about the cycles where the answer can be multiples
out - see this module's header for the measured table.
@@ -429,19 +428,19 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
if not engaged(opts, control_started=control_started):
return head
if would_w is None:
return (f"{head}; no decision has been reached yet - the inverter is "
f"held at {WIRE_W:.0f} W")
return (f"{head}. No decision yet, the inverters stay at "
f"{WIRE_W:.0f} W.")
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
# the whole sentence it ate the comma out of `head` - and `head` is the
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
# masking rule says must survive.
num = f"{float(would_w):+,.0f}".replace(",", " ")
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
f"held at {WIRE_W:.0f} W")
out = (f"{head}. This cycle it would ask for {num} W, the inverters stay "
f"at {WIRE_W:.0f} W.")
if sized_from_meter:
out += f" {SIZED_FROM_METER}"
if reason:
out += f" - {reason}"
out += f" Why: {reason}."
return out
@@ -471,25 +470,17 @@ def log_line(*, would_w: float, reason: str, sized_from_meter: bool) -> str:
def banner(opts: dict, *, control_started: bool, latched: tuple = ()) -> tuple:
"""`(level, text)` for the top of the status page, or `None` when live.
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
ABOVE every other branch of the cascade, because "the battery is not being
controlled" is the one sentence that may never be displaced - but a
failsafe latch that is up while the controller is inert is up when it
stops being inert, and an operator who turns shadow mode off without
clearing it gets a battery that will not move and a green page that
never mentioned why. So the latch is carried INTO the shadow banner
rather than losing its own.
⚠️ IT GOES RED FOR A STANDING LATCH. The shadow banner sits ABOVE every
other branch of the cascade, because "the battery is not controlled" is
the one sentence that may never be displaced - but a failsafe latch that
is up while the controller is inert is up when it stops being inert. So the
banner says a safety stop outlasts shadow mode, and each latch keeps its
own red row below, which names it and says how it clears. The words are
`messages.banner_shadow()`'s (UX-09); `latched` names them for callers.
"""
if not engaged(opts, control_started=control_started):
return None
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
"decides reaches the inverter.")
if latched:
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
+ ", ".join(latched) + ") and it will still be up when shadow "
"mode is turned off.")
return ("warn", text)
return ("bad" if latched else "warn", messages.banner_shadow(bool(latched)))
def problems(opts: dict) -> list:
@@ -510,9 +501,6 @@ def problems(opts: dict) -> list:
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return []
return [f"shadow_mode is {raw!r}, which is not one of "
+ ", ".join(f"'{m}'" for m in MODES)
+ ". 'auto' leaves the battery alone while control is stopped and "
"commands it once control is started; 'on' records every "
"decision and commands nothing at all; 'off' is ordinary "
"control."]
return [f"{messages.setting('shadow_mode')} is {messages.shown(raw)}. Choose "
+ ", ".join(f"'{m}'" for m in MODES[:-1])
+ f" or '{MODES[-1]}'."]
+50 -62
View File
@@ -204,6 +204,7 @@ import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from . import messages
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option,
@@ -262,9 +263,8 @@ def _minutes(value, field: str) -> int:
parts = text.split(":")
if len(parts) != 2:
raise ProfileInvalid(
f"{field} is {value!r}; it must be \"HH:MM\" local Belgian time, "
f"e.g. \"22:00\". A bare hour or a decimal is refused rather than "
f"guessed at - 7.5 could be 07:30 or 07:50.")
f"{field} is {value!r}. Write it as \"HH:MM\" in Belgian time, "
f"for example \"22:00\".")
try:
h, m = int(parts[0]), int(parts[1])
except ValueError:
@@ -272,9 +272,8 @@ def _minutes(value, field: str) -> int:
f"integers, e.g. \"07:00\"")
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
raise ProfileInvalid(
f"{field} is {value!r}, which is not a time of day. Hours run 0-23, "
f"minutes 0-59; \"24:00\" is accepted only as an END meaning "
f"midnight at the end of the day.")
f"{field} is {value!r}, which is not a time of day. Use 00:00 to "
f"23:59, or \"24:00\" as an end time.")
return h * 60 + m
@@ -286,11 +285,8 @@ def _days(value, field: str) -> frozenset:
"""
if value is None:
raise ProfileInvalid(
f"{field} is missing. Name the days this period applies to - "
f"\"mon-fri\", \"sat,sun\" or \"all\". It is required rather than "
f"defaulted to every day, because a Belgian night tariff that "
f"silently covered the weekend too would misprice 48 h a week with "
f"nothing reporting it.")
f"{field} is missing. Name the days, for example \"mon-fri\", "
f"\"sat,sun\" or \"all\".")
tokens = []
if isinstance(value, (list, tuple)):
for item in value:
@@ -367,10 +363,8 @@ def _price(value, field: str, means: str = None) -> float:
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
raise ProfileInvalid(
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. The "
f"usual cause is a figure in cents or in EUR/MWh - 34 instead of "
f"0.34 - which would make every hour look like an emergency. Same "
f"bound the fetched feeds are held to (price.py `_validate_raw`).")
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. "
f"Write the price in EUR/kWh, for example 0.34 and not 34.")
return out
@@ -422,9 +416,8 @@ class StaticProfile:
self.option = option
if not self.periods:
raise ProfileInvalid(
f"{option} has no periods. A profile with no periods is "
f"not a fixed tariff, it is an absent one - leave the option "
f"empty to say that on purpose (§11.4 then applies).")
f"{option} has no periods. Add at least one, or leave the "
f"setting empty.")
_check_tiling(self.periods, option=option)
# -- what kind of tariff this is, DERIVED --------------------------------
@@ -558,10 +551,7 @@ def _raise_gap(weekday, start, end, option="static_profile"):
raise ProfileInvalid(
f"{option} leaves {DAY_NAMES[weekday]} "
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
f"price. Refused rather than defaulted: an invented price is one an "
f"optimiser would trade on, and §11.4 already says what a missing price "
f"means. Add a period covering it - remember a window written "
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
f"price. Add a period that covers them.")
def _raise_overlap(weekday, over, end, periods, option="static_profile"):
@@ -569,9 +559,8 @@ def _raise_overlap(weekday, over, end, periods, option="static_profile"):
names = [p.name for p in periods if p.covers(weekday, start)]
raise ProfileInvalid(
f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
f"one hour means one of them silently wins on list order. Narrow one of "
f"the windows or split the day selector.")
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Make the "
f"periods stop overlapping.")
def parse_static_profile(value, *, option: str = "static_profile",
@@ -609,28 +598,21 @@ def parse_static_profile(value, *, option: str = "static_profile",
value = json.loads(text)
except ValueError as e:
raise ProfileInvalid(
f"{option} is not valid JSON ({e}). It is a JSON object "
f"with a \"periods\" list, on one line, e.g.\n"
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
f"Leave it empty for a dynamic-only or price-free site.")
f"{messages.not_valid(option, e)}. Copy the example from the "
f"documentation, or leave it empty if you have no fixed "
f"tariff.")
if isinstance(value, dict):
tz = str(value.get("timezone", "") or "").strip().lower()
if tz not in SUPPORTED_TZ:
raise ProfileInvalid(
f"{option}.timezone is {value.get('timezone')!r}. Only "
f"Europe/Brussels is supported: the shipped image carries no tz "
f"database (see app/price.py), so any other zone could not be "
f"honoured and would be silently treated as Brussels - which is "
f"wrong by an hour for part of the year.")
f"Europe/Brussels is supported.")
rows = value.get("periods")
if rows is None:
raise ProfileInvalid(
f"{option} has no \"periods\" list. Even a fully-fixed "
f"tariff is written as one period covering 00:00-24:00 all days "
f"- one shape for both products, so nothing downstream branches "
f"on which kind of site this is.")
f"{option} has no \"periods\" list. A fixed price is one "
f"period from 00:00 to 24:00 on all days.")
else:
rows = value
@@ -1137,18 +1119,24 @@ class TariffPlan:
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
surface said either.
"""
# UX-08: plain words, every figure in its own slot as before.
if not self.has_prices:
return f"no tariff - {self.read.reason}"
tag = {"provider": self.read.provider, "static": "static_profile",
"fallback": "static_profile (feed down)"}.get(self.origin,
self.origin)
return f"no prices, {self.read.reason}"
tag = {"provider": "the price feed", "static": "your fixed tariff",
"fallback": "your fixed tariff, because the feed is down"}.get(
self.origin, self.origin)
what = {PriceProduct.DAY_AHEAD: "day-ahead prices",
PriceProduct.REALTIME: "real-time prices",
PriceProduct.STATIC: "day/night prices",
PriceProduct.FIXED: "a fixed price"}.get(self.product,
str(self.product))
gaps = self.spacings_s
return (f"{self.product.value} from {tag}, "
f"{self.n_points} points over {self.hours} h, "
f"{self.n_firm} firm / {self.n_carried} carried"
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
return (f"{what} from {tag}, "
f"{self.n_points} prices over {self.hours} hours, "
f"{self.n_firm} published and {self.n_carried} estimated"
+ (f", {'+'.join(str(g) for g in gaps)} s apart"
if gaps else "")
+ (", provisional" if self.provisional else ""))
+ (", not final yet" if self.provisional else ""))
def _whole_hours(points) -> int:
@@ -1242,21 +1230,22 @@ def static_status(opts: dict, now: datetime):
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
now=now)
except ProfileInvalid as e:
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
"should have been refused at startup - see validate.py", e)
return False, f"static_profile unusable: {e}"
_LOG.warning("static_profile cannot be used (%s), so there is no "
"cheap window. The start-up check should have refused "
"it.", e)
return False, f"Fixed tariff cannot be used: {e}"
if not plan.has_prices:
return False, "no static profile"
return False, "No fixed tariff set"
cheap = plan.cheap_now(now)
window = plan.window_at(now)
price = plan.price_at(now)
when = plan.profile.next_change(now)
until = "" if when is None else f" until {brussels(when):%H:%M}"
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
f"mean {plan.mean_eur_kwh():.4f} over "
f"{MEAN_WINDOW_DAYS} d - "
f"{'CHEAP window' if cheap else 'not a cheap window'}")
kind = ("Fixed" if plan.product is PriceProduct.FIXED else "Day/night")
return cheap, (f"{kind} tariff, {window}{until}: {price:.4f} EUR/kWh, "
f"{'cheap' if cheap else 'not cheap'} against the "
f"{MEAN_WINDOW_DAYS}-day average of "
f"{plan.mean_eur_kwh():.4f}")
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
@@ -1353,13 +1342,12 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
"(%s), so there is no tariff at all", e)
profile = None
if profile is None:
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
"back to. The plan is provisional and arbitrage is off; "
"self-consumption, peak shaving and reserve management "
"continue (§6, §10.1).", read.reason)
_LOG.warning("%s, and no static_profile is set to fall back to, so "
"prices are provisional and trading is off. Peak "
"limiting and home use go on.", read.reason)
return TariffPlan(read, None, "none")
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
"PROVISIONAL, arbitrage off.", read.reason)
_LOG.warning("%s, so the static_profile tariff is used instead, marked "
"provisional, and trading is off.", read.reason)
points = profile.series(since, until, provisional=True)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile", stamp),
+2 -2
View File
@@ -328,8 +328,8 @@ def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
# next start rather than be recorded as finished - the one thing worse
# than a failed migration is a failed migration a later version skips.
_LOG.error("per-inverter store migration LOST STATE and has not been "
"marked done: %s", rep.explain())
_LOG.error("Moving the saved state to one part per inverter lost "
"data, so it is not marked done: %s", rep.explain())
return rep
+168 -341
View File
@@ -322,6 +322,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
import logging
from . import messages
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
@@ -571,6 +572,17 @@ def _unit_label(opts: dict, index: int) -> str:
return f" ({name or f'inverters[{index}]'})"
def S(key: str, where: str = "") -> str:
"""A setting as a refusal names it; `where` is `_unit_label()`'s " (name)"."""
return messages.setting(key, where.strip().strip("()"))
def _labels(keys, joiner: str = "and") -> str:
"""Several settings, each with its key: the config panel files a refusal
under every key it names (`configui.problems_by_field()`)."""
return f" {joiner} ".join(S(k) for k in keys)
def _roster_problems(opts: dict) -> list[str]:
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
what it says.
@@ -625,11 +637,9 @@ def _roster_problems(opts: dict) -> list[str]:
if raw is None or raw == "" or raw == []:
return problems
if not isinstance(raw, (list, tuple)):
return [f"inverters is {type(raw).__name__} {raw!r}, and it must be a "
"LIST of inverter objects - one per inverter, each naming its "
"own name, setpoint_entity and max_w. A single inverter needs "
"no list at all: leave `inverters` empty and the flat "
"setpoint_entity/soc_entity/batt_entity/max_w keys describe it."]
return [f"{S('inverters')} is {type(raw).__name__} {raw!r}, not a list. "
"Add one entry per inverter, or leave it empty for a single "
"inverter."]
entries = roster_entries(opts)
seen: dict = {}
@@ -637,45 +647,31 @@ def _roster_problems(opts: dict) -> list[str]:
where = f"inverters[{i}]"
if not isinstance(entry, dict):
problems.append(
f"{where} is {type(entry).__name__} {entry!r}, and every entry "
"must be an object with at least name, setpoint_entity and "
"max_w.")
f"{where} in the “Inverter list” is {messages.shown(entry)}. Each entry needs "
"a name, a setpoint entity and an inverter rating.")
continue
name = str(entry.get("name") or "").strip()
if not name:
problems.append(
f"{where} has no name. Every inverter needs one: the Store "
"namespaces this unit's SAFETY-02 latch, its heartbeat and its "
"write bookkeeping under it, so two unnamed units would share "
"one namespace and a trip caused by one would latch the other. "
"Any short identifier works - inverter-a, inverter-b, garage.")
f"{where} in the “Inverter list” has no name. Give every "
"inverter a short name, for example inverter-a.")
elif name in seen:
problems.append(
f"{where} and {seen[name]} are both called {name!r}. The Store "
"keys every per-unit latch under the unit's name, so two units "
"with one name share one SAFETY-02 latch and one heartbeat "
"deadline - a trip caused by either would stop both, and an "
"operator clearing it would clear both. Give them different "
"names.")
f"{where} and {seen[name]} are both called {name!r}. Give every "
"inverter its own name.")
else:
seen[name] = where
who = f"{where} ({name})" if name else where
if not str(entry.get("setpoint_entity") or "").strip():
problems.append(
f"{who} has no setpoint_entity, so this add-on cannot command "
"it. It would sit in the roster, take a share of the site "
"target every cycle and deliver none of it, while the status "
"page showed a site running at full strength. Give it the "
"ESPHome number entity that carries ITS setpoint, or remove "
"the entry.")
f"{who} has no {S('setpoint_entity')}, so the add-on cannot "
"control it. Set its ESPHome number entity, or remove the "
"entry.")
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
problems.append(
f"{who} has no usable max_w (got {entry.get('max_w')!r}). It is "
"the supervised cap on what may be written to THIS inverter, it "
"is not shared with any other unit and it is deliberately not "
"inherited from the flat max_w - a rating borrowed from a "
"sibling is exactly what DEV-02 removed. State this unit's own "
"rating in watts.")
f"{who} has no usable {S('max_w')}: it is "
f"{messages.shown(entry.get('max_w'))}. Set this inverter's own rating in "
"watts.")
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
stated, blank = [], []
@@ -694,35 +690,21 @@ def _roster_problems(opts: dict) -> list[str]:
val = None
if val is None or not (val > 0.0):
problems.append(
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
"read to weight the split of the site target by ENERGY headroom "
"rather than by power rating; a zero or an unparseable value is "
"a pack the split would quietly stop preferring while the box "
"still shows a value. State it in kWh, or leave it empty on "
"every entry.")
f"{who} has {S('capacity_kwh')} {messages.shown(raw)}, which is not a usable "
"number. Set it in kWh, or leave it empty on every inverter.")
else:
stated.append(who)
if stated and blank:
problems.append(
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
"split weights one pack's energy headroom against another's, so a "
"pack measured in kWh beside a sibling that weighs a dimensionless "
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
"whole roster and the value you filled in changes nothing. State a "
"capacity for every inverter, or for none of them.")
f"{S('capacity_kwh')} is set on {', '.join(stated)} but empty on "
f"{', '.join(blank)}. Set it on every inverter, or on none.")
flat_sp = str(opts.get("setpoint_entity") or "").strip()
if entries and flat_sp:
problems.append(
f"setpoint_entity is set to {flat_sp} AND an inverters list of "
f"{len(entries)} is declared. The flat setpoint_entity/soc_entity/"
"batt_entity/max_w keys are the SINGLE-inverter shape and are not "
"read at all once a roster exists, so this config declares an "
"actuator that nothing will ever write - a site running quietly "
"smaller than its own configuration. Move that inverter into the "
"list as its own entry and clear setpoint_entity.")
f"{S('setpoint_entity')} is set to {flat_sp}, but the "
f"{S('inverters')} is also filled in. Move that inverter into the "
"list and clear this setting.")
return problems
@@ -760,17 +742,14 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
name = getattr(unit, "name", entity)
if dev_max is not None and cap > float(dev_max):
problems.append(
f"{name}: max_w is {cap:.0f} W but {entity} accepts at most "
f"{float(dev_max):.0f} W - THE DEVICE WINS, so the {cap:.0f} W "
f"configured for this inverter would silently never happen. "
f"Lower this entry's max_w to {float(dev_max):.0f} W or less.")
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts at "
f"most {float(dev_max):.0f} W. Lower this inverter's rating to "
f"{float(dev_max):.0f} W or less.")
elif dev_min is not None and -cap < float(dev_min):
problems.append(
f"{name}: max_w is {cap:.0f} W, so this controller may command "
f"{-cap:.0f} W, but {entity} accepts no less than "
f"{float(dev_min):.0f} W - THE DEVICE WINS on the charge side. "
f"Lower this entry's max_w to {abs(float(dev_min)):.0f} W or "
"less.")
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts no "
f"less than {float(dev_min):.0f} W when charging. Lower this "
f"inverter's rating to {abs(float(dev_min)):.0f} W or less.")
return problems
@@ -803,11 +782,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
writers = actuator_writers(opts)
if not writers:
problems.append(
"setpoint_entity is not set, so this add-on has no actuator: every "
"write is a no-op, the device's own rating is never read, and the "
"ESP32 heartbeat that holds off the layer-1 failsafe never happens. "
"It would run, look healthy, and move no energy at all. Set it to "
"the ESPHome number entity that carries the setpoint.")
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
"inverter. Set it to the ESPHome number entity for the setpoint.")
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
# count of option keys. Read `actuator_writers()` for why the old check went
# green on the very config this one is here to refuse.
@@ -822,15 +798,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
for actuator, who in sorted(writers.items()):
if len(who) > 1:
problems.append(
f"{len(who)} configured writers can reach the one actuator "
f"{actuator}: {', '.join(who)}. specs §1.5 makes any "
"configuration in which two writers can reach one actuator a "
"non-goal - two independent setpoints on one inverter is the "
"arbitration problem this add-on refuses to have, and at "
"runtime it is a meter-emulation target being written twice a "
"second by two strategies that cannot see each other. Give "
"each actuator exactly one writer, or point the second one at "
"its own entity.")
f"{len(who)} settings point at the same inverter entity "
f"{actuator}: {', '.join(who)}. Give each inverter its own "
"setpoint entity.")
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
@@ -861,7 +831,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# has answered; it is not called from here because at startup no device has.
max_w = _number(opts, "max_w", 2000.0)
if max_w is None:
problems.append(f"max_w {opts.get('max_w')!r} is not a number")
problems.append(f"{S('max_w')} is {messages.shown(opts.get('max_w'))}, which is not "
"a number. Set it in watts.")
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
# the check is not for the Supervisor path - it is because
# validate_startup_config() is now also sim/inject.py's entry point and can
@@ -872,24 +843,19 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# nonsense config it is.
elif max_w <= 0:
problems.append(
f"max_w is {max_w:.0f} W and must be positive - it is a magnitude, "
"not a signed setpoint. At or below zero the arbiter's supervised "
f"limit becomes lo={-max_w:.0f} / hi={max_w:.0f}, which is a "
"contradiction it resolves to 0 W, so the controller would look "
"like it was tripping rather than misconfigured.")
f"{S('max_w')} is {max_w:.0f} W. Set it above 0 W.")
elif dev_max is not None and max_w > dev_max:
problems.append(
f"max_w is {max_w:.0f} W but {opts.get('setpoint_entity') or 'the setpoint device'} accepts at "
f"most {dev_max:.0f} W - THE DEVICE WINS, so the {max_w:.0f} W you "
f"configured would silently never happen. Lower max_w to "
f"{dev_max:.0f} W or less, or point setpoint_entity at a device that "
f"can take {max_w:.0f} W.")
f"{S('max_w')} is {max_w:.0f} W, but "
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts at "
f"most {dev_max:.0f} W. Lower the rating to {dev_max:.0f} W or "
"less.")
elif dev_min is not None and -max_w < dev_min:
problems.append(
f"max_w is {max_w:.0f} W, so this controller may command "
f"{-max_w:.0f} W, but {opts.get('setpoint_entity') or 'the setpoint device'} accepts no less "
f"than {dev_min:.0f} W - THE DEVICE WINS on the charge side. Lower "
f"max_w to {abs(dev_min):.0f} W or less.")
f"{S('max_w')} is {max_w:.0f} W, but "
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts no "
f"less than {dev_min:.0f} W when charging. Lower the rating to "
f"{abs(dev_min):.0f} W or less.")
# --- 3. a meter that can actually read -----------------------------------
# ⚠️ Not a second copy of these rules - p1.py owns them, because
@@ -898,9 +864,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
phases = _number(opts, "meter_phases", 1.0)
max_age_s = _number(opts, "meter_max_age_s", 30.0)
if phases is None or max_age_s is None:
problems.append("meter_phases and meter_max_age_s must both be numbers "
f"(got {opts.get('meter_phases')!r} / "
f"{opts.get('meter_max_age_s')!r})")
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
f"both be numbers. They are "
f"{messages.shown(opts.get('meter_phases'))} and "
f"{messages.shown(opts.get('meter_max_age_s'))}.")
else:
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
@@ -921,15 +888,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if (is_enabled(opts) and max_age_s is not None
and max_age_s > FIRMWARE_INGEST_VETO_S):
problems.append(
f"meter_max_age_s is {max_age_s:.0f} s but the ESP32 firmware vetoes "
f"any P1 sample at {FIRMWARE_INGEST_VETO_S:.0f} s and forces the "
"layer-1 failsafe - THE FIRMWARE WINS, and it wins silently. This "
f"add-on would treat a {max_age_s - 1:.0f} s reading as fresh and "
"keep commanding, while the inverter sat at 0 W with a healthy-"
"looking meter on the status page. Lower meter_max_age_s to "
f"{FIRMWARE_INGEST_VETO_S:.0f} s or less (30 s is the tested value), "
"or rebuild the firmware with a matching max_age_s and update "
"validate.FIRMWARE_INGEST_VETO_S.")
f"{S('meter_max_age_s')} is {max_age_s:.0f} s, but the control "
f"board stops the inverter after {FIRMWARE_INGEST_VETO_S:.0f} s. "
f"Set it to {FIRMWARE_INGEST_VETO_S:.0f} s or less.")
# --- 5. a sign watchdog that can actually fire ---------------------------
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
@@ -956,19 +917,15 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
caps = [cap for _, cap in caps_ix]
weakest = min(caps) if caps else None
if sign_w is None:
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
problems.append(f"{S('sign_disagree_w')} is "
f"{messages.shown(opts.get('sign_disagree_w'))}, which is not a "
"number. Set it back to 300 W.")
elif weakest is not None and weakest > 0 and sign_w >= weakest:
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
problems.append(
f"sign_disagree_w is {sign_w:.0f} W but max_w{where} is "
f"{weakest:.0f} W, so "
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
"arbiter never lets a command exceed max_w, and the watchdog needs "
f"a command over {sign_w:.0f} W. It would sit there reporting "
"'armed' on the status page while being incapable of tripping - the "
f"one runaway failure mode this system has actually observed. Lower "
f"sign_disagree_w below {weakest:.0f} W (300 W is the measured "
"default), or raise max_w.")
f"{S('sign_disagree_w')} is {sign_w:.0f} W, not below "
f"{S('max_w', where)} at {weakest:.0f} W, so this safety stop can "
f"never trip. Lower it or raise the rating.")
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
@@ -1014,20 +971,15 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
if caps_ix else "")
if unit_sign_w is None:
problems.append(f"unit_sign_disagree_w "
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
problems.append(f"{S('unit_sign_disagree_w')} is "
f"{messages.shown(opts.get('unit_sign_disagree_w'))}, which is not a "
"number. Set it back to "
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
problems.append(
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
"watchdog can never fire: no inverter is ever commanded past max_w, "
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
"inverters that is the guard which catches a unit doing the "
"OPPOSITE of what it was told while its sibling covers for it at "
"the meter - the case the site-level watchdog is structurally "
f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W "
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
"measured default), or raise max_w.")
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, not below "
f"{S('max_w', unit_where)} at {weakest:.0f} W, so this safety stop "
f"can never trip. Lower it or raise the rating.")
# --- 6. a maintenance charge the ceiling can actually accommodate --------
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
@@ -1100,18 +1052,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
maint_where = _unit_label(opts, maint_index)
if charge_w is None:
problems.append(
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
f"{S('maintenance_charge_w')} is "
f"{messages.shown(opts.get('maintenance_charge_w'))}, which is not a number. Set "
"it in watts.")
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
problems.append(
f"maintenance_charge_w is {charge_w:.0f} W but max_w{maint_where} is "
f"{maint_cap:.0f} W, so a maintenance charge in a cheap window can "
"never happen: the shaping asks the arbiter for at most "
f"{-charge_w:.0f} W while the supervised limit allows no less than "
f"{-maint_cap:.0f} W, which is a contradiction it resolves to 0 W - "
"THE CEILING WINS, and what you would see is an ARBITER "
"CONTRADICTION line that reads like a bug rather than like these "
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
f"less, or raise max_w to at least {charge_w:.0f} W.")
f"{S('maintenance_charge_w')} is {charge_w:.0f} W, but the "
f"{S('max_w', maint_where)} is {maint_cap:.0f} W, so the monthly "
f"recharge cannot run. Lower it to {maint_cap:.0f} W or less.")
# --- 10. a slew rate the plant can actually follow -----------------------
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
@@ -1140,34 +1088,19 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
slew_w = _number(opts, "slew_w", 0.0)
rail = site_rail_w(opts)
if slew_w is None:
problems.append(f"slew_w {opts.get('slew_w')!r} is not a number")
problems.append(f"{S('slew_w')} is {messages.shown(opts.get('slew_w'))}, which is "
"not a number. Set it to 0 to follow the inverter "
"rating.")
elif slew_w < 0:
problems.append(
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
f"{S('slew_w')} is {slew_w:.0f} W and cannot be negative. Set it to "
"0 to follow the inverter rating.")
elif slew_w and rail is not None and rail > 0:
bound = SLEW_RAIL_FRAC * rail
if slew_w > bound + 1.0:
ticks = rail / slew_w
problems.append(
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
f"so the loop crosses its whole output range in {ticks:.1f} "
f"cycles - {ticks * LOOP_TICK_S:.0f} s at the {LOOP_TICK_S:.0f} s "
f"tick this site was measured running - while the plant takes "
f"{PLANT_SETTLE_S:.0f} s to settle (goodwe/NOTES.md §5.10, a "
"1.78 kW step). The controller would command its entire range "
"before it could observe its answer to the first step, which is "
"not a slow loop, it is a loop that MANUFACTURES the error it "
"then chases: measured on this system 2026-09-05, a 3.4 kW "
"export drove the setpoint 380 -> 5000 W in five ticks, the "
"battery to -5154 W past a 5000 VA rating, and the grid from "
"3.4 kW of export to 2.8 kW of import. The stated limit - the "
"command moves at most slew_w per cycle, so it cannot jump "
"ahead of the inverter - is untrue at this rail; THE PLANT "
f"WINS, and it has no status row. Set slew_w to 0, which means "
f"'follow the rail' and resolves to {bound:.0f} W here and "
"moves on its own if the rail does, or write a value at or "
f"below {bound:.0f} W. Lower is always accepted.")
f"{S('slew_w')} is {slew_w:.0f} W, too fast for a {rail:.0f} W "
f"site. Set it to 0 or to at most {bound:.0f} W.")
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
@@ -1198,32 +1131,23 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
where = _unit_label(opts, _ix)
if soc_floor is None or bms_floor is None:
problems.append(
f"peak_soc_floor and bms_soc_floor{where} must both be numbers "
f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})")
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
f"both be numbers. They are {messages.shown(opts.get('peak_soc_floor'))} and "
f"{messages.shown(_raw)}.")
elif soc_floor <= bms_floor:
problems.append(
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced "
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
"the pack's protection rather than yours. The guard would report a "
"SoC floor on its status page while being incapable of ever being "
"the thing that stops the discharge, and the symptom of that is a "
"battery repeatedly sitting at the BMS limit with no record of why "
f"- which looks exactly like the SoC drift the maintenance cycle "
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
f"(the shipped default is "
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
f"bms_soc_floor{where} if it does not match this pack.")
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
f"{S('bms_soc_floor', where)} of {bms_floor:.0f} %. Raise the "
f"reserve above {bms_floor:.0f} %, or correct the cut-out "
"level.")
# --- warnings: real, but not worth refusing over -------------------------
integ = opts.get("integrator_max_w")
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
if integ_w is not None and max_w is not None and integ_w > max_w:
_LOG.warning(
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
"arbiter still bounds the wire to +-max_w - but the only thing the "
"extra headroom buys is more wrong-direction power to unwind after "
"saturation. 0 (follow max_w) is the recommended value.",
"integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
"but it only adds overshoot, so 0 is recommended.",
integ_w, max_w)
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
@@ -1246,15 +1170,13 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if (disch_w is not None and maint_cap is not None and maint_cap > 0
and disch_w > maint_cap):
_LOG.warning(
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
"refused - the drain phase SETS the setpoint and the arbiter clamps "
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
"so nothing is silent and the cycle still completes. But it drains "
"at %.0f W, not the %.0f W you configured, and the drain phase has "
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
"surprise.",
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
maint_cap)
"maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
_LOG.debug(
"Not refused: the drain phase sets the setpoint, the arbiter "
"clamps it to %.0f W and names the clamp, and the drain phase has "
"a 720 min timeout.", maint_cap)
return problems
@@ -1306,43 +1228,19 @@ def _price_problems(opts: dict) -> list[str]:
# fall back to, the site has had no tariff at all - a worse state, and
# one this message must not paper over.
been_running = (
f"the static day/night tariff in static_profile"
f"the {S('static_profile')}"
if str(opts.get("static_profile", "") or "").strip()
else "NOTHING AT ALL - static_profile is empty too, so there was "
"no tariff to fall back to either")
else "no tariff at all")
problems.append(
f"price_provider is {pid!r}, which WAS a shipped plugin and has "
f"been WITHDRAWN (TARIFF-07). This is not a typo and you did not "
f"mistype it - the add-on offered this id, accepted it, and has "
f"said nothing at every startup since. "
f"⚠️ IT HAS NEVER FETCHED A PRICE, HERE OR ANYWHERE: "
f"{RETIRED_PROVIDERS[pid]}. Every fetch it ever made ended in "
f"SourceUnavailable, and §11.4 turns that into a SILENT fall back "
f"- so what this site has actually been running since the day the "
f"id was set is {been_running}. Nothing you could see would have "
f"differed from a working provider that simply never found a cheap "
f"hour, which is why you are being told now rather than left on "
f"it. WHAT TO DO: set price_provider to 'generic_dayahead' for the "
f"wholesale EPEX BE day-ahead curve, to which the add-on adds your "
f"price_markup_eur_kwh and price_taxes_eur_kwh - that APPROXIMATES "
f"a Belgian dynamic tariff and cannot reproduce your supplier's "
f"B x spot + A formula, so the level will be off even when the "
f"shape is right; or 'manual_csv' to enter all-in prices you "
f"compute yourself; or 'off' to run on static_profile "
f"deliberately. There is deliberately NO alias to your old "
f"supplier: aliasing would need your contract's own two "
f"coefficients, which live behind your klantenzone login and are "
f"in no public index, so the add-on would have to invent the "
f"numbers it prices your battery against.")
f"{S('price_provider')} is {pid!r}, which was withdrawn and never "
f"fetched a price. This site ran on {been_running}. Choose "
"'generic_dayahead', 'manual_csv' or 'off'.")
return problems # nothing below can be judged against a dead id
if pid != "off" and pid not in PROVIDERS:
problems.append(
f"price_provider is {pid!r}, which is not a shipped plugin. The "
f"ids that exist are: off, {', '.join(sorted(PROVIDERS))}. "
"Refused rather than defaulted, per the §14 warning box - a "
"silent fall back to 'off' would look exactly like a working "
"provider that happens never to find a cheap hour.")
f"{S('price_provider')} is {pid!r}, which does not exist. Choose "
f"one of: off, {', '.join(sorted(PROVIDERS))}.")
return problems # nothing below can be judged against an unknown id
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
@@ -1386,19 +1284,9 @@ def _price_problems(opts: dict) -> list[str]:
if str(opts.get(k, "") or "").strip()]
if pid != "off" and ha_price:
problems.append(
f"price_provider is {pid!r} AND {' and '.join(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
"what main.py reduces to the `cheap` flag the maintenance cycle "
"reads, and read_inputs() never looks at the other source once "
"they are set. The provider series is consumed - "
"app/static_tariff.py builds a tariff plan and an arbitrage "
"verdict, and app/arbitrage.py trades on it off a background "
"refresh - but the `cheap` flag those entities own is a separate "
"decision they would silently keep. Clear "
"price_now_entity and price_avg_entity to use the "
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
"Home Assistant sensors.")
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} would "
f"win over it. Clear {'it' if len(ha_price) == 1 else 'them'}, or "
"set the source to 'off'.")
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
@@ -1407,14 +1295,9 @@ def _price_problems(opts: dict) -> list[str]:
# day/night tariff has no way to see that it lost.
if ha_price and str(opts.get("static_profile", "") or "").strip():
problems.append(
f"static_profile is set AND {' and '.join(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
"ENTITIES WIN: main.py reads them first and never looks at the "
"static profile, so the day/night tariff you typed would decide "
"nothing and nothing would say so. Clear price_now_entity and "
"price_avg_entity to use static_profile, or clear static_profile "
"to keep the Home Assistant sensors.")
f"{S('static_profile')} is set, but {_labels(ha_price)} would win "
f"over it. Clear the fixed tariff or "
f"{'the entity' if len(ha_price) == 1 else 'the two entities'}.")
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
# that does not tile the day has hours with no price, and the only ways to
@@ -1427,7 +1310,7 @@ def _price_problems(opts: dict) -> list[str]:
profile = parse_static_profile(raw_profile)
except ProfileInvalid as e:
profile = None
problems.append(f"static_profile cannot be used: {e}")
problems.append(f"“{messages.LABELS['static_profile']}” cannot be used. {e}")
if profile is not None and profile.product is not None \
and len(set(profile.prices)) == 1:
_LOG.info(
@@ -1452,7 +1335,7 @@ def _price_problems(opts: dict) -> list[str]:
try:
taxes_table = parse_taxes_profile(raw_taxes_profile)
except ProfileInvalid as e:
problems.append(f"{TAXES_PROFILE_OPTION} cannot be used: {e}")
problems.append(f"“{messages.LABELS[TAXES_PROFILE_OPTION]}” cannot be used. {e}")
if taxes_table is not None:
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
@@ -1482,21 +1365,12 @@ def _price_problems(opts: dict) -> list[str]:
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
superseded = ", ".join(
str(u) for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
if scalar is not None \
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
problems.append(
f"{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is "
f"{scalar} rather than its shipped {shipped}. THE TABLE WINS: "
f"the add-on reads the per-period figures and never looks at "
f"the scalar, so the number you typed there would decide "
f"nothing and nothing would say so. Set price_taxes_eur_kwh "
f"back to {shipped}, or clear {TAXES_PROFILE_OPTION} to keep "
f"the flat figure. (A scalar still sitting on a SUPERSEDED "
f"default - {superseded} - is accepted and NOT refused: "
f"that is a number this add-on wrote into your options "
f"blob, not one you typed.)")
f"{S(TAXES_PROFILE_OPTION)} is set, so "
f"{S('price_taxes_eur_kwh')} is not used. Set it back to "
f"{shipped} from {scalar}, or clear the table.")
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
@@ -1511,13 +1385,8 @@ def _price_problems(opts: dict) -> list[str]:
if abs(p.eur_per_kwh) >= 1.0})
if steep:
problems.append(
f"{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian "
f"levies and distribution run about 0.05-0.25 EUR/kWh, so "
f"these are almost certainly the CENT figures off the tariff "
f"card (ORES prints `13,27`, which is 0.1327 EUR/kWh) typed "
f"without the conversion - a factor of 100 that would make "
f"every hour look like an emergency. Divide by 100, or clear "
f"the option.")
f"{S(TAXES_PROFILE_OPTION)} has {steep} EUR/kWh, which looks "
"like cents. Divide the figures by 100, or clear this setting.")
negative = sorted({p.eur_per_kwh for p in taxes_table.periods
if p.eur_per_kwh < 0})
if negative:
@@ -1528,10 +1397,9 @@ def _price_problems(opts: dict) -> list[str]:
# observe. §14's warning box governs CONTRADICTIONS, and this is
# not one.
_LOG.warning(
"%s carries negative levies %s EUR/kWh. That is accepted, but "
"a tax component below zero is usually a sign error: the "
"add-on will SUBTRACT it from the published price in those "
"hours.", TAXES_PROFILE_OPTION, negative)
"%s has negative levies %s EUR/kWh, which are subtracted from "
"the price. This is allowed, but it is usually a sign error.",
TAXES_PROFILE_OPTION, negative)
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00.
@@ -1567,25 +1435,15 @@ def _price_problems(opts: dict) -> list[str]:
or isinstance(raw_formula, dict)
if pid == "custom_formula" and not has_formula:
problems.append(
"price_provider is 'custom_formula' but price_formula is empty, "
"so the add-on has no coefficients to price anything with. Every "
"fetch would return no-price-data and §11.4 would fall back "
"silently - which is the shape of defect this provider exists to "
"remove, arrived at by leaving one field blank. Copy the "
"Tariefformule line off your supplier's tariff card, e.g. "
"{\"index\":\"epex_be\",\"resolution\":\"hourly\","
"\"factor\":0.102,\"offset\":1.00,"
"\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,"
"\"card\":{\"month\":\"2026-09\","
"\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}} "
"- see DOCS.md. Or set price_provider to 'generic_dayahead' for "
"the wholesale curve with a flat markup.")
f"{S('price_provider')} is 'custom_formula', but "
f"{S('price_formula')} is empty. Copy the formula from your "
"supplier's tariff card, or choose 'generic_dayahead'.")
elif has_formula:
try:
spec = parse_price_formula(raw_formula)
except FormulaInvalid as e:
spec = None
problems.append(f"price_formula cannot be used: {e}")
problems.append(f"“{messages.LABELS['price_formula']}” cannot be used. {e}")
if spec is not None and pid != "custom_formula":
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
# fighting over one flag - it is one that is simply not wired to
@@ -1594,11 +1452,9 @@ def _price_problems(opts: dict) -> list[str]:
# silent loser this module's case (a) is about, so it is said out
# loud rather than left to be discovered from a bill.
_LOG.warning(
"price_formula is filled in but price_provider is %r, so the "
"coefficients decide NOTHING - only 'custom_formula' reads "
"them. Set price_provider to 'custom_formula' to use them, or "
"clear price_formula so the configuration says what the "
"add-on is doing.", pid)
"price_formula is filled in, but price_provider is %r, so the "
"formula is not used. Set price_provider to 'custom_formula' "
"or clear price_formula.", pid)
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1639,7 +1495,7 @@ def _price_problems(opts: dict) -> list[str]:
xspec = parse_export_formula(raw_export)
except FormulaInvalid as e:
xspec = None
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
problems.append(f"“{messages.LABELS[EXPORT_FORMULA_OPTION]}” cannot be used. {e}")
if xspec is not None:
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
@@ -1687,20 +1543,18 @@ def _price_problems(opts: dict) -> list[str]:
# bounds in `implausible_injection()` are applied, and the
# operator is told which of the two checks did NOT run.
_LOG.warning(
"%s has no \"card\" block, so its coefficients are "
"UNGRADED: nothing recomputed a printed price from them. "
"That is accepted because it is the real market - Eneco "
"prints its injection formula with no price beside it, so "
"there is nothing to grade against on that card - and "
"refusing would make the published line inexpressible. "
"What IS checked is the shape of the coefficients: the "
"pass-through (%.4g x the wholesale price) and the offset "
"(%+.5f EUR/kWh). If YOUR card prints an injection "
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
"..., \"expect_incl_vat\": ...} and the add-on will "
"refuse to start unless your numbers reproduce it.",
"%s has no \"card\" block, so its numbers are not checked "
"against a printed price, only their shape (pass-through "
"%.4g, offset %+.5f EUR/kWh).",
EXPORT_FORMULA_OPTION, xspec.passthrough,
xspec.offset_eur_kwh)
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
"prints an injection estimate, add {\"month\": ..., "
"\"index_eur_per_mwh\": ..., \"expect_incl_vat\": ...} "
"and the add-on refuses to start unless your numbers "
"reproduce it.")
else:
xaged = stale_card(xspec)
if xaged:
@@ -1713,11 +1567,9 @@ def _price_problems(opts: dict) -> list[str]:
# every install TARIFF-02 shipped, none of which could have had one.
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
_LOG.warning(
"price_provider is %r but static_profile is empty. §11.4's "
"degradation row is 'no price data -> fall back to the static "
"profile'; with none configured, a feed outage leaves this site "
"with no tariff at all rather than with a coarse one. Not refused, "
"but filling it in is what makes that row work.", pid)
"price_provider is %r but static_profile is empty, so a price "
"feed outage leaves no tariff at all. Fill in static_profile as a "
"fallback.", pid)
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot
@@ -1732,62 +1584,39 @@ def _price_problems(opts: dict) -> list[str]:
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
if thresh is None or thresh < 0:
problems.append(
f"cycle_cost_threshold_eur is "
f"{opts.get('cycle_cost_threshold_eur')!r} and must be a "
f"non-negative number of EUR/kWh - it is what one round trip COSTS "
f"(§10.1). A negative threshold is one that every spread clears, "
f"including a spread of nothing, which is arbitrage with the brake "
f"removed rather than arbitrage tuned aggressively.")
f"{S('cycle_cost_threshold_eur')} is "
f"{messages.shown(opts.get('cycle_cost_threshold_eur'))}. Set it to 0 or more, "
"in EUR/kWh.")
if arbitrage_on(opts):
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
problems.append(
"arbitrage_enabled is on but price_provider is 'off' and "
"static_profile is empty, so this site has NO PRICES AT ALL. "
"§10.1's priority 3 is 'charge cheap, discharge expensive' and "
"there is nothing here to tell cheap from expensive, so the "
"strategy would refuse on every cycle forever while the "
"configuration said it was running. Configure a price_provider "
"or a static_profile, or turn arbitrage_enabled off.")
f"{S('arbitrage_enabled')} is on, but the site has no prices. "
f"Set {_labels(['price_provider', 'static_profile'], 'or')}, "
"or turn trading off.")
if not is_enabled(opts):
problems.append(
"arbitrage_enabled is on but meter_source is 'off', so there "
"is no P1 telegram stream, the capacity-tariff peak guard "
"never runs, and nothing measures the 15-minute average this "
"strategy's charging would land in. §9.1 gives peak shaving "
"priority over arbitrage; an unmeasured peak cannot be shaved, "
"so arbitrage would be deliberately creating the billed "
"quantity with the one guard that limits it switched off. "
"app/arbitrage.py refuses to trade in that state anyway - this "
"refuses the configuration that asks it to. Set meter_source, "
"or turn arbitrage_enabled off.")
f"{S('arbitrage_enabled')} is on, but no meter is set in "
f"{S('meter_source')}. Choose a meter, or turn trading off.")
floor = _number(opts, "arbitrage_soc_floor",
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
ceiling = _number(opts, "arbitrage_soc_ceiling",
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
if floor is None or ceiling is None:
problems.append(
f"arbitrage_soc_floor {opts.get('arbitrage_soc_floor')!r} and "
f"arbitrage_soc_ceiling {opts.get('arbitrage_soc_ceiling')!r} "
f"must both be numbers - they are percentages of pack charge.")
f"{S('arbitrage_soc_floor')} and {S('arbitrage_soc_ceiling')} "
"must both be numbers, in %.")
elif floor >= ceiling:
problems.append(
f"arbitrage_soc_floor is {floor:g} % and arbitrage_soc_ceiling "
f"is {ceiling:g} %, so there is no band left between them: "
f"every state of charge is either at or under the floor (no "
f"discharge) or at or over the ceiling (no charge), and the "
f"strategy can never act. The floor is the reserve it may not "
f"spend and the ceiling is the headroom it may not overfill, "
f"so the floor must be the SMALLER number.")
f"{S('arbitrage_soc_floor')} is {floor:g} %, not below "
f"{S('arbitrage_soc_ceiling')} at {ceiling:g} %. Make the lower "
"limit the smaller number.")
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
problems.append(
"price_provider is 'manual_csv' but price_csv_path is empty, so "
"the offline fallback has no file to read. Every fetch would "
"return no-price-data with a filesystem error as its reason - "
"which is §11.4 behaviour arrived at by accident rather than by "
"configuration. Point it at the CSV, or set price_provider to "
"'off', which says the same thing on purpose.")
f"{S('price_provider')} is 'manual_csv', but "
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
"choose 'off'.")
return problems
@@ -1813,11 +1642,9 @@ def refuse(problems: list[str]) -> None:
at all and even the exit status distinguishes the two.
"""
_LOG.error("=" * 72)
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
_LOG.error("the add-on will start as soon as the settings below are fixed.")
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
_LOG.error("Configuration, then start the add-on again.")
_LOG.error("The add-on did not start, because its settings have problems.")
_LOG.error("Nothing is broken. Fix the settings below, then start it again.")
_LOG.error("They are on the add-on's Configuration tab.")
_LOG.error("-" * 72)
for i, problem in enumerate(problems, 1):
_LOG.error(" %d. %s", i, problem)
+7 -8
View File
@@ -31,7 +31,7 @@ import logging
from aiohttp import web
from . import selfconfig
from . import messages, selfconfig
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
@@ -125,11 +125,8 @@ refresh(); setInterval(refresh, 3000);
# them too, and says so, because a green page that is green for a reason the
# operator cannot see is the exact failure this add-on keeps refusing to ship.
DEVICE_NOTE = (
"%s did not report a range, so the two checks that compare a configured "
"ceiling against what the device itself accepts were skipped - here and at "
"startup, identically. Everything else was checked. If the device answers "
"later and disagrees, the add-on refuses to start then, with the same "
"message you would have seen here.")
"%s did not report its power range, so two checks were skipped, here and "
"at start. If they fail later, the add-on will not start then.")
def build_app(controller, selfcfg=None) -> web.Application:
@@ -264,7 +261,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts")
"not applied until the add-on restarts",
hint=messages.hint("settings_saved"))
return web.json_response({"ok": True, **result})
async def config_restart(request):
@@ -288,7 +286,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
except selfconfig.SupervisorUnavailable as err:
_LOG.error("restart request failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("restart requested from the configuration panel")
controller.log_event("restart requested from the configuration panel",
hint=messages.hint("restart_requested"))
return web.json_response({"ok": True})
app = web.Application()
+10 -9
View File
@@ -1487,13 +1487,14 @@ s47check("the shipped row carries the caveat at the worst cycle: the operator is
s47check("...and it still carries the watts, in their own slot, and the standing "
"0 W beside them - the caveat NARROWS the claim rather than deleting "
"the record a shadow run exists to produce",
"WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row
"it would ask for" in _s47_row
and "the inverters stay at 0 W" in _s47_row
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
s47check("...and the watts are introduced as what it would have commanded ON "
"THIS CYCLE, in every shadow state and not only the caveated ones - no "
"number a shadow run records is a forecast of a live run",
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row)
s47check("...and the watts are introduced as what it would ask for THIS CYCLE, "
"in every shadow state and not only the caveated ones - no number a "
"shadow run records is a forecast of a live run (UX-08: in plain words)",
"This cycle it would ask for" in _s47_row
and "This cycle it would ask for" in _s47_first_row)
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
@@ -2609,7 +2610,7 @@ check("arbitrage on with NO price source at all is refused - a strategy that "
"must refuse on every cycle forever looks exactly like one that is "
"running and finding nothing",
why(validate_startup_config(vbase(arbitrage_enabled=True, **P1_ON),
-5000, 5000), "NO PRICES AT ALL"))
-5000, 5000), "the site has no prices"))
check("arbitrage on with meter_source off is refused - §9.1 puts peak shaving "
"above arbitrage and an unmeasured peak cannot be shaved",
why(validate_startup_config(
@@ -2620,7 +2621,7 @@ check("a floor at or above the ceiling is refused - there is no band left "
why(validate_startup_config(
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
arbitrage_soc_floor=90, arbitrage_soc_ceiling=90, **P1_ON),
-5000, 5000), "no band left"))
-5000, 5000), "Make the lower limit the smaller number"))
check("...and the same numbers the right way round start",
validate_startup_config(
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
@@ -2629,7 +2630,7 @@ check("...and the same numbers the right way round start",
check("a NEGATIVE cycle_cost_threshold_eur is refused even with arbitrage OFF "
"- it is a cost, and a negative one is a threshold every spread clears",
why(validate_startup_config(vbase(cycle_cost_threshold_eur=-0.01),
-5000, 5000), "non-negative")
-5000, 5000), "Set it to 0 or more")
and raises(lambda: threshold_eur_kwh({"cycle_cost_threshold_eur": -1}),
ValueError))
# Rule 7's wording, which OPT-04 had to update because it named what main.py
+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.
+319 -47
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`
@@ -74,14 +77,17 @@ Read MUTATION.md before you report a survivor.
import asyncio
import io
import math
import os
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,
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampResult,
ClampWatchdog,
clamp_to_rating,
is_commandable,
)
@@ -99,6 +105,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):
@@ -458,7 +467,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
@@ -497,12 +506,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} "
@@ -528,12 +541,40 @@ check("...and the count in the row is the counter's, so nothing is lost",
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 c.get("id") == "not_keeping_up"
and c["text"].startswith(messages.T_LOOP_BOUND)
for c in ctl_r.red_snapshot))
check("...and once clear, a warn row still says how many times it engaged",
any(c["warn"] and f"{ctl_r.clamp_wd.engagements} time(s)" in c["text"]
for c in ctl_r.checks()))
check("...and once clear, a warn row still says how many times it engaged, "
"under the integrator bound's own title",
any(c["warn"] and c.get("id") == "not_keeping_up"
and c["text"].startswith(messages.T_LOOP_BOUND)
and messages.times(ctl_r.clamp_wd.bound) in c["text"]
and "fine now" in c["text"] for c in ctl_r.checks()))
# UX-07 item 1: the after-episode row used to say "output clamp ... engaged N
# time(s)" for integrator-bound cycles. Each count now sits under its own name.
check("UX-07 item 1: bound-only engagements are counted as the bound, and no "
"row calls them 'Power request too high'",
ctl_r.clamp_wd.bound == ctl_r.clamp_wd.engagements > 0
and ctl_r.clamp_wd.over_rating == 0
and not any(c.get("id") == "power_too_high"
or messages.T_OUTPUT_CLAMP in c["text"]
for c in ctl_r.checks()))
# ...and an earlier over-rating count stays on the page while a LATER bound
# episode runs: it is the software-fault evidence, and that episode (bug False)
# cannot have added to it, so "fine now" is true of it.
_w = ctl_r.clamp_wd
_saved = (_w.active, _w.bug, _w.over_rating)
_w.active, _w.bug, _w.over_rating = True, False, 2
_rows = ctl_r.checks()
_w.active, _w.bug, _w.over_rating = _saved
check("UX-07 item 1: during a bound episode an earlier over-rating count is "
"still shown, amber, under its own title",
any(c.get("id") == "not_keeping_up" for c in _rows)
and any(c.get("id") == "power_too_high" and c["warn"] and not c["ok"]
and c["text"] == messages.row_before(messages.T_OUTPUT_CLAMP, 2)
for c in _rows))
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
# keeps writing the clamped value. If a later cut changes that it must come here.
@@ -580,7 +621,8 @@ check("AC 2: the clamp counted ZERO engagements", ctl_s.clamp_wd.engagements ==
check("AC 2: no OUTPUT_CLAMP row in the action log",
not [e for e in ctl_s.events if REASON_CODE in e])
check("AC 2: and no clamp row on the status page",
not [c for c in ctl_s.checks() if "output clamp" in c["text"].lower()])
not [c for c in ctl_s.checks()
if c.get("id") in ("power_too_high", "not_keeping_up")])
print(" ...and the two alarms do not share a notification_id")
ids_clamp = {d["notification_id"] for d in clamp_notes(hass_r)}
@@ -806,11 +848,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):
@@ -986,12 +1028,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))
@@ -1083,19 +1127,24 @@ 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 nth(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
@@ -1107,8 +1156,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 nth(notes_w, 0)["message"])
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
# ==============================================================================
@@ -1705,6 +1753,35 @@ check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
"implemented by making every write unauthorised",
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
# UX-09: what the over-rating alarm and its row SAY in each mode. The loop
# bounds the command before the write clamp here, so an over-rating is injected
# as the write path records one, and check_output_clamp() is run on it. Nothing
# reaches the wire in shadow mode, so neither text may claim a command was sent.
def _said(mode):
h, c = _s10(mode, cycles=2)
c.inverters[0].clamp_last = ClampResult(7412.0, 5000.0, 5000.0, True,
"max_w")
asyncio.run(c.check_output_clamp())
notes = [d["message"] for d in clamp_notes(h)
if d["notification_id"] == CLAMP_ID]
rows = [r["text"] for r in c.checks() if r.get("id") == "power_too_high"]
return notes, rows
_ux_live, _ux_shad = _said("off"), _said("on")
print(f" over-rating, live: {_ux_live}\n over-rating, shadow: {_ux_shad}")
check("UX-09: live, the over-rating alarm and row say what was SENT",
_ux_live[0] and all(" and sent 5 000 W" in m for m in _ux_live[0])
and _ux_live[1] and all("sent 5 000 W" in t and "would" not in t
for t in _ux_live[1]))
check("...in shadow mode they say what WOULD have been sent and that the "
"inverter stays at 0 W, never that it was sent",
_ux_shad[0] and all("would have sent 5 000 W" in m
and " and sent " not in m and "stays at 0 W" in m
for m in _ux_shad[0])
and _ux_shad[1] and all("would send 5 000 W" in t and " sent " not in t
for t in _ux_shad[1]))
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
@@ -1789,17 +1866,17 @@ check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
_ROW in _shad_rows and _ROW in _live_rows)
check("AC 3: the banner states plainly that the battery is not being "
"controlled",
"SHADOW MODE" in _shad_pay["banner"]
and "NOT being controlled" in _shad_pay["banner"])
"Shadow mode" in _shad_pay["banner"]
and "not controlled" in _shad_pay["banner"])
check("...and it is not green. A page that reads `ok` while nothing is being "
"commanded is the 'looks healthy but is inert' failure itself",
_shad_pay["level"] == "warn")
check("...and it is FIRST in the cascade: the same controller has other "
"conditions to report and none of them displaces this sentence",
_live_pay["banner"] != _shad_pay["banner"]
and "SHADOW MODE" not in _live_pay["banner"])
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
r"([-\d]+) W", _shad_rows[_ROW])
and "Shadow mode" not in _live_pay["banner"])
_slot = re.search(r"This cycle it would ask for ([-+][\d ]+) W, the inverters "
r"stay at ([-\d]+) W", _shad_rows[_ROW])
check("the row answers 'what would it have done' with a NUMBER in its own "
"named slot, and says where the inverter actually is in a second one - "
"two slots in one sentence, so a transposition moves them",
@@ -1813,32 +1890,37 @@ if _slot is not None:
check("...and the second slot is the STANDING command, so the two cannot "
"be read as one number",
float(_slot.group(2)) == 0.0)
check("the row answers 'and why' by carrying the arbiter's own sentence, "
"rather than a re-derivation of it",
_shad_c.reason in _shad_rows[_ROW])
check("the row answers 'and why' by carrying the page's own Why line, the "
"arbiter's answer in plain words (UX-08), rather than a re-derivation",
_shad_c.why_words in _shad_rows[_ROW]
and dict(_shad_pay["rows"])["Why"] == _shad_c.why_words)
check("with shadow mode off the row says so, and says what follows from it - "
"which is the arrangement that makes the one above a claim about this "
"controller and not about the string",
"NOT in shadow mode" in _live_rows[_ROW]
and "DOES reach the inverter" in _live_rows[_ROW]
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
_live_rows[_ROW].startswith("Off")
and "reaches the inverters" in _live_rows[_ROW]
and "would ask for" not in _live_rows[_ROW])
# ⚠️ A STANDING LATCH IS NAMED RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to carry what they would have said - a
# ⚠️ A STANDING LATCH IS SHOWN RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to say what they would have said - a
# latch that is up while the controller is inert is up the moment it stops
# being inert.
# being inert. UX-09: the banner (12 words) says a safety stop outlasts shadow
# mode, and the latch's own red row, still on the list, names it.
_shad_c.inverters[0].sign_wd.tripped = True
_latched = _shad_c.status_payload()
print(f" banner with a latch up: {_latched['banner']!r}")
check("a failsafe latch that is standing is NAMED in the shadow banner and "
"turns it red, so nobody turns shadow mode off into a battery that will "
"not move and a page that never mentioned why",
check("a failsafe latch that is standing turns the shadow banner red and the "
"banner says a safety stop stays on, so nobody turns shadow mode off "
"into a battery that will not move and a page that never mentioned why",
_latched["level"] == "bad"
and "LATCH IS STILL UP" in _latched["banner"]
and _shad_c.inverters[0].name in _latched["banner"])
and "safety stop stays on" in _latched["banner"])
check("...and the latch is NAMED on its own red row, which says how it clears",
any(not r["ok"] and not r["warn"] and r.get("id") == "direction"
and "A restart does not clear this" in r["text"]
for r in _shad_c.checks()))
check("...and it still says the battery is not being controlled, so the latch "
"has not displaced the sentence that may never be displaced",
"NOT being controlled" in _latched["banner"])
"not controlled" in _latched["banner"])
_shad_c.inverters[0].sign_wd.tripped = False
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
@@ -1858,6 +1940,196 @@ 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 c.get("id") == "not_keeping_up" for c in red_i)
and not any(not c["warn"] and c.get("id") == "power_too_high"
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 its ERROR says it is a software fault and that control keeps "
"running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
"it, which is still in main.py)",
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
and "Control is still running" in x.getMessage() for x in log_o)
and "reached 14 768 W on this project" in
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
"main.py"), encoding="utf-8").read())
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 c.get("id") == "power_too_high"
and c["text"].startswith(messages.T_OUTPUT_CLAMP)
for c in red_o))
# The red row quotes the same tick's numbers as the card, asked before sent.
_row_o = [c["text"] for c in red_o if c.get("id") == "power_too_high"]
check("...and the red row quotes asked before sent, from one of those ticks",
_row_o and any(
f"asked {messages.watts(_f(d, 'requested_w'))}" in _row_o[0]
and f"sent {messages.watts(_f(d, 'clamped_w'))}" in _row_o[0]
and _row_o[0].index("asked") < _row_o[0].index("sent")
for d, t, r in over))
# --- 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)}")
+14 -10
View File
@@ -189,13 +189,13 @@ check(f"the wind-down ran: 0 W written AFTER the fault (writes after = {after})"
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
["cleanup"] in ev[(fault_at or 0):])
died_line = next((i for i, ln in enumerate(err.splitlines())
if "CONTROL LOOP DIED (RuntimeError('" + FAULT + "'))" in ln), None)
if "Control stopped unexpectedly (RuntimeError('" + FAULT + "'))" in ln), None)
wind_line = next((i for i, ln in enumerate(err.splitlines())
if "shutting down - commanding 0 W" in ln), None)
check("a CRITICAL line names the exception and the exit status",
died_line is not None
and "CRITICAL" in err.splitlines()[died_line]
and "commanding 0 W and exiting with status 70" in err.splitlines()[died_line])
and "set to 0 W and the add-on exits with code 70" in err.splitlines()[died_line])
check("the traceback is logged, down to the frame that raised",
"Traceback (most recent call last)" in err
and f"RuntimeError: {FAULT}" in err and "in cycle" in err)
@@ -211,7 +211,7 @@ print("2. the same entry point stopped by SIGTERM - the callback must stay quiet
rc2, out2, err2, ev2 = run_child("clean")
print(f" exit status {rc2}; events {ev2}")
check("an ordinary stop still exits 0", rc2 == 0)
check("an ordinary stop logs no CONTROL LOOP DIED", "CONTROL LOOP DIED" not in err2)
check("an ordinary stop logs no unexpected stop", "Control stopped unexpectedly" not in err2)
check("an ordinary stop still winds down to 0 W, from a non-zero command",
any(e[0] == "w" and e[1] != 0.0 for e in ev2)
and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]])
@@ -222,7 +222,7 @@ rc3, out3, err3, ev3 = run_child("refuse")
print(f" exit status {rc3}")
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
rc3 == 78)
check("and is not reported as a death", "CONTROL LOOP DIED" not in err3)
check("and is not reported as a death", "Control stopped unexpectedly" not in err3)
if rc3 != 78:
print(tail(err3))
@@ -337,15 +337,19 @@ def rows_for(pass_age):
r_new, r_29, r_31 = rows_for(None), rows_for(29), rows_for(31)
print(f" {r_new[0]} | {r_29[0]} | {r_31[0]}")
# UX-08: the wording is app/messages.py's; these pin the meaning - which row,
# its colour, and the two numbers (age, veto) an operator acts on.
check("before the first pass the row is ok and says 'starting'",
r_new[0] == {"ok": True, "warn": False, "text": "control loop: starting"})
r_new[0]["id"] == "controller" and r_new[0]["ok"] and not r_new[0]["warn"]
and "starting" in r_new[0]["text"])
check("29 s after a pass it is ok",
r_29[0] == {"ok": True, "warn": False,
"text": "control loop: last pass 29 s ago"})
r_29[0]["id"] == "controller" and r_29[0]["ok"] and not r_29[0]["warn"]
and "29 seconds ago" in r_29[0]["text"])
check("31 s after a pass it is RED and first, naming the age and the veto",
r_31[0] == {"ok": False, "warn": False,
"text": "control loop: NO PASS FOR 31 s (layer 1 zeroes the "
"inverter after 30 s without a heartbeat)"})
r_31[0]["id"] == "controller" and not r_31[0]["ok"]
and not r_31[0]["warn"]
and "not run for 31 seconds" in r_31[0]["text"]
and "0 W after 30 seconds" in r_31[0]["text"])
async def one_pass():
+48 -8
View File
@@ -33,6 +33,7 @@ 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,
@@ -929,14 +930,18 @@ check("...but the banner does NOT go on saying 'Running - holding grid at "
"zero'. That sentence is a claim the site cannot make while a unit is "
"absent, and 'the page reads healthy while the fault runs' is the shape "
"this whole card exists to remove. It names the unit and stays a WARNING",
_pay["level"] == "warn" and "NOT DELIVERING" in _pay["banner"]
_pay["level"] == "warn" and "is not delivering" in _pay["banner"]
and "inverter-b" in _pay["banner"]
and "holding grid at zero" not in _pay["banner"])
print(f" banner: {_pay['level']!r} / {_pay['banner']!r}")
_row = [r for r in _cw.checks() if "NOT DELIVERING" in r["text"]]
_row = [r for r in _cw.checks()
if r.get("id") == "delivery" and "not delivering" in r["text"]]
check("the status page carries it as a CAUTION, not a failure: the site is "
"still running and short, which a red cross would misdescribe",
len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True)
len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True
and "inverter-b" in _row[0]["text"])
check("...and the row says how it clears: a stop and start of control",
bool(_row) and "stopping and starting control" in _row[0]["text"])
# ===========================================================================
@@ -1172,10 +1177,11 @@ check("...and no store key was written, so a one-inverter install's state file "
"is byte-identical to what it was before this card",
unit_key(_only.name, "delivery_tripped") not in _st1.data
and unit_key(_only.name, "delivery_reason") not in _st1.data)
_n1row = [r for r in _c1.checks() if "delivery watchdog" in r["text"]]
_n1row = [r for r in _c1.checks() if r.get("id") == "delivery"]
check("...and the status page says n/a rather than 'armed', which would be a "
"claim to be watching something that cannot happen",
len(_n1row) == 1 and _n1row[0]["ok"] and "n/a at one inverter" in _n1row[0]["text"])
len(_n1row) == 1 and _n1row[0]["ok"]
and "not needed with one inverter" in _n1row[0]["text"])
check("...no persistent notification of this kind was raised either",
not [c for c in _h1.calls
if "not_delivering" in str(c[2].get("notification_id", ""))])
@@ -1195,8 +1201,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()
@@ -1232,7 +1238,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())
@@ -1250,6 +1256,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:
+55 -40
View File
@@ -85,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,
@@ -773,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):
@@ -788,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):
@@ -831,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
frozen_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 c.get("id") == "meter_frozen"]
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
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
@@ -846,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
@@ -857,9 +859,12 @@ 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(frozen_rows) == 1 and not frozen_rows[0]["ok"] and not frozen_rows[0]["warn"])
check("...and the row says control still follows the reading and what to do",
bool(frozen_rows) and "still follows it" in frozen_rows[0]["text"]
and "Check the meter" in frozen_rows[0]["text"])
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.
@@ -1104,8 +1109,7 @@ def freeze_rows(unchanged_s, grid, **over):
# p1_enabled is what a homewizard_local install has; the row's own content
# is what is under test, not the transport that feeds it.
ctl.p1_enabled = True
return ctl, [r for r in ctl.checks()
if r["text"].startswith("freeze detector")]
return ctl, [r for r in ctl.checks() if r.get("id") == "freeze_check"]
_ctl_u, _rows_u = freeze_rows(None, 4000.0, meter_source="ha_dsmr")
@@ -1114,30 +1118,31 @@ print(f" uncovered: {_rows_u[0]['text'] if _rows_u else '(no row at all)'}")
print(f" covered: {_rows_c[0]['text'] if _rows_c else '(no row at all)'}")
check("an uncovered transport gets exactly one freeze-detector row",
len(_rows_u) == 1)
check("...and it says NOT COVERED",
bool(_rows_u) and "NOT COVERED" in _rows_u[0]["text"])
# UX-08: the row says the check is off and which source it needs, in words
# (STYLE.md: no option values). DOCS.md "Status page" maps them to the keys.
check("...and it says the check is off",
bool(_rows_u) and ": off" in _rows_u[0]["text"])
check("...and it does NOT claim the guard is armed - that would be the status "
"page lying on behalf of a guard that cannot run",
bool(_rows_u) and "armed" not in _rows_u[0]["text"])
check("...and it names the meter_source it is not covering, so the operator "
"knows which install this is",
bool(_rows_u) and "ha_dsmr" in _rows_u[0]["text"])
check("...and it names homewizard_local as the transport that would be covered",
bool(_rows_u) and "homewizard_local" in _rows_u[0]["text"])
bool(_rows_u) and "alerts after" not in _rows_u[0]["text"])
check("...and it names HomeWizard local as the source that would be covered",
bool(_rows_u) and "HomeWizard local" in _rows_u[0]["text"])
check("...flagged as a warning rather than a silent ok",
bool(_rows_u) and not _rows_u[0]["ok"] and _rows_u[0]["warn"])
check("a COVERED transport gets exactly one freeze-detector row",
len(_rows_c) == 1)
check("...and that one DOES say armed - without this, 'the uncovered row does "
"not say armed' is satisfied by deleting the armed branch",
bool(_rows_c) and "armed" in _rows_c[0]["text"]
and "NOT COVERED" not in _rows_c[0]["text"])
check("...and that one DOES say it is on - without this, 'the uncovered row "
"does not say armed' is satisfied by deleting the armed branch",
bool(_rows_c) and "alerts after" in _rows_c[0]["text"]
and ": off" not in _rows_c[0]["text"])
check("...and it quotes the hold and the rail it is armed at, derived from the "
"config the controller was actually built with",
bool(_rows_c)
and f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s unchanged" in _rows_c[0]["text"]
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" in _rows_c[0]["text"])
and f"{messages.duration(_ctl_c.freeze_wd.cfg.hold_s)} unchanged"
in _rows_c[0]["text"]
and f"above {messages.watts(rail_w(LIVE_INSTALL_MAX_W))}"
in _rows_c[0]["text"])
check("...as an ok row, since a covered armed guard is not a warning",
bool(_rows_c) and _rows_c[0]["ok"] and not _rows_c[0]["warn"])
@@ -1179,17 +1184,23 @@ 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
check("...and the red status row is up too, so the page and the notification "
"agree",
any("METER MAY BE FROZEN" in r["text"] for r in _c20.checks()))
any(r.get("id") == "meter_frozen" and not r["ok"] and not r["warn"]
for r in _c20.checks()))
# --------------------------------------------------------------------------- #
@@ -1215,23 +1226,25 @@ check("a device smaller than max_w still gets exactly one freeze-detector row",
check("...and the page does NOT print the max_w rail the command can never "
"reach - THE DEFECT, in one assertion",
bool(_rows_s)
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" not in _rows_s[0]["text"])
and messages.watts(rail_w(LIVE_INSTALL_MAX_W)) not in _rows_s[0]["text"])
check("...it prints the rail the predicate actually uses",
bool(_rows_s)
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
in _rows_s[0]["text"])
check("...and it names the device range as the reason, since the operator "
"configured max_w and is owed the reason the guard is armed elsewhere",
bool(_rows_s) and "4700" in _rows_s[0]["text"]
and "max_w" in _rows_s[0]["text"])
and messages.watts(rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0,
dev_max=4700.0)) in _rows_s[0]["text"])
check("...and it names the inverter's own limit as the reason, since the "
"operator configured max_w and is owed the reason the guard is armed "
"elsewhere",
bool(_rows_s) and "the inverter's own limit" in _rows_s[0]["text"])
check("...and it does not say so when max_w itself sets the rail",
bool(_rows_c) and "own limit" not in _rows_c[0]["text"])
# ⚠️ THE NUMBER IS TAKEN OFF THE ROW AND FED BACK INTO THE SHIPPED PREDICATE.
# Asserting the page against `rail_w(...)` alone proves the two agree, which a
# revert of BOTH to bare max_w also satisfies. What a revert cannot satisfy is
# this: the rail the page advertises has to be a magnitude the wire can carry,
# and the wire stops at the device rating.
_m = re.search(r"command is over (\d+(?:\.\d+)?) W", _rows_s[0]["text"]) if _rows_s else None
_page_rail = float(_m.group(1)) if _m else None
_m = re.search(r"above (\d[\d ]*) W", _rows_s[0]["text"]) if _rows_s else None
_page_rail = float(_m.group(1).replace(" ", "")) if _m else None
print(f" the page advertises a rail of {_page_rail} W against a device that "
f"stops at {_ctl_s.dev_max:.0f} W")
check("...the row states a rail in watts at all, so the parse below means "
@@ -1249,11 +1262,13 @@ check("...as an ok row - a guard armed at a lower rail is still armed, not a "
"warning", bool(_rows_s) and _rows_s[0]["ok"] and not _rows_s[0]["warn"])
check("a device with no commandable range gets exactly one row", len(_rows_z) == 1)
check("...and it says NOT COVERED - the rail is not merely low there, it is "
"0 W, which would be TRUE of every command including a parked one",
bool(_rows_z) and "NOT COVERED" in _rows_z[0]["text"])
check("...and it NEVER says armed",
bool(_rows_z) and "armed" not in _rows_z[0]["text"])
check("...and it says the check is off for the power range - the rail is not "
"merely low there, it is 0 W, which would be TRUE of every command "
"including a parked one",
bool(_rows_z) and ": off" in _rows_z[0]["text"]
and "power range" in _rows_z[0]["text"])
check("...and it NEVER says armed",
bool(_rows_z) and "alerts after" not in _rows_z[0]["text"])
check("...flagged as a warning rather than a silent ok, like the uncovered "
"transport row it shares",
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
+31 -24
View File
@@ -69,6 +69,7 @@ from datetime import datetime, timezone
from pathlib import Path
from app import freshness as F
from app import messages
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
@@ -359,7 +360,7 @@ check("...and `inputs_ok` goes False, so the site stops rather than regulating "
_cs.inputs_ok is False and _cf.inputs_ok is True)
check("...and the operator gets a line saying which unit and why, on the same "
"status list the liveness notes use",
any("STALE_READING" in n and "inverter-a" in n
any("stopped changing" in n and "inverter-a" in n
for n in _cs.liveness_notes())
and _cf.liveness_notes() == [])
check("...and the raw number is still recorded for diagnostics, so the "
@@ -1580,7 +1581,7 @@ check("...and the site stops holding inputs bad, which is what ends the "
_c57.inputs_bad_since is None and _c57.inputs_ok is True)
_ev57 = [e for e in _c57.events if "STALE" in e]
_msgs57 = [r.getMessage() for r in _LOGREC]
_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m]
_rel57 = [m for m in _msgs57 if "is used again and rechecked" in m]
print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}")
check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR "
"CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach "
@@ -1591,8 +1592,9 @@ check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATO
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 nth(_rel57, 0)
and f"still holding {A_FROZEN_W:+.0f}" in nth(_rel57, 0))
"used again" in nth(_rel57, 0) and "recovered" not in nth(_rel57, 0)
and f"reading {A_FROZEN_W:+.0f} W is used again and rechecked"
in nth(_rel57, 0))
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
@@ -1601,7 +1603,8 @@ check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has
# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new
# 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]
_stale57 = [m for m in _msgs57
if "battery power reading stopped changing" in m]
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 "
@@ -1609,17 +1612,16 @@ check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDER
"before it is what stops the register being asked. The new clause names "
"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 nth(_stale57, 0))
f"or {BOUNDS['batt'].release_s:.0f} s after the command drops below "
f"{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"stayed below {BOUNDS['batt'].move_w:.0f} W for "
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 nth(_rel57, 0))
f"rechecked over {BOUNDS['batt'].unchanged_s:.0f} s" 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 "
@@ -1631,7 +1633,7 @@ check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN,
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])
and "stopped changing" in _stale57[0])
check("...and a re-trip's reason names the TWO durations separately, each "
"beside its own value, because after a release the verdict's window and "
"the register's own unchanged time are different facts",
@@ -1810,7 +1812,8 @@ _S56_ACCUSATIONS = [
("app/main.py _read_unit()", "register is not following its own command"),
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:] if "SAFETY-37" in r.getMessage()]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
if "battery power reading stopped changing" in r.getMessage()]
def _s56_row(c):
@@ -1823,7 +1826,8 @@ def _s56_row(c):
a red with no name attached, which reads like broken infrastructure and gets
retried rather than read. Measured: 0 named reds before, 2 after.
"""
hits = [r for r in c.checks() if r["text"].startswith("battery power")]
hits = [r for r in c.checks() if r.get("id") == "sensor"
and r["text"].startswith(messages.LABEL_BATTERY)]
if len(hits) == 1:
return hits[0]
return {"ok": None, "warn": None,
@@ -1933,22 +1937,25 @@ check("⚠️ AND THE VERDICT IS UNCHANGED BY THIS CARD - both rows are still "
_row56_ref["ok"] is False and _row56_ref["warn"] is False
and _row56_abs["ok"] is False and _row56_abs["warn"] is False
and _row56_ref["text"] == _row56_abs["text"])
check("...and what it says instead is the observation plus BOTH causes, with "
f"{_BATT_ENT} in its own slot - `label` and `entity` are two "
f"interpolations in one sentence and swapping them costs zero bytes, so "
f"the literal around each is pinned (habit 14)",
f"battery power: no usable value this cycle from {_BATT_ENT} - and this "
f"row cannot tell which of two things that is" in _row56_ref["text"]
and "the entity is missing or not numeric" in _row56_ref["text"]
and "a number that this controller is withholding" in _row56_ref["text"])
# UX-08: the row names the setting by its label, never the entity id
# (STYLE.md), so there is no second interpolation left to transpose.
check("...and what it says instead is the observation plus BOTH causes, under "
"its own label, and the check to make before replacing the sensor - with "
f"no entity id ({_BATT_ENT}) on the page row",
_row56_ref["text"].startswith(f"{messages.LABEL_BATTERY}: no usable "
f"reading")
and "may be missing" in _row56_ref["text"]
and "held back as too old" in _row56_ref["text"]
and "Developer Tools before replacing it" in _row56_ref["text"]
and _BATT_ENT not in _row56_ref["text"])
check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which is "
"why the row does not need to ask `batt_fresh.stale` itself: `checks()` "
"carries a STALE_READING row in REFUSED and NONE in ABSENT, so 'is that "
"row below?' answers the question this one declines to answer. A row "
"that pointed at a surface present in both states would be worse than "
"the accusation it replaced",
any("STALE_READING" in r["text"] for r in _c56_ref.checks())
and not any("STALE_READING" in r["text"] for r in _c56_abs.checks()))
any("stopped changing" in r["text"] for r in _c56_ref.checks())
and not any("stopped changing" in r["text"] for r in _c56_abs.checks()))
check("...and the row's wording is NOT shared with the log line's, which is a "
"design choice rather than an omission: the two sentences have no clause "
"in common longer than the word 'entity', because one explains a "
@@ -1957,7 +1964,7 @@ check("...and the row's wording is NOT shared with the log line's, which is a "
"other caller does not need",
_S56_EXPLANATIONS[0][1] not in _row56_ref["text"]
and _S56_EXPLANATIONS[1][1] not in _row56_ref["text"]
and "no usable value this cycle" not in _r56_ref)
and "no usable reading" not in _r56_ref)
# --- 11e. what part 11 does NOT grade, with its shape (habit 13) -------------
# * It does not show the live episode was a BMS refusal beyond doubt. 11a
+25 -22
View File
@@ -811,9 +811,11 @@ check("...and the status page shows BOTH per-inverter rows - one latched, one "
"'the sign watchdog is armed' printed over a latched sibling is the page "
"lying",
len([r for r in _c3r.checks()
if r["text"].startswith("sign watchdog [")]) == 1
if r.get("id") == "direction" and r["ok"]
and "inverter-a" in r["text"]]) == 1
and len([r for r in _c3r.checks()
if "SIGN-DISAGREEMENT FAILSAFE [" in r["text"]]) == 1)
if r.get("id") == "direction" and not r["ok"]
and not r["warn"] and "inverter-b" in r["text"]]) == 1)
check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At two "
"inverters there are three sign questions on this page and they are three "
"rows: is A doing what it was told, is B, and is the SITE'S TOTAL "
@@ -823,15 +825,15 @@ check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At tw
"per-unit rows would be advertising two guards where the install has "
"three",
len([r for r in _c3r.checks()
if r["text"].startswith("site sign watchdog:")]) == 1)
if r.get("id") == "site_direction"]) == 1)
check("...and the per-inverter rows now say WHAT the unit was compared "
"against, because at a roster of two it is no longer the meter. A row "
"still reading 'the command opposed the meter' would send an operator to "
"look at P1 for a fault that is visible only on one inverter's own power "
"sensor",
all("its own measured power" in r["text"] for r in _c3r.checks()
if r["text"].startswith(("sign watchdog [",
"SIGN-DISAGREEMENT FAILSAFE ["))))
len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
and all("own power" in r["text"] for r in _c3r.checks()
if r.get("id") == "direction"))
# The operator's clear, which is one button and has to reach every latch.
run(_c3r.handle_action("auto_toggle")) # -> stopped
@@ -1354,7 +1356,7 @@ check("⚠️ AND A UNIT WITH NO `setpoint_entity` IS DELIBERATELY STILL LIVE HE
inverter_b(setpoint="").is_live(_now)[0] is True)
check("...and the status page says so in its own row, which is the surface an "
"operator actually reads",
any("NO SETPOINT ENTITY" in n for n in
any("“Setpoint entity” is not set" in n for n in
built(store=MemStore(), second=inverter_b(setpoint=""))[0]
.liveness_notes()))
check("...and a healthy site adds NO liveness rows at all, so the page is not "
@@ -1627,7 +1629,7 @@ check("⚠️ AC 5: two inverters naming the SAME setpoint_entity are REFUSED. "
"life of this add-on because the schema had exactly one such key; this "
"is the first configuration in which it is expressible, and it is the "
"first time that guarantee has had to be a check",
_why(_p_same, "one actuator") and _why(_p_same, "number.sp_a"))
_why(_p_same, "point at the same inverter entity") and _why(_p_same, "number.sp_a"))
check("...and case-folded, so the hazard cannot be smuggled past on "
"NUMBER.SP_A versus number.sp_a - which is exactly how it would be "
"written by someone copying an entity id out of the HA UI",
@@ -1651,7 +1653,7 @@ check("a flat setpoint_entity left set BESIDE a declared roster is refused. It "
"names an actuator the roster does not, so nothing would ever write it - "
"a site running quietly smaller than its own configuration, which is the "
"'silently a different config' outcome this card ranks below a refusal",
_why(_p_flat, "AND an inverters list"))
_why(_p_flat, "but the “Inverter list” (inverters) is also filled in"))
_p_dup = validate_startup_config(_opts(setpoint_entity="", inverters=[
dict(name="twin", setpoint_entity="number.sp_a", max_w=A_RATING),
dict(name="twin", setpoint_entity="number.sp_b",
@@ -1664,13 +1666,14 @@ for _what, _bad, _needle in (
("with no name at all",
dict(setpoint_entity="number.sp_b", max_w=3000), "has no name"),
("this controller cannot command",
dict(name="inverter-b", max_w=3000), "has no setpoint_entity"),
dict(name="inverter-b", max_w=3000),
"has no “Inverter setpoint entity” (setpoint_entity)"),
("with no rating",
dict(name="inverter-b", setpoint_entity="number.sp_b"),
"no usable max_w"),
"no usable “Inverter rating” (max_w)"),
("whose rating is not a number",
dict(name="inverter-b", setpoint_entity="number.sp_b", max_w="lots"),
"no usable max_w")):
"no usable “Inverter rating” (max_w)")):
check(f"an entry {_what} is refused, and the refusal says which entry and "
f"which key ({_needle!r}) rather than that something is wrong",
_why(validate_startup_config(_opts(setpoint_entity="",
@@ -1678,7 +1681,7 @@ for _what, _bad, _needle in (
check("`inverters` that is not a list at all is refused with the shape it "
"should have been, rather than crashing inside build_roster()",
_why(validate_startup_config(_opts(inverters="number.sp_b")),
"must be a\nLIST".replace("\n", " ")))
"(inverters) is str 'number.sp_b', not a list"))
# --- the permissive readers INSIDE validate.py --------------------------------
# ⚠️ RUN BOTH WAYS, WHICH IS MUTATION.md's RULE. A check that only asserts the
@@ -1736,8 +1739,8 @@ check("the fixture actually desynchronises the two lists, or the two checks "
check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
"inverter-c's 1000 W; it sits at caps index 1, and `_unit_label(opts, 1)` "
"is inverter-B. The refusal says inverter-c",
_why(_p_skew, "max_w (inverter-c) is 1000 W")
and not _why(_p_skew, "max_w (inverter-b) is 1000 W"))
_why(_p_skew, "for inverter-c (inverters.max_w) is 1000 W")
and not _why(_p_skew, "for inverter-b (inverters.max_w) is 1000 W"))
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
@@ -1751,15 +1754,15 @@ check("⚠️ RULE 6 TOO, AND SINCE MAINT-05 IT IS THE WEAKEST PACK IT NAMES. "
"inverter-c's 1000 W sits at caps index 1, and `_unit_label(opts, 1)` is "
"inverter-B - so a caps index leaking into a roster index would say "
"inverter-b here. The refusal says inverter-c, with inverter-c's number",
_why(_p_skew, "maintenance_charge_w is 3000 W but max_w (inverter-c) is "
"1000 W")
and not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
"(inverter-b)"))
_why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” for "
"inverter-c (inverters.max_w) is 1000 W")
and not _why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” for "
"inverter-b"))
check("...and it is no longer graded against the FIRST usable cap at all, which "
"is the behaviour change MAINT-05 makes here: inverter-b's 2000 W was the "
"pre-card answer and appears nowhere in the maintenance refusal now",
not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
"(inverter-b) is 2000 W"))
not _why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” for "
"inverter-b (inverters.max_w) is 2000 W"))
check("...and the counterfactual, so this is not a check that would pass on a "
"validator that named nothing: the OLD expressions still evaluate, on "
"this same config, to the wrong names",
@@ -1782,7 +1785,7 @@ check("⚠️ A READER THAT SILENTLY PINNED INVERTER #1, IN main.py. run_control
"unit's max_w was graded against the device it is written to, and the "
"skip was silent. The per-unit form catches B against B's own ESPHome",
len(_p_rng) == 1 and _why(_p_rng, "inverter-b")
and _why(_p_rng, "THE DEVICE WINS"))
and _why(_p_rng, "Lower this inverter's rating to"))
check("...and it says INVERTER-B, not 'the setpoint device' - a site with "
"three inverters and one bad rating needs to be told which one",
_why(_p_rng, "number.sp_b") and not _why(_p_rng, "inverter-a"))
+4 -2
View File
@@ -1581,9 +1581,11 @@ try:
_logged, _ = controller(NAMED_OPTS, store=_log_store)
finally:
logging.getLogger("goodwe").removeHandler(_h)
_s44_lines = [m for m in _records if "SAFETY-44" in m]
# UX-08 2d: the line is plain now, so it is found by its words, not a card id.
_s44_lines = [m for m in _records
if "which is not in the inverter list" in m]
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line "
check("⭐ the repair SAYS SO, at ERROR, in one plain line - deleting this line "
"left the whole suite green, and it is how an operator learns their "
"latch was re-armed rather than never lost",
len(_s44_lines) == 1)
+803
View File
@@ -0,0 +1,803 @@
"""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 status page. Every row and banner in `app/messages.py` obeys the rule
for its colour: a row one sentence of at most 15 words, a red row up to 3
sentences and 30 words, a banner at most 12 words.
THE RATCHET for the kinds not rewritten yet. Every status-row and banner
literal left in `app/`, 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))
# The Details link (UX-09). Home Assistant 2026.2+ serves add-on pages at
# /config/app/<slug>/<tab> and 2026.5 dropped /hassio/addon/<slug>/<tab>, so
# the path follows the version the Supervisor reports; unknown means current.
_LINE = ("Details: [add-on documentation](/config/app/local_goodwe_controller/"
"documentation), section “Battery is not keeping up” under "
"Notifications.")
os.environ["HOSTNAME"] = "3f2a9c1b7d4e" # a container id, as on TEST-23
messages.set_site("local_goodwe_controller", "2026.9.1")
check("with the Supervisor's slug the Details line is exactly the current-HA "
"link plus the section by name, even when the hostname is a container id",
SAMPLES[0].data()["message"].endswith("\n\n" + _LINE))
_link = {v: messages.docs_url("local_goodwe_controller", v)
for v in ("2026.1.3", "2026.2.0", "2026.4.2", "2026.10.0b1",
"2025.12.5", None, "rubbish")}
check(f"Home Assistant 2026.1 and older get the old /hassio path, 2026.2 and "
f"newer and an unknown version the /config/app path ({_link})",
_link == {
"2026.1.3": "/hassio/addon/local_goodwe_controller/documentation",
"2025.12.5": "/hassio/addon/local_goodwe_controller/documentation",
"2026.2.0": "/config/app/local_goodwe_controller/documentation",
"2026.4.2": "/config/app/local_goodwe_controller/documentation",
"2026.10.0b1": "/config/app/local_goodwe_controller/documentation",
None: "/config/app/local_goodwe_controller/documentation",
"rubbish": "/config/app/local_goodwe_controller/documentation"})
messages.set_site("../../etc", "2026.9.1")
check("a slug that is not this add-on's shape is not put in a link",
"](" not in SAMPLES[0].data()["message"])
messages.set_site(None, None)
os.environ["HOSTNAME"] = "abcd1234-goodwe-controller"
check("without the Supervisor's answer the slug comes from a Supervisor-style "
"hostname",
"(/config/app/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",
SAMPLES[0].data()["message"].endswith(
"\n\nDetails: add-on documentation, section “Battery is not keeping "
"up” under Notifications."))
del os.environ["HOSTNAME"]
# Where the slug and the version come from: the Supervisor, read once at start.
import asyncio # noqa: E402
from app.main import docs_link_facts # noqa: E402
class _Resp:
def __init__(self, body):
self.body = body
async def __aenter__(self):
if isinstance(self.body, Exception):
raise self.body
return self
async def __aexit__(self, *a):
return False
async def json(self, content_type=None):
return self.body
class _Session:
def __init__(self, answers):
self.answers, self.asked, self.timeouts = answers, [], []
def get(self, url, **kw):
self.asked.append((url, kw.get("headers", {}).get("Authorization")))
self.timeouts.append(kw.get("timeout"))
return _Resp(self.answers[url])
_ok = _Session({
"http://supervisor/addons/self/info": {"result": "ok", "data": {
"slug": "local_goodwe_controller", "version": "0.12.0"}},
"http://supervisor/info": {"result": "ok", "data": {
"homeassistant": "2026.9.1", "supervisor": "2026.08.0"}}})
_down = _Session({"http://supervisor/addons/self/info": OSError("refused"),
"http://supervisor/info": ValueError("not json")})
check("at start the add-on asks the Supervisor for its slug and HA's version, "
"with its own token",
asyncio.run(docs_link_facts(_ok, "tok")) == {
"slug": "local_goodwe_controller", "ha_version": "2026.9.1"}
and all(a == "Bearer tok" for _u, a in _ok.asked))
check("...and a Supervisor that does not answer costs the link, never the "
"start",
asyncio.run(docs_link_facts(_down, "tok")) == {
"slug": None, "ha_version": None})
check("...and each question has a short timeout, so a Supervisor that hangs "
f"delays control start by seconds, not minutes ({_ok.timeouts})",
len(_ok.timeouts) == 2
and all(isinstance(t, (int, float)) and 0 < t <= 3 for t in _ok.timeouts))
# Shadow mode (UX-09): nothing the add-on decides reaches the inverter, so no
# text may say a command was sent or that the battery is giving power.
_c1 = [messages.Clamp(None, 7412, 5000, "rating")]
_c2 = [messages.Clamp("inverter-a", -3000, -2500, "device"),
messages.Clamp("inverter-b", 6000, 4600, "max_w+device")]
_live = [messages.output_clamp(c).body for c in (_c1, _c2, ())]
_shad = [messages.output_clamp(c, shadow=True).body for c in (_c1, _c2, ())]
check("live, the over-rating alarm says what was sent and that control runs",
" and sent 5 000 W" in _live[0] and " and sent 2 500 W" in _live[1]
and all("Control is still running" in b or "control is still running"
in b for b in _live))
check("in shadow mode it says what would have been sent, and that the "
"inverters stay at 0 W",
"would have sent 5 000 W" in _shad[0]
and "would have sent 2 500 W" in _shad[1]
and "would have been cut back" in _shad[2]
and all(" sent" not in b.replace("would have sent", "") for b in _shad)
and all("0 W" in b and "hadow mode" in b for b in _shad))
_row_live = messages.row_power_too_high(_c1, 3)
_row_shad = messages.row_power_too_high(_c1, 3, shadow=True)
check("the over-rating row says 'sent' live and 'would send' in shadow mode",
"sent 5 000 W" in _row_live and "would" not in _row_live
and "would send 5 000 W" in _row_shad and " sent" not in _row_shad
and "shadow mode" in _row_shad)
_sat = messages.peak_saturated(5000, shadow=True).body
check("in shadow mode the peak alarm does not claim the battery is giving "
"power, and keeps its action",
"giving all it can" not in _sat and "gives nothing" in _sat
and "Switch off large loads" in _sat
and "giving all it can" in messages.peak_saturated(5000).body)
check("shadow mode changes no notification's id or severity",
all(messages.output_clamp(c, shadow=True).id == messages.ID_OUTPUT_CLAMP
and messages.output_clamp(c, shadow=True).severity == messages.ALARM
for c in (_c1, _c2, ()))
and messages.peak_saturated(1, shadow=True).id == messages.ID_PEAK_SATURATED
and messages.peak_saturated(1, shadow=True).severity == messages.ALARM)
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"
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
# setting is expanded to the label and key `messages.setting()` prints, so
# `{S('max_w')}` counts as “Inverter rating” (max_w), three words, not one.
SETTING_CALLS = ("S", "_S", "setting", "messages.setting")
CONSTANTS = {} # module-level NAME = "text", per file, for keys
def _key(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return CONSTANTS.get(node.id)
return None
def expand_setting(node):
"""The shown text of a placeholder that names a setting, else None."""
if isinstance(node, ast.Call) and node.args:
name = ast.unparse(node.func)
if name in SETTING_CALLS:
key = _key(node.args[0])
if key not in messages.LABELS:
return None
unit = node.args[1] if len(node.args) > 1 else None
plain = unit is None or (isinstance(unit, ast.Constant)
and not unit.value)
return messages.setting(key, "" if plain else "inverter-b")
if name == "_labels" and isinstance(node.args[0], (ast.List, ast.Tuple)):
keys = [_key(e) for e in node.args[0].elts]
if all(k in messages.LABELS for k in keys):
return " and ".join(messages.setting(k) for k in keys)
if (isinstance(node, ast.Subscript)
and ast.unparse(node.value) == "messages.LABELS"):
key = _key(node.slice)
return messages.LABELS.get(key) if key else None
return None
def render(node, expand=False):
"""A string expression as text, placeholders as {expr}; None otherwise.
`expand` shows setting placeholders as the page shows them."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
out = []
for v in node.values:
if isinstance(v, ast.Constant):
out.append(v.value)
continue
shown = expand_setting(v.value) if expand else None
out.append(shown if shown is not None
else "{" + ast.unparse(v.value) + "}")
return "".join(out)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
a, b = render(node.left, expand), render(node.right, expand)
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, expand), render(node.orelse, expand)
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, expand=False):
"""Top-level string expressions inside `node` (not their parts)."""
t = render(node, expand)
if t is not None:
yield node, t
return
for child in ast.iter_child_nodes(node):
yield from strings_in(child, expand)
def _module_constants(tree):
"""Module-level `NAME = "text"` assignments."""
return {t.id: n.value.value for n in tree.body
if isinstance(n, ast.Assign) and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
for t in n.targets if isinstance(t, ast.Name)}
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))}
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"))
# The file's own constants, and those it imports from another app
# module (`from .validate import TAXES_PROFILE_OPTION`), so a key
# named by a constant from elsewhere is expanded too.
CONSTANTS.clear()
CONSTANTS.update(_module_constants(tree))
for n in tree.body:
if isinstance(n, ast.ImportFrom) and n.level == 1 and n.module:
theirs = _MODCONST.get(n.module, {})
CONSTANTS.update({a.asname or a.name: theirs[a.name]
for a in n.names if a.name in theirs})
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, expand=True):
if len(words(t)) > 1:
out.append(("refusal", f"{f.name}:{n.lineno}", t))
# UX-08 2d: the Dutch option texts are held to the same length.
for lang in ("en", "nl"):
tr = TL.parse_translation_file(HERE / "translations" / f"{lang}.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"{lang}.yaml:{path}{key}",
v["name"].split("·")[-1].strip()))
if isinstance(v.get("description"), str):
out.append(("option-desc", f"{lang}.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))})")
if ";" in text:
p.append("semicolon")
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 = 0
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)
STATUS = messages.status_samples()
def row_problems(colour, text):
"""STYLE.md section 1 for a status row (by colour) or a banner (None)."""
n_s, n_w = sentences(text), len(words(text))
out = []
if colour is None:
if n_w > 12:
out.append(f"banner over 12 words ({n_w})")
elif colour == messages.RED:
if n_s > 3:
out.append(f"red row over 3 sentences ({n_s})")
if n_w > 30:
out.append(f"red row over 30 words ({n_w})")
else:
if n_s > 1:
out.append(f"{colour} row over 1 sentence ({n_s})")
if n_w > 15:
out.append(f"{colour} row over 15 words ({n_w})")
if ";" in text:
out.append("semicolon: split it into sentences or drop a clause")
return out + text_problems(text, keys=False, entities=False)
for fn, colour, text in STATUS:
p = row_problems(colour, text)
check(f"{colour or 'banner':6} {text!r}{' - ' + '; '.join(p) if p else ''}",
not p)
_catalogue = {k for k, v in vars(messages).items()
if callable(v) and k.startswith(("row_", "banner_"))}
_missing = sorted(_catalogue - {fn for fn, _c, _t in STATUS})
check(f"the samples cover every row and banner in the catalogue "
f"(missing: {_missing})", not _missing)
# A red row with no banner of its own heads the page as "Not ready - <its first
# sentence>". The three trips have their own branch in status_payload(), and
# main.py gives the other two a "banner" key (both are sampled above).
_own_banner = {"row_direction_trip", "row_site_direction_trip",
"row_circulating_trip", "row_peak_reserve", "row_power_too_high"}
_long = [t for fn, c, t in STATUS if c == messages.RED and fn not in _own_banner
and len(words(messages.banner_not_ready({"text": t}))) > 12]
# The hint under the buttons (UX-08 2d): the last event in plain words, one
# per key of messages.HINTS, held to the rule for a green row.
for key, text in messages.hint_samples():
p = row_problems(messages.GREEN, text)
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
_calls, _bare = 0, []
for f in sorted(APP.glob("*.py")):
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
if (isinstance(node, ast.Call)
and ast.unparse(node.func).endswith("log_event")):
_calls += 1
h = next((k.value for k in node.keywords if k.arg == "hint"), None)
if not (isinstance(h, ast.Call)
and ast.unparse(h.func) in ("messages.hint", "hint")):
_bare.append(f"{f.name}:{node.lineno}")
check(f"every event the add-on records gives the page its plain words, from "
f"messages.hint() ({_calls} calls, without: {_bare})",
_calls >= 40 and not _bare)
_raw = ("OUTPUT_CLAMP", "BUG SIGNAL", "FAILSAFE", "SIGN_DISAGREEMENT",
"ALLOCATOR", "UNMET", "ARBITER", "PEAK GUARD")
check("no hint carries the raw event codes the page used to show",
not [t for _k, t in messages.hint_samples()
if any(r in t for r in _raw)])
check(f"every other red row makes a 'Not ready' banner of at most 12 words "
f"(too long: {_long})", not _long)
# A latch row must say how it clears: that is the one action it exists for.
_clears = {
"row_direction_trip": "A restart does not clear this",
"row_site_direction_trip": "Only starting control clears this",
"row_circulating_trip": "Only starting control clears this",
"row_not_delivering": "only stopping and starting control clears this",
}
_unclear = [fn for fn, _c, t in STATUS if fn in _clears and _clears[fn] not in t]
check(f"every latch row says how it clears (missing: {_unclear})",
not _unclear and {fn for fn, _c, _t in STATUS} >= set(_clears))
allow = read_allowlist(ALLOW_FILE)
by_kind = {}
for kind, *_ in found:
by_kind[kind] = by_kind.get(kind, 0) + 1
for _fn, colour, _t in STATUS:
kind = "row" if colour else "banner"
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)
# A refusal names a setting by its Configuration-tab label (UX-08 2c). The
# catalogue's labels must be the en.yaml names without group prefix and unit.
_TR = TL.parse_translation_file(HERE / "translations" / "en.yaml")["configuration"]
def _label_stems(key):
names = [c[key]["name"] for c in (_TR, _TR["inverters"]["fields"])
if isinstance(c.get(key), dict)]
out = set()
for n in names:
n = n.split("·")[-1].strip()
while re.search(r"\s*\([^()]*\)$", n):
n = re.sub(r"\s*\([^()]*\)$", "", n)
out.add(n)
return out
_drift = sorted(f"{k}: {v!r}" for k, v in messages.LABELS.items()
if v not in _label_stems(k))
check(f"every label a refusal quotes is the setting's name in en.yaml "
f"(drifted: {_drift})", not _drift)
check("...and a refusal quotes the label and then the key, so the config "
"panel still files it under that setting",
messages.setting("max_w", "inverter-b")
== "“Inverter rating” for inverter-b (inverters.max_w)")
# UX-08 2d: the refusals as the validator SHOWS them. The scan above expands
# setting labels in a template, but a reason that carries a parser's error
# (`{e}`) is only as long as that error, so the long ones found by the 2c test
# are driven for real here and held to the same 30 words.
from app import custom_formula as _cf # noqa: E402
from app.validate import validate_startup_config # noqa: E402
_BASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000}
_CARD = _cf._LEGS["price_formula"].example.replace("15.55", "15.05")
_TABLE = ('{"periods":[{"name":"day","from":"07:00","to":"22:00","days":'
'"mon-fri","eur_per_kwh":0.1},{"name":"night","from":"22:00","to":'
'"07:00","days":"mon-fri","eur_per_kwh":0.08},{"name":"night",'
'"from":"00:00","to":"24:00","days":"sat,sun","eur_per_kwh":0.08}]}')
_ROSTER = {k: v for k, v in _BASE.items()
if k not in ("setpoint_entity", "soc_entity", "batt_entity",
"max_w")}
_ROSTER["inverters"] = [
dict(name=n, setpoint_entity=f"number.sp_{n[-1]}", max_w=w,
soc_entity=f"sensor.soc_{n[-1]}", batt_entity=f"sensor.batt_{n[-1]}",
bms_soc_floor=10)
for n, w in (("inverter-a", 2000), ("inverter-b", 200))]
_DRIVEN = {
"formula card": dict(_BASE, price_provider="custom_formula",
price_formula=_CARD),
"formula not valid": dict(_BASE, price_provider="custom_formula",
price_formula='{"index": "epex_be"} x'),
"injection card": dict(_BASE, price_export_formula=_CARD),
"roster thresholds": dict(_ROSTER, maintenance_charge_w=100,
maintenance_discharge_w=100),
"levies table and flat": dict(_BASE, price_taxes_profile=_TABLE,
price_taxes_eur_kwh=0.1198),
"feed and entities": dict(_BASE, price_provider="generic_dayahead",
price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
"fixed tariff and entities": dict(_BASE, static_profile=_TABLE,
price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
"fixed tariff not valid": dict(_BASE, static_profile='{"periods": []} x'),
}
_LEAKS = ("c_eur_per_kwh", "vat_pct", "its own card", "line 1 column",
"None", "JSON", "one you do not use")
_shown, _long, _leaky = [], [], []
for _name, _o in _DRIVEN.items():
_ps = validate_startup_config(_o, -5000, 5000)
_shown.append((_name, len(_ps)))
_long += [f"{_name}: {len(words(t))} words: {t}" for t in _ps
if len(words(t)) > 30]
_leaky += [f"{_name}: {t}" for t in _ps if any(x in t for x in _LEAKS)]
check(f"every driven case is refused ({_shown})",
all(n >= 1 for _n, n in _shown)
and dict(_shown)["roster thresholds"] >= 2)
check(f"...and every refusal, as SHOWN, is at most 30 words (too long: "
f"{_long})", not _long)
check(f"...and shows no parser text, file units or Python's None (found: "
f"{_leaky})", not _leaky)
# The config panel files a reason beside every setting whose key it names, but
# `inverters.max_w` names a setting of one inverter in the list, not the flat
# `max_w` (UX-08 2d, from the 2c test).
from app.configui import problems_by_field # noqa: E402
_by, _un = problems_by_field(
["“Inverter rating” for inverter-b (inverters.max_w) is 200 W.",
"“Inverter rating” (max_w) is 0."], ["max_w", "inverters"])
check("a roster refusal shows beside `inverters` only, and a flat one beside "
f"`max_w` ({ {k: len(v) for k, v in _by.items()} })",
_by.get("inverters") == [
"“Inverter rating” for inverter-b (inverters.max_w) is 200 W."]
and _by.get("max_w") == ["“Inverter rating” (max_w) is 0."]
and not _un)
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)
check("DOCS.md explains the status page rows under "
f"'## {messages.DOCS_STATUS}'", f"\n## {messages.DOCS_STATUS}\n" in DOCS)
# ============================================================================== #
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("a semicolon in a refusal or an option text is refused",
bool(problems_of("refusal", "It is wrong; fix it."))
and bool(problems_of("option-desc", "It is wrong; fix it.")))
check("a label that drifted from en.yaml is caught",
"Inverter power" not in _label_stems("max_w"))
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 second sentence in an amber row is refused",
bool(row_problems(messages.AMBER, "Meter stuck. Check it.")))
check("a fourth sentence in a red row is refused",
bool(row_problems(messages.RED, "One. Two. Three. Four.")))
check("a 16-word green row is refused",
bool(row_problems(messages.GREEN, " ".join(["word"] * 16))))
check("a 13-word banner is refused",
bool(row_problems(None, " ".join(["word"] * 13))))
check("an entity id in a status row is refused",
bool(row_problems(messages.GREEN, "Battery level: sensor.soc = 54")))
check("a semicolon in a status row is refused",
bool(row_problems(messages.RED, "It stopped; check it.")))
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")
+1 -1
View File
@@ -1432,7 +1432,7 @@ def p1_rows(ctl):
a traceback instead of reddening the check that names the rule.
"""
try:
return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")]
return [c["text"] for c in ctl.checks() if c.get("id") == "meter"]
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" checks() raised {type(err).__name__}: {err}")
return err
+98 -71
View File
@@ -114,6 +114,7 @@ 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
@@ -1037,7 +1038,7 @@ check(f"a Controller built with peak_cap_w ABSENT takes the shipped "
c.peak.cap_w == CAP_W)
check("...and before the first cycle the guard has NO decision, which the "
"status page must not spell as 'idle'", c.peak_guard is None)
pre = [r for r in c.checks() if "peak guard" in r["text"]]
pre = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row before the first cycle: "
+ (pre[0]["text"] if pre else "(none)"))
check("the status page says so in its own row",
@@ -1053,17 +1054,20 @@ c_off, hass_off = built(TMP / "legacy.db",
{"meter_source": "off", "meter_entity": "sensor.grid"})
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"]]
off_rows = [r for r in c_off.checks() if r.get("id") == "peak_guard"]
print(f" meter_source off: guard={c_off.peak_guard}, wrote "
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
and hass_off.writes and hass_off.writes[-1] < 0)
check("...and the status page says NOT COVERED rather than 'idle' or a green "
"row, which is the same answer SAFETY-18 already gives on a transport "
"that cannot see it", len(off_rows) == 1
and "NOT COVERED" in off_rows[0]["text"] and off_rows[0]["warn"])
check("...and the status page says the guard is off, and why, rather than "
"'idle' or a green row - the same answer SAFETY-18 already gives on a "
"transport that cannot see it", len(off_rows) == 1
and ": off" in off_rows[0]["text"]
and "Where the meter reading comes from" in off_rows[0]["text"]
and "idle" not in off_rows[0]["text"]
and not off_rows[0]["ok"] and off_rows[0]["warn"])
# ⚠️ THE FIXTURE MUST LAND IN THE CURRENT QUARTER, AND THAT IS NOT PEDANTRY.
# `cycle()` reads the real clock, and `project()` measures the open block against
@@ -1213,10 +1217,23 @@ check("the real cycle files the peak claim and the real arbiter binds the real "
and not c.peak_guard.saturated and "peak" in (c.reason or ""))
check("...and the number written to the inverter is the guard's floor",
hass.writes and near(hass.writes[-1], c.peak_guard.floor_w, 1.5))
rows = [r for r in c.checks() if "peak guard" in r["text"].lower()]
rows = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows[0]["text"] if rows else "(none)"))
check("the status page carries exactly one peak-guard row, flagged",
len(rows) == 1 and rows[0]["warn"])
# UX-08 2d: the engaged row names what the battery is asked for FIRST and the
# limit SECOND. Both are watts, so only the ordered pair tells them apart.
from app import messages as _msg_on # noqa: E402
_on = (f"asks the battery for {_msg_on.watts(c.peak_guard.floor_w)} to stay "
f"under {_msg_on.watts(c.peak_guard.ceiling_w)}")
_on_swapped = (f"asks the battery for {_msg_on.watts(c.peak_guard.ceiling_w)} "
f"to stay under {_msg_on.watts(c.peak_guard.floor_w)}")
check("...and it names the battery's share first and the limit second, and "
"the two render differently so the swap is visible",
len(rows) == 1 and _on in rows[0]["text"]
and _on_swapped not in rows[0]["text"]
and _msg_on.watts(c.peak_guard.floor_w)
!= _msg_on.watts(c.peak_guard.ceiling_w))
check("an engagement that is not saturated raises NO notification - §10.3's "
"'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house "
"load bigger than the inverter is not a bug",
@@ -1921,12 +1938,12 @@ asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c24.cycle())
check("...once per episode, not once per cycle - a quarter of an hour of "
"identical cards is how an operator learns to dismiss the one that "
"mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1)
rows24 = [r for r in c24.checks() if "peak guard" in r["text"].lower()]
rows24 = [r for r in c24.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows24[0]["text"] if rows24 else "(none)"))
check("the status page carries exactly one peak-guard row and it is NOT ok - "
"the money is going to be spent and a human is the only actuator left",
len(rows24) == 1 and not rows24[0]["ok"]
and "FLOOR-LIMITED" in rows24[0]["text"])
len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"]
and "peak limit will be passed" in rows24[0]["text"])
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
for e in c24.events))
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
@@ -2015,12 +2032,13 @@ check("...and so is a floor EXACTLY AT it, which is the interesting case: equal
"it leaves none", len(equal) == 1)
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",
# UX-08 2c: the shipped default left the sentence (it is on the option's
# description). The two numbers the operator acts on stay.
check("...and the refusal NAMES BOTH NUMBERS, so the operator can act on it "
"without reading the source",
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])
and f"{BMS_FLOOR - 5:.0f} %" in below[0])
check("...and a non-numeric floor is refused rather than defaulted, for the "
"reason every read in this file is: a value that cannot be parsed is not "
"an observation of anything",
@@ -2478,17 +2496,23 @@ check("...and DOCS.md sends the reader to it rather than leaving the open "
# comment is a scope sentence the operator never sees.
c25, hass25, NET25, _ = wired_over_limit(
TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0)
rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()]
rows25 = [r for r in c25.checks() if r.get("id") == "peak_guard"]
def _row_scope(text):
"""SAFETY-25 in messages.row_peak_reserve()'s words."""
return ("Home use still draws the battery below it" in text
and "down to the cut-out level" in text)
print(" status row: " + (rows25[0]["text"] if rows25 else "(none)"))
check("the STATUS PAGE row for a floor-limited guard says the floor bounds the "
"guard only and that self-consumption may keep discharging below it. "
"Without that sentence the page implies a pack-wide reserve at the exact "
"moment the replay above says the loop is still emptying the pack",
len(rows25) == 1
and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"]
and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper()
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
and "at its peak reserve" in rows25[0]["text"]
and _row_scope(rows25[0]["text"]))
# ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload()
# renders the first not-ok/not-warn row as the page headline, so before this the
# loudest element on the page was the same 550-character paragraph the row three
@@ -2500,35 +2524,36 @@ 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 = 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",
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()
and "not a pack-wide reserve" not in _pay25["banner"]
and len(_pay25["banner"]) - len(_expl25) < 90)
check("...and shortening the banner did NOT shorten the row: the full AC 3 "
# UX-08: the banner is the notification's own title, and the row below keeps
# the SAFETY-25 sentence.
check("the Not ready banner is a short headline - the notification's title - "
"and NOT the row repeated at the top of the page. A headline that is a "
"paragraph is a headline nobody reads",
_pay25["level"] == "bad"
and _pay25["banner"] == (f"{messages.NOT_READY} — "
f"{messages.T_PEAK_RESERVE}")
and not _row_scope(_pay25["banner"]))
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
"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) == 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"])
"operator reads to find out what the reserve does not bound",
len(rows25) == 1 and _row_scope(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 draw the battery below it" in body
and "down to the battery 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 --------------------------------------
@@ -3883,13 +3908,12 @@ check("⚠️ AC 3. Rule 8 grades EVERY unit: a roster whose SECOND pack has a "
"inverter-b. Under the flat key this config STARTED, and the guard would "
"then have commanded a forced discharge that pack B's BMS - not the "
"operator's floor - is the thing that stops",
len(_r_second) == 1 and "(inverter-b)" in _msg(_r_second)
and "(inverter-a)" not in _msg(_r_second))
check("...and it names BOTH numbers and the shipped default, so the operator "
"can act on it without reading the source",
len(_r_second) == 1 and " for inverter-b" in _msg(_r_second)
and " for inverter-a" not in _msg(_r_second))
check("...and it names BOTH numbers, so the operator can act on it without "
"reading the source",
f"{B_PACK_FLOOR_DEEP_SIM:.0f} %" in _msg(_r_second)
and f"{SOC_FLOOR:.0f} %" in _msg(_r_second)
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in _msg(_r_second))
and f"{SOC_FLOOR:.0f} %" in _msg(_r_second))
# The mirror. A grader hardwired to index 1 - or one that reports the LAST
# offender - passes the check above and fails this one.
_r_first = rule8(validate_startup_config(
@@ -3897,8 +3921,8 @@ _r_first = rule8(validate_startup_config(
check("...and with the SAME two numbers in the OTHER ORDER it names "
"inverter-a instead. The pair of checks is what makes this an index "
"check: one of them goes red for any hardwired position",
len(_r_first) == 1 and "(inverter-a)" in _msg(_r_first)
and "(inverter-b)" not in _msg(_r_first))
len(_r_first) == 1 and " for inverter-a" in _msg(_r_first)
and " for inverter-b" not in _msg(_r_first))
# And both at once, because a loop that `break`s on the first offender reports
# one refusal for a config with two.
_r_both = rule8(validate_startup_config(
@@ -3906,8 +3930,8 @@ _r_both = rule8(validate_startup_config(
check("...and a roster where BOTH packs offend is refused TWICE, once per unit "
"and each naming its own - so an operator with two wrong packs is not "
"told about one of them and left to discover the other on the next boot",
len(_r_both) == 2 and "(inverter-a)" in _msg(_r_both, 0)
and "(inverter-b)" in _msg(_r_both, 1))
len(_r_both) == 2 and " for inverter-a" in _msg(_r_both, 0)
and " for inverter-b" in _msg(_r_both, 1))
# A missing per-entry cut-out is refused BY NAME rather than defaulted.
_r_missing = rule8(validate_startup_config(_roster32(A_PACK_FLOOR, None)))
print(f" entry with no bms_soc_floor -> {_msg(_r_missing)[:120] or '(none)'}...")
@@ -3916,7 +3940,7 @@ check("...and an entry that states NO `bms_soc_floor` is refused by name rather
"cut-out this add-on invented for a battery it has not been told about, "
"which is the same wrong number the flat key produced. See "
"DECISIONS/SAFETY-32-per-pack-bms-floor.md",
len(_r_missing) == 1 and "(inverter-b)" in _msg(_r_missing)
len(_r_missing) == 1 and " for inverter-b" in _msg(_r_missing)
and "must both be numbers" in _msg(_r_missing))
# --- 12f. nothing is inherited, with the flat key SET AND DIFFERENT ----------
@@ -4332,8 +4356,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(
@@ -4341,8 +4365,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
@@ -4366,13 +4390,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 --
@@ -5589,7 +5614,7 @@ MUT.append(("an unstated per-entry cut-out defaulted to 0 %",
# 19. rule 8 grading only the first unit, i.e. the flat rule left in place.
MUT.append(("rule 8 grading only the FIRST unit",
_r_second != [] and "(inverter-b)" in _msg(_r_second),
_r_second != [] and " for inverter-b" in _msg(_r_second),
"part 12e: 'a roster whose SECOND pack has a cut-out above "
"peak_soc_floor is refused'. ⚠️ The offender is the SECOND entry "
"for exactly this row"))
@@ -5597,15 +5622,14 @@ MUT.append(("rule 8 grading only the FIRST unit",
# 20. rule 8 naming a hardwired unit - CFG-04's rule 5/6 bug, which is why
# `unit_bms_floors_indexed()` carries the roster index.
MUT.append(("rule 8's refusal naming a hardwired inverter",
"(inverter-b)" in _msg(_r_second) and "(inverter-a)" in _msg(_r_first),
" for inverter-b" in _msg(_r_second) and " for inverter-a" in _msg(_r_first),
"part 12e: the two orders. Either hardwired index passes one of "
"them and fails the other"))
# 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 -------------------------
@@ -5921,10 +5945,12 @@ _S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_OK, soc_floor=SOC_FLOOR)
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a "
f"{_S38_CEIL_W:.0f} W ceiling")
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a "
f"{_S38_PROJ.projected_w:.0f} W ceiling")
# UX-08 2d: the row is in plain words now, and the pair is still ordered.
from app import messages as _msg38 # noqa: E402
_S38_LITERAL = (f"heading for {_msg38.watts(_S38_PROJ.projected_w)}, under "
f"this month's {_msg38.watts(_S38_CEIL_W)} peak")
_S38_SWAPPED = (f"heading for {_msg38.watts(_S38_CEIL_W)}, under "
f"this month's {_msg38.watts(_S38_PROJ.projected_w)} peak")
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
print(f" {_S38_IDLE.explain()!r}")
@@ -5947,7 +5973,8 @@ check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
_S38_SWAPPED not in _S38_IDLE.explain())
check("...and the row still names WHERE the ceiling came from, which is the "
"difference between a cap an operator set and a peak the month already "
"banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain())
"banked", "under this month's" in _S38_IDLE.explain()
and "peak limit" not in _S38_IDLE.explain())
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
@@ -5955,7 +5982,7 @@ check("...and the row still names WHERE the ceiling came from, which is the "
# main.py:1951 is what the release edge writes.
_c38, _h38 = built(TMP / "s38pin.db")
_c38.peak_guard = _S38_IDLE
_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
_rows38 = [r for r in _c38.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
"off the shipped `checks()` rather than off `explain()` a second time. "
+69 -75
View File
@@ -2123,7 +2123,7 @@ check("...and so does a configured provider on its own",
-5000, 5000) == [])
check("an unknown provider id is refused rather than defaulted to 'off'",
why(validate_startup_config(vbase(price_provider="nordpool"), -5000, 5000),
"not a shipped plugin"))
"which does not exist"))
# ⚠️ TARIFF-07: A RETIRED ID IS REFUSED BY A DIFFERENT MESSAGE, AND EVERY CHECK
# HERE GRADES THE REASON RATHER THAN THE VERDICT. The failure this section
@@ -2144,26 +2144,21 @@ check(f"every retired id is refused at startup {sorted(_retired)}",
len(_retired) == 3 and all(len(v) == 1 for v in _retired.values()))
check("...and NOT by the unknown-id message, which would tell an operator they "
"mistyped an id this add-on offered them and accepted for months",
all("not a shipped plugin" not in v[0] for v in _retired.values())
and "WITHDRAWN" not in _unknown[0])
all("which does not exist" not in v[0] for v in _retired.values())
and "was withdrawn" not in _unknown[0])
check("...and each message NAMES ITS OWN ID, so the operator can tell it is "
"about their configuration and not a generic notice",
all(repr(pid) in v[0] for pid, v in _retired.items()))
check("...and (a) says the plugin never fetched a price, naming the endpoint "
"that does not exist",
all("NEVER FETCHED A PRICE" in v[0] for v in _retired.values())
and "api.engie.be/dynamic/be" in _retired["engie_dynamic"][0]
and "api.totalenergies.be/prices/dynamic"
in _retired["totalenergies_dynamic"][0]
and "api.eneco.be/eddy/prices" in _retired["eneco_eddy"][0])
# UX-08 2c: the endpoints that never existed are in DOCS.md under "Energy price
# providers"; the refusal keeps what the operator acts on.
check("...and (a) says the plugin never fetched a price",
all("never fetched a price" in v[0] for v in _retired.values()))
check("...and (b) says what the site has ACTUALLY been running instead - the "
"half §11.4 made invisible",
all("SILENT" in v[0] and "static_profile" in v[0]
for v in _retired.values()))
check("...and (c) says what to do, naming a live plugin and refusing to "
"pretend an alias could exist",
all("This site ran on" in v[0] for v in _retired.values()))
check("...and (c) says what to do, naming a live plugin",
all("generic_dayahead" in v[0] and "manual_csv" in v[0]
and "NO alias" in v[0] for v in _retired.values()))
for v in _retired.values()))
# ⚠️ AND THE SENTENCE CHANGES WITH THE SITE. "You have been on the static
# profile" is FALSE where no profile was ever set, and that site is in a WORSE
@@ -2177,11 +2172,12 @@ _with_profile = validate_startup_config(
_without = _retired["eneco_eddy"][0]
check("a retired id on a site WITH a static profile is told it has been "
"running that profile",
"the static day/night tariff in static_profile" in _with_profile)
"ran on the “Fixed or day/night tariff” (static_profile)"
in _with_profile)
check("...and a site WITHOUT one is told it has been running nothing at all, "
"which is a different and worse state",
"NOTHING AT ALL" in _without
and "the static day/night tariff in static_profile" not in _without)
"ran on no tariff at all" in _without
and "(static_profile)" not in _without)
check("...so the two renderings are not the same sentence",
_with_profile != _without)
@@ -2193,7 +2189,7 @@ _p = validate_startup_config(
check("a provider AND the Home Assistant price entities is refused - two price "
"sources on one site", len(_p) == 1)
check("...and the message says which one actually wins",
why(_p, "HOME ASSISTANT ENTITIES WIN"))
why(_p, "would win over it"))
# TARIFF-03 added a second source with the same silent-loser shape.
_ps = validate_startup_config(
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
@@ -2202,7 +2198,7 @@ _ps = validate_startup_config(
-5000, 5000)
check("a static_profile AND the Home Assistant price entities is refused too - "
"main.py reads one of them for `cheap` and the loser is silent",
len(_ps) == 1 and why(_ps, "HOME ASSISTANT"))
len(_ps) == 1 and why(_ps, "(price_now_entity) and “Average price entity” (price_avg_entity) would win"))
check("the Home Assistant entities alone still start, so nothing is taken away "
"from a live install",
validate_startup_config(vbase(price_now_entity="sensor.now",
@@ -2945,10 +2941,10 @@ check("...and BOTH translations tell an operator which switch does which - "
"the provider option says it is what starts the fetch, and the trading "
"option says turning it off does not turn the feed off. One language "
"updated and not the other is how this surface rots",
"STARTS THE PRICE FETCH" in _T10_TR["en"]
and "DOES NOT TURN THE PRICE FEED OFF" in _T10_TR["en"]
and "HET OPHALEN VAN PRIJZEN START" in _T10_TR["nl"]
and "ZET DE PRIJSFEED NIET UIT" in _T10_TR["nl"])
"starts the price fetch" in _T10_TR["en"]
and "does not turn the price feed off" in _T10_TR["en"]
and "start het ophalen van prijzen" in _T10_TR["nl"]
and "zet de prijsfeed niet uit" in _T10_TR["nl"])
# --------------------------------------------------------------------------- #
@@ -3431,8 +3427,8 @@ check(f"network charges folded into `offset` move the card's own price to "
isinstance(_double_price, float) and abs(_double_price - 15.55) > 10)
check(f"...and it is refused, by the CARD rule, naming the discrepancy and "
f"the network-charge cause ({_double[:60]!r})",
"does not reproduce its own card" in _double and "OUT BY" in _double
and "folded into `offset`" in _double)
"the card says" in _double
and "network charges are not in the offset" in _double)
_CORRUPTIONS = {
"VAT left out (vat_pct 0)": spoilt(vat_pct=0),
@@ -3451,7 +3447,7 @@ for _name, _blob in sorted(_CORRUPTIONS.items()):
_why = refusal(_blob)
check(f"{_name}: refused, and by the CARD rule rather than by another one "
f"({_why[:48]!r})",
"does not reproduce its own card" in _why and "OUT BY" in _why)
"the card says" in _why)
# ⚠️ THE TOLERANCE IS A REAL EDGE AND IT IS GRADED FROM BOTH SIDES, or
# "within 0.01" and "within anything" would be the same check.
@@ -3463,7 +3459,7 @@ _outside = refusal(spoilt(card_expect_incl_vat=round(
+ CARD_TOLERANCE * 2.0, 6)))
check(f"half a tolerance out passes and twice a tolerance out does not, so "
f"{CARD_TOLERANCE} is the number being enforced rather than a shape",
_inside == "" and "does not reproduce its own card" in _outside)
_inside == "" and "the card says" in _outside)
# A card that is absent is refused, and NOT by the generic unknown-field rule.
check(f"a formula with NO card block is refused, and the message says why the "
@@ -3569,32 +3565,31 @@ check("...and the hourly series' coverage is still DERIVED from the "
print()
print("15e. every refusal in the table, and the two that are WARNINGS")
_TABLE = {
"units absent": (spoilt(units=""), "units", "is REQUIRED"),
"units absent": (spoilt(units=""), "units", "Choose one of"),
"units not one of the enumerated ones":
(spoilt(units="cents"), "units", "must be one of"),
(spoilt(units="cents"), "units", "Choose one of"),
"factor == 0 (every spread becomes 0, arbitrage never fires)":
(spoilt(factor=0), "factor", "static_profile"),
"factor < 0 (the dearest hour becomes the cheapest)":
(spoilt(factor=-0.102), "factor", "inverts the market"),
(spoilt(factor=-0.102), "factor", "turns the market upside down"),
"vat_pct above 30 (the 6 / 1.06 / 106 confusion)":
(spoilt(vat_pct=106), "vat_pct", "between 0 and 30"),
"vat_pct below 0": (spoilt(vat_pct=-6), "vat_pct", "between 0 and 30"),
"a non-numeric coefficient": (spoilt(factor="0.102"), "factor",
"not a number"),
"a coefficient that is an EXPRESSION rather than a number":
(spoilt(factor="0.1*x"), "factor", "evaluates nothing"),
(spoilt(factor="0.1*x"), "factor", "Write a plain number"),
"a non-finite coefficient": (spoilt(offset=float("inf")), "offset",
"not finite"),
"an unknown index": (spoilt(index="belpex_h"), "index", "cannot get it"),
"an unknown index": (spoilt(index="belpex_h"), "index", "can only fetch"),
"an unknown resolution": (spoilt(resolution="daily"), "resolution",
"must be one of"),
"Choose one of"),
"an unknown top-level field (which is how 'expr' is answered)":
(spoilt(expr="0.102*spot+1"), "expr", "NO field that takes an "
"expression"),
(spoilt(expr="0.102*spot+1"), "expr", "It takes only"),
"an unknown field inside the card": ({**ENECO_CARD,
"card": dict(ENECO_CARD["card"],
vat="6")},
"vat", "no field(s)"),
"vat", "unknown field(s)"),
}
for _name, (_blob, _names, _needle) in sorted(_TABLE.items()):
_why = refusal(_blob)
@@ -3649,7 +3644,7 @@ check("...and it is warned about, naming the month and saying why it is not "
"refused",
"2019-01" in (stale_card(parse_price_formula(json.dumps(_old_card)))
or "")
and "Not refused" in (stale_card(
and "still starts" in (stale_card(
parse_price_formula(json.dumps(_old_card))) or ""))
check("...while a card from this month draws no warning, so the rule is the "
"AGE and not the presence of the field",
@@ -3850,18 +3845,18 @@ check("custom_formula with NO formula is refused, naming the option and "
"showing the shape",
why(validate_startup_config(vbase(price_provider="custom_formula"),
-5000, 5000),
"price_formula is empty"))
"(price_formula) is empty"))
check("a formula that does not reproduce its card is refused AT STARTUP, "
"with the discrepancy named",
why(validate_startup_config(
vbase(price_provider="custom_formula",
price_formula=json.dumps(spoilt(offset=13.0))),
-5000, 5000), "does not reproduce its own card"))
-5000, 5000), "the card says"))
check("...and a formula that is not JSON at all is refused with the shape it "
"wanted, rather than with a traceback",
why(validate_startup_config(
vbase(price_provider="custom_formula", price_formula="0.102*spot+1"),
-5000, 5000), "not valid JSON"))
-5000, 5000), "price_formula is not valid near character"))
check(f"...and a card outside the plausible band is refused too, WITH THE "
f"SHIPPED taxes in place - the flat "
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} must not be able to "
@@ -4105,13 +4100,14 @@ def _t11_render(over=None, *, plan=None, plan_age_s=0.0, soc=55.0, cycles=1,
# row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are
# asserted.
_T11_SLOTS = {
"points": r"(\d+) points over \d+ h",
"hours": r"\d+ points over (\d+) h",
"firm": r"(\d+) firm / \d+ carried",
"carried": r"\d+ firm / (\d+) carried",
"horizon": r"for a (\d+) h horizon",
"age_s": r"fetched (\d+) s ago",
"spacings": r"([\d+]+) s spacing",
# UX-08 2d: the row is in plain words; every figure keeps its own slot.
"points": r"(\d+) prices over \d+ hours",
"hours": r"\d+ prices over (\d+) hours",
"firm": r"(\d+) published and \d+ estimated",
"carried": r"\d+ published and (\d+) estimated",
"horizon": r"for (\d+) hours ahead",
"age_s": r"Price plan from (\d+) s ago",
"spacings": r"([\d+]+) s apart",
# ⚠️ THE *Arbitrage* ROW'S OWN AGE SLOT, and it is deliberately the same
# pattern for "decided N s ago" and "the last one was taken N s ago" -
# WHICH of the two sentences was rendered is graded by inequality below,
@@ -4149,13 +4145,13 @@ def _slot(row, name):
return derived(_get)
_T11_GOOD = ("x (recomputed this tick); provider plan fetched 7 s ago for a "
"48 h horizon: dynamic_dayahead from generic_dayahead, "
"132 points over 33 h, 36 firm / 96 carried, 900+3600 s spacing")
_T11_SHIPPED_DEFECT = ("x (recomputed this tick); provider plan fetched 7 s "
"ago for a 48 h horizon: dynamic_dayahead from "
"generic_dayahead, 132 points over 132 h, "
"96 firm / 36 carried, 900+3600 s spacing")
_T11_GOOD = ("x. Price plan from 7 s ago, for 48 hours ahead: day-ahead "
"prices from the price feed, 132 prices over 33 hours, "
"36 published and 96 estimated, 900+3600 s apart")
_T11_SHIPPED_DEFECT = ("x. Price plan from 7 s ago, for 48 hours ahead: "
"day-ahead prices from the price feed, 132 prices over "
"132 hours, 96 published and 36 estimated, 900+3600 s "
"apart")
# The slots divide into the ones a *Tariff*/​*Arbitrage* ROW carries and the
# ones a REFUSAL sentence carries; each group is calibrated against its own
# well-formed example, because a slot asked of the wrong kind of sentence is
@@ -4404,11 +4400,11 @@ check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
f"in the options blob",
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
f"({_T11_ON_HELD[1].reason!r}), so the operator is told which input is "
f"({_T11_ON_HELD[1].why_words!r}), so the operator is told which input is "
f"missing rather than being sent to look at a switch that is already on. "
f"The reason comes from main.py, not from this file",
_T11_ON_HELD[1].reason
and _T11_ON_HELD[1].reason in _T11_ON_HELD[0]["Arbitrage"])
_T11_ON_HELD[1].why_words
and _T11_ON_HELD[1].why_words in _T11_ON_HELD[0]["Arbitrage"])
check(f"...and a cycle that DID reach the step renders the strategy's own "
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
@@ -4454,10 +4450,10 @@ check(f"DRIVEN, no clock touched: a decision stands "
and _T11_DROP_STALE is True
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
check(f"...and it names the reason the cycle stopped "
f"({_T11_ON_DROPPED[1].reason!r}) beside the age of the decision it is "
f"({_T11_ON_DROPPED[1].why_words!r}) beside the age of the decision it is "
f"still showing, so 'the strategy decided this' and 'the strategy last "
f"decided this' are not one sentence",
_T11_ON_DROPPED[1].reason in _T11_ON_DROPPED[0]["Arbitrage"]
_T11_ON_DROPPED[1].why_words in _T11_ON_DROPPED[0]["Arbitrage"]
and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int))
check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than "
@@ -4590,8 +4586,8 @@ check(f"...and the row an operator reads names the SPACING that makes those "
check("...and this is the same wording FCST-04's *Price history* row already "
"used, so the two rows describing the same feed can be compared rather "
"than being two conventions on one page",
"points over" in _T11_E2E_ROW
and "points over" in ("" if isinstance(_T11_E2E, _Raised)
"prices over" in _T11_E2E_ROW
and "prices over" in ("" if isinstance(_T11_E2E, _Raised)
else _T11_E2E["Price history"]))
@@ -5362,18 +5358,17 @@ check("...and none of those rows is vacuous: every one really did refuse on "
_T16_DIFFER = [
("factor 0 -> where a FLAT price of THIS leg lives",
spoilt(factor=0.0), "static_profile", "export_price_eur_kwh"),
("a negative factor -> which market it inverts",
spoilt(factor=-0.102), "No consumption tariff",
"No Belgian injection tariff"),
("a card miss -> what a wrong `offset` costs on THIS leg",
spoilt(factor=0.120), "bills you for it twice",
"nothing is added to this leg"),
# UX-08 2c: a negative factor gets one sentence on both legs now ("put the
# minus sign on the offset"), so it left this table.
("a card miss -> what else to check on THIS leg",
spoilt(factor=0.120), "network charges are not in the offset",
"the VAT is 0 for injection"),
("invalid JSON -> WHEN this option is read at all, which is not the "
"same answer on the two legs: `price_formula` decides nothing unless "
"price_provider is 'custom_formula', and the injection leg is read "
"whatever the consumption provider is",
"{not json", "unless price_provider is 'custom_formula'",
"read whatever price_provider is set to"),
"{not json", "unless “Dynamic price source” is 'custom_formula'",
"only if your injection price follows the market"),
]
for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER:
_a, _b = ((_reason(lambda b=_blob: parse_price_formula(b)),
@@ -5490,10 +5485,10 @@ check(f"a factor 100x out is refused BY THE PASS-THROUGH RUNG, naming the "
f"share of the wholesale price it would pay ({_T16_PT_WHY[:60]!r})",
"passes through" in _T16_PT_WHY
and f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x" in _T16_PT_WHY
and "|offset| above" not in _T16_PT_WHY)
and "larger than" not in _T16_PT_WHY)
check(f"an offset 100x out is refused BY THE OFFSET RUNG, naming the EUR/kWh "
f"it works out at ({_T16_OFF_WHY[:60]!r})",
"|offset| above" in _T16_OFF_WHY
"larger than" in _T16_OFF_WHY
and "passes through" not in _T16_OFF_WHY)
check(f"...and the WHOLE card 100x out - which trips both - is refused, with "
f"the pass-through rung answering first ({_T16_BOTH_WHY[:44]!r})",
@@ -5555,9 +5550,8 @@ check(f"a card block ON THIS LEG is graded by the same rung at the same "
f"15.55) is refused, naming the option the operator typed "
f"({_T16_SPOILT_CARD[:64]!r})",
_T16_WITH_CARD == ""
and "does not reproduce its own card" in _T16_SPOILT_CARD
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION)
and f"the tolerance is {CARD_TOLERANCE}" in _T16_SPOILT_CARD)
and "the card says" in _T16_SPOILT_CARD
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION))
check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is "
f"enforced on this leg too rather than 'something'",
_reason(lambda: parse_export_formula(json.dumps(spoilt(
+6 -6
View File
@@ -1328,7 +1328,7 @@ check(f"...and a store holding a day names the DATE, the POINT COUNT and the "
f"HOUR COUNT {words_of(_h_words)!r} - 'a cache exists' and 'the cache "
f"holds yesterday, all 24 hours of it' are different facts, and only "
f"the second says the horizon will be full tomorrow morning",
words_of(_h_words) == f"{YDAY_D} (24 points over 24 h)")
words_of(_h_words) == f"{YDAY_D} (24 prices over 24 hours)")
# ⚠️ AND THE TWO NUMBERS ARE NOT THE SAME NUMBER - FCST-05's LOW. The row
# shipped as `f"{d} ({_n_points(rec)} h)"`, which counts POINTS and prints them
@@ -1362,11 +1362,11 @@ check(f"...and the row reports BOTH, each under its own unit: {_qw!r}. "
f"A day of 96 quarter-hourly prints is 96 points over 24 h, and the "
f"figure that says whether tomorrow's fill has a whole day to carry is "
f"the HOUR one",
_qw == f"{YDAY_D} (96 points over 24 h)")
_qw == f"{YDAY_D} (96 prices over 24 hours)")
check(f"...and specifically the row does NOT say '96 h' {_qw!r} - a point "
f"count wearing the hour's name is H1's defect, and this row is where "
f"an operator would have read it",
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 points " in _qw)
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 prices " in _qw)
check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the "
f"days it dropped read as days that were simply never written",
isinstance(_w14, str) and (_h14.last_error or "") in _w14
@@ -1546,7 +1546,7 @@ for _shape, _bad in _BAD_STARTS:
f"{_wload!r}: ONE point held, and the row that is not a point named "
f"rather than counted",
isinstance(_wload, str)
and _wload.startswith(f"{TODAY_D} (1 point over 1 h) [")
and _wload.startswith(f"{TODAY_D} (1 price over 1 hour) [")
and "1 malformed point row(s) in" in _wload)
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
# OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND
@@ -1577,7 +1577,7 @@ for _shape, _bad in _BAD_STARTS:
check(f"...and the store reopened from that file is CLEAN: the whole day "
f"held and nothing to report {words_of(_hM2)!r}, so the note clears "
f"itself rather than becoming permanent furniture",
words_of(_hM2) == f"{TODAY_D} ({_want} points over {_want} h)"
words_of(_hM2) == f"{TODAY_D} ({_want} prices over {_want} hours)"
and _hM2.last_error is None)
# ...and the shipped path, end to end, through the caller that catches
@@ -1641,7 +1641,7 @@ print("5d. FCST-06 - the OTHER way to be a store that cannot persist")
# read-only and again on a full tmpfs:
#
# last_error = 'could not write ([Errno 30] Read-only file system)'
# words = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
# words = '2026-01-14 (24 prices over 24 hours); 2026-01-15 (24 points
# over 24 h) [could not write (...)]'
# yesterday()= series, 24 points
# ON DISK = {} <- and gone on the next restart
+89 -104
View File
@@ -69,7 +69,7 @@ import asyncio
import sys
from pathlib import Path
from app import mqtt, pv
from app import messages, mqtt, pv
from named_absence import field, nth, present
from app.main import Controller
@@ -499,6 +499,11 @@ def rows_of(c):
return [r["text"] for r in c.checks()]
def pv_rows(c):
"""The solar rows of the status page (UX-08: selected by id, not wording)."""
return [r for r in c.checks() if r.get("id") == "pv"]
def page_rows(c):
return c.status_payload()["rows"]
@@ -510,43 +515,44 @@ check("⚠️ AN UNSET OPTION ADDS NO STATUS ROW AND NO PAGE ROW - not a caution
"is unset, because an unset forecast leaves a money guard without an "
"input; this is instrumentation and a permanent caution on every install "
"that never asked for it is the warning spam the card forbids",
not [t for t in _off_rows if "PV" in t]
and not [r for r in page_rows(c_off) if r[0] == "PV"])
not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "Solar production"])
check("...and the whole row list is otherwise the same length, so nothing else "
"moved either", len(_on_rows) == len(_off_rows) + 1)
_pv_row = [t for t in _on_rows if t.startswith("PV production")]
_pv_row = [r["text"] for r in pv_rows(c_on)]
print(f" {_pv_row}")
# habit 14: the ordered pair - value, entity and unit each in its OWN slot in
# the literal sentence, so a transposition is visible rather than merely
# present.
check("a good read says the watts, the entity it came from and the unit it was "
"reported in, each in its own slot",
_pv_row == ["PV production: 3210 W from sensor.pv_power "
"(reported in W)"]
and ("PV", "3 210 W") in page_rows(c_on))
# UX-08: the row says the watts, and no entity id (STYLE.md). The unit it was
# read in is DEBUG detail; a refused unit still gets its own caution below.
check("a good read says the watts, as a green row, and names no entity id",
_pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
and "sensor." not in _pv_row[0]
and pv_rows(c_on)[0]["ok"] is True
and ("Solar production", "3 210 W") in page_rows(c_on))
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
_bad_row = [r for r in c_bad.checks() if r["text"].startswith("PV production")]
_bad_row = pv_rows(c_bad)
print(f" {[r['text'] for r in _bad_row]}")
check("a refused read says WHICH refusal, and names the entity - 'no reading' "
"on its own sends an operator to look at a working sensor",
check("a refused read says WHICH refusal - 'no reading' on its own sends an "
"operator to look at a working sensor",
len(_bad_row) == 1
and _bad_row[0]["text"] == ("PV production: no reading from "
"sensor.pv_power - unit '%' is not one of "
"mW, W, kW, MW, GW, TW - it cannot be read "
"in watts"))
and _bad_row[0]["text"] == ("Solar production: no reading, the entity's "
"unit is not a power unit"))
check("...and every refusal pv.watts() can give has its own plain words",
len({messages.pv_problem(pv.watts(st)[1]) for st in (
None, state("unavailable", "W"), state("abc", "W"),
state("12", ""), state("12", "%"))}) == 5)
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",
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 _bad_row[0]["ok"] is False and _bad_row[0]["warn"] is True
and not c_bad.status_payload()["banner"].startswith(messages.NOT_READY)
and c_bad.status_payload()["level"] != "bad"
and c_bad.status_payload()["banner"]
== c_off.status_payload()["banner"])
check("...and the page row renders an absence as an em dash, not as 0 W",
("PV", "—") in page_rows(c_bad))
("Solar production", "—") in page_rows(c_bad))
# ==============================================================================
@@ -923,48 +929,39 @@ print()
print("10. the operator surfaces at a roster of three - and 0.11.0 at one")
# ==============================================================================
_c_many, _, _ = run(cycles=1, by_entity=FULL, **PV_MANY)
_many_row = [t for t in rows_of(_c_many) if t.startswith("PV production")]
_many_row = [r["text"] for r in pv_rows(_c_many)]
print(f" {_many_row}")
# habit 14 at a roster: the total in its own slot and EVERY contributor in its
# own slot with its own watts and its own unit, so a sum that disagrees with the
# addends printed beside it is visible in the sentence.
check("a healthy roster says the SITE total, how many entities it came from, "
"and then every entity with its own watts and its own unit - so a total "
"that disagrees with the numbers printed beside it is visible rather than "
"merely present",
_many_row == ["PV production: 3750 W from 3 entities "
f"({E1} 1200 W in W, {E2} 2100 W in kW, "
f"{E3} 450 W in W)"]
and ("PV", "3 750 W") in page_rows(_c_many))
# UX-08: the row carries the site total and the number of sources; the
# per-source figures are DOCS/DEBUG detail (DECISIONS/UX-08-short-notifications).
check("a healthy roster says the SITE total and how many sources it came from",
_many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
f"sources"]
and ("Solar production", "3 750 W") in page_rows(_c_many))
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
_gap_row = [r for r in _c_gap.checks() if r["text"].startswith("PV production")]
_gap_row = pv_rows(_c_gap)
print(f" {[r['text'] for r in _gap_row]}")
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, names the "
"entity that is missing and why - AND names what the others reported. The "
"partial information is not thrown away, it is put where a HUMAN reading "
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, and names "
"the missing source by its place in the list - put where a HUMAN reading "
"the page live will see it, and kept out of the number a recorder hands "
"back cold a week later",
len(_gap_row) == 1
and _gap_row[0]["text"] == (
"PV production: NO SITE TOTAL - only 2 of 3 entities are reporting "
"and a partial sum would understate production: "
f"{E2} - no state (entity missing, or the read failed). "
f"Reporting: {E1} 1200 W, {E3} 450 W"))
and _gap_row[0]["text"] == ("Solar production: no total, source 2 of 3 "
"has no reading"))
check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at "
"one entity: `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",
len(_gap_row) == 1
and _gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]}
and _gap_row[0]["ok"] is False and _gap_row[0]["warn"] is True
and _c_gap.status_payload()["level"] != "bad"
and _c_gap.status_payload()["banner"]
== c_off.status_payload()["banner"])
check("...and the page row is an em dash, not a smaller number: the one row an "
"operator glances at cannot show a partial total either",
("PV", "—") in page_rows(_c_gap)
and ("PV", "3 300 W") not in page_rows(_c_gap))
("Solar production", "—") in page_rows(_c_gap)
and ("Solar production", "3 300 W") not in page_rows(_c_gap))
# ⚠️ THE ACCEPTANCE CRITERION THE REST OF THIS FILE CANNOT STATE: at a roster of
# ONE the surfaces are 0.11.0's, character for character. `c_on` above is the
@@ -974,27 +971,24 @@ _c_one_new, _, _ = run(cycles=3, by_entity={E1: [state("3210", "W")]},
pv_entities=[E1])
print(f" one entity via pv_entities : "
f"{[t for t in rows_of(_c_one_new) if 'PV' in t]}")
check("⭐ ONE ENTITY IS 0.11.0's PATH, however it was declared: the status "
"sentence, the page row and the published value are the same shape "
"through `pv_entity` and through a one-member `pv_entities`, and the "
"sentence is 0.11.0's own - `(reported in W)`, not `from 1 entities`",
[t.replace(E1, "sensor.pv_power")
for t in rows_of(_c_one_new) if "PV" in t]
== [t for t in rows_of(c_on) if "PV" in t]
== ["PV production: 3210 W from sensor.pv_power (reported in W)"]
check("⭐ ONE ENTITY IS ONE PATH, however it was declared: the status "
"sentence, the page row and the published value are the same through "
"`pv_entity` and through a one-member `pv_entities` - no `from 1 sources`",
[r["text"] for r in pv_rows(_c_one_new)]
== [r["text"] for r in pv_rows(c_on)]
== [f"Solar production: {messages.signed_watts(3210)}"]
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
.index("PV")] == ("PV", "3 210 W"))
check("...and a refused read at a roster of one is 0.11.0's refusal sentence "
"too, not the roster one - `no reading from <entity> - <reason>`",
[r["text"] for r in run(cycles=1, pv_entities=[E1],
by_entity={E1: [state("1234", "%")]})[0].checks()
if r["text"].startswith("PV production")]
== [f"PV production: no reading from {E1} - unit '%' is not one of "
"mW, W, kW, MW, GW, TW - it cannot be read in watts"])
.index("Solar production")] == ("Solar production", "3 210 W"))
check("...and a refused read at a roster of one is the one-sensor refusal "
"too, not the roster one - `no reading, <reason>`",
[r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
by_entity={E1: [state("1234", "%")]})[0])]
== [r["text"] for r in _bad_row])
# ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT.
_c_both, _, _ = run(cycles=1, by_entity=FULL, **dict(PV_ON, **PV_MANY))
_ign = [r for r in _c_both.checks() if "IGNORED" in r["text"]]
_IGN = messages.row_pv_single_ignored()
_ign = [r for r in _c_both.checks() if r["text"] == _IGN]
print(f" {[r['text'] for r in _ign]}")
check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
"entities are summed instead. `entities()` REPLACES rather than unions - "
@@ -1002,21 +996,18 @@ check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
"key still names - but a replacement nobody is told about is the 'one of "
"which silently wins' this config refuses elsewhere",
len(_ign) == 1 and _ign[0]["ok"] is False and _ign[0]["warn"] is True
and _ign[0]["text"] == ("PV production: `pv_entities` is set, so "
"`pv_entity` is IGNORED - the 3 entities in the "
"list are what is summed. Clear `pv_entity` to "
"remove this caution")
and "clear “Solar production entity”" in _ign[0]["text"]
and _c_both.pv_w == 3750.0)
check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
"configuration, not on a list-only one, and not on an install with "
"neither",
not [r for r in c_on.checks() if "IGNORED" in r["text"]]
and not [r for r in _c_many.checks() if "IGNORED" in r["text"]]
and not [r for r in c_off.checks() if "IGNORED" in r["text"]])
not [r for r in c_on.checks() if r["text"] == _IGN]
and not [r for r in _c_many.checks() if r["text"] == _IGN]
and not [r for r in c_off.checks() if r["text"] == _IGN])
check("...and an install with NO PV roster still adds no row of any kind - the "
"whole of TEL-12 is invisible at the shipped default",
not [t for t in rows_of(c_off) if "PV" in t]
and not [r for r in page_rows(c_off) if r[0] == "PV"]
not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "Solar production"]
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
and run(cycles=1)[1].state_calls == []
and run(cycles=1, pv_entities=[])[1].state_calls == []
@@ -1109,19 +1100,18 @@ 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")]
_mix_row = [r["text"] for r in _row(_c_mix, "from 3 sources")]
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("the status row prints the total AFTER the sign",
_mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
f"from 3 sources"])
check("...and the page's PV row is the corrected total",
("PV", "3 270 W") in page_rows(_c_mix))
("Solar production", "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", ""))
check("a holed roster with a flipped member names the missing source, not "
"the flipped one",
[r["text"] for r in _row(_c_hole_f, "no total")]
== ["Solar production: no total, source 1 of 3 has no reading"])
# The owner's report, at a roster of one: a meter reading +13 W at night.
_OWN = "sensor.pv_meter_power"
@@ -1134,13 +1124,11 @@ check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is
"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)"])
and [r["text"] for r in pv_rows(_c_own)]
== ["Solar production: -13 W, sign reversed"]
and _row(_c_own, "Reversed-sign solar entities") == [])
check("...and WITHOUT the option it is the plain row",
[r["text"] for r in pv_rows(_c_own0)] == ["Solar production: 13 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
@@ -1152,9 +1140,7 @@ 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") == [])
and _row(_c_pad, "Reversed-sign solar entities") == [])
# --- the negative caution -------------------------------------------------------
def _neg(watts_by_entity, flip=()):
@@ -1162,20 +1148,20 @@ def _neg(watts_by_entity, 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")
return _row(c, "reads ")
_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`",
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it by "
"its place in the list, its watts, and says to ADD it to the "
"setting 'Reversed-sign solar entities' (`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"])
== ["Solar production: source 2 reads -1 500 W, add it to "
"“Reversed-sign solar entities”"])
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`"
len(_n_rm) == 1 and "remove it from “Reversed-sign solar 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",
@@ -1198,11 +1184,10 @@ _c_typo, _, _ = run(cycles=1, by_entity=FULL,
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") == [])
for r in _row(_c_typo, "not a solar entity in use")]
== [("Solar production: entry 1 of “Reversed-sign solar entities” is "
"not a solar entity in use", False, True)]
and _row(_c_mix, "not a solar entity in use") == [])
# --- default: byte-identical to 0.12.0 ------------------------------------------
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
+9 -8
View File
@@ -172,7 +172,7 @@ class FakeController:
self.inverters = []
self.events = []
def log_event(self, text):
def log_event(self, text, hint=None):
self.events.append(text)
def status_payload(self):
@@ -236,7 +236,7 @@ check("the stored options are byte-identical to what they were",
check("...and the refusal carries the validator's OWN sentence, unedited",
refused.problems == validate_startup_config(bad, None, None))
check("...which is the sentence that names the key and says what it costs",
any("setpoint_entity is not set" in p for p in refused.problems))
any("(setpoint_entity) is empty" in p for p in refused.problems))
_good = base()
res = run(selfconfig.save(sup, _good, fp))
@@ -316,7 +316,7 @@ _probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None))
check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the "
"rail collapsing to the 2000 default under an explicit 400 W slew, so "
"the box cannot be emptied into a quietly halved rail",
any("site rail of 2000 W" in p for p in _probs))
any("for a 2000 W site" in p for p in _probs))
# HALF TWO: the backstop cannot report "no problems" for a config it could not
# compose. Returning [] there enables Save - a crash became a green.
@@ -430,7 +430,7 @@ _probs = run(selfconfig.problems_for(base(max_w=4000), FakeHass(_ranges)))
_by, _un = configui.problems_by_field(_probs, ALL_KEYS)
check("the device-range rule is REACHED from the panel - the setpoint entity's "
"own range is read, so rule 1 fires here exactly as it does at startup",
any("THE DEVICE WINS" in p for p in _probs))
any("Lower the rating to" in p for p in _probs))
check("...and it lands on `max_w`, which is the field the operator has to "
"change", "max_w" in _by)
# ⚠️ THE COLLISION, CHECKED HERE AND NOT ONLY IN THE PROBE. `sign_disagree_w`
@@ -467,7 +467,7 @@ _p2, _unread2 = run(selfconfig.problems_and_ranges(base(max_w=4000),
FakeHass(_ranges)))
check("...and with the same device answering, the same config IS refused - "
"which is what makes the line above a skip rather than a pass",
any("THE DEVICE WINS" in p for p in _p2) and _unread2 == [])
any("Lower the rating to" in p for p in _p2) and _unread2 == [])
check("the note is about the config UNDER TEST, not about the running roster: "
"a newly typed entity that does not answer is the one named",
run(selfconfig.problems_and_ranges(
@@ -495,8 +495,9 @@ _leaked = sorted(k for k in ALL_KEYS
_script))
check(f"the page's script names NOT ONE option key - so it cannot be rendering "
f"a field list of its own. Leaked: {_leaked}", not _leaked)
_phrases = ["THE DEVICE WINS", "has no actuator", "two writers",
"setpoint_entity is not set", "would silently never happen"]
_phrases = ["Lower the rating to", "cannot control the inverter",
"point at the same inverter entity", "(setpoint_entity) is empty",
"accepts at most"]
_copied = sorted(p for p in _phrases
for f, text in _ui_files.items() if p in text)
check(f"no phrase out of a validate.py refusal appears anywhere in the three "
@@ -551,7 +552,7 @@ class H:
async def limits(self, e): return (None, None, None)
class C:
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
def log_event(self, t): pass
def log_event(self, t, hint=None): pass
def status_payload(self): return {}
async def handle_action(self, w): raise AssertionError("no")
+3 -3
View File
@@ -527,7 +527,7 @@ check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "no static profile"))
static_status(opts(None), THU) == (False, "No fixed tariff set"))
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
# serve it are to invent one or to fail when the clock reaches it.
@@ -850,7 +850,7 @@ check("a good static_profile starts",
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000),
"static_profile cannot be used"))
"“Fixed or day/night tariff” cannot be used"))
check("...and the refusal names the uncovered range",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000), "22:00-23:00"))
@@ -859,7 +859,7 @@ check("static_profile alongside the Home Assistant price entities is refused - "
why(validate_startup_config(
vbase(static_profile=json.dumps(TWO_PERIOD),
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
-5000, 5000), "HOME ASSISTANT"))
-5000, 5000), "(price_avg_entity) would win over it"))
check("...and the Home Assistant pair ALONE still starts, so nothing is taken "
"away from a live install",
validate_startup_config(vbase(price_now_entity="sensor.now",
+40 -27
View File
@@ -116,6 +116,7 @@ from pathlib import Path
from named_absence import given, nth, present
from app import messages
from app.main import Controller
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
from app.tariff import (
@@ -977,16 +978,22 @@ check("...and the peak is unchanged by the repeat",
# quarter in August, so every check below passed in August and failed from
# 2026-09-01 - on a commit nobody had touched. The suite must grade the month
# it created, not the month the machine happens to be running in.
rows = [r for r in c.checks(when=B) if "capacity-tariff" in r["text"] or "peak" in r["text"]]
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
# UX-08 2d: the rows are in plain words and are found by their `id`. The peak
# and the coverage figure (TARIFF-13) are two rows now, one sentence each.
rows = c.checks(when=B)
peak_rows = [r for r in rows if r.get("id") == "month_peak"]
cov_rows = [r for r in rows if r.get("id") == "periods_measured"]
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row",
len(peak_rows) == 1)
check("...naming the peak, the soft limit and 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"{given(SHIPPED_CAP_W):.0f} W soft limit" in peak_rows[0]["text"]
and "quarter(s) banked" in peak_rows[0]["text"])
print(" status row: " + (cov_rows[0]["text"] if cov_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row, and one "
"coverage row beside it",
len(peak_rows) == 1 and len(cov_rows) == 1)
check("...naming the peak and its share of the peak limit, and the coverage "
"figure, all derived from the controller that was actually built",
bool(peak_rows) and "Month peak: 4 998 W on " in peak_rows[0]["text"]
and f"{4998 / given(SHIPPED_CAP_W) * 100:.0f} % of the peak limit"
in peak_rows[0]["text"]
and bool(cov_rows) and "periods measured" in cov_rows[0]["text"])
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
# NOT TO `when` - TARIFF-13, and it is a KNOWN GAP stated rather than rounded
@@ -1007,22 +1014,23 @@ print(f" the row read at {month_key(B)} carries the tally for "
check("the row's coverage figure is the LIVE month's tally - graded against a "
"row count taken straight from quarter_blocks, so this pins the gap "
"rather than restating the property that produces it",
bool(peak_rows)
and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
bool(cov_rows)
and cov_rows[0]["text"].endswith(f"this month: {_live_rows}"))
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
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.
c2 = built(TMP / "no-such-dir" / "nope.db")
u_rows = [r for r in c2.checks(when=B) if "capacity-tariff peak" in r["text"]]
u_rows = [r for r in c2.checks(when=B) if r.get("id") == "month_peak"]
print(" status row (unreadable store): "
+ (u_rows[0]["text"] if u_rows else "(none)"))
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1)
check("an unreadable store gets its OWN row, saying it cannot be read",
len(u_rows) == 1)
check("...and it does NOT read as a quiet month at 0 W - without this, a broken "
"instrument looks exactly like a month with no peak",
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"]
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"])
bool(u_rows) and "cannot be read" in u_rows[0]["text"]
and "%" not in u_rows[0]["text"] and u_rows[0]["warn"])
check("...and the healthy row and the unreadable row are different text",
bool(u_rows) and bool(peak_rows)
and u_rows[0]["text"] != peak_rows[0]["text"])
@@ -1040,7 +1048,7 @@ check("...and the healthy row and the unreadable row are different text",
# (That asymmetry in the row's own text is worth a look on its own; it is what
# made the original filter brittle. Not changed here - it is product text with
# other suites reading it.)
_peak_rows = lambda rows: [r for r in rows if "quarter(s) banked" in r["text"]]
_peak_rows = lambda rows: [r for r in rows if r.get("id") == "month_peak"]
_later = B + timedelta(days=45) # same fixture, a later billing month
_other_peak = _peak_rows(c.checks(when=_later))
@@ -1048,12 +1056,12 @@ print(f" the SAME controller read at {_later.date()}: "
+ (_other_peak[0]["text"] if _other_peak else "(no peak row)"))
check("asked for a LATER month, the same controller reports that month's "
"absence of a quarter, not August's 4998 W - the row follows `when`",
bool(_other_peak) and "4998 W" not in _other_peak[0]["text"]
and "no completed quarter recorded yet" in _other_peak[0]["text"])
bool(_other_peak) and "4 998 W" not in _other_peak[0]["text"]
and "none recorded yet" in _other_peak[0]["text"])
check("...and that is NO_RECORD, distinguishable from BOTH an observed peak and "
"an unreadable store - a month with no quarter is not a broken instrument",
bool(_other_peak) and bool(peak_rows)
and "UNREADABLE" not in _other_peak[0]["text"]
and "cannot be read" not in _other_peak[0]["text"]
and _other_peak[0]["text"] != peak_rows[0]["text"])
check("...and it is not FLAGGED, because an empty month is not an exceedance",
bool(_other_peak) and _other_peak[0]["ok"] and not _other_peak[0]["warn"])
@@ -1076,7 +1084,7 @@ check("calling checks() with NO argument still reads the month being billed "
bool(_now_peak) and (bool(peak_rows)
and _now_peak[0]["text"] == peak_rows[0]["text"]
if _today_is_fixture_month
else "no completed quarter recorded yet"
else "none recorded yet"
in _now_peak[0]["text"]))
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
@@ -1253,21 +1261,25 @@ for _q in range(_N13):
# Ground truth from the OTHER tier, which the tracker did not supply - the count
# under test must be graded against something that is not itself (habit 2).
_truth_before = len(c13.peak.store.blocks_in(_mon13))
_cov_row = lambda c: [r for r in c.checks()
if r.get("id") == "periods_measured"][0]["text"]
_row_before = _peak_rows(c13.checks())[0]["text"]
print(" before restart: " + _row_before)
_cov_before = _cov_row(c13)
print(" before restart: " + _row_before + " / " + _cov_before)
check(f"precondition: the fixture banked {_N13} quarters in {_mon13}, so a "
f"count of 0 and a count of the truth are different numbers",
_truth_before == _N13 and _N13 > 1)
check(f"...and the row says so before the restart",
f"{_N13} quarter(s) banked" in _row_before)
_cov_before.endswith(f"this month: {_N13}"))
c13.peak.store.close()
# THE RESTART.
c13b = built(_T13)
_row_after = _peak_rows(c13b.checks())[0]["text"]
_cov_after = _cov_row(c13b)
_truth_after = len(c13b.peak.store.blocks_in(_mon13))
print(" after restart: " + _row_after)
print(" after restart: " + _row_after + " / " + _cov_after)
print(f" rows in quarter_blocks: {_truth_before} before, "
f"{_truth_after} after; the restarted tracker has banked "
f"{len(c13b.peak._seen)} block(s) of its own through sync()")
@@ -1279,16 +1291,17 @@ check("precondition: the second Controller is a REAL restart - it shares no "
check("the peak and its attributed quarter survive the restart - the durable "
"half, asserted so that the coverage claim below is not graded against a "
"reading that had already collapsed",
_row_before.split(" - ")[0] == _row_after.split(" - ")[0]
and f"{_W13:.0f} W" in _row_after)
_row_before == _row_after
and f"Month peak: {messages.watts(_W13)} on" in _row_after)
check("...and the COUNT BESIDE IT DOES NOT COLLAPSE TO ZERO, which is the "
"defect: a coverage figure that resets while the value beside it "
"persists actively misinforms",
"; 0 quarter(s) banked" not in _row_after)
not _cov_after.endswith("this month: 0"))
check(f"...and it is the store's own tally, {_truth_after}, not a "
f"process-lifetime counter - graded against a COUNT OF ROWS in the "
f"other tier rather than against the property under test",
f"{_truth_after} quarter(s) banked" in _row_after and _truth_after >= _N13)
_cov_after.endswith(f"this month: {_truth_after}")
and _truth_after >= _N13)
# ⚠️ The restarted run banks one MORE than it drove, and that is correct: the
# checkpointed open block was stale, so `restore()` closed and banked it. The
+1 -1
View File
@@ -86,7 +86,7 @@ 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)
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)))):
+86 -73
View File
@@ -12,10 +12,11 @@ the sentence that says WHETHER TO TOUCH THE VALUE, because the tab presents a
constant derived from 1890 closed-loop test runs and the operator's own inverter
rating as the same kind of field.
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE
CITATIONS - that every option has a name and a description in both languages,
that no translation names an option that does not exist, and that each derived
constant's English description cites the record it was derived from. It does NOT
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE -
that every option has a name and a description in both languages, that no
translation names an option that does not exist, that each derived constant's
English description says to leave it, and that DOCS.md's "For installers"
entry for it names the record it came from (moved there by UX-08). It does NOT
grade whether a sentence is true, whether the Dutch is good Dutch, or whether
the result looks right on screen. The last of those is measured, not asserted,
by `tools/supervisor_shape_probe.py` section F, which loads these files with
@@ -107,83 +108,95 @@ check("...and no Dutch string was left as a copy of the English one, which is "
== TR["nl"]["configuration"][k]["description"]])
print("\n3. ⭐ the description says WHETHER TO TOUCH IT")
# ⚠️ THE HIGHEST-VALUE ITEM IN THE CARD, AND THE ONE A COVERAGE COUNT CANNOT
# REACH. Sixty-odd descriptions that all say "the threshold, in watts" would
# pass part 2 completely. What makes this surface safe is that a DERIVED
# constant names the campaign it came from and says the value is not a knob,
# while a number that really is the operator's says so too.
#
# ⚠️ AND THIS IS A TEXT CHECK, WHICH IS WEAK ON PURPOSE AND IS SAID TO BE. It
# cannot know whether a citation is TRUE; it can only know that the citation is
# there and names the right record, so that deleting the provenance - or
# renaming the card it points at - goes red instead of shipping. The table is
# written out rather than derived because there is nothing in config.yaml that
# distinguishes a derived constant from an operator's number; that distinction
# is the whole of what this card had to add.
# The value of this surface is the sentence that says whether a number is a
# knob. Since UX-08 phase 2c the tab keeps that sentence short ("Measured value,
# leave it unchanged.") and the record it came from lives in DOCS.md under
# "For installers: where the numbers come from", where UX-01's citations moved.
# So each derived key is pinned twice:
# its English description tells the reader what to do, and its DOCS entry names
# the record. A text check, weak on purpose: it cannot know a citation is TRUE,
# only that deleting it, or the instruction, goes red.
DOCS = (HERE / "DOCS.md").read_text(encoding="utf-8")
_inst = DOCS.split("\n### For installers: where the numbers come from\n", 1)
INSTALLERS = _inst[-1].split("\n#", 1)[0] if len(_inst) == 2 else ""
check("DOCS.md has the 'For installers: where the numbers come from' section",
bool(INSTALLERS.strip()))
def docs_entry(key):
"""The DOCS bullet that names `key` in backticks, lower-cased."""
hits = [b for b in INSTALLERS.split("\n- ") if f"`{key}`" in b]
return hits[0].lower() if hits else ""
LEAVE_M, LEAVE_D = "Measured value, leave it unchanged", \
"Derived value, leave it unchanged"
# key: (what the English description must say, what its DOCS entry must name)
DERIVED = {
"sign_disagree_w": ("SAFETY-02", "MEASURED"),
"sign_disagree_s": ("SAFETY-02", "MEASURED"),
"unit_sign_disagree_w": ("SAFETY-30", "1890"),
"unit_sign_disagree_s": ("SAFETY-30", "1890"),
"site_sign_disagree_w": ("SAFETY-30", "CARRIED"),
"site_sign_disagree_s": ("SAFETY-30", "1890"),
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
"circulation_w": ("SAFETY-31", "924"),
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
# SAFETY-42. Two needles rather than one: the campaign it was derived from,
# and the word that says what it does NOT do. An operator who reads
# "not-delivering report" and assumes the add-on has handled it is the
# failure this alarm's whole wording is written against.
"delivery_hold_s": ("SAFETY-42", "DERIVED FROM", "REPORTS ONLY"),
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
"bms_soc_floor": ("0x0560", "MEASURED"),
"integrator_max_w": ("MEASURED", "Do not raise it"),
"gain": ("MEASURED ON", "do not raise it"),
# SAFETY-39. `slew_w` joins the table because it stopped being an
# operator's convenience and became a DERIVED value the moment it was
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
# ~18 s measured step response. It is the one entry here whose value is
# a RATIO, so the description has to say what it follows as well as
# where the ratio came from - a citation alone would leave an operator
# reading "0" on the tab with no idea it is not "off".
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
# TARIFF-18. `price_taxes_eur_kwh` joins the table for `slew_w`'s reason:
# it stopped being a figure somebody picked and became one SUMMED FROM
# NAMED CARD ROWS. Three needles, because three different things about it
# can go wrong on the Configuration tab and only the first is provenance:
# where the number came from, that the operator's own DSO gives a
# different one, and that changing it moves no trade - which is the claim
# an operator is most likely to assume the opposite of when a number
# doubles.
"price_taxes_eur_kwh": ("TARIFF-18", "YOUR OWN DSO",
"MOVES NO TRADING DECISION"),
"sign_disagree_w": (LEAVE_M, ("SAFETY-02", "measured")),
"sign_disagree_s": (LEAVE_M, ("SAFETY-02", "measured")),
"unit_sign_disagree_w": (LEAVE_D, ("SAFETY-30", "1890")),
"unit_sign_disagree_s": (LEAVE_D, ("SAFETY-30", "1890")),
"site_sign_disagree_w": (LEAVE_M, ("SAFETY-30", "carried")),
"site_sign_disagree_s": (LEAVE_D, ("SAFETY-30", "1890")),
"meter_freeze_s": (LEAVE_M, ("SAFETY-18", "measured")),
"circulation_w": (LEAVE_M, ("SAFETY-31", "924")),
"circulation_hold_s": (LEAVE_D, ("SAFETY-31", "derived")),
# SAFETY-42: the alarm changes nothing, and both surfaces say so.
"delivery_hold_s": (LEAVE_D, ("SAFETY-42", "derived from",
"reports only")),
"peak_soc_floor": (LEAVE_D, ("SAFETY-24", "derived")),
"bms_soc_floor": ("Read it off your battery", ("0x0560", "measured")),
"integrator_max_w": ("Do not raise it above", ("measured",)),
"gain": ("do not raise it", ("measured on",)),
# SAFETY-39: 0 is not "off", it follows the rating.
"slew_w": ("0 follows", ("SAFETY-39", "derived", "0 means follow")),
# TARIFF-18: where the figure came from, that your DSO differs, and that
# changing it moves no trade.
"price_taxes_eur_kwh": ("Check it against your own grid operator",
("TARIFF-18", "your own dso",
"moves no trading decision")),
}
_uncited = []
for key, needles in sorted(DERIVED.items()):
desc = TR["en"]["configuration"][key]["description"]
for key, (says, needles) in sorted(DERIVED.items()):
if says not in TR["en"]["configuration"][key]["description"]:
_uncited.append(f"{key} description does not say {says!r}")
for needle in needles:
if needle not in desc:
_uncited.append(f"{key} does not cite {needle!r}")
print(f" {len(DERIVED)} derived constants must cite their campaign; "
f"{len(_uncited)} do not")
check(f"every derived constant's English description names WHERE THE NUMBER "
f"CAME FROM, so an operator reading the tab can tell it apart from their "
f"own inverter rating. Uncited: {_uncited}", not _uncited)
# The other direction, and it matters as much: a number that IS the operator's
# must not be dressed up as derived, or the labels stop meaning anything.
if needle.lower() not in docs_entry(key):
_uncited.append(f"{key}: DOCS does not name {needle!r}")
print(f" {len(DERIVED)} derived constants; {len(_uncited)} problem(s)")
check(f"every derived constant's description says what to do with it, and its "
f"DOCS entry names where the number came from. Problems: {_uncited}",
not _uncited)
_theirs = ["max_w", "peak_cap_w"]
check("...and the numbers that really are the OPERATOR'S say so instead of "
"carrying a citation - `max_w` and `peak_cap_w` are the two the card "
"names, and both read 'THIS IS YOUR NUMBER'",
all("THIS IS YOUR NUMBER" in TR["en"]["configuration"][k]["description"]
check("...and the numbers that really are the OPERATOR'S say so instead - "
"`max_w` and `peak_cap_w` start 'Your own number', here and in DOCS",
all(TR["en"]["configuration"][k]["description"].startswith(
"Your own number") and "your own number" in docs_entry(k)
for k in _theirs))
check("...and the one threshold that is NOT derived from a measurement admits "
"it rather than borrowing the others' authority - "
"`cycle_cost_threshold_eur` is an open product question (OPT-08)",
"NOT DERIVED FROM A MEASUREMENT"
"it - `cycle_cost_threshold_eur` is a product choice, and DOCS names the "
"open question (OPT-08)",
"product choice, not a measured one"
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
and "OPT-08"
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"])
and "opt-08" in docs_entry("cycle_cost_threshold_eur")
and "not derived from a measurement"
in docs_entry("cycle_cost_threshold_eur"))
# DOCS.md's "Settings at a glance" shows the tab's text. It must not drift.
_glance = DOCS.split("\n### Settings at a glance\n", 1)[-1].split("\n#", 1)[0]
_rows = {}
for line in _glance.splitlines():
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) == 4 and cells[2].startswith("`"):
_rows[cells[2].strip("`")] = (cells[1], cells[3])
_drift = []
for key in ORDER:
conf = TR["en"]["configuration"][key]
want = (conf["name"].split(" · ")[-1], conf["description"])
if _rows.get(key) != want:
_drift.append(key)
check(f"DOCS.md 'Settings at a glance' shows every option's English label and "
f"description exactly as the tab does. Drifted: {_drift}", not _drift)
print("\n4. task ordering, and the labels that carry it")
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced
+66 -65
View File
@@ -77,7 +77,7 @@ p = validate_startup_config(base(max_w=8000), -5000, 5000)
check("max_w above the device maximum is refused", len(p) == 1)
check("...and the message names the configured value", why(p, "8000"))
check("...and the device's value", why(p, "5000"))
check("...and says which one wins", why(p, "THE DEVICE WINS"))
check("...and says what to lower it to", why(p, "Lower the rating to 5000 W"))
check("max_w exactly at the device maximum is fine",
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
check("max_w under it is fine",
@@ -85,7 +85,7 @@ check("max_w under it is fine",
# The charge side is a separate rail and an asymmetric device is legal.
p = validate_startup_config(base(max_w=4000), -2500, 5000)
check("a max_w the device cannot take on the CHARGE side is refused too",
len(p) == 1 and why(p, "charge side"))
len(p) == 1 and why(p, "when charging"))
check("...naming the device's floor", why(p, "-2500"))
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
# re-runs this the moment the real rating arrives.
@@ -122,7 +122,7 @@ check("...and rule 2 REFUSES that whole configuration at startup, which is why "
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
check("...on the rating side specifically, not incidentally through some other "
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
"THE DEVICE WINS"))
"Lower the rating to"))
check("...and the case the refusal CANNOT reach - the rating unread because "
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
"answer had to be a runtime status row and not a startup gate",
@@ -136,9 +136,9 @@ print("2. an actuator to write to")
for blank in ("", " ", None):
p = validate_startup_config(base(setpoint_entity=blank), None, None)
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
check("...and the message says why it is dangerous rather than untidy",
check("...and the message says what it costs: nothing is controlled",
why(validate_startup_config(base(setpoint_entity=""), None, None),
"heartbeat"))
"cannot control the inverter"))
# --------------------------------------------------------------------------- #
# specs §1.5: TWO WRITERS ON ONE ACTUATOR, AND WHAT ACTUALLY GRADES IT
# --------------------------------------------------------------------------- #
@@ -181,7 +181,7 @@ check("...and the replacement SEES it, because it counts writers per ACTUATOR "
and len(_census["number.goodwe_setpoint"]) == 3)
_hazard_problems = validate_startup_config(_PER_INVERTER_HAZARD, -5000, 5000)
check("AC 3: and the config is REFUSED, where today it starts",
why(_hazard_problems, "one actuator"))
why(_hazard_problems, "point at the same inverter entity"))
check("...and the refusal names the actuator and every writer aimed at it, so "
"an operator can see WHICH declarations to remove rather than being told "
"only that something is wrong",
@@ -243,7 +243,8 @@ check("...and entity ids are folded to lower case, so a second writer cannot "
"be smuggled past the census on Number.X versus number.x",
why(validate_startup_config(base(inverters=[{"setpoint_entity":
"NUMBER.GOODWE_SETPOINT"}]),
-5000, 5000), "one actuator"))
-5000, 5000),
"point at the same inverter entity"))
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
# the whole config ACCEPTED - "blank is how an operator leaves a slot
@@ -265,7 +266,8 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
"max_w": 2000,
"setpoint_entity": " "}]),
-5000, 5000), "has no setpoint_entity"))
-5000, 5000),
"has no “Inverter setpoint entity” (setpoint_entity)"))
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
@@ -303,9 +305,9 @@ check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left emp
"weighing a dimensionless 1.0 is not a comparison, so the key would "
"silently change nothing at all",
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"not PARTIAL")
"Set it on every inverter, or on none")
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
"not PARTIAL"))
"Set it on every inverter, or on none"))
check("...and the refusal names WHICH entry is which, because an operator with "
"three inverters told 'one of them is wrong' is sent to the wrong "
"cupboard",
@@ -339,7 +341,7 @@ check("a phase list disagreeing with meter_phases is refused",
why(validate_startup_config(base(meter_source="ha_signed",
p1_net_entity="sensor.n", meter_phases=3,
p1_phase_net_entities=["a", "b"]),
-5000, 5000), "phase-count"))
-5000, 5000), "Make them match"))
check("mqtt_p1 with no topic is refused",
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
"meter_mqtt_topic"))
@@ -349,7 +351,7 @@ check("ha_dsmr with only half its entities is refused",
"p1_export_entity"))
check("a misspelt meter_source is refused rather than silently ignored",
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
"is not one of"))
"Choose one of"))
# --------------------------------------------------------------------------- #
print("4. meter_max_age_s against the firmware's compile-time veto")
@@ -361,7 +363,8 @@ p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
check("...naming the configured value", why(p, "120"))
check("...naming the firmware's value", why(p, "30"))
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
check("...and saying that the control board stops the inverter first",
why(p, "control board stops the inverter after 30 s"))
check("the tested pair 30/30 is accepted",
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
check("one second above it is refused",
@@ -391,7 +394,7 @@ p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
check("maintenance_charge_w above max_w is refused", len(p) == 1)
check("...and the message names the charge rate", why(p, "2500"))
check("...and the ceiling", why(p, "2000"))
check("...and says which one wins", why(p, "THE CEILING WINS"))
check("...and says what it blocks", why(p, "monthly recharge cannot run"))
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
# deleted, and it is the boundary the rule is written `>` for.
@@ -407,7 +410,8 @@ check("below it starts",
check("...and it is refused with maintenance_enabled off, as shipped",
why(validate_startup_config(
base(max_w=2000, maintenance_charge_w=2500,
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
maintenance_enabled=False), -5000, 5000),
"monthly recharge cannot run"))
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
# The comment this replaces in validate.py said, in as many words, that it took
@@ -448,11 +452,11 @@ check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even thoug
"recalibrated, so its cap could not contradict anything; it is now, and "
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
len(_p_two) == 1 and why(_p_two, "max_w (inverter-b) is 2000 W"))
len(_p_two) == 1 and why(_p_two, "for inverter-b (inverters.max_w) is 2000 W"))
check("...and the message names the pack the number came off, not the first "
"entry - an operator told to lower a number on the wrong device is being "
"sent to the wrong device by the message that exists to stop that",
not why(_p_two, "max_w (inverter-a)"))
not why(_p_two, "for inverter-a (inverters.max_w)"))
check("...while the same rate against two packs that can both take it starts, "
"so this is a comparison and not a blanket refusal of rosters",
[p for p in validate_startup_config(
@@ -463,7 +467,7 @@ check("...while the same rate against two packs that can both take it starts, "
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
"message, the same boundary, the same refusal the flat config always got",
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
-5000, 5000), "THE CEILING WINS")
-5000, 5000), "monthly recharge cannot run")
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
-5000, 5000) == [])
@@ -539,17 +543,15 @@ check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSE
"It crosses the whole output range in 10 s against a plant measured at "
"18 s, which is not a fast loop, it is a loop that commands its entire "
"range before it can observe its answer to the first step",
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
"crossing takes, because 'slew_w is too high' sends an operator to lower "
"a number when raising max_w or setting 0 are both correct answers too",
why(_broken, "site rail of 5000 W")
and why(_broken, "crosses its whole output range in 5.0 cycles"))
check("...and it names the plant measurement it is graded against, so the "
"number is traceable to a step response rather than to a preference",
why(_broken, "goodwe/NOTES.md §5.10"))
len(_broken) == 1 and why(_broken, "(slew_w) is 1000 W"))
check("...and the refusal says WHICH RAIL it graded against, because 'slew_w "
"is too high' sends an operator to lower a number when raising max_w or "
"setting 0 are both correct answers too",
why(_broken, "for a 5000 W site"))
# UX-08 2c: the plant measurement the rule is graded against (the ~18 s step
# response) is in DOCS.md "For installers", beside slew_w, not in the refusal.
check("...and it says what to DO, including the setting that cannot go stale",
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
why(_broken, "Set it to 0") and why(_broken, "556 W"))
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
@@ -600,7 +602,7 @@ check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
"is a magnitude, and 0 already has a meaning here",
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
"must not be negative"))
"cannot be negative"))
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
@@ -649,7 +651,7 @@ check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
check("...and a slew that outruns even the doubled rail is still refused there, "
"so the roster path is not simply un-graded",
why(validate_startup_config(_roster(slew_w=2000)),
"site rail of 9600 W"))
"for a 9600 W site"))
# --------------------------------------------------------------------------- #
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
@@ -777,9 +779,10 @@ print("max_w must be a positive magnitude")
# sim/inject.py's entry point and can be handed any dict at all.
for bad in (0, -1, -2000):
p = validate_startup_config(base(max_w=bad), -5000, 5000)
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
check("...and the message explains it would read as a trip, not a mistake",
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "Set it above 0 W"))
check("...and the message names the setting by its label and its key",
why(validate_startup_config(base(max_w=0), -5000, 5000),
"“Inverter rating” (max_w) is 0 W"))
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
@@ -792,7 +795,7 @@ check("the smallest sane max_w still starts",
unit_sign_disagree_w=0.5,
maintenance_charge_w=1), -5000, 5000) == [])
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
why(validate_startup_config(base(max_w=1), -5000, 5000), "can never trip"))
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
# and the fixture above now has to name BOTH thresholds - which is the check
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
@@ -1188,9 +1191,9 @@ def _roster_n(caps, **over):
# The three rules that reduce over `unit_caps_indexed()`, each as
# (option name, the token its refusal is recognised by).
_CAP_RULES = (
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
("maintenance_charge_w", "maintenance charge in a cheap window"),
("sign_disagree_w", "(sign_disagree_w) is"),
("unit_sign_disagree_w", "(unit_sign_disagree_w) is"),
("maintenance_charge_w", "monthly recharge cannot run"),
)
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
@@ -1220,7 +1223,8 @@ def _verdict(opts, token):
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
if not ps:
return (False, None, None)
m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0])
m = re.search(r"for (\S+) \(inverters\.max_w\),? (?:is|at) (\d+) W",
ps[0])
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
@@ -1330,7 +1334,7 @@ check("...and at a ROSTER OF ONE every candidate that still has a member to "
and {n for n, v in _at_one.items() if v is None}
== {"min(caps[1:])", "min(caps[:-1])"}
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
"SAFETY-02 sign-disagreement")[0])
"(sign_disagree_w) is")[0])
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
@@ -1342,7 +1346,7 @@ check("...and at a ROSTER OF ONE every candidate that still has a member to "
_leftover = _roster_n([_SMALL, _SMALL],
**dict(_QUIET, unit_sign_disagree_w=_RATE))
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
_lv = _verdict(_leftover, "(unit_sign_disagree_w) is")
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
@@ -1358,7 +1362,7 @@ check("...and the over-refusal in the OTHER direction is gone too: a roster of "
"reach. It starts",
_verdict(_roster_n([_BIG, _BIG],
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
"SAFETY-30 per-inverter sign-disagreement")
"(unit_sign_disagree_w) is")
== (False, None, None))
check("...and the flat single-inverter config it always refused is refused "
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
@@ -1411,7 +1415,7 @@ print(f" {(_T17_P_GAP or [''])[0][:120]}")
check("a table that leaves 00:00-07:00 unpriced is REFUSED, by the same tiling "
"rule static_profile has - and the refusal names the KEY and the HOURS, "
"not merely that something was wrong",
why(_T17_P_GAP, "price_taxes_profile cannot be used:")
why(_T17_P_GAP, "“Day/night levies and VAT” cannot be used.")
and why(_T17_P_GAP, "leaves mon 00:00-07:00 with no period"))
check("...and it is refused for THAT reason and not as a side effect of "
"something else this fixture happens to break: exactly one problem",
@@ -1427,9 +1431,9 @@ print(f" {(_T17_P_BOTH or [''])[0][:120]}")
check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
"refusal says WHICH ONE WINS and what to do - not merely that the two "
"are incompatible",
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
"0.1198")
and why(_T17_P_BOTH, "THE TABLE WINS"))
why(_T17_P_BOTH, "(price_taxes_profile) is set, so")
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used. Set it "
"back to"))
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
"Walloon site would have to clear a key it never touched. The refusal is "
"about a value the operator chose, and the shipped default is the one "
@@ -1479,19 +1483,16 @@ check("...while a value that is NEITHER the shipped default NOR a superseded "
"one is still REFUSED beside the table, naming it - the set of "
"'untouched' figures widened by exactly the old defaults and by nothing "
"else",
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
"0.1198")
why(_T17_P_BOTH, "from 0.1198, or clear")
and 0.1198 not in _T18_SUPERSEDED
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
# both which figure to return to and that the old default is not the problem.
check("...and the refusal names the SHIPPED default to return to AND says the "
"superseded default is accepted, so the sentence is actionable on both "
"sides of the upgrade",
# UX-08 2c: that a superseded default is accepted is said in DOCS.md "For
# installers" now. The refusal keeps the one figure the operator acts on.
check("...and the refusal names the SHIPPED default to return to",
bool(_T18_SUPERSEDED)
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}")
and why(_T17_P_BOTH, "SUPERSEDED")
and all(why(_T17_P_BOTH, str(u)) for u in _T18_SUPERSEDED))
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}"))
check("...and the same non-default scalar WITHOUT the table still starts, "
"which is what makes the line above about the PAIR rather than about "
"0.1198 being disliked",
@@ -1531,18 +1532,18 @@ print(f" ORES : {(_T17_P_ORES or [''])[0][:110]}")
print(f" Sibelga : {(_T17_P_SIB or [''])[0][:110]}")
check("ORES's card typed in CENTS is refused by the PARSER's bound, and the "
"sentence is the parser's - `beyond +-10.0`, naming the row",
why(_T17_P_ORES, "price_taxes_profile cannot be used:")
why(_T17_P_ORES, "“Day/night levies and VAT” cannot be used.")
and why(_T17_P_ORES, "beyond +-10.0"))
check("...and SIBELGA's whole card - 9,39 and 7,10, BOTH under that bound - is "
"refused by THIS rung instead, naming both bands and the factor. Without "
"it a Brussels site would price a 9.39 EUR/kWh levy and start",
why(_T17_P_SIB, "price_taxes_profile carries [7.1, 9.39] EUR/kWh")
and why(_T17_P_SIB, "factor of 100"))
why(_T17_P_SIB, "(price_taxes_profile) has [7.1, 9.39] EUR/kWh")
and why(_T17_P_SIB, "Divide the figures by 100"))
check("...and the two refusals are DIFFERENT sentences, so the rungs cannot "
"mask each other: deleting the units rung leaves the Sibelga line red "
"while the ORES line goes on passing for the parser's reason",
_T17_P_ORES != _T17_P_SIB
and not why(_T17_P_ORES, "factor of 100")
and not why(_T17_P_ORES, "Divide the figures by 100")
and not why(_T17_P_SIB, "beyond +-10.0"))
check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
"above are about the units and not about Brussels or Wallonia",
@@ -1639,13 +1640,13 @@ check("PRECONDITION: each of those two blobs differs from the accepted "
== _T16_ENECO)
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
"naming the option the operator typed",
why(_T16_P_PT, "price_export_formula cannot be used:")
why(_T16_P_PT, "“Your own injection formula” cannot be used.")
and why(_T16_P_PT, "passes through")
and not why(_T16_P_PT, "|offset| above"))
and not why(_T16_P_PT, "larger than"))
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
"rungs are graded on their reasons and cannot mask each other",
why(_T16_P_OFF, "price_export_formula cannot be used:")
and why(_T16_P_OFF, "|offset| above")
why(_T16_P_OFF, "“Your own injection formula” cannot be used.")
and why(_T16_P_OFF, "larger than")
and not why(_T16_P_OFF, "passes through"))
check("...and a malformed blob is refused as a malformed blob, naming the "
"field rather than 'invalid configuration'",
@@ -1659,7 +1660,7 @@ check("...and a malformed blob is refused as a malformed blob, naming the "
# coefficients against arithmetic done with the same coefficients. So the leg
# starts, and the operator is told WHICH check did not run.
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
if lvl == "WARNING" and "UNGRADED" in m]
if lvl == "WARNING" and "not checked against a printed price" in m]
print(f" {(_T16_UNGRADED or [''])[0][:118]}")
check("a leg with no card block starts, and says at startup that its "
"coefficients are UNGRADED - naming what was checked instead, so the "
@@ -1682,13 +1683,13 @@ check("...and a leg that DOES carry a card does NOT print it, or the sentence "
"would be noise an operator learns to ignore rather than a report of "
"which check ran",
_T16_WITH_CARD == []
and not [m for lvl, m in _T16_CARD_LOG if "UNGRADED" in m])
and not [m for lvl, m in _T16_CARD_LOG if "not checked against a printed price" in m])
# ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung.
check("...and a card ON THIS LEG that the coefficients do not reproduce is "
"REFUSED at startup, so the optional block is optional and not inert",
why(_t16_start(price_export_formula=_T16_CONS.replace(
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
"price_export_formula does not reproduce its own card"))
"price_export_formula gives"))
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
@@ -1736,7 +1737,7 @@ for _v in _S10_BAD:
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
"configui.problems_by_field() can file it under the right control",
why(_p, "shadow_mode is") and why(_p, "'auto'")
why(_p, "(shadow_mode) is") and why(_p, "'auto'")
and why(_p, "'on'") and why(_p, "'off'"))
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "
+1 -1
View File
@@ -138,7 +138,7 @@ r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
check("refuses a config the validator rejects", r5.returncode == 1)
check("...and warns it is exit 78", "78" in r5.stdout)
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
check("...and prints the validator's own words", "(slew_w) is 1000 W" in r5.stdout)
print()
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
+106 -34
View File
@@ -22,6 +22,7 @@ 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,
)
@@ -153,7 +154,7 @@ p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 300},
check("300 W under a 2000 W ceiling is accepted", p == [])
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
-5000, 5000)
check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
check("a threshold AT max_w is refused", len(p) == 1 and "can never trip" in p[0])
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W
# max_w is also below the 2000 W shipped maintenance charge - so without this
@@ -161,7 +162,7 @@ check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
# `len(p) == 1` below would be measuring the interaction rather than this rule.
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000,
"maintenance_charge_w": 500}, -5000, 5000)
check("a threshold above max_w is refused", len(p) == 1 and "never fire" in p[0])
check("a threshold above max_w is refused", len(p) == 1 and "can never trip" in p[0])
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
-5000, 5000)
check("one watt under the ceiling is reachable, so accepted", p == [])
@@ -368,10 +369,19 @@ check("0 W actually reached the device", 0.0 in hass.writes)
check("and it got there through the failsafe claim, not by accident",
"failsafe" in ctrl.reason)
print(f" status line: {ctrl.reason!r}")
check("the status page says so in words",
any("SIGN-DISAGREEMENT FAILSAFE" in c["text"] for c in ctrl.checks()))
check("the status page says so in words: a red row, at 0 W, cleared by start",
any(c.get("id") == "direction" and not c["ok"] and not c["warn"]
and "set to 0 W" in c["text"] and "start control" in c["text"]
for c in ctrl.checks()))
check("the banner leads with it",
"FAILSAFE" in ctrl.status_payload()["banner"])
ctrl.status_payload()["banner"] == messages.banner_direction(False))
check("...the line under the buttons says it in plain words, and the action "
"log with the raw reason is still reachable in the status JSON",
ctrl.status_payload()["hint"].endswith(
messages.hint("direction", unit=ctrl.inverters[0].name))
and ctrl.status_payload()["events"] == ctrl.events
and any(e.split(" ", 1)[1].startswith("FAILSAFE ")
for e in ctrl.status_payload()["events"]))
check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
@@ -478,7 +488,7 @@ check("...commands 0 W from its very first cycle", post.target == 0.0)
check("...and never commanded anything else after the restart",
hass2.writes != [] and set(hass2.writes) == {0.0})
check("the banner still leads with the failsafe",
"FAILSAFE" in post.status_payload()["banner"])
post.status_payload()["banner"] == messages.banner_direction(False))
# ⚠️ TWO PRESSES, and the first one must NOT clear it. `auto` is True while
# tripped, so the first press stops control; only the transition back to True
# clears. That is the documented behaviour and it is worth an assertion, because
@@ -951,12 +961,25 @@ check("...and it got there through the `circulation` claim, by name, rather "
"circulation" in _c.reason)
print(f" status line: {_c.reason!r}")
check("the status page says so in words, and says the meter cannot see it",
any("CIRCULATING POWER" in ck["text"] and "meter cannot see" in ck["text"]
for ck in _c.checks()))
any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
and "meter cannot see" in ck["text"] for ck in _c.checks()))
check("the banner leads with it - above the sign failsafe, because a sign "
"disagreement is visible on every dashboard in the house and this is "
"visible nowhere at all",
"CIRCULATING POWER" in _c.status_payload()["banner"])
_c.status_payload()["banner"] == messages.banner_circulating())
# UX-09: the red shadow banner no longer names the latch, so the latch's own
# red row must stay on the list in shadow mode, name it and say how it clears.
_c_mode = _c.o.get("shadow_mode")
_c.o["shadow_mode"] = "on"
check("in shadow mode the banner goes red and says a safety stop stays on, and "
"the circulating row is still red and says only starting control clears "
"it",
_c.status_payload()["level"] == "bad"
and "safety stop" in _c.status_payload()["banner"]
and any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
and "Only starting control clears this" in ck["text"]
for ck in _c.checks()))
_c.o["shadow_mode"] = _c_mode
_pub = {}
_c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
@@ -1027,7 +1050,8 @@ check("...no `circulation` claim is ever filed, so the arbitration is "
check("...and the status page says n/a rather than claiming to watch something "
"that cannot happen - an 'armed' row for an identically-zero quantity is "
"a guard advertising work it is not doing",
any("circulating power: n/a at one inverter" in ck["text"]
any(ck.get("id") == "circulating" and ck["ok"]
and "not needed with one inverter" in ck["text"]
for ck in _c1.checks()))
_pub1 = {}
_c1.mqtt = type("P", (), {"enabled": True,
@@ -1055,8 +1079,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)
@@ -1105,17 +1129,49 @@ check("...under a fixed notification_id of its own, so a second episode "
for c in _notes1 + _notes2)
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
_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")
@@ -1240,8 +1296,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 giving power while shed was charging"
_WHO_S = "shed was giving power 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 "
@@ -1262,12 +1318,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")
@@ -1876,9 +1934,23 @@ check("...the latch is FLAT - `site_sign_tripped`, beside SAFETY-31's pair - "
check("the status page carries a SITE row of its own, and the banner says the "
"site's TOTAL command opposed the meter rather than naming an inverter - "
"naming one would send an operator to swap the wrong box",
any("SITE SIGN-DISAGREEMENT FAILSAFE" in ck["text"]
any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"] and "every inverter" in ck["text"]
for ck in _c31.checks())
and "total command" in _c31.status_payload()["banner"])
and "Total power" in _c31.status_payload()["banner"]
and "inverter-" not in _c31.status_payload()["banner"])
_c31_mode = _c31.o.get("shadow_mode")
_c31.o["shadow_mode"] = "on"
check("UX-09: in shadow mode the banner goes red and says a safety stop stays "
"on, and the site row is still red and says only starting control "
"clears it",
_c31.status_payload()["level"] == "bad"
and "safety stop" in _c31.status_payload()["banner"]
and any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"]
and "Only starting control clears this" in ck["text"]
for ck in _c31.checks()))
_c31.o["shadow_mode"] = _c31_mode
_pub30 = {}
_c31.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub30.update(v),
@@ -2107,16 +2179,16 @@ check("⚠️ ...AND THE SITE DETECTOR IS NEVER ARMED. Not 'did not trip', and n
_n1_site_calls == []
and not _c1s.site_sign_wd.tripped
and _c1s.site_sign_wd.disagree_since is None)
check("...the banner is the sentence that shipped",
_c1s.status_payload()["banner"]
== "FAILSAFE — the command opposed the meter. Inverter held at 0 W.")
check("...the banner is the one-inverter sentence, naming the meter",
_c1s.status_payload()["banner"] == messages.banner_direction(False)
and "meter" in messages.banner_direction(False))
check("...the status page carries ONE sign row, with no unit tag and no site "
"row - an extra row for an identically-duplicated question is a page "
"advertising two guards where the install has one",
len([ck for ck in _c1s.checks()
if "sign watchdog" in ck["text"] or "SIGN-DISAGREEMENT" in ck["text"]
]) == 1
and not any("site sign" in ck["text"].lower() for ck in _c1s.checks()))
len([ck for ck in _c1s.checks() if ck.get("id") == "direction"]) == 1
and not any(ck.get("id") == "site_direction" for ck in _c1s.checks())
and not any("inverter-" in ck["text"] for ck in _c1s.checks()
if ck.get("id") == "direction"))
check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason)
@@ -2288,12 +2360,12 @@ check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED,
"the page then leads with whatever the next branch says",
_bn_b["level"] == _bn_a["level"] == "bad"
and _bn_b["banner"] == _bn_a["banner"]
and _bn_b["banner"].startswith("FAILSAFE")
and "its own measured power" in _bn_b["banner"])
and _bn_b["banner"] == messages.banner_direction(True)
and "own power" in _bn_b["banner"])
check("...and the counterfactual again: the same site with nothing latched "
"carries neither that level nor that sentence, so the banner really is "
"being produced by the reduction under test",
not _bn_n["banner"].startswith("FAILSAFE")
_bn_n["banner"] != messages.banner_direction(True)
and _bn_n["banner"] != _bn_b["banner"])
+139 -141
View File
@@ -34,269 +34,267 @@
# is approximated by ORDER plus the `Group · ` prefix in each label. Do not
# promise the operator visual sections; there are none.
#
# ⚠️ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM. The Configuration tab
# presents a number read off 1890 closed-loop test runs and the operator's own
# inverter rating as the same kind of field. The descriptions are the only place
# that difference can be stated, so a derived value names its campaign and says
# "do not change without re-deriving", and a value that is genuinely the
# operator's says so too.
# Names and descriptions follow STYLE.md (UX-08): a short label with its unit,
# and one or two plain sentences. A measured or derived value says "leave it
# unchanged", and where it came from is in DOCS.md under "For installers:
# where the numbers come from".
configuration:
setpoint_entity:
name: "Hardware · Inverter setpoint entity"
description: "The ESPHome number entity this add-on writes the power setpoint to, normally number.*_goodwe_setpoint_w. This is the only thing the add-on commands. Leave it empty if you fill in the inverter list below: a site with a roster must not also name one loose actuator, and the add-on refuses to start when both are set."
description: "The ESPHome number entity the add-on writes the inverter's power to. Leave it empty when you fill in the inverter list."
soc_entity:
name: "Hardware · Battery charge level entity (%)"
description: "The sensor that reports the pack's state of charge in percent. Use the ESP32's own reading, not a figure computed somewhere else. The maintenance cycle and every charge-level floor read this."
name: "Hardware · Battery level entity (%)"
description: "The sensor with the battery level in percent. Use the control board's own reading."
batt_entity:
name: "Hardware · Battery power entity (W)"
description: "The sensor that reports this inverter's own battery power, positive while discharging. On this installation that is holding register 0x0518, read by the ESP32. The sign watchdog and the circulating-power alarm both grade the inverter against this reading."
description: "The sensor with this inverter's battery power. Positive must mean the battery is giving power."
batt_invert:
name: "Hardware · Battery power sign is reversed"
description: "Only switch this on if your battery power sensor reports discharging as a negative number. The add-on expects positive to mean discharging."
description: "Switch this on only if your battery power sensor shows giving power as a negative number."
inverters:
name: "Hardware · Inverter list (leave empty if you have one inverter)"
description: "Leave this empty unless the site really has more than one inverter. EMPTY DOES NOT MEAN 'not configured yet': it means the four entity settings above, together with the inverter rating and the battery cut-out under Limits, describe the one inverter you have. That is what every existing installation reads, and nobody has to touch anything. Filling this in REPLACES those settings instead of extending them, and nothing is inherited: each entry has to name its own actuator, its own rating and its own battery cut-out, so that no inverter is ever judged against another unit's numbers. The flat setpoint entity above must then be left empty."
name: "Hardware · Inverter list"
description: "Leave it empty if you have one inverter. With two or more, add one entry per inverter, which replaces the inverter settings above."
fields:
name:
name: "Name"
description: "A short identifier for this inverter, for example inverter-a. It gives this unit its own namespace for its safety latch, heartbeat and write bookkeeping in the add-on's state file, so two units may not share one."
description: "A short, unique name for this inverter, for example inverter-a."
setpoint_entity:
name: "Setpoint entity"
description: "This inverter's own ESPHome number.*_goodwe_setpoint_w. Two entries may not name the same one - two writers on one actuator is refused at startup."
description: "This inverter's own ESPHome setpoint entity. Two entries may not use the same one."
max_w:
name: "Inverter rating (W)"
description: "The supervised ceiling on what may be written to THIS inverter; the site's ceiling is the sum over the list. Required of every entry: an entry without one would have to borrow a rating, and judging one inverter against another unit's numbers is exactly the fault this per-entry list exists to remove."
description: "The most power the add-on may ask of this inverter. The site total is the sum of the list."
bms_soc_floor:
name: "Battery cut-out level (%)"
description: "The percent at which THIS pack's own battery management system cuts out. Read it off the pack that is actually installed (register 0x0560) before that pack is ever commanded; do not copy another unit's figure. Required of every entry, deliberately: an optional one would be a defaulted one, and one default for two batteries of different models protects the shallower pack with the deeper pack's number."
description: "The level at which this battery's own management system stops giving power. Read it off the battery that is installed."
soc_entity:
name: "Battery charge level entity (%) (optional)"
description: "This pack's own state of charge. Leave it empty if this unit reports none - that is what it means, and it never falls back to another unit's sensor."
name: "Battery level entity (%) (optional)"
description: "This battery's own level sensor. Leave it empty if this inverter has none."
batt_entity:
name: "Battery power entity (W) (optional)"
description: "This unit's own battery power, positive while discharging. Leave it empty if this unit reports none; nothing is inherited from another entry."
description: "This inverter's own battery power sensor. Leave it empty if it has none."
batt_invert:
name: "Battery power sign is reversed (optional)"
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
description: "Switch this on if this inverter's sensor shows giving power as a negative number."
capacity_kwh:
name: "Usable battery capacity (kWh) (optional)"
description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in."
description: "This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left."
estop_fitted:
name: "Hardware · RS485 emergency stop is fitted"
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
description: "Switch this on if the emergency stop is installed. It only changes a warning on the status page."
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 - 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."
name: "Hardware · Solar production entity (W)"
description: "Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control."
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."
name: "Hardware · Solar production entities, per inverter (W)"
description: "Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded."
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."
name: "Hardware · Reversed-sign solar entities"
description: "Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up."
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."
description: "How the add-on reads your meter. 'off' uses “Grid power entity” below, and only homewizard_local can spot a stuck meter."
meter_entity:
name: "Meter · Grid power entity (W)"
description: "The net grid power sensor, used when the meter source is 'off'. POSITIVE MUST MEAN DRAWING FROM THE GRID. Get this the wrong way round and the loop drives the battery in the wrong direction; use the sign switch below rather than a template sensor."
description: "Used when the meter source is 'off'. Positive must mean drawing from the grid."
meter_invert:
name: "Meter · Grid power sign is reversed"
description: "Switch this on if your grid sensor reports drawing from the grid as a negative number."
description: "Switch this on if your grid sensor shows drawing from the grid as a negative number."
meter_phases:
name: "Meter · Number of phases (1 or 3)"
description: "Must match your connection. A telegram whose phase count disagrees is rejected as it arrives and logged, so a wrong value shows up straight away as 'no telegrams accepted' rather than as a quietly wrong number. The range accepts 2, but that is not a real Belgian connection."
description: "Must match your grid connection. Meter readings with a different number of phases are rejected."
p1_host:
name: "Meter · HomeWizard P1 address"
description: "For meter source homewizard_local only: the meter's own address on your network, as host or host:port, for example 192.168.2.250."
description: "For homewizard_local only. The meter's address on your network, as host or host:port."
meter_poll_s:
name: "Meter · Seconds between meter polls"
description: "For meter source homewizard_local only. 5 s is the meter's own update rate, so there is nothing to gain below it. It must stay well under the staleness limit below - the reading is never fresher than this interval - and the add-on refuses to start if it is at or above that limit."
name: "Meter · Time between meter reads (s)"
description: "For homewizard_local only. The meter updates every 5 s, so lower gains nothing. Keep it well below “Reading too old after”."
meter_max_age_s:
name: "Meter · A reading counts as stale after (s)"
description: "Beyond this the grid figure counts as missing. On its own it does not command 0 W: the 'inputs missing' timer under Advanced starts only after this one has expired, and the two stack. 30 s is six polls of headroom at the default poll interval."
name: "Meter · Reading too old after (s)"
description: "After this a meter reading counts as missing. The “Missing inputs time limit” starts only after this one runs out."
meter_mqtt_topic:
name: "Meter · MQTT topic"
description: "For meter source mqtt_p1 only: the topic the P1 readings are published on."
description: "For mqtt_p1 only. The topic your meter readings are published on."
p1_import_entity:
name: "Meter · Import register entity (W)"
description: "The UNSIGNED consumption register. Do not point this at a signed template sensor: the add-on works out net power itself as import minus export, so the sign convention lives in tested code rather than in unreviewed YAML."
name: "Meter · Grid import entity (W)"
description: "The sensor for power drawn from the grid, never negative. The add-on works out the net value itself."
p1_export_entity:
name: "Meter · Export register entity (W)"
description: "The UNSIGNED injection register, the counterpart of the import register above. Same rule: no signed template sensor."
name: "Meter · Grid export entity (W)"
description: "The sensor for power fed into the grid, never negative. It pairs with “Grid import entity”."
p1_net_entity:
name: "Meter · Signed net power entity (W)"
description: "For meter source ha_signed only. ONE signed sensor: positive is drawn from the grid, negative is fed back into it. Do not split it into two template sensors - the add-on does that split itself, so that it is tested."
description: "For ha_signed only. One sensor that is positive when drawing from the grid and negative when feeding in."
p1_phase_import_entities:
name: "Meter · Per-phase import entities (L1, L2, L3)"
description: "Optional, in L1..L3 order. Needed for the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases above."
name: "Meter · Import entities per phase (L1-L3)"
description: "Optional, in L1, L2, L3 order. Needed for the peak limit on a three-phase connection, one entry per phase."
p1_phase_export_entities:
name: "Meter · Per-phase export entities (L1, L2, L3)"
description: "Optional, in L1..L3 order, alongside the per-phase import entities above. The number of entries must equal the number of phases."
name: "Meter · Export entities per phase (L1-L3)"
description: "Optional, in L1, L2, L3 order, next to the import entities per phase. One entry per phase."
p1_phase_net_entities:
name: "Meter · Per-phase signed net entities (L1, L2, L3)"
description: "For meter source ha_signed only. Optional, in L1..L3 order, each signed the same way as the net entity above. Same job as the per-phase import and export entities: the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases."
name: "Meter · Net entities per phase (L1-L3)"
description: "For ha_signed only. Optional, in L1, L2, L3 order, signed like “Signed net power entity”, one entry per phase."
max_w:
name: "Limits · Inverter rating (W)"
description: "THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may ever be commanded, in watts. Set it to what your inverter is actually rated for, start low and raise it once the site has been commissioned. It is not read at all when the inverter list is filled in - each entry then carries its own, and the site ceiling is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not."
description: "Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning."
bms_soc_floor:
name: "Limits · Battery cut-out level (%)"
description: "MEASURED, not chosen: the percent at which the pack's OWN battery management system stops discharging, whatever this add-on is set to. Register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure. It is here so the add-on can refuse a capacity-guard floor at or below it: such a floor could never bind, the BMS would win first, and the status page would report an energy backstop that does not exist. Not read when the inverter list is filled in - each entry then states its own pack's cut-out."
description: "The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure."
peak_cap_w:
name: "Limits · Capacity-tariff target (W)"
description: "THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tries to stay under. Take it from your grid contract - in Belgium it is the capacity-tariff peak your distribution charge is billed on."
name: "Limits · Peak limit (W)"
description: "Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract."
peak_forecast_entity:
name: "Limits · Quarter-hour demand forecast entity (W)"
description: "Optional. A forecast of the current billing quarter's average demand. Leave it empty and there is no capacity cap at all."
name: "Limits · 15-minute demand forecast entity (W)"
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
peak_soc_floor:
name: "Limits · Peak guard will not discharge below (%)"
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
name: "Limits · Peak reserve (%)"
description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged."
price_provider:
name: "Tariff · Dynamic price source"
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed. ⚠️ THREE IDS IN THIS LIST ARE WITHDRAWN AND WILL NOT START: engie_dynamic, totalenergies_dynamic and eneco_eddy. Their endpoints do not exist and never did, so those plugins never once fetched a price - a site set to one of them has silently been running on the fixed or day/night tariff below, or on nothing, since the day it was set. They are still offered here only so that such a site gets that explanation at startup instead of an unreadable schema error. Choose generic_dayahead for the wholesale curve with a flat markup, custom_formula to enter your own contract's B x index + A coefficients off your tariff card, manual_csv for a file you write yourself, or off."
description: "Where prices come from. Choosing a source starts the price fetch, even with trading off."
price_markup_eur_kwh:
name: "Tariff · Supplier markup (EUR/kWh)"
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under manual_csv, whose figures are all-in, it is ignored, and logged as ignored, never applied twice."
description: "Your supplier's margin. It is added only when the price source does not already include it."
price_taxes_eur_kwh:
name: "Tariff · Distribution, levies and VAT (EUR/kWh)"
description: "Everything on your bill that is not the energy itself - distribution, levies, excise and VAT - per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them. ⚠️ THIS NUMBER IS NOT A PREFERENCE AND IT IS NOT YOUR INVERTER'S. It ships at 0.11736, DERIVED in TARIFF-18 from the Eneco residential card for Flanders of September 2026 including 6% VAT: Fluvius distribution for Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. The energiebijdrage is 0 since 1 August 2026 - it was merged into the excise, so a figure copied from an older guide would count it twice. ⚠️ CHECK IT AGAINST YOUR OWN DSO. Only the distribution row moves: across the eight Fluvius DSOs it runs from 5,28 c/kWh in Midden-Vlaanderen to 6,70 in West-Vlaanderen, so the Flemish total runs from about 0.117 to 0.131. In Wallonia or Brussels the distribution tariff has a separate day and night rate and does not belong in this field at all - use the levies-by-time-of-day option below instead. ⚠️ CHANGING IT MOVES NO TRADING DECISION: the same amount is added to every hour, so it cancels out of both the cheapest hour and the spread. What it does move is every price you are shown, and the rule that refuses to discharge into an hour whose all-in price is below zero."
description: "Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates."
export_price_eur_kwh:
name: "Tariff · Injection price (EUR/kWh)"
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
description: "What you are paid per kWh fed into the grid, all in. It may be negative."
static_profile:
name: "Tariff · Fixed or day/night tariff (one line of JSON)"
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
name: "Tariff · Fixed or day/night tariff"
description: "Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback."
price_taxes_profile:
name: "Tariff · Levies and VAT by time of day (one line of JSON)"
description: "Leave this empty unless your network tariff really does charge a different day and night rate - in Flanders it does not, Fluvius distribution is one rate, and the flat figure above is correct. In Wallonia and Brussels it does: ORES charges 13,27 c/kWh by day against 7,39 at night, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10. Written as a single line of JSON in exactly the same shape as the fixed or day/night tariff above, with the same rule that the periods must cover every day type once with no gap and no overlap. ⚠️ THE NUMBERS HERE ARE THE LEVIES ALONE, NOT A FULL PRICE - the add-on adds them to whatever your price source publishes, exactly as it adds the flat figure. Why it matters: a flat figure is the same in every hour, so it moves every hour equally and cannot change which hour is cheapest - and a night network rate is cheap in the same hours the market is cheap, so flattening it makes the add-on think the day is flatter than it is and turn down battery cycles that would have paid for themselves. Set this or the flat figure above, not both: the add-on refuses to start if the two disagree."
name: "Tariff · Day/night levies and VAT"
description: "Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both."
price_formula:
name: "Tariff · Your own tariff formula (one line of JSON)"
description: "For price source custom_formula only: your own contract's two coefficients, written as a single line of JSON. Your supplier's tariff card prints the formula as B x index + A - those two numbers exist only on your card, there is no public index of them anywhere, and you are the only person who can read them. Type what the card prints, IN THE UNITS THE CARD PRINTS: every unit conversion and the VAT step are the add-on's job, because those are the two places a human silently loses a factor of 10, 100 or 1.06. Example: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ THE card BLOCK IS REQUIRED AND IT IS WHAT MAKES THIS SAFE: your card also prints the reference index it assumed and the price that comes out, so the add-on recomputes that price from your numbers and REFUSES TO START if it does not come out, telling you by how much. That catches a wrong unit, VAT left out or counted twice, a transposed digit, and the expensive one - network charges folded into the offset, which the add-on would then bill you for a second time. ⚠️ AND ITS LIMIT, IN THE SAME BREATH: it checks your coefficients against your card, not your card against reality, so two numbers copied off the wrong product agree with each other and pass. Fixed numbers only - no expression of any kind is evaluated, and an unrecognised field is refused rather than ignored."
name: "Tariff · Your own tariff formula"
description: "For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card."
price_export_formula:
name: "Tariff · Your own INJECTION formula (one line of JSON)"
description: "Optional, and only if your supplier pays you a price that moves with the market. Your tariff card has a SECOND formula for what you are paid for the electricity you export, and it is not the first one with a minus in front: Eneco prints `0,1 x BELPEX-H - 1,188` c/kWh for injection against `0,102 x ...` for consumption, and Aspiravi `0,07 x Belpex - 2`. Written in exactly the same shape as your own tariff formula above. ⚠️ INJECTION IS EXEMPT FROM VAT in Belgium while consumption is at 6%, so vat_pct here is normally 0 - that is the one field people copy across from the other formula and get wrong. ⚠️ THE PRICE THIS GIVES CAN BE NEGATIVE, and that is not an error: Eneco pays below zero whenever BELPEX-H is under 11,88 EUR/MWh, which is the middle of a sunny day - you can be charged to export. The add-on accepts that rather than hiding it. The card block is OPTIONAL here and required above, because Eneco prints the injection formula with NO example price beside it and there would be nothing to check your numbers against; if your card does print one, fill it in and the add-on will refuse to start unless your numbers reproduce it. ⚠️ WHAT THIS DOES NOT DO: no part of the add-on trades on an injection price today - it charges cheap to avoid buying dear, and both sides of that comparison are consumption prices. This is what you are shown, and what a future export strategy would read. Leave it empty and the flat injection price above is used, which is what every installation does now."
name: "Tariff · Your own injection formula"
description: "Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price."
price_csv_path:
name: "Tariff · Price CSV file"
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
description: "For manual_csv only. Where in /share the add-on reads your price file."
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere. WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN: the add-on adds nothing to it, not even the date range it normally asks its own feed for, so an override gets whatever horizon that URL returns - often a single day, with the rest of the plan carried over from yesterday and excluded from price arbitrage. A warning in the log says so on every price refresh. Clear this option to get the full horizon back."
description: "Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day."
price_horizon_h:
name: "Tariff · How far ahead to fetch prices (hours)"
name: "Tariff · Price look-ahead (hours)"
description: "How many hours of prices to ask the source for."
price_timeout_s:
name: "Tariff · Price request timeout (s)"
description: "How long a single request to the price source may take before it is given up on."
description: "How long one request to the price source may take before it is given up."
price_now_entity:
name: "Tariff · Current price entity (older price path)"
description: "The older way of getting a price, kept because retiring it would take the maintenance cycle's only price signal away from a live installation. The add-on reads this and the average below and derives exactly one thing from them - whether now is cheaper than average - which only the maintenance cycle's cheap-window charging uses. It may not be combined with a dynamic price source."
name: "Tariff · Current price entity (older)"
description: "The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source."
price_avg_entity:
name: "Tariff · Average price entity (older price path)"
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
name: "Tariff · Average price entity (older)"
description: "The average the current price is compared with. Use it only with the current price entity, not with a dynamic price source."
arbitrage_enabled:
name: "Tariff · Trade on the price spread"
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. ⚠️ LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF. A price source selected above is fetched, checked and recorded either way; this switch decides only whether the add-on ACTS on those prices. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
description: "Charge in the cheapest hour and draw the battery down in the dearest, when the spread covers the cost. Turning it off does not turn the price feed off."
cycle_cost_threshold_eur:
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
name: "Tariff · Battery cycle cost (EUR/kWh)"
description: "What one full battery cycle costs per kWh. A smaller price spread is not traded, and the value is a product choice, not a measured one."
arbitrage_soc_floor:
name: "Tariff · Trading may not discharge below (%)"
description: "The reserve price trading may not spend. Deliberately not the same key as the maintenance floor: pointing a trading strategy at the monthly balancing cycle's numbers would couple two unrelated policies through one setting."
name: "Tariff · Trading keeps at least (%)"
description: "Price trading will not draw the battery below this level. It is separate from the maintenance settings."
arbitrage_soc_ceiling:
name: "Tariff · Trading may not charge above (%)"
description: "The headroom price trading may not fill. The counterpart of the trading floor above, and kept separate from the maintenance target for the same reason."
name: "Tariff · Trading charges at most (%)"
description: "Price trading will not charge the battery above this level."
maintenance_enabled:
name: "Maintenance · Run the monthly battery cycle"
description: "A deliberate deep discharge followed by a full recharge and a hold, so the battery management system can recalibrate its charge estimate and balance the cells. Off by default."
description: "Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default."
maintenance_interval_days:
name: "Maintenance · Minimum days between cycles"
description: "The shortest gap between two maintenance cycles."
maintenance_start_hour:
name: "Maintenance · Hour of day a due cycle starts"
description: "Local hour, 0 to 23, at which a cycle that has become due begins."
name: "Maintenance · Start hour"
description: "The local hour, 0 to 23, at which a due cycle starts."
maintenance_discharge_w:
name: "Maintenance · Discharge rate (W)"
description: "How fast the cycle drains the pack; the surplus is exported. Above the inverter rating it is CLAMPED to the rating rather than refused - the cycle still completes, just more slowly, and the status page says so. It ships at 2000 to match the shipped rating; raise the rating first, then this."
name: "Maintenance · Draw-down rate (W)"
description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer."
maintenance_charge_w:
name: "Maintenance · Recharge rate (W)"
description: "⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses to start otherwise. Above it, the cheap-window charge shaping asks for more than the supervised limit allows, the arbiter resolves that contradiction to 0 W, and maintenance would silently never charge in a cheap window on a default installation. It is also capped again by whatever headroom the peak guard leaves."
description: "How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start."
maintenance_soc_floor:
name: "Maintenance · Discharge down to (%)"
description: "The depth the monthly recalibration deliberately goes to, once a month, on purpose. ⚠️ THIS IS NOT THE PEAK GUARD'S FLOOR: that one is the depth a money-saving discharge may never reach on any day. The right values genuinely differ - 11 here against 20 there - which is why they are two separate settings and must not be merged."
name: "Maintenance · Draw down to (%)"
description: "The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose."
maintenance_soc_target:
name: "Maintenance · Recharge up to (%)"
description: "The level the recharge phase aims for before the hold begins."
description: "The level the recharge aims for before the hold starts."
maintenance_hold_min:
name: "Maintenance · Hold at full for (minutes)"
description: "How long to sit at the target so the battery management system can finish balancing the cells."
description: "How long the battery stays full so its cells can balance."
sign_disagree_w:
name: "Safety · Sign watchdog threshold (W) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and the grid reading must exceed this before their opposite signs count as a disagreement at all. On the captured healthy day (2026-08-23) sign agreement over the gated samples is 93.4 %; on the captured fault day (2026-08-20) the clusters run 0.0-10.5 % with opposed runs up to 1752 s. There is no reason to move this without a new capture that says so. It must stay below the inverter rating or the watchdog can never fire, and the add-on refuses that configuration."
name: "Safety · Wrong-direction threshold (W)"
description: "Part of the safety stop for an inverter pushing the wrong way. It counts only when command and meter reading both exceed this. Measured value, leave it unchanged."
sign_disagree_s:
name: "Safety · Sign watchdog hold (s) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter must stay opposed, continuously, before the add-on commands 0 W and latches until an operator starts control again. 180 s sits in the middle of a very wide measured gap: the longest legitimate opposed run on the healthy day is 30 s, while the fault day still trips 13 times at 300 s. Do not change it without a new capture."
name: "Safety · Wrong-direction time (s)"
description: "How long a wrong direction may last before a safety stop sets every inverter to 0 W. Measured value, leave it unchanged."
unit_sign_disagree_w:
name: "Safety · Per-inverter sign threshold (W) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothing at all. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS on the two-master rig (SAFETY-30), in which nothing was faulty, swept across the whole range this add-on accepts for loop gain and slew rate. 250 W is exactly half the saturation threshold, so the smallest divergence this can see is precisely the gap at which the control law itself already says the inverter is not following. Do not change it without re-deriving it. It must stay below the inverter rating or it can never fire."
name: "Safety · Per-inverter wrong-direction threshold (W)"
description: "For two or more inverters: the wrong-direction threshold for each inverter. Derived value, leave it unchanged."
unit_sign_disagree_s:
name: "Safety · Per-inverter sign hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs produce (10.0 s), 2.8 times the firmware's own 30 s link veto, and under half the 180 s the site-wide check takes. Do not change it without re-deriving it."
name: "Safety · Per-inverter wrong-direction time (s)"
description: "For two or more inverters: how long one inverter may push the wrong way before it is set to 0 W. Derived value, leave it unchanged."
site_sign_disagree_w:
name: "Safety · Site-wide sign threshold (W) — measured, do not change"
description: "TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter threshold, CARRIED ACROSS DELIBERATELY (SAFETY-30): the argument behind it does not depend on how many inverters there are - near zero the sign is noise, the deadband is 15 W, and there is one meter whatever is behind it. Do not change it without a capture that says so."
name: "Safety · Site wrong-direction threshold (W)"
description: "For two or more inverters: the wrong-direction threshold for the whole site. Measured value, leave it unchanged."
site_sign_disagree_s:
name: "Safety · Site-wide sign hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30), and deliberately NOT carried across from the single-inverter 180 s. On those runs the worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, because the command has to travel the whole site rail at a slew rate that is not the site's - so carrying 180 s across would have shipped a false trip reachable at the lowest gain and slew with an ordinary 2.2 kW load reversal. 325 s is 1.51 times that, still inside the 900 s billing quarter, and still inside every episode of the captured fault day. Do not change it without re-deriving it."
name: "Safety · Site wrong-direction time (s)"
description: "For two or more inverters: how long the whole site may push the wrong way before a safety stop. Derived value, leave it unchanged."
meter_freeze_s:
name: "Safety · Frozen-meter alarm after (s) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answering with an UNCHANGED reading, while the command stands at the rail, before the add-on raises an alarm. Over 2399 s of this house at its settled operating point - 1194 polls, 735 of them repeating the previous reading - the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that and still inside the 900 s billing quarter. ALARM ONLY: it does not command 0 W and does not latch. ⚠️ Only the homewizard_local meter source can see this at all; on every other source the status page says NOT COVERED rather than armed. Do not change it without re-deriving it."
name: "Safety · Stuck meter alarm after (s)"
description: "An alarm when the meter repeats one reading this long while the command is at full power, with homewizard_local only. Measured value, leave it unchanged."
circulation_w:
name: "Safety · Circulating-power threshold (W) — measured, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identically zero. Two inverters pushing against each other net out to zero at the meter and are invisible to every other guard in this add-on. MEASURED ON 924 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-31): the worst legitimate opposition collapses from 505 s at a 100 W threshold to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below - under it, ordinary operation holds the sensor over the threshold for minutes and nothing shorter than the billing quarter could tell that from a fault. 700 W is 1.75 times the knee, and the extra margin is there because the second inverter's ramp and lag are still copied from the first one rather than measured. Do not change it without re-deriving it."
name: "Safety · Opposing inverters threshold (W)"
description: "For two or more inverters: how much power one may push against another before it counts. Measured value, leave it unchanged."
circulation_hold_s:
name: "Safety · Circulating-power hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
name: "Safety · Opposing inverters time (s)"
description: "For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged."
delivery_hold_s:
name: "Safety · Not-delivering report after (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
name: "Safety · Not delivering alarm after (s)"
description: "For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged."
shadow_mode:
name: "Safety · Shadow mode — run every decision, command nothing"
description: "Runs the whole decision path — strategies, arbiter, clamps and guards — and sends NOTHING to the inverter. Every inverter is held at 0 W, refreshed on the ordinary heartbeat, and the status page says plainly that the battery is not being controlled; the decision it WOULD have commanded, and why, is on the status page and in the action log. This is how you watch a site for a day before letting it trade. 'auto' (the shipped value) means shadow mode while control is stopped and ordinary control once you press Start — so a NEW install observes before it ever commands anything, and an install that is already controlling is not changed by one watt. 'on' means shadow mode always, whatever the Start / stop button says: that is the setting to pick when you want the loop and the strategies running on a live site with nothing reaching the battery. 'off' is ordinary control. ⚠️ While this is on the battery is NOT being regulated — the grid is not held at zero, no arbitrage trade is executed and no capacity peak is shaved. ⚠️ A failsafe latch that was already up stays up: shadow mode does not clear it, and the banner names it."
name: "Safety · Shadow mode"
description: "The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never."
auto_start:
name: "Advanced · Start controlling automatically on boot"
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
name: "Advanced · Start control on boot"
description: "Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart."
gain:
name: "Advanced · Loop gain"
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
name: "Advanced · Control gain"
description: "How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it."
slew_w:
name: "Advanced · Largest change per cycle (W)"
description: "How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW THE INVERTER RATING and is the recommended setting: the limit becomes about a ninth of the rating, so the loop takes the plant's own MEASURED settling time to cross its whole output range and the number cannot be left behind when the rating changes. DERIVED (SAFETY-39) from an ~18 s measured step response against a 2 s meter tick. A fixed number is accepted and is never silently reduced, but a number faster than the rating allows is refused at startup - at 1000 W against a 5000 W rating this loop drove the meter from 3.4 kW of export to 2.8 kW of import on 2026-09-05. Lower is always accepted."
description: "How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended."
deadband_w:
name: "Advanced · Deadband (W)"
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
name: "Advanced · Ignore errors below (W)"
description: "Errors smaller than this are ignored. Keep it small, since any resting point inside it holds."
target_grid_w:
name: "Advanced · Resting point at the meter (W)"
description: "What the meter should sit at when nothing is happening; negative means a slight export. -10 W is deliberate: resting at 0 W leaks about half the deadband in billed import all day - roughly 0.2 to 0.35 kWh at a 15 W deadband - and biasing the rest point below zero moves that leak into the export register, which is not billed. It costs about this many watts of given-away export, so keep it small."
name: "Advanced · Meter resting point (W)"
description: "What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day."
step_w:
name: "Advanced · Setpoint quantisation (W)"
description: "The setpoint is rounded to a multiple of this before it is written, so the inverter is not handed a new number for every watt of noise."
name: "Advanced · Command rounding (W)"
description: "The command is rounded to a multiple of this, so noise does not change it every cycle."
saturation_w:
name: "Advanced · Saturation threshold (W)"
description: "The gap between what was commanded and what the inverter delivers that counts as 'this inverter is at a limit'. ⚠️ The per-inverter sign threshold under Safety is derived as exactly half of this, so moving this moves that measurement's argument with it."
name: "Advanced · At-limit shortfall (W)"
description: "When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this."
saturation_cycles:
name: "Advanced · Saturated cycles before freezing (count)"
description: "How many consecutive saturated cycles before the loop stops winding up. ⚠️ Do not set this to 1: a single noisy cycle would then freeze the loop."
name: "Advanced · Cycles at limit before freezing"
description: "How many cycles in a row at the limit before control stops building up error. Do not set it to 1."
integrator_max_w:
name: "Advanced · Integrator bound (W, 0 means the same as the rating)"
description: "Caps how much stale error can be waiting to unwind when the sign flips. MEASURED on the closed-loop simulator with a 4000 W load dropped to zero: at a bound equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating it is 1800 W. ⚠️ Do not raise it above the inverter rating - the output clamp already bounds what reaches the wire, so extra headroom buys nothing but wrong-direction watts after every saturation event. Lowering it below the rating is the useful direction, and the only reason this is a separate setting."
name: "Advanced · Catch-up limit (W)"
description: "Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating."
heartbeat_s:
name: "Advanced · Setpoint refresh interval (s)"
description: "How often the setpoint is rewritten even when it has not changed. It must stay well under the firmware's own watchdog, which vetoes a link that has been quiet for 30 s."
name: "Advanced · Command refresh interval (s)"
description: "How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit."
stale_input_s:
name: "Advanced · Command 0 W after inputs missing for (s)"
description: "How long the inputs may be missing before the add-on commands 0 W. With a P1 meter this clock starts only AFTER the meter staleness limit has already expired, so the two stack: the total is longer than this number on its own."
name: "Advanced · Missing inputs time limit (s)"
description: "How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”."
cycle_floor_s:
name: "Advanced · Longest gap between control cycles (s)"
description: "A deadline on the gap between cycles, reset by every cycle. On a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than about 10 s, and keep it under the meter staleness limit - past that there is no reading left to evaluate anyway."
name: "Advanced · Longest time between cycles (s)"
description: "A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s."
log_level:
name: "Advanced · Log level"
description: "How much detail the add-on writes to its log. 'info' is the shipped setting; 'debug' and 'trace' are for diagnosing a problem and are noisy."
description: "How much detail the add-on writes to its log. 'debug' and 'trace' are for finding a problem."
+149 -150
View File
@@ -16,267 +16,266 @@
# volgorde van het `schema:`-blok van config.yaml; de volgorde hieronder is
# alleen een spiegel daarvan, zodat beide bestanden gelijk lezen.
#
# ⚠️ AFGELEIDE CONSTANTEN VERMELDEN WAAR ZE VANDAAN KOMEN. Het tabblad toont een
# getal dat uit 1890 gesloten-lustests komt en het omvormervermogen van de
# eigenaar als hetzelfde soort veld. De beschrijving is de enige plek waar dat
# verschil gezegd kan worden.
# Namen en beschrijvingen volgen STYLE.md (UX-08): een kort label met de
# eenheid, en een of twee eenvoudige zinnen. Waar een gemeten of afgeleide
# waarde vandaan komt, staat in DOCS.md onder "For installers".
configuration:
setpoint_entity:
name: "Hardware · Setpoint-entiteit van de omvormer"
description: "De ESPHome number-entiteit waar deze add-on het vermogen naartoe schrijft, meestal number.*_goodwe_setpoint_w. Dit is het enige wat de add-on aanstuurt. Laat leeg als u hieronder de lijst met omvormers invult: een installatie met een lijst mag niet óók één losse aansturing hebben, en de add-on start dan niet op."
description: "De ESPHome-entiteit waar de add-on het vermogen van de omvormer naartoe schrijft. Laat dit leeg als u de lijst met omvormers invult."
soc_entity:
name: "Hardware · Entiteit met het laadniveau van de batterij (%)"
description: "De sensor die de laadtoestand van de batterij in procent doorgeeft. Gebruik de meting van de ESP32 zelf, geen waarde die elders berekend wordt. De onderhoudscyclus en alle ondergrenzen voor het laadniveau lezen deze sensor."
name: "Hardware · Entiteit laadniveau (%)"
description: "De sensor met het laadniveau van de batterij in procent. Gebruik de meting van de stuurmodule zelf."
batt_entity:
name: "Hardware · Entiteit met het batterijvermogen (W)"
description: "De sensor die het batterijvermogen van deze omvormer doorgeeft, positief bij ontladen. Op deze installatie is dat holding register 0x0518, uitgelezen door de ESP32. Zowel de tekenbewaking als het alarm voor rondpompend vermogen beoordelen de omvormer aan de hand van deze meting."
name: "Hardware · Entiteit batterijvermogen (W)"
description: "De sensor met het batterijvermogen van deze omvormer. Positief moet betekenen dat de batterij stroom levert."
batt_invert:
name: "Hardware · Teken van het batterijvermogen omkeren"
description: "Zet dit alleen aan als uw sensor ontladen als een negatief getal doorgeeft. De add-on gaat ervan uit dat positief ontladen betekent."
name: "Hardware · Teken batterijvermogen omkeren"
description: "Zet dit alleen aan als uw sensor het leveren van stroom als een negatief getal toont."
inverters:
name: "Hardware · Lijst met omvormers (leeg laten bij één omvormer)"
description: "Laat dit leeg tenzij er echt meer dan één omvormer staat. LEEG BETEKENT NIET 'nog niet ingesteld': het betekent dat de vier entiteiten hierboven, samen met het omvormervermogen en de uitschakelgrens van de batterij onder Grenzen, de ene omvormer beschrijven die u hebt. Dat is wat elke bestaande installatie leest, zonder dat iemand iets hoeft aan te passen. Vult u de lijst wel in, dan VERVANGT die de instellingen hierboven in plaats van ze aan te vullen, en wordt er niets overgenomen: elk item moet zijn eigen aansturing, zijn eigen vermogen en zijn eigen uitschakelgrens vermelden, zodat geen enkele omvormer ooit beoordeeld wordt op de cijfers van een andere. De losse setpoint-entiteit hierboven moet dan leeg blijven."
name: "Hardware · Lijst met omvormers"
description: "Laat dit leeg als u één omvormer hebt. Hebt u er meer, voeg dan per omvormer een item toe. De lijst vervangt dan de instellingen hierboven."
fields:
name:
name: "Naam"
description: "Een korte aanduiding voor deze omvormer, bijvoorbeeld inverter-a. Ze geeft deze omvormer een eigen naamruimte voor haar veiligheidsvergrendeling, hartslag en schrijfadministratie in het statusbestand van de add-on, dus twee omvormers mogen er niet één delen."
description: "Een korte, unieke naam voor deze omvormer, bijvoorbeeld inverter-a."
setpoint_entity:
name: "Setpoint-entiteit"
description: "De eigen ESPHome-entiteit number.*_goodwe_setpoint_w van deze omvormer. Twee items mogen niet dezelfde entiteit noemen - twee schrijvers op één aansturing wordt bij het opstarten geweigerd."
description: "De eigen ESPHome-setpoint-entiteit van deze omvormer. Twee items mogen niet dezelfde gebruiken."
max_w:
name: "Vermogen van deze omvormer (W)"
description: "De bewaakte bovengrens van wat er naar DEZE omvormer geschreven mag worden; het plafond van de installatie is de som over de lijst. Verplicht bij elk item: een item zonder deze waarde zou een vermogen moeten lenen, en een omvormer beoordelen op de cijfers van een andere is precies de fout die deze lijst per omvormer moet wegnemen."
description: "Het hoogste vermogen dat de add-on aan deze omvormer mag vragen. Het totaal van de installatie is de som van de lijst."
bms_soc_floor:
name: "Uitschakelniveau van de batterij (%)"
description: "Het percentage waarbij het batterijmanagementsysteem van DEZE batterij uitschakelt. Lees dit af van de batterij die er werkelijk staat (register 0x0560) voordat die batterij ooit aangestuurd wordt; neem geen waarde van een andere omvormer over. Bewust verplicht bij elk item: optioneel maken zou neerkomen op een standaardwaarde, en één standaardwaarde voor twee verschillende batterijen beschermt de kleinste batterij met het getal van de grootste."
description: "Het niveau waarop het beheersysteem van deze batterij stopt met leveren. Lees het af van de batterij die er staat."
soc_entity:
name: "Entiteit met het laadniveau (%) (optioneel)"
description: "Het laadniveau van deze batterij zelf. Laat leeg als deze omvormer dat niet doorgeeft - dat is wat het betekent, en er wordt nooit teruggevallen op de sensor van een andere omvormer."
name: "Entiteit laadniveau (%) (optioneel)"
description: "De eigen sensor voor het laadniveau van deze batterij. Laat leeg als deze omvormer er geen heeft."
batt_entity:
name: "Entiteit met het batterijvermogen (W) (optioneel)"
description: "Het batterijvermogen van deze omvormer zelf, positief bij ontladen. Laat leeg als deze omvormer dat niet doorgeeft; er wordt niets van een ander item overgenomen."
name: "Entiteit batterijvermogen (W) (optioneel)"
description: "De eigen sensor voor het batterijvermogen van deze omvormer. Laat leeg als die er niet is."
batt_invert:
name: "Teken van het batterijvermogen omkeren (optioneel)"
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
name: "Teken batterijvermogen omkeren (optioneel)"
description: "Zet dit aan als de sensor van deze omvormer leveren als een negatief getal toont."
capacity_kwh:
name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)"
description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren."
name: "Bruikbare capaciteit (kWh) (optioneel)"
description: "De bruikbare capaciteit van deze batterij, van het datablad. Vul ze bij elke omvormer in of bij geen. Dan verdeelt de add-on het werk volgens de energie in elke batterij."
estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst"
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
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 - 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."
name: "Hardware · Entiteit zonneproductie (W)"
description: "Optioneel. De vermogenssensor van uw zonne-omvormer, positief tijdens productie. De add-on houdt ze bij en regelt er niet mee."
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."
name: "Hardware · Entiteiten zonneproductie, per omvormer (W)"
description: "Optioneel. Eén rij per zonne-omvormer, in plaats van de losse entiteit hierboven, als u er meerdere hebt. Valt er één weg, dan wordt er geen totaal bijgehouden."
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."
name: "Hardware · Zonne-entiteiten met omgekeerd teken"
description: "Optioneel. Zet hier elke zonne-entiteit van hierboven die negatief leest tijdens productie, precies zo gespeld. Het teken wordt omgedraaid voor het optellen."
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."
description: "Hoe de add-on uw meter uitleest. Met 'off' gebruikt ze de entiteit netvermogen hieronder. Alleen homewizard_local merkt een vastgelopen meter op."
meter_entity:
name: "Meter · Entiteit met het netvermogen (W)"
description: "De sensor met het netto netvermogen, gebruikt wanneer de meterbron 'off' is. POSITIEF MOET AFNAME VAN HET NET BETEKENEN. Staat dit omgekeerd, dan stuurt de regeling de batterij de verkeerde kant op; gebruik daarvoor de tekenschakelaar hieronder en geen template-sensor."
name: "Meter · Entiteit netvermogen (W)"
description: "Gebruikt als de meterbron 'off' is. Positief moet afname van het net betekenen."
meter_invert:
name: "Meter · Teken van het netvermogen omkeren"
description: "Zet dit aan als uw netsensor afname van het net als een negatief getal doorgeeft."
name: "Meter · Teken netvermogen omkeren"
description: "Zet dit aan als uw netsensor afname als een negatief getal toont."
meter_phases:
name: "Meter · Aantal fasen (1 of 3)"
description: "Moet overeenkomen met uw aansluiting. Een telegram met een ander aantal fasen wordt bij binnenkomst geweigerd en gelogd, zodat een verkeerde waarde meteen zichtbaar wordt als 'geen telegrammen aanvaard' in plaats van als een stil verkeerd getal. Het bereik aanvaardt 2, maar dat is geen echte Belgische aansluiting."
description: "Moet overeenkomen met uw aansluiting. Meterstanden met een ander aantal fasen worden geweigerd."
p1_host:
name: "Meter · Adres van de HomeWizard P1"
description: "Alleen voor meterbron homewizard_local: het adres van de meter zelf op uw netwerk, als host of host:poort, bijvoorbeeld 192.168.2.250."
description: "Alleen voor homewizard_local. Het adres van de meter op uw netwerk, als host of host:poort."
meter_poll_s:
name: "Meter · Seconden tussen twee uitlezingen"
description: "Alleen voor meterbron homewizard_local. 5 s is het tempo waarin de meter zelf ververst, dus lager instellen levert niets op. Deze waarde moet ruim onder de verouderingsgrens hieronder blijven - de meting is nooit verser dan dit interval - en de add-on start niet op als ze eraan gelijk is of erboven ligt."
name: "Meter · Tijd tussen twee uitlezingen (s)"
description: "Alleen voor homewizard_local. De meter ververst elke 5 s, lager levert niets op. Houd dit ruim onder de grens voor een te oude meting."
meter_max_age_s:
name: "Meter · Een meting is verouderd na (s)"
description: "Daarna geldt de netwaarde als ontbrekend. Op zichzelf stuurt dit nog geen 0 W aan: de teller 'invoer ontbreekt' onder Geavanceerd begint pas te lopen nadat deze verstreken is, en de twee stapelen. 30 s is zes uitlezingen speling bij het standaard uitleesinterval."
name: "Meter · Meting te oud na (s)"
description: "Daarna telt een meterstand als ontbrekend. De tijdslimiet voor ontbrekende invoer begint pas daarna te lopen."
meter_mqtt_topic:
name: "Meter · MQTT-topic"
description: "Alleen voor meterbron mqtt_p1: het topic waarop de P1-metingen gepubliceerd worden."
description: "Alleen voor mqtt_p1. Het topic waarop uw meterstanden verschijnen."
p1_import_entity:
name: "Meter · Entiteit van het afnameregister (W)"
description: "Het afnameregister ZONDER teken. Wijs dit niet aan een template-sensor mét teken toe: de add-on berekent het nettovermogen zelf als afname min injectie, zodat de tekenafspraak in geteste code zit en niet in ongecontroleerde YAML."
name: "Meter · Entiteit afname (W)"
description: "De sensor voor afname van het net, nooit negatief. De add-on berekent het nettovermogen zelf."
p1_export_entity:
name: "Meter · Entiteit van het injectieregister (W)"
description: "Het injectieregister ZONDER teken, de tegenhanger van het afnameregister hierboven. Zelfde regel: geen template-sensor mét teken."
name: "Meter · Entiteit injectie (W)"
description: "De sensor voor injectie in het net, nooit negatief. Hoort bij de entiteit afname hierboven."
p1_net_entity:
name: "Meter · Entiteit met het nettovermogen mét teken (W)"
description: "Alleen voor meterbron ha_signed. ÉÉN sensor mét teken: positief is afname van het net, negatief is injectie. Splits die niet in twee template-sensoren op - de add-on doet die splitsing zelf, juist zodat ze getest is."
name: "Meter · Entiteit nettovermogen met teken (W)"
description: "Alleen voor ha_signed. Eén sensor die positief is bij afname en negatief bij injectie."
p1_phase_import_entities:
name: "Meter · Afname-entiteiten per fase (L1, L2, L3)"
description: "Optioneel, in de volgorde L1..L3. Nodig voor de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen hierboven."
name: "Meter · Afname-entiteiten per fase (L1-L3)"
description: "Optioneel, in de volgorde L1, L2, L3. Nodig voor de piekgrens bij een driefasige aansluiting, één item per fase."
p1_phase_export_entities:
name: "Meter · Injectie-entiteiten per fase (L1, L2, L3)"
description: "Optioneel, in de volgorde L1..L3, naast de afname-entiteiten per fase hierboven. Het aantal items moet gelijk zijn aan het aantal fasen."
name: "Meter · Injectie-entiteiten per fase (L1-L3)"
description: "Optioneel, in de volgorde L1, L2, L3, naast de afname-entiteiten per fase. Eén item per fase."
p1_phase_net_entities:
name: "Meter · Netto-entiteiten per fase mét teken (L1, L2, L3)"
description: "Alleen voor meterbron ha_signed. Optioneel, in de volgorde L1..L3, elk met hetzelfde teken als de netto-entiteit hierboven. Zelfde rol als de afname- en injectie-entiteiten per fase: de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen."
name: "Meter · Netto-entiteiten per fase (L1-L3)"
description: "Alleen voor ha_signed. Optioneel, in de volgorde L1, L2, L3, met hetzelfde teken als de netto-entiteit. Eén item per fase."
max_w:
name: "Grenzen · Vermogen van de omvormer (W)"
description: "DIT IS UW GETAL, geen afgeleide waarde: de harde bovengrens van wat er ooit gestuurd mag worden, in watt. Zet het op wat uw omvormer werkelijk aankan, begin laag en verhoog het pas na de indienstname. Het wordt helemaal niet gelezen zodra de lijst met omvormers is ingevuld - elk item draagt dan zijn eigen waarde en het plafond van de installatie is hun som. Verschillende veiligheidsdrempels moeten eronder blijven; de add-on start niet op als er één niet aan voldoet."
description: "Uw eigen getal: het hoogste vermogen dat de add-on ooit aan de omvormer mag vragen. Begin laag en verhoog het na de indienstname."
bms_soc_floor:
name: "Grenzen · Uitschakelniveau van de batterij (%)"
description: "GEMETEN, niet gekozen: het percentage waarbij het batterijmanagementsysteem van de batterij ZELF stopt met ontladen, wat u hier ook instelt. Register 0x0560 leest 10 op de referentie-GW5000S-BP. Lees deze waarde af van de batterij die er werkelijk staat in plaats van dit getal over te nemen. Ze staat hier zodat de add-on een ondergrens voor de piekbewaking op of onder deze waarde kan weigeren: zo'n ondergrens kan nooit werken, het BMS wint altijd eerst, en de statuspagina zou een reserve tonen die niet bestaat. Wordt niet gelezen zodra de lijst met omvormers is ingevuld - elk item vermeldt dan de uitschakelgrens van zijn eigen batterij."
description: "Het niveau waarop het beheersysteem van de batterij zelf stopt met leveren. Lees het af van uw batterij in plaats van een getal over te nemen."
peak_cap_w:
name: "Grenzen · Streefwaarde capaciteitstarief (W)"
description: "DIT IS UW GETAL: het gemiddelde kwartiervermogen waar de piekbewaking onder probeert te blijven. Neem het over uit uw netcontract - in België is dit de kwartierpiek waarop het capaciteitstarief van uw distributiefactuur berekend wordt."
name: "Grenzen · Piekgrens (W)"
description: "Uw eigen getal: het gemiddelde vermogen per kwartier waaronder de add-on de afname houdt. Neem het over uit uw netcontract."
peak_forecast_entity:
name: "Grenzen · Entiteit met de kwartierprognose (W)"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende factuurkwartier. Laat u dit leeg, dan is er helemaal geen piekbegrenzing."
name: "Grenzen · Entiteit kwartierprognose (W)"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."
peak_soc_floor:
name: "Grenzen · Piekbewaking ontlaadt niet onder (%)"
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
name: "Grenzen · Piekreserve (%)"
description: "De piekbeperking laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."
price_provider:
name: "Tarief · Bron van dynamische prijzen"
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten. ⚠️ DRIE IDS IN DEZE LIJST ZIJN INGETROKKEN EN STARTEN NIET MEER: engie_dynamic, totalenergies_dynamic en eneco_eddy. Hun endpoints bestaan niet en hebben nooit bestaan, dus die plugins hebben nooit één prijs opgehaald - een installatie die op zo'n bron stond, draait sindsdien stilzwijgend op het vaste of dag/nacht-tarief hieronder, of op niets. Ze staan hier alleen nog zodat zo'n installatie bij het opstarten die uitleg krijgt in plaats van een onleesbare schemafout. Kies generic_dayahead voor de groothandelscurve met een vaste marge, custom_formula om de coëfficiënten B x index + A van uw eigen contract van uw tariefkaart over te typen, manual_csv voor een bestand dat u zelf schrijft, of off."
description: "Waar de prijzen vandaan komen. Een bron kiezen start het ophalen van prijzen, ook als handelen uit staat."
price_markup_eur_kwh:
name: "Tarief · Marge van de leverancier (EUR/kWh)"
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij manual_csv, waarvan de bedragen alles inbegrepen zijn, wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
description: "De marge van uw leverancier. Ze wordt alleen opgeteld als de prijsbron ze nog niet bevat."
price_taxes_eur_kwh:
name: "Tarief · Distributie, heffingen en btw (EUR/kWh)"
description: "Alles op uw factuur dat niet de energie zelf is - distributie, heffingen, accijns en btw - per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat. ⚠️ DIT GETAL IS GEEN VOORKEUR EN HET IS NIET DAT VAN UW OMVORMER. Het staat standaard op 0.11736, AFGELEID in TARIFF-18 van de Eneco-kaart voor residentiele klanten in Vlaanderen van september 2026, inclusief 6% btw: Fluvius-distributie voor Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. De energiebijdrage is sinds 1 augustus 2026 nul - ze is opgegaan in de accijns, dus een bedrag uit een oudere gids telt ze dubbel. ⚠️ CONTROLEER HET BIJ UW EIGEN DISTRIBUTIENETBEHEERDER. Alleen de distributieregel verschilt: over de acht Fluvius-netgebieden loopt die van 5,28 c/kWh in Midden-Vlaanderen tot 6,70 in West-Vlaanderen, zodat het Vlaamse totaal ongeveer van 0.117 tot 0.131 loopt. In Wallonie en Brussel heeft het distributietarief een apart dag- en nachttarief en hoort het hier helemaal niet thuis - gebruik dan de optie voor heffingen per tijdstip hieronder. ⚠️ DIT WIJZIGEN VERANDERT GEEN ENKELE HANDELSBESLISSING: hetzelfde bedrag komt bij elk uur, dus het valt weg uit zowel het goedkoopste uur als het prijsverschil. Wat het wel verandert is elke prijs die u te zien krijgt, en de regel die weigert te ontladen in een uur waarvan de totaalprijs onder nul ligt."
description: "Netkosten, heffingen en btw per kWh, ingesteld voor Vlaanderen. Controleer het bij uw eigen netbeheerder."
export_price_eur_kwh:
name: "Tarief · Injectievergoeding (EUR/kWh)"
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."
description: "Wat u krijgt per kWh die u op het net zet, alles inbegrepen. Dit mag negatief zijn."
static_profile:
name: "Tarief · Vast of dag/nacht-tarief (één regel JSON)"
description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief."
name: "Tarief · Vast of dag/nacht-tarief"
description: "Uw vaste of dag/nacht-contract op één regel, prijzen alles inbegrepen, zoals de documentatie toont. Vul dit ook in bij een dynamisch contract. Het is de reserve als de prijsfeed uitvalt."
price_taxes_profile:
name: "Tarief · Heffingen en btw per dagdeel (één regel JSON)"
description: "Laat dit leeg tenzij uw netbeheerder werkelijk een ander dag- en nachttarief aanrekent - in Vlaanderen is dat niet zo, de Fluvius-distributie is één tarief, en het vaste bedrag hierboven klopt. In Wallonië en Brussel is dat wél zo: ORES rekent 13,27 c/kWh overdag tegen 7,39 's nachts, AIESH 15,17 tegen 8,21, Sibelga 9,39 tegen 7,10. Geschreven als één regel JSON in precies dezelfde vorm als het vaste of dag/nacht-tarief hierboven, met dezelfde regel dat de periodes elk dagtype één keer moeten bedekken zonder gat en zonder overlap. ⚠️ DE BEDRAGEN HIER ZIJN ALLEEN DE HEFFINGEN, GEEN VOLLEDIGE PRIJS - de add-on telt ze op bij wat uw prijsbron publiceert, net zoals bij het vaste bedrag. Waarom het uitmaakt: een vast bedrag is in elk uur hetzelfde, verschuift dus elk uur evenveel en kan niet veranderen welk uur het goedkoopste is - en een nachttarief van het net is goedkoop in dezelfde uren waarin de markt goedkoop is, dus door het plat te slaan lijkt de dag vlakker dan hij is en weigert de add-on batterijcycli die zichzelf zouden terugverdienen. Vul dit in óf het vaste bedrag hierboven, niet allebei: de add-on weigert te starten als de twee elkaar tegenspreken."
name: "Tarief · Heffingen en btw dag/nacht"
description: "Laat dit leeg, tenzij uw netbeheerder een ander dag- en nachttarief rekent, zoals in Wallonië en Brussel. Vul dit of het vaste bedrag hierboven in, niet allebei."
price_formula:
name: "Tarief · Uw eigen tariefformule (één regel JSON)"
description: "Alleen voor prijsbron custom_formula: de twee coëfficiënten van uw eigen contract, als één regel JSON. De tariefkaart van uw leverancier drukt de formule af als B x index + A - die twee getallen staan alleen op uw kaart, er bestaat nergens een publieke lijst van, en u bent de enige die ze kan lezen. Typ wat de kaart afdrukt, IN DE EENHEID DIE DE KAART GEBRUIKT: elke eenheidsomrekening en de btw-stap zijn het werk van de add-on, want dat zijn precies de twee plaatsen waar een mens stilzwijgend een factor 10, 100 of 1,06 verliest. Voorbeeld: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ HET BLOK card IS VERPLICHT EN MAAKT DIT VEILIG: uw kaart vermeldt ook de referentie-index die ze veronderstelt en de prijs die eruit volgt, dus de add-on berekent die prijs opnieuw uit uw getallen en START NIET als het resultaat niet klopt - met vermelding van het verschil. Dat vangt een verkeerde eenheid, btw die ontbreekt of dubbel geteld is, een omgewisseld cijfer, en de dure fout: nettarieven die in de offset verwerkt zitten en die de add-on er dan nog een tweede keer bovenop zou rekenen. ⚠️ EN DE GRENS DAARVAN, IN DEZELFDE ZIN: ze toetst uw coëfficiënten aan uw kaart, niet uw kaart aan de werkelijkheid - twee getallen van het verkeerde product kloppen onderling en komen er dus door. Alleen vaste getallen: er wordt geen enkele expressie geëvalueerd, en een onbekend veld wordt geweigerd in plaats van genegeerd."
name: "Tarief · Uw eigen tariefformule"
description: "Alleen voor custom_formula. Typ de getallen van de tariefkaart van uw leverancier op één regel, zoals de documentatie toont. De add-on controleert ze met de kaart."
price_export_formula:
name: "Tarief · Uw eigen INJECTIEformule (één regel JSON)"
description: "Optioneel, en alleen als uw leverancier u een prijs betaalt die met de markt meebeweegt. Op uw tariefkaart staat een TWEEDE formule voor wat u krijgt voor de stroom die u injecteert, en dat is niet de eerste met een minteken ervoor: Eneco drukt `0,1 x BELPEX-H - 1,188` c/kWh af voor injectie tegenover `0,102 x ...` voor afname, en Aspiravi `0,07 x Belpex - 2`. Geschreven in precies dezelfde vorm als uw eigen tariefformule hierboven. ⚠️ INJECTIE IS IN BELGIË VRIJGESTELD VAN BTW terwijl afname op 6% staat, dus vat_pct is hier normaal 0 - dat is het ene veld dat men van de andere formule overneemt en verkeerd invult. ⚠️ DE PRIJS DIE HIERUIT VOLGT KAN NEGATIEF ZIJN, en dat is geen fout: Eneco betaalt onder nul zodra BELPEX-H lager is dan 11,88 EUR/MWh, en dat is het midden van een zonnige dag - u kunt moeten betalen om te injecteren. De add-on aanvaardt dat in plaats van het te verbergen. Het blok card is hier OPTIONEEL en hierboven verplicht, omdat Eneco de injectieformule afdrukt ZONDER voorbeeldprijs ernaast en er dan niets is om uw getallen aan te toetsen; drukt uw kaart er wél een af, vul ze dan in en de add-on start niet als uw getallen die prijs niet reproduceren. ⚠️ WAT DIT NIET DOET: geen enkel onderdeel van de add-on handelt vandaag op een injectieprijs - hij laadt goedkoop op om duur te vermijden, en beide zijden van die vergelijking zijn afnameprijzen. Dit is wat u te zien krijgt, en wat een toekomstige exportstrategie zou lezen. Laat het leeg en de vaste injectieprijs hierboven wordt gebruikt, zoals elke installatie nu doet."
name: "Tarief · Uw eigen injectieformule"
description: "Optioneel. De injectieformule van uw tariefkaart, in dezelfde vorm als uw eigen tariefformule. Laat leeg om de vaste injectievergoeding te gebruiken."
price_csv_path:
name: "Tarief · CSV-bestand met prijzen"
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
description: "Alleen voor manual_csv. Waar in /share de add-on uw prijsbestand leest."
price_feed_url:
name: "Tarief · Afwijkende URL voor de prijsfeed"
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen. WAT U HIER TYPT WORDT LETTERLIJK VERSTUURD: de add-on voegt er niets aan toe, ook niet het datumbereik dat ze normaal aan haar eigen feed vraagt. Een afwijkende URL levert dus de horizon op die die URL zelf teruggeeft - vaak één dag, waarna de rest van het plan uit de prijzen van gisteren wordt overgenomen en buiten de prijsarbitrage valt. Bij elke prijsvernieuwing verschijnt daarover een waarschuwing in het logboek. Maak dit veld leeg om de volledige horizon terug te krijgen."
description: "Laat leeg om de eigen feed van de bron te gebruiken. Een afwijkende URL wordt letterlijk verstuurd en geeft vaak maar één dag terug."
price_horizon_h:
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
name: "Tarief · Prijzen vooruit (uren)"
description: "Hoeveel uur aan prijzen de add-on bij de bron opvraagt."
price_timeout_s:
name: "Tarief · Time-out voor een prijsaanvraag (s)"
description: "Hoe lang één aanvraag bij de prijsbron mag duren voordat ze wordt opgegeven."
name: "Tarief · Time-out prijsaanvraag (s)"
description: "Hoe lang één aanvraag bij de prijsbron mag duren voor ze wordt opgegeven."
price_now_entity:
name: "Tarief · Entiteit met de huidige prijs (oudere prijsweg)"
description: "De oudere manier om aan een prijs te komen, behouden omdat ze weghalen de onderhoudscyclus op een draaiende installatie zijn enige prijssignaal zou afnemen. De add-on leest deze entiteit en het gemiddelde hieronder en leidt er precies één ding uit af - of het nu goedkoper is dan gemiddeld - wat alleen gebruikt wordt om tijdens de onderhoudscyclus in een goedkoop venster te laden. Mag niet gecombineerd worden met een dynamische prijsbron."
name: "Tarief · Entiteit huidige prijs (oud)"
description: "De oudere manier om een prijs te krijgen, alleen gebruikt om tijdens onderhoud goedkoop te laden. Niet combineren met een dynamische prijsbron."
price_avg_entity:
name: "Tarief · Entiteit met de gemiddelde prijs (oudere prijsweg)"
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
name: "Tarief · Entiteit gemiddelde prijs (oud)"
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen samen met de entiteit huidige prijs, niet met een dynamische prijsbron."
arbitrage_enabled:
name: "Tarief · Handelen op het prijsverschil"
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. ⚠️ DIT UIT LATEN STAAN ZET DE PRIJSFEED NIET UIT. Een prijsbron die hierboven gekozen is, wordt hoe dan ook opgehaald, gecontroleerd en bewaard; deze schakelaar bepaalt alleen of de add-on op die prijzen HANDELT. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
description: "Laden in het goedkoopste uur en de batterij laten leveren in het duurste, als het verschil de kosten dekt. Dit uitzetten zet de prijsfeed niet uit."
cycle_cost_threshold_eur:
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
name: "Tarief · Kosten van een batterijcyclus (EUR/kWh)"
description: "Wat één volledige batterijcyclus per kWh kost. Een kleiner prijsverschil wordt niet verhandeld. De waarde is een productkeuze, geen meting."
arbitrage_soc_floor:
name: "Tarief · Handel ontlaadt niet onder (%)"
description: "De reserve die de prijshandel niet mag aanspreken. Bewust een andere instelling dan de ondergrens van het onderhoud: een handelsstrategie op de cijfers van de maandelijkse balanceercyclus richten, zou twee ongerelateerde keuzes aan één getal koppelen."
name: "Tarief · Handel houdt minstens (%)"
description: "Prijshandel laat de batterij niet onder dit niveau zakken. Dit staat los van de onderhoudsinstellingen."
arbitrage_soc_ceiling:
name: "Tarief · Handel laadt niet boven (%)"
description: "De ruimte die de prijshandel niet mag volmaken. De tegenhanger van de ondergrens hierboven, en om dezelfde reden losgehouden van het onderhoudsdoel."
name: "Tarief · Handel laadt hoogstens (%)"
description: "Prijshandel laadt de batterij niet boven dit niveau."
maintenance_enabled:
name: "Onderhoud · Maandelijkse batterijcyclus uitvoeren"
description: "Een bewuste diepe ontlading, gevolgd door volledig opladen en een rustperiode op vol, zodat het batterijmanagementsysteem zijn schatting van het laadniveau kan herijken en de cellen kan balanceren. Staat standaard uit."
name: "Onderhoud · Maandelijkse batterijcyclus"
description: "Eén keer per maand de batterij diep leegmaken, volladen en vol houden, zodat het laadniveau juist blijft. Staat standaard uit."
maintenance_interval_days:
name: "Onderhoud · Minimum aantal dagen tussen cycli"
name: "Onderhoud · Minimum dagen tussen cycli"
description: "De kortste tijd tussen twee onderhoudscycli."
maintenance_start_hour:
name: "Onderhoud · Uur waarop een geplande cyclus start"
description: "Lokaal uur, 0 tot 23, waarop een cyclus die aan de beurt is begint."
name: "Onderhoud · Startuur"
description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint."
maintenance_discharge_w:
name: "Onderhoud · Ontlaadvermogen (W)"
description: "Hoe snel de cyclus de batterij leegtrekt; het overschot gaat naar het net. Boven het vermogen van de omvormer wordt dit BEGRENSD tot dat vermogen en niet geweigerd - de cyclus loopt gewoon door, alleen trager, en de statuspagina meldt dat. Standaard 2000, afgestemd op het standaardvermogen; verhoog eerst het omvormervermogen en dan pas dit."
description: "Hoe snel de cyclus de batterij leegmaakt. Boven het vermogen van de omvormer wordt het daartoe beperkt, en duurt de cyclus gewoon langer."
maintenance_charge_w:
name: "Onderhoud · Laadvermogen (W)"
description: "⚠️ MOET GELIJK AAN OF LAGER ZIJN dan het vermogen van de omvormer; anders start de add-on niet op. Erboven vraagt het laden in een goedkoop venster meer dan de bewaakte grens toelaat, lost de arbiter die tegenstrijdigheid op naar 0 W, en zou het onderhoud op een standaardinstallatie stilzwijgend nooit laden in een goedkoop venster. Daarnaast wordt dit nog eens begrensd door de ruimte die de piekbewaking overlaat."
description: "Hoe snel de cyclus de batterij weer vollaadt. Het moet gelijk aan of lager zijn dan het vermogen van de omvormer, anders start de add-on niet."
maintenance_soc_floor:
name: "Onderhoud · Ontladen tot (%)"
description: "De diepte waar de maandelijkse herijking bewust naartoe gaat, één keer per maand, met opzet. ⚠️ DIT IS NIET DE ONDERGRENS VAN DE PIEKBEWAKING: die geeft aan hoe diep een besparende ontlading op geen enkele dag mag komen. De juiste waarden verschillen echt - 11 hier tegenover 20 daar - en daarom zijn het twee aparte instellingen die niet samengevoegd mogen worden."
description: "Het niveau tot waar de maandelijkse cyclus de batterij leegmaakt. Dit is niet de piekreserve, en de twee verschillen bewust."
maintenance_soc_target:
name: "Onderhoud · Opladen tot (%)"
description: "Het niveau waar de laadfase naartoe werkt voordat de rustperiode begint."
description: "Het niveau waartoe de cyclus oplaadt voor het vasthouden begint."
maintenance_hold_min:
name: "Onderhoud · Op vol houden gedurende (minuten)"
description: "Hoe lang er op het streefniveau gebleven wordt zodat het batterijmanagementsysteem het balanceren van de cellen kan afmaken."
name: "Onderhoud · Vol houden gedurende (minuten)"
description: "Hoe lang de batterij vol blijft zodat de cellen in evenwicht komen."
sign_disagree_w:
name: "Veiligheid · Drempel tekenbewaking (W) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Zowel het uitgestuurde setpoint als de netmeting moet boven deze waarde liggen voordat hun tegengestelde tekens überhaupt als onenigheid tellen. Op de vastgelegde gezonde dag (23-08-2026) komt het teken in 93,4 % van de beoordeelde metingen overeen; op de vastgelegde foutdag (20-08-2026) ligt dat tussen 0,0 en 10,5 %, met tegengestelde periodes tot 1752 s. Er is geen reden om hieraan te sleutelen zonder een nieuwe meetcampagne die dat onderbouwt. De waarde moet onder het omvormervermogen blijven, anders kan de bewaking nooit aanslaan, en de add-on weigert die instelling."
name: "Veiligheid · Drempel verkeerde richting (W)"
description: "Deel van de veiligheidsstop voor een omvormer die de verkeerde kant op duwt. Het telt pas als commando en meterstand allebei boven deze waarde liggen. Gemeten waarde, laat ze ongewijzigd."
sign_disagree_s:
name: "Veiligheid · Wachttijd tekenbewaking (s) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Hoe lang het setpoint en de meter onafgebroken tegengesteld moeten blijven voordat de add-on 0 W stuurt en vergrendelt tot iemand de regeling opnieuw start. 180 s ligt midden in een zeer brede gemeten marge: de langste terechte tegengestelde periode op de gezonde dag duurt 30 s, terwijl de foutdag bij 300 s nog altijd 13 keer aanslaat. Verander dit niet zonder nieuwe metingen."
name: "Veiligheid · Tijd verkeerde richting (s)"
description: "Hoe lang een verkeerde richting mag duren voor een veiligheidsstop elke omvormer op 0 W zet. Gemeten waarde, laat ze ongewijzigd."
unit_sign_disagree_w:
name: "Veiligheid · Tekendrempel per omvormer (W) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer doet deze instelling niets. AFGELEID UIT 1890 GESLOTEN-LUSTESTS op de testopstelling met twee omvormers (SAFETY-30), waarbij niets defect was, over het volledige bereik dat deze add-on toelaat voor versterking en volgsnelheid. 250 W is precies de helft van de verzadigingsdrempel, zodat het kleinste verschil dat hier zichtbaar is, exact de afwijking is waarbij de regeling zelf al zegt dat de omvormer niet volgt. Verander dit niet zonder de afleiding opnieuw te maken. De waarde moet onder het omvormervermogen blijven, anders kan ze nooit aanslaan."
name: "Veiligheid · Drempel verkeerde richting per omvormer (W)"
description: "Bij twee of meer omvormers: de drempel voor verkeerde richting per omvormer. Afgeleide waarde, laat ze ongewijzigd."
unit_sign_disagree_s:
name: "Veiligheid · Wachttijd per omvormer (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30): 85 s is 8,5 keer de langste terechte tegengestelde periode die die tests opleveren (10,0 s), 2,8 keer het eigen veto van 30 s in de firmware, en minder dan de helft van de 180 s die de installatiebrede controle neemt. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Tijd verkeerde richting per omvormer (s)"
description: "Bij twee of meer omvormers: hoe lang één omvormer de verkeerde kant op mag duwen voor ze op 0 W gaat. Afgeleide waarde, laat ze ongewijzigd."
site_sign_disagree_w:
name: "Veiligheid · Tekendrempel voor de hele installatie (W) — gemeten, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. Dezelfde 300 W als de drempel voor één omvormer, BEWUST OVERGENOMEN (SAFETY-30): de onderbouwing hangt niet af van het aantal omvormers - rond nul is het teken ruis, de dode zone is 15 W, en er is één meter, wat er ook achter hangt. Verander dit niet zonder metingen die dat onderbouwen."
name: "Veiligheid · Drempel verkeerde richting installatie (W)"
description: "Bij twee of meer omvormers: de drempel voor verkeerde richting van de hele installatie. Gemeten waarde, laat ze ongewijzigd."
site_sign_disagree_s:
name: "Veiligheid · Wachttijd voor de hele installatie (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30), en bewust NIET overgenomen van de 180 s voor één omvormer. In die tests duurt de langste terechte tegengestelde periode bij 300 W 110 s met één omvormer en 215 s met twee, omdat het commando de volledige installatiegrens moet doorlopen aan een volgsnelheid die niet die van de installatie is. 180 s overnemen zou dus een vals alarm hebben opgeleverd bij de laagste versterking en volgsnelheid en een gewone belastingomkering van 2,2 kW. 325 s is 1,51 keer die waarde, blijft binnen het factuurkwartier van 900 s en binnen elke episode van de vastgelegde foutdag. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Tijd verkeerde richting installatie (s)"
description: "Bij twee of meer omvormers: hoe lang de hele installatie de verkeerde kant op mag duwen voor een veiligheidsstop. Afgeleide waarde, laat ze ongewijzigd."
meter_freeze_s:
name: "Veiligheid · Alarm vastgelopen meter na (s) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-18). Hoe lang de meter mag blijven antwoorden met een ONVERANDERDE waarde, terwijl het commando op de grens staat, voordat de add-on alarm slaat. Over 2399 s van deze woning op haar ingeregelde werkpunt - 1194 uitlezingen, waarvan 735 een herhaling van de vorige waarde - duurt de langste periode zonder een nieuwe waarde 15,4 s; 300 s is 19,5 keer zoveel en blijft binnen het factuurkwartier van 900 s. ALLEEN EEN ALARM: er wordt geen 0 W gestuurd en er wordt niets vergrendeld. ⚠️ Alleen de meterbron homewizard_local kan dit überhaupt zien; bij elke andere bron meldt de statuspagina NIET GEDEKT in plaats van actief. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Alarm vastgelopen meter na (s)"
description: "Een alarm als de meter zo lang dezelfde waarde herhaalt terwijl het commando op vol vermogen staat, alleen met homewizard_local. Gemeten waarde, laat ze ongewijzigd."
circulation_w:
name: "Veiligheid · Drempel rondpompend vermogen (W) — gemeten, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer is deze grootheid altijd nul. Twee omvormers die tegen elkaar in duwen, heffen elkaar aan de meter op en zijn onzichtbaar voor elke andere bewaking in deze add-on. GEMETEN OVER 924 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-31): de langste terechte tegenwerking valt terug van 505 s bij een drempel van 100 W naar 5 s bij 400 W, en 400 W is de scherpe knik waaronder deze add-on u niet laat gaan - daaronder houdt gewone werking de sensor minutenlang boven de drempel en kan niets korter dan het factuurkwartier dat van een storing onderscheiden. 700 W is 1,75 keer de knik; die extra marge is er omdat het aanloopgedrag van de tweede omvormer nog gekopieerd is van de eerste en niet gemeten. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Drempel tegenwerkende omvormers (W)"
description: "Bij twee of meer omvormers: hoeveel vermogen de ene tegen de andere in mag duwen voor het telt. Gemeten waarde, laat ze ongewijzigd."
circulation_hold_s:
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Tijd tegenwerkende omvormers (s)"
description: "Bij twee of meer omvormers: hoe lang ze tegen elkaar in mogen duwen voor een veiligheidsstop. Afgeleide waarde, laat ze ongewijzigd."
delivery_hold_s:
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
name: "Veiligheid · Alarm niet-leverende omvormer na (s)"
description: "Bij twee of meer omvormers: hoe lang één omvormer te weinig mag leveren voor een alarm. Het alarm verandert niets. Afgeleide waarde, laat ze ongewijzigd."
shadow_mode:
name: "Veiligheid · Schaduwmodus — alles beslissen, niets sturen"
description: "Doorloopt het volledige beslispad — strategieën, arbiter, begrenzers en bewakingen — en stuurt NIETS naar de omvormer. Elke omvormer wordt op 0 W gehouden, ververst op de gewone hartslag, en de statuspagina zegt onomwonden dat de batterij niet wordt aangestuurd; wat er gestuurd ZOU zijn, en waarom, staat op de statuspagina en in het actielogboek. Zo bekijkt u een installatie een dag lang voordat u hem laat handelen. 'auto' (de meegeleverde waarde) betekent schaduwmodus zolang de regeling gestopt is en gewone regeling zodra u op Start drukt — een NIEUWE installatie kijkt dus eerst toe voordat ze ooit iets stuurt, en een installatie die al regelt verandert geen watt. 'on' betekent altijd schaduwmodus, wat de Start/stop-knop ook zegt: die stand kiest u als u de lus en de strategieën wilt laten draaien op een werkende installatie zonder dat er iets bij de batterij aankomt. 'off' is gewone regeling. ⚠️ Zolang dit aanstaat wordt de batterij NIET geregeld — het net wordt niet op nul gehouden, er wordt geen prijshandel uitgevoerd en er wordt geen capaciteitspiek afgevlakt. ⚠️ Een vergrendeling die al aanstond blijft aanstaan: schaduwmodus heft die niet op, en de banner noemt hem."
name: "Veiligheid · Schaduwmodus"
description: "De add-on neemt elke beslissing maar houdt elke omvormer op 0 W. 'auto' doet dit zolang de regeling gestopt is, 'on' altijd, 'off' nooit."
auto_start:
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
name: "Geavanceerd · Regeling starten bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is, zodat iemand na elke herstart op Start drukt."
gain:
name: "Geavanceerd · Versterking van de regellus"
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
name: "Geavanceerd · Versterking van de regeling"
description: "Welk deel van de resterende afwijking de regeling per cyclus wegwerkt. Gemeten op echte hardware, verhoog dit niet."
slew_w:
name: "Geavanceerd · Grootste verandering per cyclus (W)"
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. ⚠️ 0 BETEKENT VOLG HET OMVORMERVERMOGEN en is de aanbevolen instelling: de grens wordt ongeveer een negende van dat vermogen, zodat de regeling er de GEMETEN insteltijd van de installatie over doet om haar volledige bereik te doorlopen en het getal niet achterblijft wanneer het vermogen verandert. AFGELEID (SAFETY-39) uit een gemeten stapresponsie van ~18 s bij een meterinterval van 2 s. Een vast getal wordt aanvaard en wordt nooit stilzwijgend verlaagd, maar een getal sneller dan het vermogen toelaat wordt bij het starten geweigerd - bij 1000 W tegen 5000 W dreef deze regeling de meter op 2026-09-05 van 3,4 kW injectie naar 2,8 kW afname. Lager wordt altijd aanvaard."
name: "Geavanceerd · Grootste stap per cyclus (W)"
description: "Hoe ver het commando in één cyclus mag verschuiven. 0 volgt het vermogen van de omvormer en is aanbevolen."
deadband_w:
name: "Geavanceerd · Dode zone (W)"
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."
name: "Geavanceerd · Negeer afwijkingen onder (W)"
description: "Kleinere afwijkingen worden genegeerd. Houd dit klein, want elk rustpunt daarbinnen blijft staan."
target_grid_w:
name: "Geavanceerd · Rustpunt aan de meter (W)"
description: "Waar de meter moet staan wanneer er niets gebeurt; negatief betekent een lichte injectie. -10 W is bewust gekozen: rusten op 0 W lekt de hele dag door ongeveer de helft van de dode zone aan gefactureerde afname - grofweg 0,2 tot 0,35 kWh bij een dode zone van 15 W - en het rustpunt onder nul leggen verplaatst dat lek naar het injectieregister, dat niet gefactureerd wordt. Het kost ongeveer evenveel watt weggegeven injectie, dus houd het klein."
description: "Waar de meter moet staan als er niets gebeurt. Een kleine injectie voorkomt dat u de hele dag kleine afname betaalt."
step_w:
name: "Geavanceerd · Afronding van het setpoint (W)"
description: "Het setpoint wordt op een veelvoud hiervan afgerond voordat het geschreven wordt, zodat de omvormer niet voor elke watt ruis een nieuw getal krijgt."
name: "Geavanceerd · Afronding van het commando (W)"
description: "Het commando wordt afgerond op een veelvoud hiervan, zodat ruis het niet elke cyclus verandert."
saturation_w:
name: "Geavanceerd · Verzadigingsdrempel (W)"
description: "Het verschil tussen wat gevraagd werd en wat de omvormer levert dat geldt als 'deze omvormer zit aan een grens'. ⚠️ De tekendrempel per omvormer onder Veiligheid is afgeleid als precies de helft hiervan, dus deze waarde verschuiven, verschuift ook de onderbouwing daarvan."
name: "Geavanceerd · Tekort aan de grens (W)"
description: "Levert een omvormer zoveel minder dan gevraagd, dan zit ze aan haar grens. De drempel verkeerde richting per omvormer is de helft hiervan."
saturation_cycles:
name: "Geavanceerd · Verzadigde cycli voor bevriezen (aantal)"
description: "Hoeveel opeenvolgende verzadigde cycli er nodig zijn voordat de regeling stopt met verder oplopen. ⚠️ Zet dit niet op 1: één ruiscyclus zou de regeling dan al bevriezen."
name: "Geavanceerd · Cycli aan grens voor bevriezen"
description: "Hoeveel cycli na elkaar aan de grens voor de regeling stopt met afwijking opbouwen. Zet dit niet op 1."
integrator_max_w:
name: "Geavanceerd · Begrenzing van de integrator (W, 0 betekent gelijk aan het vermogen)"
description: "Begrenst hoeveel oude afwijking er kan klaarstaan om af te bouwen wanneer het teken omslaat. GEMETEN op de gesloten-lussimulator met een belasting van 4000 W die naar nul valt: bij een grens gelijk aan het omvormervermogen staat het commando twee cycli later op 1000 W, bij anderhalf keer dat vermogen op 1800 W. ⚠️ Zet dit niet hoger dan het omvormervermogen - de uitgangsbegrenzing bepaalt toch al wat er naar de omvormer gaat, dus extra ruimte levert alleen watts in de verkeerde richting op na elke verzadiging. Lager zetten dan het omvormervermogen is de nuttige richting, en de enige reden dat dit een aparte instelling is."
name: "Geavanceerd · Inhaalgrens (W)"
description: "Begrenst hoe hard de regeling blijft duwen als de batterij achterloopt. 0 betekent het vermogen van de omvormer. Zet dit niet hoger dan dat vermogen."
heartbeat_s:
name: "Geavanceerd · Verversingsinterval van het setpoint (s)"
description: "Hoe vaak het setpoint opnieuw geschreven wordt, ook als het niet veranderd is. Dit moet ruim onder de eigen bewaking van de firmware blijven, die een verbinding die 30 s stil is geweest, blokkeert."
name: "Geavanceerd · Commando opnieuw sturen na (s)"
description: "Hoe vaak het commando opnieuw gestuurd wordt, ook als het gelijk bleef. Houd dit ruim onder de eigen grens van 30 s van de stuurmodule."
stale_input_s:
name: "Geavanceerd · 0 W sturen nadat invoer ontbreekt gedurende (s)"
description: "Hoe lang de invoer mag ontbreken voordat de add-on 0 W stuurt. Bij een P1-meter begint deze teller pas te lopen NADAT de verouderingsgrens van de meter al verstreken is, dus de twee stapelen: het totaal is langer dan dit getal op zich."
name: "Geavanceerd · Tijdslimiet ontbrekende invoer (s)"
description: "Hoe lang invoer mag ontbreken voor elke omvormer op 0 W gaat. Bij een P1-meter begint dit pas na de grens voor een te oude meting."
cycle_floor_s:
name: "Geavanceerd · Langste tijd tussen twee regelcycli (s)"
description: "Een uiterste termijn voor de tijd tussen twee cycli, die door elke cyclus opnieuw begint. Bij een gezonde meter verstrijkt hij nooit en verandert hij niets. Verhoog hem alleen als uw meter trager is dan ongeveer 10 s, en houd hem onder de verouderingsgrens van de meter - daarna is er toch geen meting meer om te beoordelen."
name: "Geavanceerd · Langste tijd tussen cycli (s)"
description: "Een uiterste termijn tussen twee regelcycli die een gezonde meter nooit haalt. Verhoog dit alleen voor een meter trager dan ongeveer 10 s."
log_level:
name: "Geavanceerd · Logniveau"
description: "Hoeveel detail de add-on naar zijn logboek schrijft. 'info' is de standaardinstelling; 'debug' en 'trace' dienen om een probleem te onderzoeken en leveren veel tekst op."
description: "Hoeveel detail de add-on in zijn logboek schrijft. 'debug' en 'trace' zijn om een probleem te zoeken."
+5
View File
@@ -0,0 +1,5 @@
# UX-08 ratchet (test_messages.py part 3). Each line would be a user-facing
# string that does not follow STYLE.md yet: "<kind> <sha1-12 of its text> #
# where :: start". Phase 2d emptied it: every row, banner, refusal, option text
# and WARNING+ log line now follows STYLE.md, and ALLOWLIST_MAX is 0. Never add
# a line for a new string: write the new string to STYLE.md instead.