Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0e091cca3 | ||
|
|
44cd1fde2a | ||
|
|
e7c83d3a57 | ||
|
|
004f859ec7 | ||
|
|
ccdb9dc17a | ||
|
|
06339a2446 | ||
|
|
bf9019e70d | ||
|
|
6862dea52a | ||
|
|
c68fd275cc | ||
|
|
2f261f8ba4 | ||
|
|
0801776645 | ||
|
|
4eb88f7fda | ||
|
|
87ad958842 | ||
|
|
9ac3a9d2bc | ||
|
|
0ce7d35f80 | ||
|
|
6aa1c32cbb | ||
|
|
5ee1061398 | ||
|
|
fd059126cb | ||
|
|
21d8a944bc | ||
|
|
79dd0770f9 | ||
|
|
7f0f00b01c | ||
|
|
065792aad3 | ||
|
|
e3e4ba674d | ||
|
|
df6bc0f76b | ||
|
|
d94cb0c263 | ||
|
|
3050fa5208 | ||
|
|
92a80c09b2 | ||
|
|
9b1fa758ad | ||
|
|
e36565fe39 | ||
|
|
808ec04dc4 | ||
|
|
35432ce065 | ||
|
|
580c200527 | ||
|
|
35b2a699de | ||
|
|
cd1bf2a694 | ||
|
|
db9e2315d1 | ||
|
|
848140c29f | ||
|
|
d12228d986 | ||
|
|
bf3ac7998c | ||
|
|
61a61941bb | ||
|
|
ef35f4b925 | ||
|
|
f39e52efd3 | ||
|
|
8a17711f60 | ||
|
|
ea31289ec0 | ||
|
|
6836f61bd4 | ||
|
|
f7b83cc75c | ||
|
|
6f05b2e4be | ||
|
|
28eff568b5 | ||
|
|
b527e99f13 | ||
|
|
c6f23a69d4 | ||
|
|
98dd110a4f | ||
|
|
138e45b53f | ||
|
|
451729c9b5 | ||
|
|
1c607a961a | ||
|
|
756b50ddbb | ||
|
|
340c7eef42 | ||
|
|
4707d37019 | ||
|
|
9c0314a826 | ||
|
|
bdec66e97a | ||
|
|
b2f55732b6 | ||
|
|
956711b801 | ||
|
|
88b9cd470a | ||
|
|
1bf6b7f9a7 | ||
|
|
1d28daf8a0 | ||
|
|
0b6b34f563 | ||
|
|
08fb1235fc | ||
|
|
7ef73474dc | ||
|
|
24dbbd18ad | ||
|
|
8dfe91e1d2 | ||
|
|
9d3fbe1be9 | ||
|
|
3ed3302d2f | ||
|
|
8c6e5bca2e | ||
|
|
f01152b113 | ||
|
|
3129daed62 | ||
|
|
64dddd64bf | ||
|
|
cf705a9058 | ||
|
|
7d06aa674d | ||
|
|
88ebf3a5a6 | ||
|
|
3db2b6392f | ||
|
|
80bfc5721e | ||
|
|
6918e154c8 | ||
|
|
dd5eb6fdc8 | ||
|
|
6759d21ea5 | ||
|
|
02ed1200dc | ||
|
|
f13dab1407 | ||
|
|
b307db1049 | ||
|
|
e51e3e02b2 | ||
|
|
69dc1f0e7b | ||
|
|
858f77cfb5 | ||
|
|
214051d1a8 | ||
|
|
05eb93f139 | ||
|
|
f3e409cf76 | ||
|
|
4cd604afcb | ||
|
|
6872353e2e | ||
|
|
8c438ab2e6 | ||
|
|
b37b7f95dc | ||
|
|
28cab03947 | ||
|
|
61072ca460 | ||
|
|
7b723a6cd5 | ||
|
|
4b81387908 | ||
|
|
935878193b | ||
|
|
b4d25dd485 | ||
|
|
a4a04d203c | ||
|
|
aa501ffec7 | ||
|
|
d89c093318 | ||
|
|
d0db5b8c39 | ||
|
|
b8ddfd787f | ||
|
|
1c41a1e412 | ||
|
|
ba31e7c8b1 | ||
|
|
513183aba3 | ||
|
|
4ad8298ead | ||
|
|
5c9f64d87a | ||
|
|
43a52e7f60 | ||
|
|
9d5b7d129a | ||
|
|
bc6d744c6c | ||
|
|
79b94024cb | ||
|
|
2f5ab1f378 | ||
|
|
fbdd552555 | ||
|
|
1145a931ac | ||
|
|
56cafefa79 | ||
|
|
02c0a3dd7b | ||
|
|
3f69c174af | ||
|
|
d77f1b1fdf | ||
|
|
b171ada894 | ||
|
|
c38dd1e969 | ||
|
|
0b96e3191c | ||
|
|
35cdffe766 | ||
|
|
a692560960 | ||
|
|
fbd884797d | ||
|
|
516bc8b4ec | ||
|
|
4f25b24aa6 | ||
|
|
044fc0d5bd | ||
|
|
f967073cfe | ||
|
|
fae6681e19 | ||
|
|
aabc2a27fd | ||
|
|
af39c10893 | ||
|
|
6eb67c0f9c | ||
|
|
5d27f8496f | ||
|
|
eb10537aee | ||
|
|
5a4cdc273e | ||
|
|
7104515229 | ||
|
|
6dab4ec16b | ||
|
|
b014fd2e16 | ||
|
|
902524ae5e | ||
|
|
7a00f12c76 | ||
|
|
ce0aea30b6 | ||
|
|
e6096d3e17 | ||
|
|
7337b791e5 | ||
|
|
308adc9277 | ||
|
|
c664e38ef4 | ||
|
|
0951cb227f | ||
|
|
7995e4df0f | ||
|
|
e5a0ba23d7 | ||
|
|
a9f7d78527 | ||
|
|
b310282729 | ||
|
|
7c4c8f5c66 | ||
|
|
2d2b6afb07 | ||
|
|
ed739d4cdb | ||
|
|
ffc9a4d721 | ||
|
|
d69fd8fbfd | ||
|
|
a5dddadf45 | ||
|
|
ede01b8a04 | ||
|
|
667b8e9145 | ||
|
|
8c34647d05 | ||
|
|
39e6c260fe | ||
|
|
30cba1e58d | ||
|
|
63e7e8a133 | ||
|
|
159d7461d8 | ||
|
|
4de96752ff | ||
|
|
3198f346fd | ||
|
|
be57b87c34 | ||
|
|
515c11b380 | ||
|
|
7be09197d4 | ||
|
|
1752adf6e6 | ||
|
|
0a28f71872 | ||
|
|
c9ab48d6fc | ||
|
|
28a49ccf75 | ||
|
|
06aa09e28c | ||
|
|
1a9c7523c9 | ||
|
|
a36dca60dd | ||
|
|
fad73d4012 | ||
|
|
5a8d832eba | ||
|
|
f8d63cc614 | ||
|
|
016ed5a5a3 | ||
|
|
afd6af068d | ||
|
|
badea3cb2d | ||
|
|
15627c38a6 | ||
|
|
652097abde | ||
|
|
3c0f2b9669 | ||
|
|
1d33265aac | ||
|
|
1f97c7652f | ||
|
|
d1f7e3336e | ||
|
|
3c298af3d8 | ||
|
|
c628099e69 | ||
|
|
29837dfaa7 | ||
|
|
c4032ebc21 | ||
|
|
2f1627e287 | ||
|
|
2b14b88cb4 | ||
|
|
e0b578756f | ||
|
|
f1a660881c | ||
|
|
23a3a3f5b5 | ||
|
|
125b0702a2 | ||
|
|
b878663b2d | ||
|
|
32b9e56357 | ||
|
|
569f16aa91 | ||
|
|
6a95447248 | ||
|
|
a02cd4b713 | ||
|
|
c84d5fd14f | ||
|
|
2c516a032c | ||
|
|
e1d0970b23 | ||
|
|
63dee6db1e | ||
|
|
00e3ee9de8 | ||
|
|
d57767fe38 | ||
|
|
571b884315 | ||
|
|
696142bccc | ||
|
|
d017fc67bf | ||
|
|
5c151a3458 | ||
|
|
24f955cb26 | ||
|
|
48ef06ac1f | ||
|
|
7b65a3e69a | ||
|
|
50ccea3dd7 | ||
|
|
2503de124e | ||
|
|
55a6d5dcdb | ||
|
|
fd14569a71 | ||
|
|
59415ea83f | ||
|
|
f37268f3e4 | ||
|
|
397dd15dba | ||
|
|
6028e21298 | ||
|
|
fe132515fe | ||
|
|
00e9a7b4e1 | ||
|
|
82f032e8cf | ||
|
|
b963153de6 | ||
|
|
a2c571b20e | ||
|
|
550bd6fa52 | ||
|
|
6a8a5ffa27 | ||
|
|
8ff42ea85d | ||
|
|
841424463a | ||
|
|
c63eda0b0f | ||
|
|
54cc5263fb | ||
|
|
c493a6d67a | ||
|
|
d0891a6bca | ||
|
|
fe80047235 | ||
|
|
6e3399322a | ||
|
|
16c796fa30 | ||
|
|
20260e86ec | ||
|
|
7fa045af85 | ||
|
|
93f349a6af | ||
|
|
7559d21eda |
@@ -0,0 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
|
||||
@@ -286,8 +286,9 @@ jobs:
|
||||
run: |
|
||||
set -eu
|
||||
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
|
||||
git init -q .
|
||||
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
|
||||
git init -q . && git remote add origin "$URL"
|
||||
# Token as an env-set header: not in argv or .git/config (TOOL-9).
|
||||
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN"
|
||||
if [ -n "$PRNUM" ]; then
|
||||
# Two fetches, not one, so that a failure to reach either ref fails
|
||||
# HERE with the ref in the message, rather than inside the merge
|
||||
@@ -488,6 +489,31 @@ jobs:
|
||||
fi
|
||||
echo "OK: the \`on:\` block of the merged checks.yml carries no event filter."
|
||||
|
||||
# CI-12: the infra repo at the ONE pinned commit in .gitea/infra-pin (read
|
||||
# from the merged tree), as the sibling test_freeze.py looks for. Any
|
||||
# failure here fails the job; the tests step below refuses the stub.
|
||||
- name: check out admin/goodwe at the pin in .gitea/infra-pin
|
||||
env:
|
||||
URL: ${{ github.server_url }}/admin/goodwe.git
|
||||
# Read access to admin/goodwe only; this job's own token cannot see it.
|
||||
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
# `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9).
|
||||
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true
|
||||
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
|
||||
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
|
||||
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
|
||||
git init -q .
|
||||
# The token goes in a header via env: not in argv, and not in .git/config,
|
||||
# which is mounted into the tests' container and outlives a failed job.
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
|
||||
git fetch -q "$URL" "$PIN" || { echo "FAIL: could not fetch admin/goodwe at $PIN."; exit 1; }
|
||||
git -c advice.detachedHead=false checkout -qf FETCH_HEAD
|
||||
test "$(git rev-parse HEAD)" = "$PIN"
|
||||
test -f sim/hwsim.py || { echo "FAIL: admin/goodwe@$PIN has no sim/hwsim.py."; exit 1; }
|
||||
echo "infra: admin/goodwe @ $(git log --oneline -1)"
|
||||
|
||||
- name: build the test image (shipped deps, from apk - see .gitea/ci/Dockerfile)
|
||||
run: |
|
||||
set -eu
|
||||
@@ -508,16 +534,9 @@ jobs:
|
||||
# shorter timeout - a 60 s limit was tried during CI-01 and reported
|
||||
# exactly the false hang the docstring predicts.
|
||||
#
|
||||
# ⚠️ WHAT THIS RUN DOES NOT COVER, stated because a pass here would
|
||||
# otherwise over-claim. test_freeze.py's third section wants the infra
|
||||
# repo's `sim/hwsim.py` to drive a real HTTP meter through the real
|
||||
# transport. admin/goodwe is PRIVATE (checked via the API) and this
|
||||
# workflow's GITHUB_TOKEN is minted for THIS repository only, so hwsim is
|
||||
# absent here and `find_hwsim()` returns None. The rest of the file runs
|
||||
# and passes. It does so SILENTLY - the file prints nothing to say the
|
||||
# hwsim section was skipped - so "test_freeze.py: PASS" below is a weaker
|
||||
# claim than it looks. That belongs to whoever owns test_freeze.py next;
|
||||
# naming it here is the least this gate can do about it.
|
||||
# test_freeze.py part 3 runs against the real `sim/hwsim.py` from the
|
||||
# checkout above; GOODWE_REQUIRE_HWSIM=1 makes it fail rather than use its
|
||||
# stub (CI-12). Lines a test prints with a `CI: ` prefix are echoed on PASS.
|
||||
# ⚠️ PYTHONDONTWRITEBYTECODE=1 IS BELT AND BRACES, NOT A FIX FOR A LIVE
|
||||
# HOLE. TEST-15 measured this runner's actual exposure and found none: the
|
||||
# checkout below is bind mounted `:ro`, so the interpreter physically
|
||||
@@ -533,7 +552,7 @@ jobs:
|
||||
- name: the add-on's own tests
|
||||
run: |
|
||||
set -eu
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
docker run --rm -e PYTHONDONTWRITEBYTECODE=1 -e GOODWE_REQUIRE_HWSIM=1 -v "$WS:/w:ro" goodwe-addon-checks:1 sh -eu -c '
|
||||
cd /w/goodwe-addon/goodwe_controller
|
||||
set -- test_*.py
|
||||
if [ "$1" = "test_*.py" ]; then
|
||||
@@ -546,6 +565,7 @@ jobs:
|
||||
start=$(date +%s)
|
||||
if python3 "$f" > /tmp/out 2>&1; then
|
||||
echo "PASS $f ($(( $(date +%s) - start ))s)"
|
||||
grep "^CI: " /tmp/out || true
|
||||
else
|
||||
echo "FAIL $f ($(( $(date +%s) - start ))s)"
|
||||
tail -40 /tmp/out
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# CI-12: goes red when admin/goodwe release/1.0 has moved past .gitea/infra-pin.
|
||||
# It runs on add-on activity only; admin/goodwe's own `addon-pin` workflow
|
||||
# covers a move of release/1.0 there. Deliberately NOT a required check: it
|
||||
# reports; `checks / unit` stays the only gate. How to bump: README.md,
|
||||
# "CI and the infra pin".
|
||||
name: infra-pin
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [release/1.0]
|
||||
|
||||
jobs:
|
||||
fresh:
|
||||
runs-on: [self-hosted, goodwe-rig]
|
||||
timeout-minutes: 30 # seconds of work; the rest is queueing for the shared slot
|
||||
steps:
|
||||
- name: compare .gitea/infra-pin with admin/goodwe release/1.0
|
||||
env:
|
||||
SELF: ${{ github.server_url }}/${{ github.repository }}.git
|
||||
INFRA: ${{ github.server_url }}/admin/goodwe.git
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
|
||||
run: |
|
||||
set -eu
|
||||
D=$(mktemp -d) && trap 'cd / && rm -rf "$D"' EXIT && cd "$D"
|
||||
git init -q .
|
||||
# The pin of the commit this event is about (the PR head, or the push).
|
||||
# Token as an env-set header: not in argv or .git/config (TOOL-9).
|
||||
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
|
||||
git fetch -q --depth 1 "$SELF" "$GITHUB_SHA" || { echo "FAIL: could not fetch $GITHUB_SHA here."; exit 1; }
|
||||
# `|| true`: under the runner's pipefail a missing or comment-only pin
|
||||
# would stop here on git's error instead of the FAIL line below (TOOL-9).
|
||||
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]') || true
|
||||
test -n "$PIN" || { echo "FAIL: .gitea/infra-pin is missing at $GITHUB_SHA, or holds only comments."; exit 1; }
|
||||
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
|
||||
# Header via env, as in checks.yml: the token stays out of argv.
|
||||
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
|
||||
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1) || true
|
||||
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
|
||||
if [ "$PIN" != "$HEAD" ]; then
|
||||
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
|
||||
echo " Test against $HEAD and bump the pin (README.md, \"CI and the infra pin\")."
|
||||
exit 1
|
||||
fi
|
||||
echo "OK: .gitea/infra-pin $PIN is admin/goodwe release/1.0 HEAD."
|
||||
@@ -105,6 +105,12 @@ has the mechanism, the measurement, and what the flags do not do. They are not u
|
||||
each assertion corresponds to a rule whose absence produced an observed failure
|
||||
on real hardware, and the comments in `control.py` say which.
|
||||
|
||||
### CI and the infra pin
|
||||
|
||||
CI (`.gitea/workflows/checks.yml`) runs every `test_*.py`, with `admin/goodwe` checked out next to this repo at the commit in `.gitea/infra-pin`, so `test_freeze.py` grades against the real `sim/hwsim.py`. If that checkout fails, the job fails. The local stub is used only in local runs, and it prints `STUB USED` when it is.
|
||||
The pin tracks `admin/goodwe` **release/1.0**, not master. Two non-required checks go red when release/1.0 has moved past the pin: `infra-pin / fresh` here, on any add-on PR or push to release/1.0, and `addon-pin / fresh` in `admin/goodwe`, on every push to its release/1.0. Nothing re-runs `addon-pin` after a bump, so it stays red on that admin/goodwe commit; `infra-pin / fresh` green on the bump is the all-clear.
|
||||
**Bump the pin** whenever either is red. Set `.gitea/infra-pin` to the new release/1.0 SHA (`git ls-remote <gitea>/admin/goodwe.git refs/heads/release/1.0`) in a PR, and let `checks / unit` grade the tests against it. CI reads `admin/goodwe` with the Actions secret `INFRA_READ_TOKEN`.
|
||||
|
||||
## Compatibility
|
||||
|
||||
**Check the serial number, not the model name.** Characters 6-8 of the serial are
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 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.
|
||||
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words in `app/ui_text/<lang>.json` under `home_words`, built by `app/homeview.py`, checked by `test_messages.py` part 8. The page's own words, English and Dutch, are checked by `test_ui_text.py`.
|
||||
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
|
||||
- **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,9 @@
|
||||
# UX-13 screenshots
|
||||
|
||||
Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not
|
||||
edit them by hand: run the script again.
|
||||
|
||||
- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup.
|
||||
- `settings-*.png`: Settings, Simple and Installer, and the time-window editor with an overlap.
|
||||
- `batteries*.png`: the Batteries screen with modes on, in Dutch, an Off battery helping the peak limit, a refusal at phone width, and modes off.
|
||||
- `setup-step-1*.png`: Setup, step 1.
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -1,5 +1,335 @@
|
||||
# Changelog
|
||||
|
||||
## 0.18.0
|
||||
|
||||
**A minor bump: new behaviour, three new options and one new value for an
|
||||
existing option.** No option was removed or renamed, and no default of an
|
||||
existing option changed (83 options -> 86). **Existing installs need no option
|
||||
change:** options that start 0.17.0 also start 0.18.0, and the new options are
|
||||
off or empty until you use them. The changes below apply with one inverter or
|
||||
two, unless a line says "Two inverters".
|
||||
|
||||
New options, and what they do if you leave them alone:
|
||||
|
||||
| option | default | left alone |
|
||||
|---|---|---|
|
||||
| `pv_forecast_entities`, `pv_forecast_tomorrow_entities` | `[]` | the single forecast sensors work as before ([[OPT-17]]) |
|
||||
| `price_service_token` | empty | not used; it is read only with `price_provider: price_service` ([[PRICE-01]]) |
|
||||
|
||||
`price_provider` also accepts a new value, `price_service`. Its default is
|
||||
unchanged, so the price source stays as it is.
|
||||
|
||||
**Price service.** The new price source `price_service` reads prices from the
|
||||
add-on's price server, with the token you were given. If it does not answer,
|
||||
prices come from energy-charts.info, then from the prices the add-on saved.
|
||||
Sites on the Home Assistant price sensors or a fixed tariff need no change. To
|
||||
move a site over, see `DOCS.md`, "Moving a site from the Home Assistant price
|
||||
sensors to `price_service`".
|
||||
|
||||
Contents of this release, in merge order since 0.17.0 (`e36565f`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3050fa5` | The solar forecast can come from several sensors, added up, for example one per inverter. If one of them has no reading, that day's forecast is not used, and the status says which one. The single sensor still works ([[OPT-17]]) |
|
||||
| `e3e4ba6` | While Home Assistant is still starting, the page now says "Waiting for Home Assistant" and the log says the add-on waits, instead of looking like a fault. Control itself is unchanged: it starts when Home Assistant answers ([[SAFETY-112]]) |
|
||||
| `79dd077` | Battery modes on only: a charge in a cheap run no longer stops at the start of a new 15-minute period, and with two inverters a battery that refused to charge is tried again every 15 min. In one test, the export at the end of a charge was 48 W where the code before this fix gave 746 W ([[OPT-15]]) |
|
||||
| `0ce7d35` | Two inverters: each battery card has Start cycle and Stop cycle buttons, so you can start or stop the cycle on the battery you choose. Only one battery can cycle at a time, and a second start is refused with a short message ([[MAINT-11]]) |
|
||||
| `bf9019e` | New price source `price_service`. If it fails, prices come from energy-charts.info, then from saved prices. With no price at all and the monthly cycle on, a notice shows and the cycle waits ([[PRICE-01]]) |
|
||||
| `004f859` | Two inverters, battery modes on: the 15-minute retry of a battery that refused to charge now asks that battery for 200 W, and no longer puts the rest of the charge on another battery. In the test, without this fix, a battery in its maintenance charge took about 2,300 W for about 34 s at each retry ([[OPT-20]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.17.0
|
||||
|
||||
**A minor bump: changed behaviour, and no option changes.** No option was
|
||||
added, removed or renamed, and no default changed (83 options). Options you
|
||||
saved work as before. The changes below apply with one inverter or two, unless
|
||||
a line says "Two inverters".
|
||||
|
||||
**Maintenance with no battery level.** When the battery level is missing,
|
||||
battery maintenance now ends on its own time limits instead of waiting for the
|
||||
level to come back (MAINT-14, MAINT-15). You do not need to change any option.
|
||||
|
||||
Contents of this release, in merge order since 0.16.0 (`451729c`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `b527e99` | Two inverters: after a drain ends, the other battery no longer swings on old readings. In the test, it held steady for 23 s where 0.16.0 moved it by 720 W ([[SAFETY-103]]) |
|
||||
| `8a17711` | A full battery that stops at 98 or 99 % no longer counts as a failed charge. After 60 min with no new high, the charge counts as done; a charge that truly fails now waits 7 days and shows one notice ([[MAINT-13]]) |
|
||||
| `bf3ac79` | With no battery level, a maintenance charge still ends at its time limit and a drain waits at 0 W. The log no longer repeats a line every second ([[MAINT-14]]) |
|
||||
| `db9e231` | One inverter: when maintenance charges a battery that is already full, the log no longer repeats "The battery refuses to give power" every second. In the test, 1 line in 16 min where the code before this fix wrote about 1,700 in 15 min; the power does not change ([[SAFETY-107]]) |
|
||||
| `580c200` | The balancing hold after a charge now ends on time when the battery level is missing. Before, it waited until the level came back ([[MAINT-15]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.16.0
|
||||
|
||||
**A minor bump: changed behaviour, no option added or removed, and one default
|
||||
and its meaning changed.** There are still 83 options. `maintenance_start_hour`
|
||||
used to be read as UTC and its default was 10. It is now a local hour (in Home
|
||||
Assistant's time zone) and its default is 19. The other changes below apply
|
||||
with one inverter or two, unless a line says "Two inverters".
|
||||
|
||||
**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and
|
||||
it now means local time. If you set it in UTC to get an evening start (for
|
||||
example 17 for 19:00 in summer), set it back to the local hour (19). A saved
|
||||
10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches
|
||||
installs that have no hour saved.
|
||||
|
||||
Contents of this release, in merge order since 0.15.0 (`3ed3302`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) |
|
||||
| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) |
|
||||
| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) |
|
||||
| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s ([[SAFETY-99]]).
|
||||
- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.15.0
|
||||
|
||||
**A minor bump: changed behaviour, and no option changes.** No option was
|
||||
added, removed or renamed, and no default changed (83 options). Options that
|
||||
start 0.14.0 also start 0.15.0. The changes below apply with one inverter and
|
||||
with two.
|
||||
|
||||
The add-on now learns and saves, per battery, what its power reading shows
|
||||
at rest (`unit/<name>/idle_w`). It learns it after about two quiet minutes and
|
||||
uses 0 W until then, as before. There is nothing to set.
|
||||
|
||||
Contents of this release, in merge order since 0.14.0 (`02ed120`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) |
|
||||
| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±170 W is still not caught when it refuses, as in 0.14.0 ([[SAFETY-97]]).
|
||||
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
|
||||
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.14.0
|
||||
|
||||
**A minor bump: new behaviour with two inverters, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (83
|
||||
options). Options that start 0.13.0 also start 0.14.0. With one inverter,
|
||||
control is the same as in 0.13.0.
|
||||
|
||||
⭐ **Two inverters are now supported** for the monthly maintenance (drain,
|
||||
charge, hold) and the peak guard. To add a second inverter, follow
|
||||
`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the
|
||||
add-on takes control of it, needs 0.14.0. Read the known issues below first:
|
||||
three of them are about two inverters.
|
||||
|
||||
Contents of this release, in merge order since 0.13.0 (`8c438ab`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) |
|
||||
| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) |
|
||||
| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
|
||||
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries,
|
||||
Setup), and six new options.** No option was removed or renamed, and no
|
||||
default of an existing option changed (77 options -> 83). The only new
|
||||
start-up checks are on the new options, so options that start 0.12.0 also
|
||||
start 0.13.0.
|
||||
|
||||
New options, and what they do if you leave them alone:
|
||||
|
||||
| option | default | left alone |
|
||||
|---|---|---|
|
||||
| `peak_margin_w` | `50` | the peak guard aims 50 W below your peak limit, or below a higher peak already reached this month; `0` is the old aim ([[SAFETY-79]]) |
|
||||
| `battery_modes_enabled` | `false` | no battery modes, control as before ([[OPT-11]]) |
|
||||
| `pv_forecast_entity`, `pv_forecast_tomorrow_entity` | empty | no solar forecast is used ([[OPT-11]]) |
|
||||
| `solar_trust_pct` | `70` | only used with a solar forecast ([[OPT-11]]) |
|
||||
| `pv_invert_entities` | `[]` | PV readings as before ([[TEL-14]]) |
|
||||
|
||||
⚠️ **Turn on Watchdog** on the add-on's Info tab. It is off by default. If
|
||||
the control loop crashes, the add-on now sets the batteries to 0 W and stops.
|
||||
Home Assistant only starts it again when Watchdog is on ([[SAFETY-61]],
|
||||
[[DOC-5]]).
|
||||
|
||||
⚠️ **The peak guard now starts a little earlier.** It aims 50 W below your
|
||||
peak limit, so the batteries give a bit more at peak times. Set
|
||||
`peak_margin_w: 0` for the old aim ([[SAFETY-79]]).
|
||||
|
||||
Contents of this release, in merge order since 0.12.0 (`a45ed06`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `fa3d237` | Docs name the right P1 age entity and a battery list that starts ([[DOC-1]]) |
|
||||
| `6e65272` | If the control loop crashes, the batteries go to 0 W and the add-on stops ([[SAFETY-61]]) |
|
||||
| `3e38baf` | The check tools refuse a file they cannot read, instead of saying all clear (tools only) ([[SAFETY-60]], [[SAFETY-62]]) |
|
||||
| `43b2740` | A missing value in a check is a named failure (tests only) ([[TEST-26]]) |
|
||||
| `20260e8` | Docs: turn on Watchdog ([[DOC-5]]) |
|
||||
| `16c796f` | New option `pv_invert_entities`: flip the sign of a PV sensor that reads backwards ([[TEL-14]]) |
|
||||
| `6a8a5ff` | CI tests against the real inverter simulator (CI only) ([[CI-12]]) |
|
||||
| `82f032e` | CI pin checks report failures properly (CI only) ([[TOOL-9]]) |
|
||||
| `fe13251` | CI pin update (CI only) |
|
||||
| `397dd15` | Alarm notifications that are still active come back after a Home Assistant restart ([[SAFETY-34]]) |
|
||||
| `59415ea` | CI pin update (CI only) |
|
||||
| `2503de1` | "Battery is not keeping up" is a warning with its own notification, not a "bug" alarm; its figures are correct ([[SAFETY-40]]) |
|
||||
| `48ef06a` | Shorter, plainer notifications ([[UX-08]]) |
|
||||
| `5c151a3` | CI pin update (CI only) |
|
||||
| `d57767f` | Shorter, plainer status rows and banners ([[UX-08]]) |
|
||||
| `e1d0970` | Details link works on current Home Assistant; plainer words ([[UX-09]]) |
|
||||
| `a02cd4b` | Shorter refusals, errors and option texts ([[UX-08]]) |
|
||||
| `32b9e56` | Plainer log lines and docs ([[UX-08]]) |
|
||||
| `125b070` | CI pin update (CI only) |
|
||||
| `2b14b88` | Last plain-language fixes in logs and the status page ([[UX-10]]) |
|
||||
| `c4032eb` | CI pin update (CI only) |
|
||||
| `d1f7e33` | Safety log lines: one plain sentence and a code ([[UX-11]]) |
|
||||
| `652097a` | A battery that refuses power (e.g. empty) is asked for 0 W, and tried at 200 W for 34 s every 15 min ([[SAFETY-52]]) |
|
||||
| `afd6af0` | Groundwork for the new settings pages ([[UX-13]]) |
|
||||
| `a36dca6` | New page with a Home screen ([[UX-13]]) |
|
||||
| `28a49cc` | With two or more inverters, a battery that refuses power is held on its own; the others take over ([[SAFETY-73]]) |
|
||||
| `515c11b` | New Installer settings view ([[UX-13]]) |
|
||||
| `4de9675` | New setup wizard for installers ([[UX-13]]) |
|
||||
| `8c34647` | A new install with no inverter opens the setup wizard instead of stopping ([[UX-15]]) |
|
||||
| `a5dddad` | Dutch text and a time-period editor ([[UX-13]]) |
|
||||
| `ed739d4` | Battery modes (off by default): per-battery limits, on/off, solar order ([[OPT-11]]) |
|
||||
| `7995e4d` | Simple settings view and a Batteries screen ([[UX-13]]) |
|
||||
| `7337b79` | Battery modes in Home Assistant, when modes are on ([[OPT-11]]) |
|
||||
| `7a00f12` | New option `peak_margin_w`: the peak guard keeps a margin below the limit ([[SAFETY-79]]) |
|
||||
| `5a4cdc2` | Battery modes: charge in cheap hours, hold for expensive hours; selling is off for now ([[OPT-11]]) |
|
||||
| `4f25b24` | No false "Load too high" alarms at quarter edges or with a noisy load (10 s hold) ([[SAFETY-81]]) |
|
||||
| `35cdffe` | Maintenance drain with two batteries no longer stalls ([[SAFETY-78]]) |
|
||||
| `1145a93` | Battery modes: a solar forecast decides how much to buy from the grid ([[OPT-11]]) |
|
||||
| `9d5b7d1` | With one battery, the peak guard wins over the maintenance drain ([[SAFETY-83]]) |
|
||||
| `4ad8298` | Maintenance charge with two batteries no longer stalls; it gives way to the peak guard ([[SAFETY-84]]) |
|
||||
| `b8ddfd7` | The peak guard wins over the maintenance hold; the hold ends on time, with no top-up ([[SAFETY-86]]) |
|
||||
| `9358781` | With two or more batteries, the peak guard uses every battery that can still give; peak alarms no longer say control stopped ([[SAFETY-87]]) |
|
||||
| `61072ca` | No false "Battery is not keeping up" during maintenance; real shortfalls still warn; the status row counts each episode once ([[SAFETY-85]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- Two inverters: at the end of a maintenance charge one battery can charge from the grid while the house uses a lot. Do not add a second inverter before the next release ([[SAFETY-93]]).
|
||||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- Two batteries: one at its peak reserve can be used before a draining battery has used its room ([[SAFETY-91]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.12.0
|
||||
|
||||
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
|
||||
|
||||
@@ -285,32 +285,11 @@ cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
|
||||
card changed there is **which** roster entry the claim is filed on: the one whose
|
||||
state machine produced the result, rather than `inverters[0]`.
|
||||
|
||||
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
|
||||
## 6. FIXED BY SAFETY-78: the site loop contradicted a drain at N>=2
|
||||
|
||||
Measured while wiring this card, and **pre-existing on `release/1.0` at
|
||||
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
|
||||
refusal with the two unit names swapped.
|
||||
|
||||
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
|
||||
When the site target is *below* the pin — a 2000 W drain while the house imports
|
||||
less than that, which is most of a night — the only split that sums to the site
|
||||
target asks the sibling to **charge** while the maintained pack **discharges**,
|
||||
and `allocator.plan()` refuses it by name as circulating power. The refusal is
|
||||
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
|
||||
line in the action log.
|
||||
|
||||
**The consequence an operator would see is a misdiagnosis.** The pack is held at
|
||||
0 W while its state machine believes it is draining, so after 12 h the DRAIN
|
||||
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
|
||||
following commands. The battery is fine; the site refused to allocate.
|
||||
|
||||
**Why it is not fixed here.** The fix is to change what the site loop's target
|
||||
*means* while a unit is pinned — regulate the meter using only the unpinned
|
||||
packs — which is loop and allocator territory and changes the integrator's
|
||||
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
|
||||
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
|
||||
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
|
||||
its own.
|
||||
Found here and pre-existing: below the pin the other pack had to charge, the
|
||||
allocator refused it and the site sat at 0 W. Fixed by a site `drain` floor;
|
||||
see [[SAFETY-78]] (`DECISIONS/SAFETY-78-drain-at-two.md`).
|
||||
|
||||
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# MAINT-10 — the maintenance start hour is local time, default 19:00
|
||||
|
||||
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
|
||||
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
|
||||
11:00 in winter. It is now read on the site's local clock, and the default moves
|
||||
to 19 so the drain runs into the evening and night rather than the solar day.
|
||||
|
||||
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
|
||||
peak guard outrank the drain.
|
||||
|
||||
## 1. Which zone
|
||||
|
||||
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
|
||||
else the container's `TZ`, else UTC. One log line says which (WARNING only for
|
||||
UTC). Resolved once per start: a zone change in HA takes effect on the next
|
||||
add-on restart. HA can still be starting when the add-on starts (502 from the
|
||||
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
|
||||
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
|
||||
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
|
||||
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
|
||||
already is HA's zone, so the rare UTC case gets a warning rather than more code.
|
||||
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
|
||||
not, so the tests grade DST on `tariff._EUTimezone` too.
|
||||
|
||||
## 2. DST
|
||||
|
||||
`maintenance.in_start_slot()` works from UTC instants only, so it does not
|
||||
depend on how a tzinfo resolves a missing or doubled wall time:
|
||||
|
||||
- normally the slot is the local hour `start_hour`;
|
||||
- clocks forward and the hour does not exist: the first hour after the jump;
|
||||
- clocks back and the hour happens twice: the first pass only. Without this an
|
||||
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
|
||||
|
||||
Every day has exactly one 60 min slot for every hour 0-23: checked against
|
||||
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
|
||||
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
|
||||
over all of 2026.
|
||||
|
||||
## 3. Existing installs
|
||||
|
||||
The Supervisor merges the shipped defaults under the STORED options, and saving
|
||||
the settings stores every key (the reference install's blob has all 76). So
|
||||
almost every existing install keeps its stored 10, which now means 10:00 local
|
||||
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
|
||||
The add-on does not rewrite a stored option: a silent migration would override
|
||||
an operator's choice with no way to tell it apart from the old default. A site
|
||||
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
|
||||
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
|
||||
note (drafted on the PR, for the version bump) say this.
|
||||
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
|
||||
|
||||
## 4. Two inverters
|
||||
|
||||
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
|
||||
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
|
||||
cycles at a time, and the one that lost the slot starts in the first start
|
||||
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
|
||||
a repeated hour does not start there. `test_maint10.py` part 10.
|
||||
|
||||
## 5. What does not change
|
||||
|
||||
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
|
||||
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
|
||||
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
|
||||
same instant): every command, claim, reason and log line is identical. A
|
||||
`Maintenance` built without a zone reads UTC, as before.
|
||||
@@ -0,0 +1,37 @@
|
||||
# MAINT-11 - start or stop the maintenance cycle per battery
|
||||
|
||||
**Status: for review.** Tests: `test_maint11.py`. No option added.
|
||||
|
||||
**The fault.** At two or more batteries the page had one "start a battery cycle"
|
||||
button, and `_maint_button()` chose the battery. An operator could not cycle a
|
||||
chosen one (inverter B, new after storage, needed its first cycle).
|
||||
|
||||
## Rule
|
||||
|
||||
1. `maint_start` and `maint_abort` take an optional `unit`. With no unit they
|
||||
run the old code unchanged. The Batteries screen sends the unit from a
|
||||
per-battery Start cycle and Stop cycle button, at two or more batteries only.
|
||||
2. **A start while any battery holds the maintenance slot is refused, not
|
||||
queued.** The message names the battery that is cycling. One slot per site
|
||||
stays the rule, and the scheduler is untouched.
|
||||
3. Also refused, with a plain message: an unknown unit, a start on a battery
|
||||
that is switched off (battery modes on), and a stop on a battery that is not
|
||||
cycling. Nothing changes on a refusal.
|
||||
4. A stop on the cycling battery is today's abort: the cycle ends, then every
|
||||
inverter goes to 0 W.
|
||||
|
||||
## Why refuse and not queue
|
||||
|
||||
A queued start is a cycle that begins hours later, unseen, after the operator
|
||||
has moved on. A refusal is immediate and says why. The scheduler already
|
||||
queues by waiting time, so nothing is lost.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- Today's bare button never refused a battery that is refusing to give power
|
||||
(SAFETY-73); its drain waits. The per-battery start does the same.
|
||||
- A start on the battery that is already cycling is refused as "busy", where
|
||||
the bare button restarts its drain. The bare button is unchanged.
|
||||
- At one battery, a request that names the battery uses the new path, so a
|
||||
second start is refused instead of restarting the drain. Nothing sends a name
|
||||
at one battery, and the bare request is unchanged.
|
||||
@@ -0,0 +1,109 @@
|
||||
# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge
|
||||
|
||||
**Status: for review.** Tests: `test_maint13.py`. No option added; every number
|
||||
is a constant in `app/maintenance.py`.
|
||||
|
||||
**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge
|
||||
began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00,
|
||||
fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never
|
||||
reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock
|
||||
after the charge began) it aborted "charge stuck at 98 %". `last_completed` was
|
||||
not set, so B was still due and would have drained again from 98 % to 11 % at the
|
||||
next start hour, with no notice. Three faults: a pack may never report 100 %
|
||||
while the BMS balances; a failed cycle came back the next day; and the 36 h
|
||||
counted hours the price gate kept the pack waiting.
|
||||
|
||||
## 1. The charge is complete when
|
||||
|
||||
1. SoC >= target (as before), or
|
||||
2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its
|
||||
best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or
|
||||
3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3.
|
||||
|
||||
Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and
|
||||
3 logs one plain line ("the battery takes no more charge at 98 %, so the charge
|
||||
counts as done", "charge time is up at 98 %, close enough to full ...").
|
||||
|
||||
**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the
|
||||
one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power
|
||||
was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be
|
||||
invented. SoC also covers the case where power flows but nothing is stored.
|
||||
**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat
|
||||
at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of
|
||||
the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to
|
||||
06:24) without a new high, so 60 min is well inside it and long enough that a
|
||||
pack still creeping up (one new high within the hour) keeps resetting the wait.
|
||||
On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00
|
||||
UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to
|
||||
step up more slowly than that, raise the constant. Falling back by one point
|
||||
does not reset the wait; only a new best does.
|
||||
|
||||
## 2. A failed cycle backs off 7 days
|
||||
|
||||
A drain stuck 12 h or a charge that times out below target - 3 is a failure
|
||||
(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is
|
||||
stored, and `wants_start()` is false until 7 days later. The operator's forced
|
||||
start ignores it; a cycle that completes after the failure ends it. An operator
|
||||
abort, an interlock stop and a switched-off pack are not failures.
|
||||
|
||||
**Why 7 days, not the 28 day interval.** A judgement: a failure may have a
|
||||
short cause (several dull days with no cheap hours, a pack switched off), and
|
||||
waiting the full interval leaves the coulomb counter uncalibrated for a month
|
||||
more. A week is long enough that a bad pack costs one drain a week, with one
|
||||
notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks;
|
||||
the notice says so each time.
|
||||
|
||||
**Notice, once.** The tick that fails carries `failed`; the Controller sends one
|
||||
persistent notification "Battery cycle did not finish" (EN, with the Dutch
|
||||
below it; per pack at N=2). It is not repeated, and Home Assistant restarts
|
||||
wipe it, so the status page shows a "Maintenance paused" row with the date for
|
||||
as long as the wait lasts. The operator may also see the log lines.
|
||||
|
||||
## 3. The timeout counts only allowed time
|
||||
|
||||
`Controller._charge_gate_open()` says whether the pack may charge now. It is
|
||||
true when no price gate is set up (no price entities, no fixed tariff: nothing
|
||||
holds the charge back, and `cheap` is always false there), in a cheap window, or
|
||||
while the pack is really taking charge (battery power at or below -50 W, which is
|
||||
sun outside cheap hours). It is false only when a gate exists, is shut, and the
|
||||
pack is not charging; a pack asked for charge that delivers nothing then does not
|
||||
count, which is the case the clock cannot judge. A site with a price provider
|
||||
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
|
||||
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
|
||||
top-up there and only the sun charges the pack, as on a site with no tariff. Its
|
||||
time counts in full. When a provider plan starts to drive `cheap`,
|
||||
`_price_gated` must become true for it. Whether a fixed tariff is set up is
|
||||
worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick
|
||||
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
|
||||
while the gate is closed. The accounting is written to the state file at most
|
||||
every 5 min (`charge_track`); a restart loses at most that. A state file from
|
||||
before this change, or with no record for the current charge, starts from the
|
||||
wall-clock minutes so far, which is the old behaviour.
|
||||
|
||||
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
|
||||
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
|
||||
complete at >= target - 3, a failure below.
|
||||
|
||||
**MAINT-12.** This is the smallest change that makes the timeout honest: one
|
||||
boolean in, one counter. MAINT-12's block plan can replace the boolean (the
|
||||
minutes it plans are the allowed ones) without touching the rest. Two things it
|
||||
must keep: the back-off is in `wants_start()`, so any new entry point (such as
|
||||
`price_forecast.begin_phase`, which is not wired today) must go through it or
|
||||
check `backoff_until()`; and `gate_open` must stay "the pack is allowed to
|
||||
charge now". The peak guard pausing the charge shaping is not seen as a closed
|
||||
gate: that time still counts.
|
||||
|
||||
## 4. What does not change, and what this does not cover
|
||||
|
||||
- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A
|
||||
backed-off pack does not want a start, so at N=2 the other pack may take the
|
||||
slot. One pack at a time at N=1 and N=2.
|
||||
- `last_failed` is a maintenance key and moves with the others when a second
|
||||
inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it
|
||||
restarts from the wall-clock minutes.
|
||||
- It does not find out why a pack stopped. A pack that stops at 96 % and never
|
||||
gets near is still a failure.
|
||||
- The Dutch text is only in the notification. The status row and the log lines
|
||||
are English, like the rest of the status page.
|
||||
- It does not change the target: a site that wants a different stop point sets
|
||||
`maintenance_soc_target`.
|
||||
@@ -0,0 +1,68 @@
|
||||
# MAINT-14 - no SoC reading in a maintenance cycle
|
||||
|
||||
**Status: for review.** Tests: `test_maint14.py`. No option added; the one new
|
||||
number is a constant in `app/maintenance.py`.
|
||||
|
||||
**The fault.** `Maintenance.tick()` returned early when the SoC was `None`,
|
||||
before any timeout check. (1) A charge on a pack whose SoC reading was lost
|
||||
never ended: no timeout, no back-off, no notice, and the 7-day backstop of
|
||||
MAINT-13 sat behind the same return. (2) A drain kept commanding `discharge_w`
|
||||
with an unknown level, which breaks the TEL-08 rule that an unknown level never
|
||||
allows discharge.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Charge, no SoC.** The charge timeout (`CHARGE_TIMEOUT_MIN`, gate-open
|
||||
minutes) and the 7-day wall backstop still run. When one fires: if the last
|
||||
SoC seen was >= target - 3 the charge counts as done (the MAINT-13 "close
|
||||
enough to full" rule, HOLD follows); otherwise it fails: MAINT-13 back-off
|
||||
and its one notification. If no SoC was ever seen, it fails.
|
||||
2. **Drain, no SoC.** DRAIN holds at 0 W and owns the setpoint, exactly as HOLD
|
||||
does. The 12 h drain timeout still runs and fails as before.
|
||||
3. **SoC comes back.** Nothing is recorded; the drain continues at the drain
|
||||
rate, the charge carries on, and the stall wait of MAINT-13 restarts from
|
||||
what it was: it paused while the reading was missing, so a gap never counts
|
||||
as "no more charge".
|
||||
4. **HOLD** is unchanged (0 W).
|
||||
5. **Log.** One line when a drain starts holding for this reason ("the battery
|
||||
level is unknown, so the drain holds at 0 W until it is back"), and one line
|
||||
when the level has been missing for `NOSOC_NOTE_MIN` = 15 min. Both are said
|
||||
once per episode; the old line that repeated every tick is gone.
|
||||
|
||||
## Why
|
||||
|
||||
- **"Last seen", not "best seen".** A pack that was at 99 and then dropped to 50
|
||||
before the reading went is not close to full. Only the last reading counts.
|
||||
It is saved with the charge record (`charge_track.last`), and saved at once
|
||||
when the reading first goes missing, so a restart mid-gap still knows it
|
||||
(within the usual 5 min save interval otherwise).
|
||||
- **Failure when nothing was seen.** With no level we cannot call a pack full.
|
||||
The failure costs a 7-day wait and one notice; calling it done costs a
|
||||
missed balance and a month-long false "complete". The first is the honest one.
|
||||
- **Timeouts count the same minutes as with a reading.** Gate-open charging
|
||||
minutes only; the wall backstop for a gate that never opens.
|
||||
- **15 min.** A judgement. A poll glitch of a few minutes should not write a
|
||||
line; a reading lost for a quarter of an hour is worth one. Raise it if the
|
||||
action log shows noise.
|
||||
- **0 W, not "stop the phase".** Aborting a drain on a short gap would throw
|
||||
away the part already drained. Holding at 0 W loses nothing and the 12 h
|
||||
timeout is the limit.
|
||||
|
||||
## Cost
|
||||
|
||||
- A drain that loses its reading stops draining, and the 12 h clock keeps
|
||||
running. A gap of many hours can therefore fail a drain that was working. The
|
||||
back-off then applies (7 days).
|
||||
- A charge whose reading is lost early (last SoC below target - 3) fails at the
|
||||
timeout, even if the pack was in fact filling. The same 7-day wait applies.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`).
|
||||
- It does not tell the operator why the reading is missing (sensor, register,
|
||||
link). It does not reuse the stale-input gate; it is the cycle's own rule.
|
||||
- The charge itself is still driven by the control loop, which has its own
|
||||
handling of a missing SoC (TEL-08 for the loop is a separate card).
|
||||
- A restart loses at most 5 min of accounting, as in MAINT-13. The "unknown for
|
||||
long" line may be said again after a restart: the episode start is in memory.
|
||||
- Text added is two log lines, English only, like the other log lines.
|
||||
@@ -0,0 +1,47 @@
|
||||
# MAINT-15 - the balancing hold ends on time with no SoC reading
|
||||
|
||||
**Status: for review.** Tests: `test_maint15.py`. No option added.
|
||||
|
||||
**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for
|
||||
HOLD that returned 0 W without the time check. A hold that lost its reading
|
||||
never ended: `last_completed` was never written and the next due date never
|
||||
moved. Harmless for power (0 W), but the cycle was stuck. This was left open
|
||||
in MAINT-14.
|
||||
|
||||
## Rule
|
||||
|
||||
1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper,
|
||||
`Maintenance._hold()`, holds the time check and is used by both paths. When
|
||||
the time is up it writes `last_completed`, goes IDLE and logs the same
|
||||
"cycle COMPLETE" line. Before the time is up it commands 0 W as before.
|
||||
2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold
|
||||
still ends at its normal time." Still once per episode.
|
||||
|
||||
## Why
|
||||
|
||||
HOLD is reached only after the charge counted as done. Every entry into HOLD
|
||||
is in `step()` or `_no_soc()` and follows a charge that reached the target,
|
||||
stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else
|
||||
sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done
|
||||
charge). The hold is a wait for the BMS to balance; it never needed the level.
|
||||
So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE"
|
||||
line is true without a reading: it names minutes of hold, not a level.
|
||||
|
||||
## Cost
|
||||
|
||||
- If the level was lost during the hold, the cycle is recorded as complete
|
||||
without anyone seeing the final level. The charge had counted as done, so this
|
||||
is the same trust MAINT-14 already gives a charge that ends with no reading.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- It does not check that the pack is still full at the end of the hold.
|
||||
- It does not say why the reading was lost.
|
||||
- With a reading, nothing changes. `test_maint15.py` section 7 compares the new
|
||||
HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase,
|
||||
setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider
|
||||
trace of the whole cycle (800 seeded runs against db9e231) was run outside the
|
||||
repo; its script and log are kept under `scratch/MAINT-15/` of the dev
|
||||
workspace, not in this repository.
|
||||
- A state file written by an older version with `phase: hold` loads and ends on
|
||||
time; the file is not changed in format.
|
||||
@@ -0,0 +1,203 @@
|
||||
# OPT-11 - battery modes, per battery
|
||||
|
||||
A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at
|
||||
`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides
|
||||
it; a limit above 35 (the peak guard) makes it give way only as far as that
|
||||
limit needs, and a battery its mode holds back then carries only what the
|
||||
others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27);
|
||||
P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
|
||||
- Off by default (`battery_modes_enabled`); off, commands are byte-identical to
|
||||
a5dddad (`test_modes.py` part 1).
|
||||
- Off is a mode range, so the peak guard can still use an Off battery when the
|
||||
others cannot cover the floor (design sections 2-3). Whether Off should be
|
||||
absolute is an open owner question.
|
||||
- Review (PR #149): releasing only when the peak floor bound S let a trade won
|
||||
above the floor keep the modes and place less than the floor, and let a
|
||||
capped grid charge release an Off battery. Both are graded in
|
||||
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the
|
||||
rail to the rail, so SAFETY-03 still flags a loop winding into it.
|
||||
- P2 (HA entities): a select and a switch per battery, only while modes are
|
||||
on; commands go through `handle_action()`, the page's path. A retained
|
||||
command is dropped, not applied: the store already holds the last choice,
|
||||
and a replay after a restart could otherwise undo a newer one. The config
|
||||
topics announced are kept in the store (`mqtt_mode_configs`), so turning
|
||||
modes off removes exactly those, and an install that never turned them on
|
||||
publishes what ed739d4 did (`test_mqtt.py` part 10). A removed topic stays
|
||||
in that list and its empty config is re-sent on each start, so a start with
|
||||
no broker cannot lose it; re-sending is harmless. The HA options are the
|
||||
page's English names, pinned by a test, and are not renamed with the page:
|
||||
automations send them.
|
||||
- In P1 only Off closes a direction; Leftover solar differs by charging last.
|
||||
- P3 (price runs, `priceruns.py`, `modetrade.py`): each battery's range is
|
||||
narrowed by its role this tick (hold, give, charge, rest, sell); main.py
|
||||
files the intersection with the switch, so it still holds 0 W. With modes on
|
||||
the site trade is filed by the modes, sized from the loop bound (so no
|
||||
battery a mode or a refusal holds is asked for watts, P1's unmet share) and
|
||||
the guard's headroom; `arbitrage.decide()` runs only with modes off, which
|
||||
stays byte-identical to 7a00f12 (`test_modes_p3.py` part 1).
|
||||
- SAFETY-46 item 2 is decided as the owner answered (2026-09-25): a trade is
|
||||
a run of consecutive firm slots. It applies with battery modes on; the
|
||||
modes-off trade keeps its one-period shape, because that path must not move.
|
||||
- Choices that were measured, not assumed (tools/opt11p3_replay.py):
|
||||
- a sale needs the guard to have measured the quarter, not to be clear: a
|
||||
5 kW discharge alone reads "engaged" on export and flipped the sale every
|
||||
5 s;
|
||||
- a grid charge makes the others rest, so it starts only at 500 W and, once
|
||||
the guard or the headroom stops it, stays off for that 15-minute period
|
||||
(without: 13 flips in 4 minutes under a 3.5 kW house);
|
||||
- stored energy is valued at the last cheap run for 12 h, and a charge
|
||||
needs its own run to clear the spread (without: a night charge was spent
|
||||
on flat morning prices, then charging went on at them);
|
||||
- a run edge reseeds the loop at the house's level (a 3 kW import spike
|
||||
removed) and, since OPT-15, holds it there until the meter shows the step.
|
||||
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
|
||||
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
|
||||
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
|
||||
trade 18.14. Review of #153 (same model, with a per-action split):
|
||||
- the cheapest hours were at midday on 15 of 16 days, so Leftover solar (acting
|
||||
as Cheap hours until P4) bought 12.2 kWh there that the sun would have
|
||||
given: 7 kWh more exported, 12 kWh less solar stored. Holding for the
|
||||
expensive hours saved 4 kWh of import, not enough. With no grid charge on
|
||||
Leftover solar: 19.44;
|
||||
- the old trade charged one hour, so the afternoon sun still fit;
|
||||
- a sale earned the wholesale price (2.27 EUR for 11.4 kWh) and the house
|
||||
then bought the same energy back (2.29 EUR): a wash before wear. It pays
|
||||
only when the sale hour's wholesale price beats the later import price,
|
||||
levies included (about 0.13 EUR/kWh above that hour's wholesale here);
|
||||
- valuing the charge left at the end, wear and real Flemish levies make
|
||||
selling worse and do not change the order.
|
||||
- Owner decision 2026-09-29 (option A): selling is off until it can be judged
|
||||
on the price the grid pays (`packmode.SELLING_ENABLED`, code kept), and
|
||||
Leftover solar takes solar only until P4's forecast
|
||||
(`packmode.GRID_CHARGE_MODES`); Cheap hours only still charges from the grid. Replayed
|
||||
(16 days, as above): Solar first + Leftover solar 19.44 EUR net against
|
||||
P2's 19.33; Solar first + Cheap hours only 19.57.
|
||||
- Not done in P3: the forecast (P4), and a headroom too short for the run
|
||||
moving the rest to the next cheap periods.
|
||||
- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6,
|
||||
`test_price_run_fixes.py`):
|
||||
- F1, a charge stopped at a quarter edge and latched for the quarter. The
|
||||
P1 averager opens a quarter only when a sample arrives, and the HA source
|
||||
builds one 0.35 s after the reading, so the cycle on the boundary still
|
||||
saw the old block: "no seconds left", headroom None, "no trade
|
||||
(headroom)", latched for the new quarter. With modes on the guard now
|
||||
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
|
||||
crediting the held sample as the averager will), and only a measured
|
||||
stop latches. Modes off still reads the old block (identity).
|
||||
Since SAFETY-91, `next_quarter()` does the same for the guard at two
|
||||
inverters (no stop there without the roll, same banked quarters); the
|
||||
roll is what covers one inverter, where the old projection stops the
|
||||
charge at every edge. With modes on at one inverter the roll also removes
|
||||
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
|
||||
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
|
||||
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
|
||||
read "no seconds left"), so a standing overload is still notified once
|
||||
per quarter.
|
||||
- F2, -1120 W export at a charge end against -257 W modelled. The replay
|
||||
acted every 5 s on readings of that instant, and by then the lagged
|
||||
battery had nearly arrived, so it never acted on a reading that still
|
||||
showed the trade. The add-on cycles on each meter update (1 s on the rig)
|
||||
on readings 1-2 s old: the two cycles after the reseed saw the trade's
|
||||
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
|
||||
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
|
||||
still moves). The replay gained `Lag` (dead time, reading age, the P1
|
||||
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
|
||||
lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same
|
||||
numbers hold. The hold gives the loop the target as its grid, so it
|
||||
yields to SAFETY-95's drain tail, and any other claim that moves the
|
||||
command ends it.
|
||||
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
|
||||
stands only while the site asks that way, and nothing asked to charge.
|
||||
Probing was chosen over changing the card: the probe is 200 W for 34 s
|
||||
each 15 min under the same peak gates, and it wins back the run. In its
|
||||
probe window the refusing battery is a charger, pinned at 200 W by its
|
||||
own claim, and the others rest. A monthly drain (SAFETY-78) still comes
|
||||
first, and with a day plan a probing Leftover solar battery goes only to
|
||||
its planned level. At one inverter SAFETY-52 already ends a charge
|
||||
refusal as soon as the house asks for power.
|
||||
- Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter
|
||||
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
|
||||
reading older than the meter can make a charge start look like a house
|
||||
over the aim, a real guard stop.
|
||||
- Review: an unmeasured stop that never latched let a headroom missing
|
||||
every other cycle flip the charge 1799 times in 30 min, so the second one
|
||||
in a quarter latches (at most 4 changes a quarter). A house that rises
|
||||
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
|
||||
before its end (two inverters, lagged readings): the hold adds at most
|
||||
+33 W to that quarter against the loop without it, and the most any
|
||||
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
|
||||
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
|
||||
e3e4ba6). The import during the hold is the rise, below the charge's
|
||||
own, and a rise past that is the peak floor's, which ends the hold.
|
||||
A maintenance drain, hold or charge that starts during the hold ends it
|
||||
on that tick (replayed at edge + 3 s).
|
||||
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
|
||||
The trade was sized at the probing battery's rating (2500 W), B's pin held
|
||||
200 W of it, and the allocator put the other 2300 W on A, in its
|
||||
maintenance charge phase (no mode range there, so nothing kept it out):
|
||||
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
|
||||
what the other chargers would have had without the probe (none: 200 W
|
||||
alone), and it carries the pin only on the ticks the pin is filed (the
|
||||
window's first and last cycle moved another battery's share by 200 W).
|
||||
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
|
||||
normal charger its command is the same and the grid rises 200 W (about
|
||||
7 W on the quarter) rather than that charger giving 200 W up. A probe
|
||||
that the headroom or the bound cannot hold at 200 W is not filed.
|
||||
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
|
||||
give (one sign), so an idle-looking A that gives the house stops for the
|
||||
window and the grid rises by what it gave. The trade line now says 200 W
|
||||
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
|
||||
trade on a battery in maintenance charge) is not touched.
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
|
||||
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
|
||||
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
|
||||
older than 3 h is stale (one missed hourly poll must not flag it).
|
||||
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
|
||||
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
|
||||
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
|
||||
keeps grid + battery per local hour for 7 days in the store
|
||||
(`house_profile`), median per hour. It is net of the solar the house used,
|
||||
so the solar sum never takes it below the night median. Solar readings are
|
||||
not used (TEL-11). The first plan needs one day of it.
|
||||
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
|
||||
until the expensive run the cheap run is for. Solar first takes it first.
|
||||
- Leftover solar may then charge from the grid up to min(ceiling, floor +
|
||||
the house's use in the expensive runs / 0.95), less its sun share: only
|
||||
the shortfall.
|
||||
- Cheap hours only ignores the plan and fills to the ceiling (design
|
||||
section 1; review of #156, see the replay below).
|
||||
- Solar first never charges from the grid (owner, 2026-09-29).
|
||||
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
|
||||
(no, stale or failed forecast, no house profile, no capacity) every mode
|
||||
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
|
||||
5a4cdc2 (modes on, with and without an unavailable forecast).
|
||||
- The plan is made with the runs (every 15 min, or at once when the forecast
|
||||
comes or goes). A lost forecast drops the levels at the next cycle.
|
||||
- Not done: the design's scaling by measured solar from 10:00 (it needs the
|
||||
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
|
||||
FCST-01.
|
||||
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
|
||||
solar, net EUR, lower is better; site solar rebuilt from the recording,
|
||||
forecast errors from Elia's day-ahead Belgian solar forecast):
|
||||
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
|
||||
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
|
||||
missing 19.44);
|
||||
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
|
||||
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
|
||||
32.01).
|
||||
- Solar first + Cheap hours only, skipping its sun share (the first
|
||||
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
|
||||
moved runs left B lower, so later midday top-ups rested A while the sun
|
||||
shone. Cheap hours now ignores the plan, which is P3 exactly.
|
||||
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
|
||||
gain is Leftover solar buying the evening's shortfall in the cheap run;
|
||||
the forecast itself was worth 0.14 EUR in 16 September days.
|
||||
- Bias: the site's solar is rebuilt from the same net use the replay
|
||||
plays, and "recorded" keeps its hourly shape and adds only the national
|
||||
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
|
||||
No threshold was tuned to these days. The 2026-09-24 PV recording could not
|
||||
be replayed: it is only in the live Home Assistant.
|
||||
@@ -0,0 +1,79 @@
|
||||
# OPT-17 - the solar forecast from several entities, summed
|
||||
|
||||
**Status: for review.** Tests: `test_opt17.py`. Two options added.
|
||||
|
||||
A site whose forecast comes as several Home Assistant sensors (one per
|
||||
inverter or roof side) had to build a template sensor to sum them. Now each
|
||||
day can list them.
|
||||
|
||||
## Options
|
||||
|
||||
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
|
||||
sensors, default `[]`, the same shape as `pv_entities`.
|
||||
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
|
||||
install that uses them needs no edit.
|
||||
- **Why new keys, not "string or list" in the old ones.** Measured on
|
||||
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
|
||||
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
|
||||
string an install already stores (`Invalid list for option
|
||||
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
|
||||
spelling that takes both, `match(.*)?`, also takes the integer 417 and
|
||||
shows a plain text box, so a second sensor cannot be added in the UI.
|
||||
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
|
||||
the lists render as repeatable fields; a downgrade drops them and keeps
|
||||
the single keys.
|
||||
- `[]` in `options:` is load-bearing, as for `pv_entities`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One key per day.** Setting the single key and the list for the same day
|
||||
is refused at start, with one message: keep the list, clear the single one.
|
||||
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
|
||||
`pv_entity` with a caution row. Here the brief asked for a refusal: a
|
||||
doubled or dropped forecast changes what a battery buys, and no install
|
||||
has both keys yet, so the refusal breaks nothing.)
|
||||
2. **Each member in its own unit.** Every member is read as one forecast
|
||||
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
|
||||
day's kWh are added up. A repeated id counts once.
|
||||
3. **One member not usable means the day is not usable.** Missing,
|
||||
unavailable, unknown, stale, not an energy unit, or a bad number: that
|
||||
day's sum is not used, exactly as when the single sensor reads that way.
|
||||
No today means no forecast (P3 option A). No tomorrow means today only.
|
||||
4. **Hourly split.** If every member has a split, the splits are joined
|
||||
(overlapping periods add up). If any member has none, the sum is spread
|
||||
over the bell, as for a state-only sensor.
|
||||
5. **Time.** The forecast is as old as its oldest member.
|
||||
6. **One read.** All members of both days are read in one gather, inside the
|
||||
gather that reads the inverters, so a slow sensor does not delay control.
|
||||
7. **Shown and logged once.** With two or more members, each one not usable
|
||||
is named on the status page by its place in the list (STYLE.md: no entity
|
||||
ids on the page), as a caution row titled "Solar forecast" on Home. The
|
||||
action log names the entity id, once when the set of failing members
|
||||
changes.
|
||||
8. **One member is the single key.** A one-member list reads, plans, shows
|
||||
and logs exactly as the single key with that entity.
|
||||
|
||||
## Why
|
||||
|
||||
- A partial sum is a smaller number that looks real. Two of three inverters'
|
||||
forecasts read as a duller day, and Leftover solar would then buy grid
|
||||
energy the sun was going to give. TEL-12 took the same rule for the same
|
||||
reason. No forecast is a known state with a tested fallback.
|
||||
- The bell for a mixed list: joining one member's hours with another's total
|
||||
has no honest shape, and the bell is the existing state-only rule.
|
||||
|
||||
## Cost
|
||||
|
||||
- One flaky member costs the whole day's forecast. A site with three
|
||||
sensors loses the plan more often than one with a template sum that hides
|
||||
gaps.
|
||||
- Two more options (85).
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- No weighting or calibration per member; FCST-01 is still the own forecast.
|
||||
- Members on different integrations with different refresh times: each must
|
||||
be fresh (3 h) on its own; there is no wait for a lagging one.
|
||||
- The day plan itself, the trust and the house profile are unchanged.
|
||||
- The Batteries screen still says only "no solar forecast now". The member
|
||||
is named on the status page.
|
||||
@@ -0,0 +1,117 @@
|
||||
# PRICE-01 - the owner's price server as the price source, with fallbacks
|
||||
|
||||
**Status: for review.** Tests: `test_price_service.py`. One option added,
|
||||
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
|
||||
card answers for one provider. Design: the owner's price service note of
|
||||
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
|
||||
|
||||
## What it is
|
||||
|
||||
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
|
||||
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
|
||||
plus `covers`, so the existing columnar parser reads it. The price is wholesale
|
||||
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **The host is a constant in the plugin**, declared as its only egress host,
|
||||
HTTPS only. The operator's `price_feed_url` is not read by this provider.
|
||||
*Cost:* every client talks to the one host, and moving the server is a new
|
||||
add-on version. *What replaces the per-site setting:* the egress allow-list,
|
||||
TLS, and a token per client that the server can revoke.
|
||||
2. **The chain is server, then energy-charts, then the saved prices**
|
||||
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
|
||||
server that answers with old prices is skipped like one that is down. A step
|
||||
down and a step up are each one log line, written when the step changes. The
|
||||
operator's `price_feed_url` is cleared for the energy-charts step, which is
|
||||
asked for its own feed.
|
||||
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
|
||||
already keeps (the last three Brussels days of what was published). They are
|
||||
the all-in prices as stored; a change of markup shows from the next fetch
|
||||
that works. A read from the store is not filed again.
|
||||
4. **No price for now** means no published price covers the present moment. Not
|
||||
"none for the coming hours": before about 13:00 tomorrow's prices are not
|
||||
published on any healthy day, and carried hours are already marked estimated.
|
||||
It sets one status row (*Prices*), one Home plan line (English and Dutch),
|
||||
and one notification (*No prices*, English and Dutch), taken down when a
|
||||
price returns. A start-up before the first refresh finished is not a gap.
|
||||
The notification is not re-asserted after a Home Assistant core restart
|
||||
(SAFETY-34 does that for latched safety alarms); the status row stays.
|
||||
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
|
||||
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
|
||||
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
|
||||
run when the *current 15-minute price is at or below today's average*, today
|
||||
being the Brussels day, computed from the provider's prices. This is the rule
|
||||
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
|
||||
the pair to the provider does not change when the maintenance charge runs.
|
||||
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
|
||||
hours of the whole plan). This replaces, for this provider only,
|
||||
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
|
||||
writers: the pair beside a provider stays refused; `static_profile` beside
|
||||
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
|
||||
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
|
||||
did. (3) The basis is the one above, not `cheap_now()`.
|
||||
6. **The token is a secret.** `password?` in the schema; hidden on the settings
|
||||
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
|
||||
never put in a message by the plugin; taken out of any reason text by
|
||||
`price.scrub()`; the real transport reports only a status or an exception's
|
||||
name. validate.py refuses `price_service` with no token, a token with a
|
||||
space or line break, and one under 16 characters (so `scrub()`, which skips
|
||||
values under 4 characters, always has something to take out). Round 2: the
|
||||
token is in no page and no JSON answer of the web app. Every `password`
|
||||
option of the schema is shown as `********` (set) or empty, in
|
||||
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
|
||||
classic page draws it as a password field; a save or a validate that sends
|
||||
`********` or nothing keeps the stored value, any other value replaces it.
|
||||
A token can therefore be changed but not cleared from the page.
|
||||
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
|
||||
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
|
||||
cheap window and the gate ignore it, because they read the provider's
|
||||
prices. Keeping it set is the recommended setup, and clearing it triggers the
|
||||
"static_profile is empty" warning, so no notification (round 2's *Fixed
|
||||
tariff not used* was false and fired on every sound start; round 3 removed
|
||||
it). Instead one INFO line at start (English, as every log line) and an info
|
||||
note beside the setting (EN and NL, "With the price service, the fixed
|
||||
tariff is only used if no prices are available."), both
|
||||
only with a non-blank `static_profile` (whitespace counts as blank). At
|
||||
start the add-on dismisses the old notification id, in case a pre-release
|
||||
build raised it.
|
||||
8. **The no-price words show only with the maintenance charge on** (owner,
|
||||
2026-10-14): the status row, the Home line and the notification say
|
||||
'maintenance waits', so with `maintenance_enabled` off none is shown.
|
||||
|
||||
## Where the gate equals the Home Assistant pair, and where it does not
|
||||
|
||||
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
|
||||
rules in `price_service.yaml` (price at the quarter, average of today's
|
||||
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
|
||||
clock-change days, at three instants in every period, and through a real
|
||||
`Controller` for a whole day. Every answer is the same.
|
||||
|
||||
Not equal, by design or by arithmetic:
|
||||
|
||||
- The comparison is made on all-in prices. With a flat taxes figure and a
|
||||
markup and taxes of at most five decimals the offset cancels and the answer
|
||||
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
|
||||
prices differ by hour, so the answer can differ from the wholesale comparison
|
||||
the HA sensors made. A site on that profile should check before moving.
|
||||
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
|
||||
have more than five decimals.
|
||||
- The plan is retired when no refresh has succeeded for 90 minutes
|
||||
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
|
||||
answer after its REST sensor stopped updating was not measured here.
|
||||
- The average is over the firm prices the provider holds for today. After a
|
||||
restart in the middle of a day with only the saved part of today, it is the
|
||||
average of that part.
|
||||
|
||||
## Not covered
|
||||
|
||||
- **No real answer of the server was seen.** No token was available. The
|
||||
layout is the design's; `test_price_service.py` uses a mock and, for the
|
||||
parser, the real energy-charts capture reshaped with the design's extra keys.
|
||||
The first capture from the live server (rig test) should replace the mock.
|
||||
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
|
||||
30 minutes, and at most two requests when the server fails.
|
||||
- Battery modes read the plan as for `generic_dayahead` and were not changed.
|
||||
- The `price_service` REST and template sensors in Home Assistant are not
|
||||
touched; they stop being read when the pair is cleared.
|
||||
@@ -0,0 +1,80 @@
|
||||
# SAFETY-103 - no swing on old battery readings after a drain ends
|
||||
|
||||
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
|
||||
|
||||
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
|
||||
from its ended drain, SAFETY-95 steers on the house's point: grid + every
|
||||
battery's reading - aim. The meter is 1 s old; a battery register can be
|
||||
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
|
||||
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
|
||||
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
|
||||
|
||||
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
|
||||
while a drain's tail settles):
|
||||
|
||||
1. Each cycle, per battery: the range its power can be in. That is its last
|
||||
changed reading, widened by every command on its wire since (a lagging
|
||||
battery moves from one toward the others, never beyond). A new reading
|
||||
starts the range again.
|
||||
2. A reading that changed this cycle is taken as it reads. So is the side a
|
||||
battery refuses (SAFETY-73): it is not heading that way.
|
||||
3. The house point is held (first cycle: the loop's start from SAFETY-95).
|
||||
It moves only as far as the meter proves: to the nearest value of grid +
|
||||
the batteries' ranges - aim.
|
||||
|
||||
So the loop no longer moves on a reading that has not caught up. A real
|
||||
house step still moves it, as soon as the meter shows more than the
|
||||
batteries can explain.
|
||||
|
||||
**Why not the tester's rule** ("a battery whose reading is older than its
|
||||
last command counts at its command"). B's command changes almost every
|
||||
cycle, so B is almost always at its command. That is the plain meter, which
|
||||
chases A's tail: B swings 1420 W. A lag model needs the plant's time
|
||||
constant, which is not known for a client's inverter. Measured on the plant
|
||||
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
|
||||
|
||||
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|
||||
|---|---|---|---|---|
|
||||
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
|
||||
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
|
||||
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
|
||||
| lag model, tau 5 s, plant 20 s | | | 600 | |
|
||||
| hold all window | 0 | 0 | 0 | 0 |
|
||||
| **this rule** | **0** | **0** | **0** | **130** |
|
||||
|
||||
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
|
||||
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
|
||||
exported 16.9 Wh (release 8.5, this rule 8.9).
|
||||
|
||||
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
|
||||
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
|
||||
at -100 W; summed swing of the others in the 23 s after the end):
|
||||
|
||||
| case | release/1.0 | after |
|
||||
|---|---|---|
|
||||
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
|
||||
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
|
||||
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
|
||||
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
|
||||
| registers as fresh as the meter (1 s) | | rows byte-identical |
|
||||
| refusal end | | rows byte-identical |
|
||||
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
|
||||
|
||||
**Cost.** In the window, a house change smaller than what the batteries
|
||||
could still be doing is not followed until a reading catches up (at most a
|
||||
register period) or the window ends (23 s). Under a surplus the others
|
||||
still swing up to 130 W, a third of a slew step.
|
||||
|
||||
**Not covered.**
|
||||
- Outside the drain tail the loop steers on the meter; readings only reach
|
||||
the saturation freeze (SAFETY-96), unchanged.
|
||||
- A lost link or a refusal ends the tail at once (A is out of the split, or
|
||||
reads ~0 W). The swing after them is the loop's own on a lagging plant,
|
||||
the same with 1 s registers and on release/1.0: under a surplus up to
|
||||
1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW
|
||||
even without one. Not this card.
|
||||
- A battery that overshoots its command, or a reading offset beyond the
|
||||
range, can put the true power outside the range; the meter then moves the
|
||||
point, as before.
|
||||
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
|
||||
batteries were steady during the drain, so they are current there.
|
||||
@@ -0,0 +1,96 @@
|
||||
# SAFETY-107 - a refusal held under another writer is learned once
|
||||
|
||||
**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing
|
||||
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
|
||||
|
||||
**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a
|
||||
maintenance charge in a cheap window takes no charge. At N=1 the action log then
|
||||
showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another
|
||||
writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head,
|
||||
652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line
|
||||
log flooded and the hint under the buttons changed every second, so the
|
||||
MAINT-13 notice was gone at once.
|
||||
|
||||
**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested
|
||||
`self.target != self.loop_w`: the wire differs from the loop's ask. A held
|
||||
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
|
||||
charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every
|
||||
cycle. The refusal was ended against the same writer it had been learned
|
||||
against. `sat_count` is not reset by that end, so `compute()` learned the same
|
||||
refusal again in the same cycle. Nothing about the battery or the wire had
|
||||
changed; only the log and the hint did.
|
||||
|
||||
**Other writers.** The same flap comes from any writer that holds the wire
|
||||
while the loop is refused, as long as the evidence lasts: a trade (a SET at
|
||||
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
|
||||
refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on
|
||||
release/1.0).
|
||||
A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle.
|
||||
|
||||
**The rule** (`_refusal_update()`, `rejudged`). When another writer held the
|
||||
wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no
|
||||
probe, exactly as before the change. Then:
|
||||
|
||||
1. it finds the same refusal against that writer's ask: the same episode
|
||||
continues, with no line and no hint. The probe clock restarts, as it did on
|
||||
the old end and start: a probe is the loop's ask, and it is not on the wire
|
||||
while another writer is;
|
||||
2. it does not: the refusal ends, logged "another writer", or "the battery
|
||||
gives power" when the reading shows it (it was "another writer" before);
|
||||
3. it finds the other direction: end, then a new start, as before. In
|
||||
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
|
||||
sign, so a writer that turns round ends the refusal at once and the other
|
||||
way is learned ~12 cycles later (`test_safety107.py` part 3e).
|
||||
|
||||
**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no
|
||||
longer describes what is on the wire, judged by the same test that learns it.
|
||||
`compute()` sees the same inputs as before and the state after each cycle is
|
||||
the same (`batt_refused`, `batt_refused_at`), so every command is unchanged.
|
||||
Keeping the held refusal into `compute()` instead (mutation M5) would also
|
||||
stop the lines, but it holds the loop at 0 W where the old law let it ask past
|
||||
a writer's limit, which changes commands; it also removes SAFETY-52's
|
||||
owner-decided "another writer" exit.
|
||||
|
||||
**Evidence.**
|
||||
- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this
|
||||
repo; the real Controller in the CI image, release/1.0 bf3ac79 against this
|
||||
tree): 300 seeds, N=1 (153) and
|
||||
N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping
|
||||
house, maintenance charge with the cheap window opening and closing, P1 and
|
||||
the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a
|
||||
refusal held. Every command, site target, integrator, `sat_count`, refusal
|
||||
state and reason is identical. Refusal lines: 100 748 before, 2 188 after.
|
||||
- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house
|
||||
importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
|
||||
|
||||
**Mutations** (`tools/mutation_run.sh` on purged copies; reds in
|
||||
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
|
||||
commands differ from release/1.0):
|
||||
|
||||
| | mutation | reds | trace |
|
||||
|---|---|---|---|
|
||||
| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 |
|
||||
| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 |
|
||||
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
|
||||
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
|
||||
| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 |
|
||||
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
|
||||
|
||||
M4 is equivalent: a probe matters only if `compute()` engages in that call,
|
||||
which needs `SETTLE_CYCLES` saturated against the writer, and each of those
|
||||
cycles restarted the clock. The line stays because it states the rule.
|
||||
|
||||
**Cost.** None in power. A takeover by a different writer while the battery
|
||||
still refuses is logged as an end and, ~12 cycles later, a new start against
|
||||
that writer (3 lines for the episode, `test_safety107.py` part 3a).
|
||||
|
||||
**Not covered.**
|
||||
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
|
||||
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
|
||||
writer's ask returns fresh, `compute()` has no saturation history against it,
|
||||
so the refusal ends at the first cycle and the next trip seeds it again: 2
|
||||
lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0.
|
||||
The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it
|
||||
changes commands. None of the rig runs showed it: real readings move.
|
||||
- At N=1 a refusal shapes only the loop. A writer above it still asks the
|
||||
battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.
|
||||
@@ -0,0 +1,98 @@
|
||||
# SAFETY-112 - control waits for Home Assistant to finish starting
|
||||
|
||||
**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`.
|
||||
Control is unchanged. The add-on now says plainly that it is waiting.
|
||||
|
||||
## Cause
|
||||
|
||||
The Supervisor's proxy refuses every add-on request with 502 until Core's
|
||||
state is `RUNNING`. The add-on reads every battery reading and writes every
|
||||
power request through that proxy, so it cannot control until then. Readings
|
||||
that are already live inside Home Assistant do not help.
|
||||
|
||||
Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04):
|
||||
|
||||
- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and
|
||||
`number.set_value`) and `APIProxy.websocket()` both start with
|
||||
`if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`.
|
||||
- `supervisor/homeassistant/api.py`, `check_api_state()` returns
|
||||
`state.core_state == "RUNNING" or state.offline_db_migration`. The state
|
||||
comes from Core's `/api/core/state`.
|
||||
- Upstream `main` has the same gate in both handlers (read 2026-10-13).
|
||||
- A Core restart restarts only the Core container
|
||||
(`homeassistant/core.py`, `restart()`). The add-on keeps running.
|
||||
|
||||
The add-on itself has no wait. Nothing in `app/` reads Core's state or waits
|
||||
for an event. The loop reads its inputs every second, a 502 gives `None`,
|
||||
and missing inputs give 0 W. P1 backoff is not involved: the live site reads
|
||||
the meter straight from the HomeWizard (`homewizard_local`), not through Home
|
||||
Assistant.
|
||||
|
||||
## Measured
|
||||
|
||||
Repro stack `safety112-*`, all removed afterwards:
|
||||
|
||||
- Home Assistant 2026.9.4, as live.
|
||||
- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`.
|
||||
- A fake meter and two fake inverters that update every 2 s from load.
|
||||
- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0
|
||||
image. Only the add-on token lookup, Core's access token and the container
|
||||
check are stubbed.
|
||||
- The add-on on `meter_source: homewizard_local` against a fake HomeWizard.
|
||||
|
||||
Times are seconds after `docker restart` of Home Assistant:
|
||||
|
||||
| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings |
|
||||
|---|---|---|---|---|---|---|
|
||||
| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 |
|
||||
| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 |
|
||||
| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 |
|
||||
| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 |
|
||||
|
||||
The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the
|
||||
proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's
|
||||
stage timeout), so one slow integration holds a start stage for about 5
|
||||
minutes. The whole start can take longer: the live one took 414 s.
|
||||
|
||||
Before this change the page said "Not ready — Battery level: no usable
|
||||
reading", which sends the reader to Developer Tools for an entity that is
|
||||
fine.
|
||||
|
||||
## Rule
|
||||
|
||||
1. No way around the Supervisor. The add-on still talks to Home Assistant
|
||||
only through the proxy.
|
||||
2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the
|
||||
first status row is red: "Home Assistant is not answering, perhaps it is
|
||||
still starting. Control resumes by itself when it answers." The banner
|
||||
reads "Not ready — Waiting for Home Assistant to finish starting". The Home
|
||||
pill reads "Waiting for Home Assistant", in English and Dutch.
|
||||
3. A safety stop, a stalled control loop or shadow mode keeps its own banner
|
||||
and state over the wait, because none of them clears by itself.
|
||||
4. The log has one WARNING when Home Assistant stops answering, an INFO line
|
||||
every 5 minutes while it is down, and an INFO line with the length of the
|
||||
wait when it answers again.
|
||||
5. Only the API counts. The meter websocket alone does not, because the meter
|
||||
has its own row.
|
||||
|
||||
## Why it is safe
|
||||
|
||||
The change is text only. `cycle()`, the writes, the heartbeat, the stand-down
|
||||
on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows
|
||||
the same targets and writes with the flag on and off, for N=1 and N=2.
|
||||
|
||||
## Open, for the owner
|
||||
|
||||
The add-on cannot control while Home Assistant starts. These ways around it
|
||||
were not taken:
|
||||
|
||||
- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`).
|
||||
Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state`
|
||||
answered from 12-13 s, and a direct `number.set_value` 10 s into
|
||||
`NOT_RUNNING` returned 200 and set the value. The cost is a secret stored
|
||||
at every client site, which `hass.py` exists to avoid.
|
||||
- **Talk to the control board directly** (ESPHome native API), so control
|
||||
does not need Home Assistant at all. This is a large change.
|
||||
- **Remove the slow start at the site.** On 2026-10-12 the Solis
|
||||
integration's discovery held the start. Fixing or disabling it shortens the
|
||||
wait there, but not on a client site with another slow integration.
|
||||
@@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack.
|
||||
|
||||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||||
|
||||
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
|
||||
|
||||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,110 @@
|
||||
# SAFETY-52 - a battery that refuses is asked for 0 W
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
|
||||
questions answered yes.**
|
||||
|
||||
## The defect
|
||||
|
||||
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
|
||||
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
|
||||
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
|
||||
inside the interval a limit can produce. The request stood at +5000 W for
|
||||
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
|
||||
moved again only when the grid error changed sign. SAFETY-48's lead cap only
|
||||
changes where `i_w` parks (one slew step ahead instead of at the rail). When
|
||||
the request moved after the reading went flat, the stale-reading gate won
|
||||
instead and ran SAFETY-59's ~70 s trip cycle.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
|
||||
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
|
||||
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
|
||||
`saturation_w`. The drop to 0 skips
|
||||
the slew limit (it only removes an ask nobody meets). The direction is
|
||||
carried like `sat_count` (`refused=`, `Decision.refused`).
|
||||
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
|
||||
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
|
||||
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
|
||||
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
|
||||
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
|
||||
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
|
||||
cycle. The log keeps SAFETY-56's two-explanation wording.
|
||||
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
|
||||
|
||||
Other exits: the loop wants the other direction (charge is accepted at the
|
||||
floor), control stopped or started, another writer on the wire (the arbiter
|
||||
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
|
||||
what seeds it.
|
||||
|
||||
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
|
||||
battery's discharge end and moves its share. There, a one-unit gate trip must
|
||||
not refuse the site. `compute()` is passed `refused=None` there, and that path
|
||||
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
|
||||
`test_control.py` still holds).
|
||||
|
||||
## Evidence
|
||||
|
||||
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
|
||||
shapes, recovery with no sign change, the probe cadence, every exit).
|
||||
`tools/replay_refusal.py` replays the 16-day capture, same model as
|
||||
`replay_recorded_loop.py`, and should be read for deltas:
|
||||
|
||||
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
|
||||
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
|
||||
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
|
||||
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
|
||||
|
||||
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
|
||||
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
|
||||
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
|
||||
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
|
||||
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
|
||||
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
|
||||
just above its floor, the probe is answered, control ramps, the pack empties
|
||||
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
|
||||
follow an other-direction exit the same way. On a site this is one
|
||||
`STALE_READING` ERROR line per answered probe at most, and only on a battery
|
||||
that answers a probe and then cuts out again. Catching the re-cut before the
|
||||
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
|
||||
battery, so it is left to SAFETY-58/59. After the review changes below the
|
||||
fitted-plant row reads 133 trips (the delivery exit now also catches answers
|
||||
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
|
||||
the phase A plan: bounding the integrator by demand made the worst quarter
|
||||
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
|
||||
configured floor to be right and needs SoC.
|
||||
|
||||
## Review changes (#141)
|
||||
|
||||
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
|
||||
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
|
||||
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
|
||||
old law. The capture has 0.11 h in that band, so the replay does not move.
|
||||
- **Any delivery ends the hold, not only an answered probe.** With
|
||||
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
|
||||
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
|
||||
held it for 15 min. Now the late reading ends it within seconds (2 s held in
|
||||
`test_refusal.py` part 5b).
|
||||
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
|
||||
battery only when its cut-out level is configured right.
|
||||
|
||||
## Not covered
|
||||
|
||||
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
|
||||
genuine limit (SAFETY-13 case a) and still holds its request.
|
||||
|
||||
## What SAFETY-58 and SAFETY-59 must take into account
|
||||
|
||||
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
|
||||
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
|
||||
the first verdict, the seed arrives later. The 09-19 shape then needs the
|
||||
compute-side engagement (12 cycles) to win, which it does only while the
|
||||
request stands still. Keep `test_refusal.py` part 4 green.
|
||||
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
|
||||
change a refusing battery no longer produces the pattern at one inverter. The
|
||||
DOCS text should say so, and that the `battery refuses` reason is the
|
||||
discriminator the add-on now has. At N>1 the old pattern can still occur.
|
||||
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
|
||||
`test_refusal.py` part 2 checks the inequality.
|
||||
@@ -0,0 +1,104 @@
|
||||
# SAFETY-73 - a battery that refuses, with two or more inverters
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option R). All four
|
||||
new questions answered yes. SAFETY-52's four decisions carry over.**
|
||||
|
||||
## The defect
|
||||
|
||||
SAFETY-52 held a refusing battery at 0 W at one inverter only. At N>1
|
||||
(`main.py` passed `refused=None`) nothing closed a unit that gives ~0 W while
|
||||
its state of charge reads above its configured cut-out, so the allocator kept
|
||||
handing it its weighted half. Measured on the real `Controller`, two units,
|
||||
house 3000 W, A cut out:
|
||||
|
||||
| register | law | B gives | grid | A's gate trips / h |
|
||||
|---|---|---|---|---|
|
||||
| flat -4 W | old | 533 W | +2471 W | 46 (each stops the whole site) |
|
||||
| flat -4 W | new | 2000 W (its rating) | +1004 W | 1 |
|
||||
| jitter | old | 1910 W | +1094 W | 0 |
|
||||
| jitter | new | 2000 W | +1004 W | 0 |
|
||||
|
||||
House 1200 W, which B alone carries: old +671 W (flat) and +284 W (jitter)
|
||||
standing import, new -16 W. Both cut out: old keeps asking two empty batteries
|
||||
(+1910 W each on the jitter shape), new holds the site at 0 W.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
|
||||
claim, source `refused`. It engages when the unit gives under
|
||||
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least
|
||||
`REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W`
|
||||
(200 W), or when its stale-reading gate
|
||||
trips on a held reading under 100 W. The allocator then hands its share to
|
||||
the others within their own limits (owner decision 1). It is a hard unit
|
||||
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
|
||||
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
|
||||
arbitrage and maintenance are placed around it like the loop.
|
||||
3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending
|
||||
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
|
||||
unit: its share is 0 W, and `account()` treats it as parked.
|
||||
4. **All units refusing the same way** pass that sign to `compute()`, so the
|
||||
site holds 0 W with SAFETY-52's law (decision 4). Probes go one unit at a
|
||||
time.
|
||||
5. **Probe:** every 15 min for 34 s the claim becomes a pin at +-200 W and the
|
||||
others carry the rest. The pin stands only while the resolved site target
|
||||
asks at least 200 W its way; otherwise it would make a sibling take the
|
||||
opposite direction, so it falls back to the closed limit for that cycle.
|
||||
6. **Exits:** a reading of 100 W or more in the held direction (probe or not);
|
||||
100 W or more taken the other way for 12 cycles (charge at the floor; one
|
||||
cycle of the loop overshooting through zero is not enough); stop or start.
|
||||
On every exit the site target and integrator restart from the watts on the
|
||||
wire, or the reopened unit is handed a parked remainder at once.
|
||||
|
||||
**Why 200 W and not `saturation_w`.** A unit's share is often small: a 400 W
|
||||
night load split two ways asks 200 W of each. With 500 W the empty battery
|
||||
kept a phantom share that SAFETY-43's relative arm then reported as not
|
||||
delivering, the ERROR decision 3 replaces. Shares of 200 W or less that give
|
||||
under 100 W are "too small to call" in `account()`: the sibling covers the
|
||||
meter and nothing is asked of the empty battery that matters.
|
||||
|
||||
**Why one direction throughout.** On TEST-19's flapping family a command that
|
||||
swaps sign each cycle reads ~0 through the plant's lag: 42 engagements in 21
|
||||
no-fault runs without the rule, 0 with it.
|
||||
|
||||
**N=1 is unchanged.** Every SAFETY-73 path is gated on `len(inverters) > 1`.
|
||||
`test_refusal_units.py` part 6 runs SAFETY-52's whole suite with every
|
||||
SAFETY-73 method made to raise.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `test_refusal_units.py`: cases a, b and c on both register shapes against
|
||||
the old behaviour, probes and their spacing, one-at-a-time probing, the
|
||||
recovery reseed, each exit, the small-ask and small-site-target cases, the
|
||||
WARNING, the status row and shadow mode. Mutations U1-U13 in its docstring.
|
||||
- **No false engagements.** TEST-19's no-fault campaign (admin/goodwe
|
||||
`sim/delivery_window.py` families, 2640 runs at N=2 and 2640 at N=1, every
|
||||
unit executing its share) graded with the engage clause: 0 engagements, the
|
||||
longest qualifying streak 5 cycles against 12. The grader is
|
||||
`goodwe-session/scratch_s73/s73_nofault.py`.
|
||||
- **Recovery.** Case a, A recovering into a house that fell below the parked
|
||||
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
|
||||
reseed and the site hold.
|
||||
|
||||
## Review changes
|
||||
|
||||
- **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12
|
||||
cycles were 11 s, shorter than a healthy reading's lag (dead time plus a
|
||||
10-17 s publish). The reviewer's harness (real `Controller`, both units with
|
||||
6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then
|
||||
refused both healthy units 10-16 times an hour, and each time the site held
|
||||
0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without
|
||||
the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is
|
||||
`(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged.
|
||||
- **The cut-out WARNING is for a refusal to give power only.** A full battery
|
||||
that stops taking charge is always above its cut-out level, and the WARNING
|
||||
told the installer to check a setting that has nothing to do with it.
|
||||
|
||||
## Not covered
|
||||
|
||||
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
|
||||
N=1. SAFETY-42/43 still report it.
|
||||
- `pack_permissions` stays SoC-based, so the peak guard may size a shave with a
|
||||
refusing battery counted. The shortfall shows as `unmet_w`.
|
||||
- A wedged unit (HW-01) reading ~0 W is now shown as a refusal, not reported as
|
||||
not delivering. Its share still moves, which is the gain SAFETY-42 measured.
|
||||
@@ -0,0 +1,43 @@
|
||||
# SAFETY-78 - a maintenance drain at two inverters
|
||||
|
||||
**Status: DECIDED 2026-09-29.** Fixes MAINT-05 §6 (OPT-11 P1 F1, UX-13 P3
|
||||
check 5, OPT-11 P3 F4). Tests: `test_safety78.py`.
|
||||
|
||||
**The defect.** At N>=2 the drain pins its pack (a unit SET at
|
||||
`P_MAINTENANCE`) and the site target is split around the pin. Whenever the
|
||||
site target was below the pin, the other pack had to charge. The allocator
|
||||
refuses opposite directions (SAFETY-25 §0a, SAFETY-31), so the site went to
|
||||
0 W and logged `ARBITER: REFUSED` every cycle. That covers a house below the
|
||||
drain rate, a grid-charge trade, and a house above it too: after a refusal the
|
||||
loop restarts from 0 W and its 400 W slew step is below the pin again.
|
||||
|
||||
**The fix.** While a pack drains at N>=2, main.py files a site limit
|
||||
`drain`, `lo` = what the pin really places (`allocator.pin_w()`: the drain
|
||||
rate inside that pack's own hard limits), at `P_MAINTENANCE`. So the site
|
||||
target is never below the pin and the other pack only adds power in the same
|
||||
direction. Opposite splits stay forbidden.
|
||||
|
||||
- The order is the arbiter's own: a safety SET (100) outranks the limit and
|
||||
the pin, so everything parks; the peak floor (50) and the drain floor are
|
||||
both applied, so the higher wins; a trade or the loop below the drain is
|
||||
lifted to it.
|
||||
- Below the drain rate the drained pack exports the rest, as the drain does at
|
||||
N=1 (`maintenance.py`: "forced discharge, exports the surplus"). Holding it
|
||||
to the house load would make N=2 drain differently from N=1; that is an
|
||||
owner question, not this fix.
|
||||
- A draining pack that can place nothing (a refusal, a stale link) adds no
|
||||
floor, and the wait is logged once per episode.
|
||||
- With battery modes on, no pack is a grid charger while another drains
|
||||
(`modetrade.decide(draining=)`, why `cycle`), and the other battery's card
|
||||
says why (`other_cycle`). Not while the drain waits: no floor is filed
|
||||
then, so the other battery may charge from solar.
|
||||
- Not filed at N=1, where the drain is the site SET itself: byte-identical
|
||||
(`tools/safety78_identity.py`, which hashes the claims too).
|
||||
|
||||
**Not changed (measured in the PR #154 review, follow-ups).**
|
||||
- At N=1 a drain (60) outranked the peak floor (50): a 5000 W house imported
|
||||
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
|
||||
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
|
||||
charging pack's `hi = -floor` against the loop asking the other to give is
|
||||
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
|
||||
the peak floor's `lo` (0 W), so it is not this card. Fixed by [[SAFETY-84]].
|
||||
@@ -0,0 +1,41 @@
|
||||
# SAFETY-79 — the peak guard keeps a margin below the peak limit
|
||||
|
||||
**Status: DECIDED 2026-09-28 (owner decision).**
|
||||
|
||||
## Decision
|
||||
|
||||
The peak guard aims `peak_margin_w` under the limit it holds: under
|
||||
`peak_cap_w`, or under a month peak already banked above it
|
||||
(`quarter_target_w()`). `peak_margin_w` is a setting: default 50 W, range
|
||||
0-500 W, whole watts. An install without the key gets 50.
|
||||
|
||||
**Why (owner):** the guard aimed exactly at the cap, and in the OPT-11 P1 test
|
||||
a quarter banked 3501.9 W against a 3500 W limit (OPT-11-P1-test-report.md,
|
||||
check 4). A peak even 1 W over still sets the billed peak. It is a setting and
|
||||
not a constant because the add-on is sold to several sites.
|
||||
|
||||
**Banked peak (review, 2026-09-29):** the PR first aimed at a banked month
|
||||
peak as it stood. That can bank a few W over it, which raises the bill the
|
||||
same way; and a peak banked between cap - margin and the cap (common in the
|
||||
month an install upgrades, since the old guard held quarters at the cap) was
|
||||
aimed at exactly, so one overshoot went over the cap. The margin now applies
|
||||
under it too. Cost: up to `peak_margin_w` of a peak already paid for goes
|
||||
unused (more discharge, less charge headroom); no bill cost. Undo is one line
|
||||
in `quarter_target_w()`.
|
||||
|
||||
## What uses which number
|
||||
|
||||
- **the aim:** `peakguard.decide()` (the claim's floor, and so arbitrage's
|
||||
`charge_headroom_w()` and OPT-11's give-way, which read it), and, as cap -
|
||||
margin, `shaping_suspended()` and `maintenance_charge_floor()` (the forecast
|
||||
checks, via `Controller.peak_aim_w()`).
|
||||
- **the real cap (reporting and billing):** `PeakTracker` (month peak,
|
||||
headroom, 80 % warning), the decision's `ceiling_w` and every notice and
|
||||
status row built from it, Home's limit, and `validate.config_notes()`.
|
||||
|
||||
## Checks
|
||||
|
||||
`peak_margin_w: 0` is byte-identical to ed739d4 (test_peak_margin.py part 1,
|
||||
digests from tools/safety79_identity.py, which also runs a maintenance charge
|
||||
with a forecast). validate.py rule 12 refuses a value outside 0-500, not
|
||||
whole, or not below `peak_cap_w`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# SAFETY-81 — no false "Load too high for battery" alarms
|
||||
|
||||
**Status: 2026-09-29, owner go for the card. Review kept the 10 W tolerance
|
||||
as a constant and added the 10 s hold.**
|
||||
|
||||
## Defect
|
||||
|
||||
The SAFETY-79 test (SAFETY-79-test-report.md, F1/F2) saw 6 false alarms in 7.
|
||||
The alarm fired whenever `decide()` clamped its floor to the rating
|
||||
(`saturated`). Three things set that while the quarter landed on target:
|
||||
|
||||
- **Double count.** `decide()` sizes from the held meter reading plus the
|
||||
battery reading. When the meter sample predates the battery's last increase,
|
||||
the increase is counted twice. It happens under noise, and at each quarter
|
||||
start, where the battery is back while the held sample is still the spike.
|
||||
- **The aim, not the cap.** `saturated` is graded against cap − margin, so at
|
||||
margin 50 noise needs 50 W less to trip it (F2).
|
||||
- **Last seconds.** The gain 900 / remaining turns a few W over the aim into
|
||||
thousands of W wanted, with the battery far from its rating.
|
||||
|
||||
## Decision
|
||||
|
||||
The alarm needs `saturated` and `peakguard.load_too_high()`: with the battery
|
||||
at full power from now on, the quarter still ends more than `ALARM_MISS_W`
|
||||
(10 W) over the real limit (`ceiling_w`). That is the miss itself, without the
|
||||
gain. The battery reading it uses is the lower of this cycle's and the last
|
||||
one's. It must hold for `ALARM_HOLD_S` (10 s). At most one notification per
|
||||
quarter, because near the threshold the test flickers with the meter; the
|
||||
ERROR log line is written for every episode.
|
||||
|
||||
**Why 10 W:** at margin 0 the guard itself lands up to +5.7 W over (SAFETY-79
|
||||
test, check 5), and those misses are real but tiny; the alarm would fire in
|
||||
the last second of most guarded quarters. The margin (default 50) is the fix
|
||||
for them. Cost: a real miss of 10 W or less is not alarmed; at a capacity
|
||||
tariff of the order of EUR 50 per kW a year, 10 W in one month is cents.
|
||||
Review measured +13 W on a 10 kW two-unit site at margin 0, not saturated, so
|
||||
no alarm: the tolerance only matters when the battery is at its rating. A
|
||||
constant, not an option: `peak_margin_w` is the owner's knob.
|
||||
|
||||
**Why the hold (review):** the test assumes the load now lasts the rest of
|
||||
the quarter. A load over cap + battery for ~5 s every 30 s (`sine4500_fast`,
|
||||
a cycling hob or heater) alarmed at each quarter start while the quarter
|
||||
landed under the aim. The hold stops that.
|
||||
|
||||
**Cost:** a real alarm comes 10-12 s later than on 5a4cdc2 (`step6500` at
|
||||
1211 s, not 1201 s); a slow ramp 20 s later (`ramp6500`), as it waits for the
|
||||
real cap. While the battery refuses or its reading is lost, the alarm waits
|
||||
until the quarter is lost even at full battery (27-60 s later in review runs);
|
||||
those faults have their own alarms. A load over cap + battery for more than
|
||||
10 s still alarms even if the quarter later lands under. `ALARM_HOLD_S = 0`
|
||||
removes the hold.
|
||||
|
||||
## Not changed: the 1 s spike itself
|
||||
|
||||
The cycle after a quarter boundary and before the new quarter's first meter
|
||||
sample still sees 0 s left, files no claim, and an Off battery drops to 0 W for
|
||||
one cycle. That is the same edge tick as OPT-15 F1, so it is left to OPT-15.
|
||||
Control is unchanged here: every command in `tools/safety81_sim.py` is
|
||||
byte-identical to 5a4cdc2 (test_safety81.py part 1).
|
||||
|
||||
The double count also moves control, not only the alarm: on a late sample the
|
||||
guard asks for the full rating for one cycle (edge4600 at 902 s: 2000 W where
|
||||
1150 W holds the aim). Left to a follow-up card.
|
||||
@@ -0,0 +1,34 @@
|
||||
# SAFETY-83 - the peak guard outranks the maintenance drain at N=1
|
||||
|
||||
**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not
|
||||
changed" list. Tests: `test_safety83.py`.
|
||||
|
||||
**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The
|
||||
peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or
|
||||
above the winner, so the floor was ignored and the house imported what the
|
||||
drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked
|
||||
3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a
|
||||
breach starts at about 5950 W.
|
||||
|
||||
**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks
|
||||
for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that
|
||||
peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the
|
||||
floor, still inside the safety limits, and a safety SET (100) still parks
|
||||
everything. Where the floor is at or below the drain rate nothing changes.
|
||||
|
||||
- The order in effect is now the same at N=1 and N=2: safety limits and the
|
||||
SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance >
|
||||
modes > trade > loop (`arbiter.py`, beside the priorities).
|
||||
- The drain keeps counting: the battery discharges at least the drain rate on
|
||||
every cycle, so the cycle is not reset or stalled; it only ends sooner.
|
||||
- Below the drain rate the rest is still exported (owner, 2026-09-29).
|
||||
- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only
|
||||
cycles where the guard asked for more than the drain; after such an episode
|
||||
the commands are identical again and only the log and the idle loop's
|
||||
integrator differ. N=2 is unchanged.
|
||||
|
||||
**Not changed.**
|
||||
- Between the drain rate and the peak aim, the house still imports what the
|
||||
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
|
||||
- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak
|
||||
guard at N=1. Fixed by [[SAFETY-86]].
|
||||
@@ -0,0 +1,46 @@
|
||||
# SAFETY-84 - a maintenance charge at two inverters
|
||||
|
||||
**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list.
|
||||
Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`.
|
||||
|
||||
**The defect.** In a cheap window the charging pack files its own
|
||||
`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it
|
||||
(a house to cover, or its 400 W slew step from 0 W), so the other pack had to
|
||||
give while this one charged. The allocator refuses opposite directions
|
||||
(SAFETY-31), so the site sat at 0 W whatever the house
|
||||
(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1
|
||||
was fine.
|
||||
|
||||
**The fix.**
|
||||
- While a pack's own limits make it charge at N>=2, `main.py` files a site
|
||||
limit `charge`, `hi` = what that pack's own `hi` places
|
||||
(`allocator.hi_w()`, whole W as the allocator rounds). The site target is
|
||||
never above it, so the other pack only adds power in the same direction.
|
||||
Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor.
|
||||
- Peak guard first (owner order, SAFETY-83). The charge is sized to the
|
||||
quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak
|
||||
minus `peak_margin_w`), so it charges less instead of stopping: the meter
|
||||
sits on the aim and the guard does not need to step in. Where the house
|
||||
alone is over the aim the guard engages, the shaping is suspended as before
|
||||
(`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and
|
||||
this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W).
|
||||
Without the headroom sizing the aim still held (the suspension did it), but
|
||||
a battery gave power back on 285 of 540 cycles at a 3000 W house
|
||||
(test_safety84.py part 2).
|
||||
- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W:
|
||||
the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other
|
||||
pack to give. It was refused, with an ARBITER line, on most cycles of the
|
||||
stop (test_safety84.py part 4). The stop still parked the site at 0 W.
|
||||
- A pack that cannot take the charge (a refusal, a full pack, a stale link)
|
||||
files no ceiling; the other pack follows the loop.
|
||||
- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes
|
||||
every claim, in and out of a cheap window and under a banked peak).
|
||||
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix.
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
comes as often at N=2 as at N=1 (2 in 480 cycles).
|
||||
@@ -0,0 +1,91 @@
|
||||
# SAFETY-85 - no false "not keeping up" while another owner sets the battery
|
||||
|
||||
**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`.
|
||||
|
||||
**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`)
|
||||
fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the
|
||||
peak guard, while the battery gave exactly what it was told. Each warning
|
||||
re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in
|
||||
7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page
|
||||
counter counted cycles (861), not episodes.
|
||||
|
||||
**Cause, measured on the real Controller.**
|
||||
- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the
|
||||
target each cycle, then `compute()` adds `gain * error` for a grid error the
|
||||
loop does not own. Target + kick crosses the rail and sets `i_clamped`. The
|
||||
carried integrator is not wound (SAFETY-48 caps the lead at one slew step),
|
||||
so control comes back one slew step at a time.
|
||||
- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and
|
||||
winds to its rail for watts the pinned battery may not give. When the pin
|
||||
lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W
|
||||
house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s).
|
||||
|
||||
**Decision.** Three rules; the alarm, its text and its id are unchanged.
|
||||
1. **The loop's clamp counts only on a cycle the loop decided.** The winner is
|
||||
the loop. Its number is on the wire, or only a rating (`device`,
|
||||
`supervised`) cut it. No bound held it, and nothing was unmet.
|
||||
(`Controller._loop_alarm_input`.)
|
||||
2. **A battery another owner set to discharge alarms when it gives less than
|
||||
it was told.** This covers every battery on a cycle the loop did not
|
||||
decide, and a battery with its own pin otherwise. The test is more than
|
||||
`saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles
|
||||
and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for
|
||||
`saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a
|
||||
real shortfall during maintenance or under the peak guard. Before, that
|
||||
shortfall was silent unless the kick happened to reach the rail (house
|
||||
1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min
|
||||
now).
|
||||
3. **At N>=2, while maintenance holds a battery, the loop is bounded** by
|
||||
OPT-11's `loop_bound()` (what the batteries may place). It does not wind,
|
||||
and control comes back one slew step a cycle. Why maintenance only: that is
|
||||
where the jump was measured and where the owner order puts maintenance
|
||||
above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep
|
||||
the old law. Rule 1 still silences them (the target is unmet), so they no
|
||||
longer warn either.
|
||||
|
||||
Fixing the windup was chosen over only ignoring the wound part, because at
|
||||
N>=2 the wound integrator moves real watts at hand-back. At N=1 there was
|
||||
nothing to fix: re-seeding already prevents windup.
|
||||
|
||||
**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator
|
||||
bound still warns (a runaway, a house above the rating), and a saturated
|
||||
inverter under the loop's own command still does not alarm (SAFETY-03 AC 2):
|
||||
rule 2 grades only batteries another owner set.
|
||||
|
||||
**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a
|
||||
bound engagement. The status row shows it ("N times since start").
|
||||
`bound` (cycles) stays for the log and the tests.
|
||||
|
||||
**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt.
|
||||
|
||||
| digest | what changed |
|
||||
|---|---|
|
||||
| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
|
||||
| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical |
|
||||
| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) |
|
||||
| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles |
|
||||
|
||||
test_safety86's "without the ceiling the log floods" now needs the ceiling
|
||||
and the bound both removed: either one alone keeps the loop inside what the
|
||||
batteries can give. The `alarmed()` helpers in test_safety84/86 also read
|
||||
SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of
|
||||
the 40-row log.
|
||||
|
||||
**Review (PR #163).** The first draft graded each battery against last
|
||||
cycle's command and graded charges too. On plantsim's battery (10 s lag) at
|
||||
the live 2 s meter, every drain or charge start warned once, and an N=2 drain
|
||||
or HOLD under the peak guard with a stepping house warned 15 times an hour. A
|
||||
maintenance charge tapering near full warned 11 times in 50 min, which
|
||||
SAFETY-42 section 7 had ruled out. With the settle window and discharge only:
|
||||
0 in all of those cases. A battery that gives nothing still warns, lagging
|
||||
or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost:
|
||||
a battery that does not take a charge another owner set is not named by this
|
||||
alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout).
|
||||
`test_safety85.py` part 7.
|
||||
|
||||
**Not changed, open for the owner.**
|
||||
- With the loop in charge and the house above the batteries' total rating,
|
||||
it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's
|
||||
count came from houses of 4500 and 6500 W.
|
||||
- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds
|
||||
the loop, with modes off. DOCS lists the value with both meanings.
|
||||
@@ -0,0 +1,47 @@
|
||||
# SAFETY-86 - the peak guard outranks the maintenance HOLD
|
||||
|
||||
**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed"
|
||||
list. Tests: `test_safety86.py`.
|
||||
|
||||
**The defect.** The HOLD (battery resting full after the maintenance charge)
|
||||
is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50),
|
||||
so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and
|
||||
'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other
|
||||
battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
|
||||
|
||||
**The fix** (`main.py`, `_peak_over_hold()`).
|
||||
- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as
|
||||
for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised
|
||||
for the peak limit".
|
||||
- N>=2: the holding battery's pin is raised to the floor minus what the other
|
||||
live batteries may place (their hard `hi`, or their pin). The others give
|
||||
first, the holding one only the shortfall. Why this split and not the
|
||||
allocator's shares: the hold is maintenance and outranks the others' use
|
||||
(owner order), and the owner rule for an Off battery is the same, "give way
|
||||
only for the shortfall". A battery Off by its mode (P_MODE, below
|
||||
maintenance) therefore still gives before the holding one.
|
||||
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
|
||||
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
|
||||
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
|
||||
row each cycle and filled the action log (review, PR #161).
|
||||
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
|
||||
are identical to before (test_safety86.py part 2).
|
||||
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
|
||||
and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is
|
||||
pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is
|
||||
never split both ways.
|
||||
- The hold resumes by itself: the rule is evaluated each cycle, so when the
|
||||
guard lets go the command is 0 W again.
|
||||
|
||||
**The level dips below the target: the hold finishes anyway.** Owner
|
||||
decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
|
||||
when the peak guard pulls the level down; no top-up. A top-up would re-enter
|
||||
CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
|
||||
slot meanwhile.
|
||||
|
||||
**Not changed.**
|
||||
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
|
||||
B under `peak_soc_floor` the floor is limited and A holds even when it is
|
||||
full (SAFETY-87).
|
||||
- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge
|
||||
flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched.
|
||||
@@ -0,0 +1,83 @@
|
||||
# SAFETY-87 - the peak guard uses every battery that can still give
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
|
||||
changed" list. Tests: `test_safety87.py`; identity:
|
||||
`tools/safety87_identity.py`.
|
||||
|
||||
**The defect.** At N>=2 the guard graded the emptiest battery against the
|
||||
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
|
||||
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
|
||||
even with the other battery full. Measured: a maintenance charge (A at 11 %)
|
||||
raised the alarm 10 times while both batteries gave 2000 W and the quarter
|
||||
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
|
||||
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
|
||||
review; rig case 7a: 4563.8 W).
|
||||
|
||||
**The fix.**
|
||||
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
|
||||
then grades each battery on its own floor (`peakguard.reserve_split()`).
|
||||
One at or under it is *held*. The others that are live and not refusing
|
||||
(SAFETY-73) are *able*.
|
||||
- A floor-limited decision is re-taken on the able batteries
|
||||
(`peakguard.place_on_able()`): their own permission (min SoC against max
|
||||
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
|
||||
rating plus the drain a held battery gives anyway (its maintenance pin).
|
||||
- The held batteries weigh 0 in the split while the site gives
|
||||
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
|
||||
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
|
||||
so the holding battery covers the need.
|
||||
- Floor-limited now means: some battery is held and the able ones cannot hold
|
||||
the aim. The claim still asks them for `able_w`. The alarm fires only if
|
||||
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
|
||||
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
|
||||
is not an alarm. With nothing able (all held, or the rest refusing or
|
||||
dropped) it is the old state: 0 W, immediate alarm.
|
||||
- The alarm and the row name `held`, each battery graded on its own floor. The
|
||||
old code named batteries against the site's highest floor.
|
||||
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
|
||||
title without "Not ready", and the Home page is not shown as held at 0 W.
|
||||
Rows that do stop control still outrank it.
|
||||
|
||||
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
|
||||
max(floor) so the guard never sizes a forced discharge a battery's own BMS
|
||||
stops. Now no battery at or under its own floor is asked for the peak: it is
|
||||
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
|
||||
The min/max pairing still decides permission, over the able batteries. The
|
||||
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
|
||||
call-site and reduction checks still bite. What changed is only that one held
|
||||
battery no longer stops the others. One exception, with battery modes on: see
|
||||
Open.
|
||||
|
||||
**Maintenance pins beside a held battery** (review). A draining battery that
|
||||
may still give is raised from its drain rate towards its rating for the peak
|
||||
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
|
||||
floor's rest landed on the held battery. In a HOLD, a held battery counts its
|
||||
own pin (a drain) among the others, and a held battery pinned to charge stops
|
||||
the raise (never split both ways).
|
||||
|
||||
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
|
||||
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
|
||||
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
|
||||
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
|
||||
| both under the reserve | alarm | alarm (unchanged) |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Open, for the owner.**
|
||||
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
|
||||
the peak guard only). It gives last, after the able ones. So with house 6500
|
||||
in the charge case, B alone cannot hold the aim, the alarm fires as
|
||||
specified, and the loop then lands the quarter on the aim from A. The text
|
||||
says "cannot keep … alone", not "will go over", so it stays true.
|
||||
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
|
||||
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
|
||||
before a held battery in the first case needs a third allocator tier. The
|
||||
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
|
||||
Until then, with modes on, a held battery can give for the peak.
|
||||
- The notification and row text live in `messages.py`, which has no NL layer
|
||||
yet (UX-19). The Home page text, which has NL, is unchanged.
|
||||
|
||||
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
|
||||
@@ -0,0 +1,81 @@
|
||||
# SAFETY-90 - the peak guard goes on after a P1 meter gap
|
||||
|
||||
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
|
||||
|
||||
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
|
||||
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
|
||||
not reach the bill record). `peakguard.blindness()` then called the projection
|
||||
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
|
||||
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
|
||||
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
|
||||
The page still read "Keeping the grid near zero". At start-up mid-quarter the
|
||||
same rule left the start-up quarter blind, and a held battery was drawn as last
|
||||
resort (SAFETY-91 test, O2).
|
||||
|
||||
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
|
||||
`Controller._fill_peak_gaps()`).
|
||||
- Once readings are back, every second of a silence longer than
|
||||
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
|
||||
the gap, the import just after it)`, where "import" is the grid plus the
|
||||
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
|
||||
`meter_max_age_s`, which the averager credits at the last reading, is topped
|
||||
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
|
||||
blind and gives what keeps the quarter at the aim.
|
||||
- **The bound.** The count is at or above the true import as long as, during
|
||||
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
|
||||
before and just after it, or under the limit, and the batteries did not
|
||||
start charging (during a gap they hold their last command, then go to 0 W).
|
||||
It never counts the gap below the limit. A load that rises above all three
|
||||
only inside the gap is the one case it can miss.
|
||||
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
|
||||
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
|
||||
battery energy.
|
||||
- **While the gap lasts** nothing changes: the reading is too old to steer by,
|
||||
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
|
||||
before.
|
||||
- **Before the first reading** (start-up or restart mid-quarter) the
|
||||
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
|
||||
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
|
||||
`gap_w(the limit, the import seen first)`; after a restart the restored
|
||||
reading's held `meter_max_age_s` is topped up the same way. The bound is
|
||||
the gap's, with only the "after" side known. A load the batteries cannot
|
||||
make up then raises "Load too high", which is true: that quarter is going
|
||||
over. The page says so: the status row "Peak guard: part of this
|
||||
15-minute period was not measured, counted as high use" and the home line
|
||||
"Part of this 15-minute period was not measured." / "Een deel van dit
|
||||
kwartier is niet gemeten." until the quarter ends.
|
||||
*Review:* the first version counted them at the aim, with "nothing is
|
||||
known" as the reason. The first reading does show the load, and with the
|
||||
batteries at 0 W the import was that load, not the aim: a 60 s restart
|
||||
during a 6500 W HOLD landed 3653 W against 3450 W.
|
||||
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
|
||||
was measured. The estimate steers the guard and is never banked.
|
||||
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
|
||||
the watts it was counted at.
|
||||
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
|
||||
`peak_cap_w` and `peak_margin_w`.
|
||||
|
||||
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
|
||||
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
|
||||
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
|
||||
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
|
||||
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
|
||||
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
|
||||
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
|
||||
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
|
||||
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
|
||||
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
|
||||
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
|
||||
| meter never drops, N=1 and N=2 | | rows identical |
|
||||
|
||||
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
|
||||
less than `meter_max_age_s` is not a gap, and the seconds since it are not
|
||||
counted in the projection (on any quarter, not only after a gap). Near a
|
||||
quarter's end the 900/remaining gain turns that into a large negative excess
|
||||
and the floor drops for a cycle or more. Counting those seconds at the held
|
||||
reading would fix it, but it changes every quarter's last seconds on a
|
||||
healthy meter; it is handed back as its own card.
|
||||
@@ -0,0 +1,74 @@
|
||||
# SAFETY-91 - a held battery is drawn only after the others are full
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
|
||||
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
|
||||
|
||||
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
|
||||
its own peak reserve was held back (weight 0) only on cycles where the peak
|
||||
guard asked for power.
|
||||
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
|
||||
near zero the loop set the target, A had its normal weight and B's drain
|
||||
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
|
||||
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
|
||||
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
|
||||
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
|
||||
with no seconds left and asked for nothing for one cycle. The HOLD's
|
||||
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
|
||||
for 1 s.
|
||||
|
||||
**The fix** (`main.py`, N>=2 only).
|
||||
- `_held_back()`: the batteries at or under their own reserve while another
|
||||
can still give (live, not refusing), graded as in `_peak_on_able()`. They
|
||||
weigh 0 in every split where the site gives, not only for the peak. With
|
||||
nobody able (all held, or the rest refusing or dropped) nobody is held back
|
||||
and the split is as before.
|
||||
Review: once held back, a battery stays so until its SoC reads more than 1
|
||||
point above its reserve. A reading flipping 20/21 % otherwise swapped
|
||||
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
|
||||
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
|
||||
- `_drain_rise()`: while a battery is held back, a draining battery's pin
|
||||
becomes a floor at its drain rate (`lo`), so it rises towards its rating
|
||||
before the held one gives. Not for a held battery's own drain, not under a
|
||||
site stop, not for a battery that is not live.
|
||||
- `tariff.next_quarter()`: past a block's end, before the next block's first
|
||||
telegram, the guard grades the next quarter, projected from the held
|
||||
reading (the averager credits it there). Not when that reading is too old
|
||||
to be credited (`max_hold_s`).
|
||||
|
||||
**What stays.** SAFETY-25 option A: once every able or draining battery is
|
||||
at full power, the loop still draws a held battery (`allocator._fill()`'s
|
||||
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
|
||||
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
|
||||
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
|
||||
|
||||
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
|
||||
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
|
||||
| cycles A asked while B not full | 612 of 900 | 0 |
|
||||
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
|
||||
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
|
||||
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
|
||||
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
|
||||
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
|
||||
extending it is a one-line change for a later ticket.
|
||||
|
||||
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
|
||||
at its 20 % reserve, so B carries the house and A gives last (100 of 540
|
||||
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
|
||||
test grades that.
|
||||
Two fixtures changed with it: `test_safety87.py` part 10's "without the
|
||||
raise" run also turns off `_drain_rise()` (else B rises anyway), and
|
||||
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
|
||||
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
|
||||
|
||||
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
|
||||
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
|
||||
(SAFETY-87 Open). The first cycle after start, before any quarter is
|
||||
measured, can still put the loop's first step on a held battery in a HOLD.
|
||||
SAFETY-88, -89, -90, -92, -93, -94.
|
||||
@@ -0,0 +1,60 @@
|
||||
# SAFETY-93 - the maintenance charge end hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
|
||||
|
||||
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
|
||||
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
|
||||
from the grid with the house at 6000 W. The HOLD end was already smooth
|
||||
(SAFETY-85).
|
||||
|
||||
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
|
||||
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
|
||||
loop starts its next step from last cycle's site target. The slew limit
|
||||
moves the site total by 400 W (-2000 -> -1600), and the only battery left
|
||||
to place it on is B. The same happens when the charge times out or the
|
||||
battery is switched Off (A free, its -2000 W shared out). With the charge
|
||||
suspended by the guard and A giving under the loop, B took A's share in
|
||||
one step (675 W).
|
||||
|
||||
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
|
||||
battery leaves CHARGE, the loop's start point (`self.target`, and the
|
||||
integrator with it) is what the other batteries stood at plus that
|
||||
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
|
||||
last discharge (a charge stops). The loop then moves from there one slew
|
||||
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
|
||||
pin, as before; it lowers the grid import. The end is kept until the loop
|
||||
next runs, so a missed poll on that cycle (the input hold) does not lose it.
|
||||
|
||||
Review: a charge also ends, for the site, when the battery is still in
|
||||
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
|
||||
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
|
||||
1200 W house). So the end is noted after the unit claims are filed: a
|
||||
battery leaves the charge when it leaves CHARGE, is not live, or its own
|
||||
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
|
||||
|
||||
Why a start-point reseed, and not a per-battery slew in the allocator: the
|
||||
jump comes from one stale number at one edge, the kind of edge SAFETY-73
|
||||
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
|
||||
The change stays at that edge; nothing else at N>=2 moves.
|
||||
|
||||
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
|
||||
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
|
||||
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
|
||||
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
|
||||
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
|
||||
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
|
||||
| N=1, both charge ends above | | byte-identical |
|
||||
|
||||
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
|
||||
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
|
||||
charge end.
|
||||
|
||||
**Not changed.** The peak guard's floor still moves the site in one step
|
||||
when it engages (a house step, a quarter edge): the guard outranks the slew
|
||||
(owner order). A HOLD pin still drops the holding battery to 0 W at once
|
||||
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
|
||||
above the aim) is untouched.
|
||||
@@ -0,0 +1,102 @@
|
||||
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
|
||||
|
||||
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
|
||||
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
|
||||
|
||||
## The defects
|
||||
|
||||
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
|
||||
change. After a rest that clock was already past 35 s, so the first read
|
||||
after a new command tripped it, before the register could answer. Live
|
||||
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
|
||||
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
|
||||
1 s after its command; each register answered within 11 s. This is
|
||||
SAFETY-58's shape.
|
||||
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
|
||||
frozen cycles if the reading was under 100 W at that moment. The frozen
|
||||
cycles include a reversal through the battery's lag. At a 1 s meter the
|
||||
12th cycle comes as a battery reversing out of a charge passes 0 W
|
||||
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
|
||||
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
|
||||
15-minute probe. N>1 already required 22 s in the band, so it was safe
|
||||
at a 10 s lag but not at 20 s.
|
||||
|
||||
## What changed
|
||||
|
||||
- **The gate counts only time the register was asked to move**
|
||||
(`freshness.Freshness._oblige`). A read counts the time since the last
|
||||
read when, at that read, the command is 250 W or more and has moved 250 W
|
||||
from where it stood at the register's last change. A register change
|
||||
(or a SAFETY-57 release) restarts the count. A command flapping in and
|
||||
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
|
||||
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
|
||||
- **The bound is unchanged at 35 s and now means what its derivation
|
||||
says.** The floor was always "how long a healthy register takes to answer
|
||||
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
|
||||
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
|
||||
6 and 11 s after the command.
|
||||
- **Refusal evidence is the same at every roster size**
|
||||
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
|
||||
The reading must stay under 100 W, one way, while asked over 200 W, for 12
|
||||
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
|
||||
needs it on top of its own freeze test. `None` (compute alone) keeps the
|
||||
old law.
|
||||
- **The evidence restarts when the reading moves 50 W towards the ask**
|
||||
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
|
||||
readings wander by under 25 W at rest. A first-order lag crossing the band
|
||||
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
|
||||
This also covers B, whose reading is 100 W below its power.
|
||||
|
||||
## Bounds and worst-case catch
|
||||
|
||||
| fault | before | after | budget |
|
||||
|---|---|---|---|
|
||||
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
|
||||
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
|
||||
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
|
||||
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
|
||||
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
|
||||
|
||||
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
|
||||
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Live replay (part 1): 4 trips -> 0.
|
||||
- Refusing B (its -100 W idle reading): see SAFETY-97.
|
||||
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
|
||||
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
|
||||
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
|
||||
refused, N=2 6 / 1; after 0 / 0.
|
||||
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
|
||||
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
|
||||
a register held 351 s under a command hovering at the 250 W travel edge
|
||||
(under 35 s of asking before it moved; survivor 2's shape), and a
|
||||
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
|
||||
refusal covers it).
|
||||
|
||||
## SAFETY-58 and SAFETY-74
|
||||
|
||||
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
|
||||
start with the obligation and for the bound to be re-derived. Both are
|
||||
done above. `test_freshness.py` survivor 5 is now graded as closed.
|
||||
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
|
||||
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
|
||||
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
|
||||
command that moved is exactly what the gate is for; telling "limited"
|
||||
from "frozen" needs a second signal (the meter, or SoC).
|
||||
|
||||
## Known gaps
|
||||
|
||||
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
|
||||
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
|
||||
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
|
||||
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
|
||||
- A command that asks only part of the time (a fraction d) is caught after
|
||||
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
|
||||
SAFETY-30's per-unit check restarts when its command stops opposing the
|
||||
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
|
||||
SAFETY-31's circulation check (75 s) ignores the command, so its margin
|
||||
holds only while d >= 0.47.
|
||||
- A register frozen under a command that stays near the 250 W travel edge
|
||||
is caught late or not at all (survivor 2).
|
||||
@@ -0,0 +1,69 @@
|
||||
# SAFETY-95 - a maintenance drain that ends hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
|
||||
|
||||
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
|
||||
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
|
||||
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
|
||||
|
||||
**Cause.** During a drain the site target carries A's drain floor
|
||||
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
|
||||
command drops (pin, Off, abort, stale link, refusal), but the loop slews
|
||||
from last cycle's target (2000 -> 1600) and only B is left to place it.
|
||||
Under a solar surplus B gave 1600 W into the export.
|
||||
|
||||
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
|
||||
only). On the cycle a drain ends:
|
||||
|
||||
1. The start point is what the others stood at plus A's new command (its
|
||||
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
|
||||
2. If the house needs more, the start is raised to the house's own point
|
||||
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
|
||||
but never above last cycle's start. So the others cover the house at
|
||||
once and never take over the drain's export.
|
||||
3. While A settles (SAFETY-96's window: within the deadband, at most
|
||||
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
|
||||
start, not the meter. The meter still shows A's tail and the others'
|
||||
climb; chasing it sent B down and then up again.
|
||||
4. Then the loop moves one slew step a cycle.
|
||||
|
||||
A drain ends when its battery leaves DRAIN, is not live, its claims
|
||||
contradict, or it will not give (SAFETY-73's limit outranks the pin
|
||||
without a contradiction, so the refusal is read from the battery; +1 only,
|
||||
as `_held_back()`). Today a contradiction on a drain only arises through
|
||||
a refusal; the clause stays because a contradiction sends A 0 W, which is
|
||||
an end (tested directly). The end is kept
|
||||
until the loop runs (a missed poll). A dropped draining battery is this
|
||||
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
|
||||
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
|
||||
start by its own battery, and the drain end's raise reads the start the
|
||||
others left.
|
||||
|
||||
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
|
||||
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
|
||||
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
|
||||
784 W where base imports 42 W, as the others give up A's share of the house.
|
||||
The house point keeps the grid at its aim; the cap at last cycle's start
|
||||
keeps it from exporting.
|
||||
|
||||
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
|
||||
meter; base = the same tree with the change taken out):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
|
||||
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
|
||||
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
|
||||
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
|
||||
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
|
||||
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
|
||||
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
|
||||
| N=1, every drain end | | byte-identical |
|
||||
|
||||
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
|
||||
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
|
||||
`test_modes.py`. The other 45 digests are byte-identical.
|
||||
|
||||
**Not changed.** The peak guard's floor still binds (arbiter, after the
|
||||
loop). Held batteries still give last; the idle clamp and the freeze test
|
||||
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
|
||||
@@ -0,0 +1,59 @@
|
||||
# SAFETY-96 - a battery the loop cannot move does not freeze it
|
||||
|
||||
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
|
||||
|
||||
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
|
||||
the site target with the site sum of the readings. After A's link went
|
||||
stale, A was out of the split but its reading (-2000 W, still charging) was
|
||||
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
|
||||
~236 cycles until the circulation trip. After a charge timeout or Off at
|
||||
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
|
||||
drop, B also took A's whole share in one step.
|
||||
|
||||
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
|
||||
A battery is held out of the freeze test, counted at its command instead of
|
||||
its reading, while:
|
||||
|
||||
- it is out of the split (writes refused for `LINK_STALE_S`);
|
||||
- its last write was refused (its board may have gone to 0 W on its own
|
||||
watchdog);
|
||||
- it settles after a step the loop did not make: a charge end (SAFETY-93:
|
||||
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
|
||||
as the split is made again). Settling ends when its reading is within the
|
||||
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
|
||||
measured plant settle).
|
||||
|
||||
Its real power is not lost and not counted twice: the meter sees it, and
|
||||
the loop follows the meter. Only the freeze stops reading it as the loop's.
|
||||
The freeze still fires on the other batteries' own lag and on unplaced
|
||||
watts.
|
||||
|
||||
At a drop the battery's share leaves the loop's start point, so the others
|
||||
take over one slew step a cycle. At a rejoin it enters at its reading.
|
||||
|
||||
**Not a drop.** A missing reading or a stale register stops the site
|
||||
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
|
||||
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
|
||||
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
|
||||
battery whose writes are refused; its board going to 0 W is no refusal. Its
|
||||
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
|
||||
a battery that really refuses (review).
|
||||
|
||||
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
|
||||
about the remaining battery's own lag):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
|
||||
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
|
||||
| A drops while charging, board to 0 W | 5 | 0 |
|
||||
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
|
||||
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
|
||||
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
|
||||
| N=1, N=2 with no drop | | byte-identical |
|
||||
|
||||
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
|
||||
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
|
||||
charging on its own after its link drops still trips the circulation check
|
||||
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
|
||||
from the full site sum.
|
||||
@@ -0,0 +1,118 @@
|
||||
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
|
||||
|
||||
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
|
||||
review.** Owner decision 2026-10-04: the band's centre is clamped to
|
||||
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
|
||||
Closes SAFETY-94 review finding F1.
|
||||
|
||||
## The defect
|
||||
|
||||
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
|
||||
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
|
||||
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
|
||||
B sat on the band's edge and never latched:
|
||||
|
||||
- N=2: never caught, before or after SAFETY-94.
|
||||
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
|
||||
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
|
||||
|
||||
## Design
|
||||
|
||||
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
|
||||
new option: a required one is a setup step every client gets wrong, and an
|
||||
optional one is a second source to reconcile. An override can be added
|
||||
later if a site needs one.
|
||||
- **When it learns:** control runs; the unit is not refused (unit or site),
|
||||
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
|
||||
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
|
||||
than 50 W, and while refused it does not learn, so its refusal cannot be
|
||||
learned as idle. A capped battery under a big ask is not sampled either.
|
||||
- **How:** the first value is the mean of 120 s of samples; then it follows
|
||||
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
|
||||
is dropped (a register held at a giving value). The band is centred on
|
||||
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
|
||||
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
|
||||
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
|
||||
start is never quiet, so never learned, so never caught.
|
||||
- **Until learned the idle reading is 0 W**: the rule as it was.
|
||||
|
||||
## Not chosen: the GoodWe LAN integration's DC battery power
|
||||
|
||||
Both inverters also appear through HA's GoodWe integration (B:
|
||||
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
|
||||
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
|
||||
configuration, but:
|
||||
|
||||
- the band must be safe without it: other installs may not have it;
|
||||
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
|
||||
drop while the board keeps reading (HW-01 lost both once); a missing
|
||||
reading stops the site;
|
||||
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
|
||||
integration's poll interval (believed 10 s, not checked here) and latency
|
||||
are not measured;
|
||||
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
|
||||
|
||||
It stays an install choice to test on the rig, not a code change here. With
|
||||
it, the learned idle reading is simply ~0 W.
|
||||
|
||||
## Where it applies
|
||||
|
||||
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
|
||||
|
||||
| check | change |
|
||||
|---|---|
|
||||
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
|
||||
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
|
||||
| N=1 end in `_refusal_update` | centred |
|
||||
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
|
||||
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
|
||||
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
|
||||
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
|
||||
| SAFETY-57 release | unchanged: it reads the command only |
|
||||
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
|
||||
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
|
||||
|
||||
## Bounds: owner decision 2026-10-04 (review R1)
|
||||
|
||||
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
|
||||
|
||||
- **Why a clamp under the band.** A wrong idle reading (taught during a
|
||||
rest while the reading shows something else, e.g. a grid top-up of a unit
|
||||
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
|
||||
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
|
||||
clamp the rig missed a refusing A whenever the error was 110-250 W. At
|
||||
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
|
||||
while refusing is caught whatever was learned or restored, and a wrong idle
|
||||
cannot refuse a healthy 0 W battery (its ask is over 200 W).
|
||||
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
|
||||
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
|
||||
"old band or centred band" (rejected: a correct B would need to give 200 W
|
||||
to answer its 200 W probe).
|
||||
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
|
||||
at most 75 W out, so its refusal falls outside the band: it behaves as in
|
||||
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
|
||||
clamped centre. B is caught across -150..+90 W of error in its idle
|
||||
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
|
||||
- A frozen register is still caught by the stale gate at 35 s of asking.
|
||||
- Recovery from a wrong value needs quiet time: tau 30 min.
|
||||
|
||||
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
|
||||
|
||||
| refusing | meter 1 s | 2 s | 5 s |
|
||||
|---|---|---|---|
|
||||
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
|
||||
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
|
||||
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
|
||||
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
|
||||
|
||||
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
|
||||
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
|
||||
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
|
||||
24 s; with nothing saved: never.
|
||||
|
||||
## Before -> after
|
||||
|
||||
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
|
||||
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
|
||||
0 refused. Reversals, lag 10/20 s, idle restored: 0.
|
||||
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
|
||||
@@ -38,6 +38,8 @@ the trade [[product-not-one-house]] names.
|
||||
|
||||
## 2. The sign convention: **positive is production**, and there is no `pv_invert`
|
||||
|
||||
> Reversed by TEL-14 (flag and negative caution): `DECISIONS/TEL-14-pv-sign.md`.
|
||||
|
||||
### The convention is derived, not assumed
|
||||
|
||||
Two sign rules already ship, and both are recorded in `DOCS.md`'s *Sources*
|
||||
|
||||
@@ -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,83 @@
|
||||
# TEL-14 — the PV sign, declared per entity
|
||||
|
||||
Owner report on 0.12.0: `sensor.espressif_vermogen` (ESPHome clamp) and
|
||||
`sensor.kwh_meter_actueel_vermogen` (kWh meter in the PV circuit) read
|
||||
**positive for consumption, negative for production**. At night the page said
|
||||
"✓ PV production: 13 W" — standby draw. By day production would publish negative.
|
||||
|
||||
## 1. This reverses TEL-11 §2, and why
|
||||
|
||||
TEL-11 §2 refused a `pv_invert` on two grounds. "PV is read by nothing in the
|
||||
control path" still holds: the flip lives in `pv.total()`, and `test_pv.py`
|
||||
part 12 re-runs part 6's 40-cycle identity with a flip set. "A reversed sensor
|
||||
announces itself" did not survive a real site: these sensors follow a different
|
||||
convention, read a plausible small positive number at night, and the only other
|
||||
fix is a template sensor, the place TEL-12 §2 says goes quietly wrong.
|
||||
|
||||
## 2. The shape: a parallel list, `pv_invert_entities`
|
||||
|
||||
Measured by `tools/supervisor_shape_probe.py` §H (Supervisor 2026.08.0, against
|
||||
a 0.12.0 blob); re-run it for the transcript.
|
||||
|
||||
| shape | schema | why it lost / won |
|
||||
|---|---|---|
|
||||
| (a) `-sensor.x` prefix | accepted | invisible: the sign is a syntax only the description explains, in a field an installer fills with the entity picker's text |
|
||||
| (b) parallel list | accepted, 0.12.0 blob unchanged | **ships**: its own labelled, translated field ("PV entities whose sign is reversed"); an installer lists the odd ones out |
|
||||
| (c) dicts on `pv_entities` | **refuses 0.12.0's stored strings** | bricks the upgrade — TEL-12's trap again |
|
||||
| (c2) dicts as a third key | accepted | per-entry labels render raw (`entity`, `invert`) — probe §F; and a third PV surface |
|
||||
| (d) global bool | accepted | one sign cannot describe inverter + meter in one roster |
|
||||
|
||||
(b)'s known cost is a typo in the second list: `pv.stray_inverts()` turns it into
|
||||
a named caution. `[]` in `options:` is load-bearing (§H, isolated counterfactual).
|
||||
Default `[]` flips nothing, so every 0.12.0 install is byte-identical (part 12).
|
||||
|
||||
## 3. The negative caution: per entity, below −50 W
|
||||
|
||||
The owner's two meters draw ~13 W at night, so a *correctly* signed meter reads
|
||||
about −13 W then. −50 W clears that with room for a few such devices behind one
|
||||
entity; a reversed sensor by day reads hundreds to thousands of watts below
|
||||
zero. It is tested **per entity, not on the total** (a deviation from the brief):
|
||||
three correct meters at −20 W sum to −60 W and must stay quiet, while a reversed
|
||||
−1500 W hidden inside a positive total must not. It is a caution, never `bad`;
|
||||
a wrong threshold costs a spurious or missed caution, never data. The text says
|
||||
ADD (unflipped) or REMOVE (already flipped).
|
||||
|
||||
## 4. Mutations
|
||||
|
||||
Fresh `cp -a` each, `__pycache__` purged, CI image, `:ro`, `--network none`;
|
||||
`test_pv.py` only unless stated. T1-T12 at 7559d21; M2/M5 are review #1628's,
|
||||
red since B1 (merged into release/1.0 @ 43b2740).
|
||||
|
||||
| # | mutation | bytes | reds |
|
||||
|---|---|---|---|
|
||||
| T1 | flip ignored in `total()` | −45 | 9 |
|
||||
| T2 | flip applied to every member | −7 | 6 |
|
||||
| T3 | `0.0 - w` → `-w` | −5 | 1 |
|
||||
| T4 | flip written as `abs()` | −1 | 2 |
|
||||
| T5 | `_read_pv()` stops passing the flip | −26 | 7 |
|
||||
| T6 | caution threshold −50 → 0 W | −17 | 3 |
|
||||
| T7 | `[sign inverted]` marker dropped | −39 | 3 |
|
||||
| T8 | negative caution made `bad` | +1 | 1 |
|
||||
| T9 | `stray_inverts()` always empty | −50 | 1 |
|
||||
| T10 | `inverted()` stops stripping | −8 | 1 |
|
||||
| T11 | ADD/REMOVE advice swapped | +4 | 2 |
|
||||
| T12 | `pv_invert_entities: []` deleted from `options:` | −25 | 2 test_translations + 17 probe |
|
||||
| M2 | `stray_inverts()` roster ignores `pv_entity` | +15 | 2 (REPORTED CASE, PADDING) |
|
||||
| M5 | `_read_pv()` flip set not stripped | +25 | 1 (PADDING) |
|
||||
|
||||
## 5. History
|
||||
|
||||
OPT-10's `sensor.goodwe_pv_power` recorded since 2026-09-24 on an install with
|
||||
such sensors carries the old sign; nothing rewrites it.
|
||||
|
||||
## 6. CHANGELOG entry for the next PKG cut
|
||||
|
||||
Not written into `CHANGELOG.md`: `test_version_gate.py` 11b/11d refuse an
|
||||
`## Unreleased` heading (measured on this branch: 4 reds). For PKG to paste:
|
||||
|
||||
> **`pv_invert_entities` (TEL-14).** A PV sensor that reads negative while
|
||||
> producing — typically a kWh meter or power clamp in the PV circuit — can now
|
||||
> be listed under *PV entities whose sign is reversed* in the Configuration tab;
|
||||
> its sign is flipped before the sum and the status page marks it. Empty (the
|
||||
> default) changes nothing. ⚠️ PV history recorded before you set it keeps the
|
||||
> old sign.
|
||||
@@ -148,6 +148,11 @@ exits 78 and the panel goes with it. Recovery is the Configuration tab. That is
|
||||
the reason it stays, and the reason the panel refuses to write a blob the
|
||||
validator rejected: this surface must never be the thing that removes itself.
|
||||
|
||||
*Amended by [[UX-15-setup-only-mode]] (2026-09-28):* one refused configuration
|
||||
keeps the panel: options that name no inverter at all and have no other
|
||||
refusal, which is what a new install ships. The add-on then serves the page in
|
||||
setup-only mode and commands nothing. Every other refusal still exits 78.
|
||||
|
||||
**The panel shows fewer refusals than the startup gate can, never more.** Two
|
||||
of `validate.py`'s rules compare a configured ceiling against the setpoint
|
||||
device's *own* reported range, which is only knowable by asking Home Assistant.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# 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.
|
||||
|
||||
# UX-10: the last plain-language leftovers (after the 2d test)
|
||||
|
||||
**Unchanged:** every safety behaviour, every alarm's log level (WARNING and ERROR stay), every notification id, trigger and cadence, the banner cascade, and every row colour except the meter row right after a start (item 6).
|
||||
|
||||
1. **Why the 2d scan missed the clamp lines.** Part 3 reads a log call's own literal and counts `%s` as one word. The clamp lines are `"…software fault: %s …"`, and the text in `%s` was built elsewhere, by `loop_clamp_note()` and `ClampResult.describe()`. So the scan passed a line whose logged text had a semicolon, "BUG SIGNAL", "clamped" and "1 times". The same was true of every quoted reason. Part 6 closes the gap. It lists every dynamic part of every WARNING+ line as data (a number, a name, a path, a library's own error text) or as a built text, and a new part in neither list fails. Each built text is produced by the real code (the watchdogs, `decide()`, `loop_clamp_note()`, `MigrationReport.explain()`) or, for a family of builders, by every template of it (`PriceRead.no_data`, `Gate.block`). The text and the line as logged are then checked. A mutation shows the 2d clamp text is caught on both clamp lines.
|
||||
2. **A quoted reason gets the red-alarm shape**, 3 sentences of at most 30 words, and not the 25-word line limit. It is the evidence behind an alarm, and it keeps its code and its limits, which the owner asked to keep. The alarm shape is STYLE.md's existing rule for the texts behind a safety stop, so no new limit was made up. Rewritten: `OUTPUT_CLAMP` (both kinds), `METER_FROZEN`, `CIRCULATING_POWER`, `STALE_READING` (SAFETY-56's two explanations and "cannot tell which" kept, in order), the peak guard's reasons, the state-migration report, the price feed and price history reasons, and the maintenance and trading gate reasons. "Software fault" left the clamp reason, because the ERROR line around it already says it.
|
||||
3. **Codes are one token.** `PEAK GUARD`, `PEAK GUARD FLOOR-LIMITED` and `PEAK GUARD BLIND` read as capitals, so they are now `PEAK_GUARD`, `PEAK_GUARD_FLOOR_LIMITED` and `PEAK_GUARD_BLIND`, like `OUTPUT_CLAMP`. Nothing in admin/goodwe or in the scripts under `/home/homelab` greps them (checked). `/home/homelab/clampwatch.sh` greps `OUTPUT_CLAMP`, which stays.
|
||||
4. **Top table.** *Price history* writes "96 prices over 24 hours for 26 Sep", joined by commas. *Tariff* writes the spacing as "one every 15 minutes or every hour". "Not final yet" was dropped, because a plan is provisional exactly when it holds an estimated price (`n_carried` counts the provisional points), so the estimated count already says it.
|
||||
5. **Levels and noise.** The shadow line of every cycle is DEBUG, and "Shadow mode is on/off" is INFO: it follows a setting or a button, and the banner, hint and event are unchanged. aiohttp's access log is on only at log level debug. A settings warning is logged once per start (a filter on `goodwe.config`, WARNING only, so refusals are untouched), because the validator runs twice at start. When Home Assistant restarts, `hass.Outage` says so in one WARNING line for the REST API and the meter websocket together, puts the library's text on DEBUG, and says at INFO when both answer again. INFO lines lost "§8.3", ISO timestamps, "banked" and "keys ARE", and error texts lost "NOT_OBSERVED", "did NOT land" and "AND".
|
||||
6. **Display bugs.**
|
||||
- "Why: Other" during a latched per-inverter stop: `main.py` turns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit", main.py's own refusal to split) had no entry in `WHY_WINNER`. The mapping is now `main.why_key()`, and part 7 finds every `Claim.set()` in the code and checks that every key it can produce has words.
|
||||
- Red "Meter: no reading for 0 seconds" after a restart: until the first reading arrives, `P1Ingest.stale` is true, and the age is the time since start. The row now reads amber "waiting for the first reading" while that age is inside the limit. **Colour change, justified:** red at 0 s contradicted the limit the same row prints, and once readings exist the row is red only past that limit. Amber, not green, because control waits for the reading. Past the limit it is red as before. `stale` itself, which gates control, is unchanged.
|
||||
- "A reading is missing for 0 seconds" stuck at 0: with the meter reading gone, the grid value stops changing, so the loop runs a cycle only every `cycle_floor_s`, and the line was computed once per cycle. The age is now counted when the page asks. The hold and its 15 s escalation are unchanged.
|
||||
7. **Review: the line as logged gets the shape too.** Part 6 first checked the quoted reason alone, so a title, a 2-sentence reason and two tails still logged as 4 sentences ("Power request too high …: OUTPUT_CLAMP: … +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far."). Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. The wrappers became one sentence each, the circulation and peak guard reasons two, and `STALE_READING`'s fixed limits moved from the reason to a DEBUG line beside the ERROR (its release clause stays, pinned by test_freshness). The clamp lines for one inverter are pinned at 30 words at most: `OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W`. Several clamped inverters read as one sentence of asked/sent pairs; which limit bound each is on its own action-log row and in the notification. Totals above 30 words remain on the direction, delivery, frozen-meter and stale lines (up to 74 words). Shortening them is a follow-up: the direction and delivery reasons are also persisted in state.json, and the direction one is pinned byte-for-byte in test_watchdog.
|
||||
8. **Home Assistant 400 while it loads** now reads "Home Assistant refused number.set_value for number.x (error 400, no details). The entity may still be loading, or the value is out of range." A body with more than "400: Bad Request" is still quoted. Still WARNING, still once a minute, still a failed write.
|
||||
9. **Witness** (admin/goodwe): the resolved push says "after no answers from … to …", as the resolved card does. The YAML with every title and message stripped is identical.
|
||||
|
||||
# UX-11: safety log lines give the code, the figures go to DEBUG
|
||||
|
||||
**Unchanged:** every trigger, latch, threshold and alarm log level (the WARNING and ERROR lines stay), every notification id and cadence, every row colour and the banner cascade. No notification is added.
|
||||
|
||||
1. **One plain sentence and the code.** A WARNING/ERROR line that reports a safety reason (direction, delivery, stuck meter, stale reading, peak guard, circulation, over-rating) now ends in the reason's code, taken by `messages.reason_code()`. The reason itself, figures and limits included, is on a DEBUG line logged right after it ("Evidence for inverter-a: …"), and the action log entry carries it as before. Part 6 checks that pairing in the code. Circulation and the clamp lines were not among the five named, but they break the same 25-word rule, so they follow the same pattern. The clamp numbers stay in the notification (phase 2a item 3).
|
||||
2. **The reasons and `state.json` are unchanged.** `reason` stays the evidence text, so what is saved has the same format as before. An old state file therefore loads and displays like a new one: every saved reason has opened with its code since it was first written (checked in the git history), and that code is all the ERROR line reads. The saved text goes on the DEBUG line. A stop saved without a reason shows `REASON_NOT_SAVED`. `test_messages.py` part 7 restarts from a state file holding the old long texts. `test_watchdog.py` pins the meaning (the code first, then command, reading, time and both limits, in that order) and, since the text did not change, still the bytes.
|
||||
3. **The 25-word rule is checked as logged.** Part 6 fails any WARNING+ line over 25 words with its built text filled in. Nine non-safety families still run long (tariff plan words, price feed reasons, the state migration report, write refusals and claim scope errors that only a software fault raises, maintenance, trading gate and price history reasons). They are listed with a reason in `LONG_LINE_OK`, capped at 9, and a listed family that gets shorter fails until it is removed. Shortening them is a follow-up.
|
||||
4. **The meter row's "the add-on log shows why" is now true.** When the meter goes quiet past its limit, the add-on logs one WARNING per episode, not one per cycle, and an INFO line when readings return. A meter that flaps (on `ha_signed` a healthy meter under a flat load can go 42–97 s between changes) logs at most one such pair per 10 minutes, and the episodes in between go to DEBUG. This is a new log line, not a new alarm: it has no notification and changes no decision.
|
||||
5. **The Why line past the 15 s hold** names the missing reading ("No usable meter reading for over 15 seconds: 0 W", or the battery power or level of an inverter), instead of "A reading is missing: 0 W".
|
||||
6. **Action log:** "25 engagement(s)" is "25 engagements", and "SHADOW MODE OFF - …" is "Shadow mode off: …". "SHADOW MODE ON" was not named in the card and is left as it is.
|
||||
7. **Review: after a restart the figures go on the action log too.** `log_level` defaults to `info`, so the DEBUG evidence line is not seen by default, and `events` starts empty. Each restored stop (direction per inverter and site, delivery, circulation) now also writes its saved reason to the action log ("FAILSAFE inverter-a, saved before the restart: …"), and the page's event line gets the same plain words the live stop gives. `LONG_LINE_OK` also refuses a key whose text is a safety reason, not only a `reason_code` key.
|
||||
@@ -0,0 +1,104 @@
|
||||
# UX-13 — one layout table, and why UI code may now name an option key
|
||||
|
||||
**Status: DECIDED 2026-09-27 (owner approved the UX-13 design and mockup).**
|
||||
Card 6ab979852f3eca02fb40f69e. Amends [[UX-02]] §3.1. Design note and
|
||||
mockup: `goodwe-session/UX-13-design.md`, `UX-13-mockup.html`.
|
||||
|
||||
## 1. What UX-02 said, and why it no longer fits
|
||||
|
||||
UX-02 built the configuration panel from the Supervisor's schema, so
|
||||
`app/configui.py` contains "no option key, no label and no rule", and
|
||||
test_selfconfig.py part 5 checks that the page script names no key. That kept
|
||||
a second copy of the option surface from drifting, and it is why the panel is
|
||||
a flat list of fields in schema order.
|
||||
|
||||
UX-13 needs things a schema cannot say: groups and sub-groups, a slider in kW
|
||||
for a value stored in W, a picker for an entity, a field shown only for one
|
||||
meter source, locked measured values, a Simple view and a setup wizard. It
|
||||
also needs page fields that are not options at all: one inverter card that is
|
||||
stored as six flat keys, solar rows that become `pv_entity` or `pv_entities`,
|
||||
a contract and two prices that become `static_profile` JSON, and a region
|
||||
that becomes the levies figure. None of that can be read off the schema, so
|
||||
some code has to name option keys.
|
||||
|
||||
## 2. The decision
|
||||
|
||||
Option keys may appear in UI code in exactly two files:
|
||||
|
||||
- **`app/uilayout.py`**, the layout table: screen → group → part → field, with
|
||||
control, unit, display scale, step, entity kind, when it is shown, whether it
|
||||
is on the Simple view or a wizard step, and whether it applies now or after a
|
||||
restart. Every option in `config.yaml` is a field, `DERIVED` or `HIDDEN`,
|
||||
exactly once.
|
||||
- **`app/uimodel.py`**, the derivations the table names: `options_to_ui()` and
|
||||
`ui_to_options()`. It may name only the keys it writes.
|
||||
|
||||
What UX-02 made authoritative stays authoritative:
|
||||
|
||||
- **Ranges** come from the schema (`uilayout.range_of()`); the table writes
|
||||
none. A unit holds no digit.
|
||||
- **Labels and help** come from `translations/*.yaml` by key. Page-only fields,
|
||||
group titles and choices are in `app/ui_text/*.json`, held to STYLE by
|
||||
test_messages.py.
|
||||
- **Rules** stay in `validate.py`. It gains a warning tier (`validate.Note`,
|
||||
"warn" or "info") for the page. A note never blocks a save, and passing the
|
||||
notes list does not change the refusals returned.
|
||||
- **The stored blob** is still the one truth. The page model carries the stored
|
||||
values of every derived key (`keep`). A derivation whose page fields are
|
||||
unchanged writes those values back untouched, so a blob the page cannot
|
||||
express survives a save of another field.
|
||||
|
||||
## 3. How it is held
|
||||
|
||||
- test_uilayout.py fails when `config.yaml` gains an option, or an inverter
|
||||
field, that the table does not place. It also checks that uimodel.py names
|
||||
only the keys it writes and that web.py and haentities.py name none.
|
||||
- test_uimodel.py round-trips options → page model → JSON → options for every
|
||||
validator call the suite made at 652097a (`scenarios/ux13/`), the TEST-23
|
||||
option files and config.yaml's defaults. All must be identical.
|
||||
- `tools/ux13_validator_calls.py compare` runs those calls through the old and
|
||||
new validator. At P0: 261 calls, 158 refused, no refusal changed.
|
||||
- test_selfconfig.py part 5 still checks the old page script; P1 moves the page
|
||||
into `app/static/` and the same check applies there.
|
||||
- P2: the Installer view (`app/static/settings.js`) draws from
|
||||
`app/uipage.py`, which serialises the table and names no key itself
|
||||
(test_uilayout part 5, test_settings part 2). The view posts its page model
|
||||
to the existing `/config/validate` and `/config/save`, which turn it into
|
||||
options with `ui_to_options()` before the shipped validator runs. No route
|
||||
was added.
|
||||
- P4: the setup wizard draws the same model by each field's `wizard` step.
|
||||
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
|
||||
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
|
||||
names no key. See [[UX-13-setup-wizard]].
|
||||
- P5: the page words are in `app/ui_text/en.json` and `nl.json`, one key set,
|
||||
loaded by `app/uitext.py` with an English fallback per key. The page picks
|
||||
Dutch when the browser's language is `nl`, as the classic page does. Home's
|
||||
sentences moved from `homeview.py` into `home_words`, so `/status?lang=nl`
|
||||
builds them in Dutch. Status rows, banners, hints and validator sentences
|
||||
(`messages.py`, `validate.py`) stay English: they are shared with
|
||||
notifications, the log and the start refusal. `test_ui_text.py` checks both
|
||||
files against STYLE.md and each other, using the rules in
|
||||
`tools/style_rules.py`, which it shares with test_messages.py.
|
||||
- P5: "Other time windows" is a list editor (`ui.periods`, control
|
||||
`period_list`). `uimodel._periods_json()` builds `static_profile` and names
|
||||
the first bad time, empty price, overlap or gap. Its tiling rule matches
|
||||
`static_tariff._check_tiling`. A stored profile whose days the editor has no
|
||||
choice for (for example "mon,wed") is still kept as stored and edited in the
|
||||
Configuration tab. Supervisor 2026.08.0's `AppOptions` accepted all 133
|
||||
wizard blobs, 17 of them with time windows
|
||||
(`tools/ux13_wizard_blobs.py --supervisor`, run offline on 2026-09-28).
|
||||
- P3: Settings, Simple draws the fields the table marks `simple`, with an
|
||||
optional `note` under each (words in `settings.simple.note`), plus the
|
||||
batteries' switches and modes. Those are store state set through the
|
||||
existing `/action` values, not options, so the table places no key for
|
||||
them. The Batteries screen's Now, Today and Why lines are built in
|
||||
`homeview.py` from what the add-on knows this cycle; the plan per mode is
|
||||
OPT-11 P3/P4, so until then Today says "Not planned yet" when no block
|
||||
is planned for the battery (`test_batteries_screen.py`).
|
||||
|
||||
## 4. What this costs
|
||||
|
||||
The table is a second place that knows the option surface, which UX-02 set
|
||||
out to avoid. The cost is bounded by the coverage test: an option added to
|
||||
config.yaml and not placed here fails CI, rather than silently missing from
|
||||
the page. Changing a key's type or range needs no change here.
|
||||
@@ -0,0 +1,101 @@
|
||||
# UX-13 P4 — the setup wizard, and when it opens by itself
|
||||
|
||||
**Status: DECIDED 2026-09-28 (P4 dev; the design and mockup were approved
|
||||
2026-09-27).** Card 6ab979852f3eca02fb40f69e. Builds on
|
||||
[[UX-13-one-layout-table]]. Design note: `goodwe-session/UX-13-design.md` §1, §3.6.
|
||||
|
||||
## 1. What the wizard is
|
||||
|
||||
The Setup tab draws the Installer view's page model one step at a time: a
|
||||
field is on step *n* when its `uilayout.Field` says `wizard=n`. It uses P2's
|
||||
controls, `/config/validate` and `/config/save`. No route was added.
|
||||
`uilayout.py` holds what a field cannot say: the kettle table (`KETTLE`), the
|
||||
optional step and the shadow switch (`SHADOW`). Step 5 has no battery modes
|
||||
yet (OPT-11); it says they come later.
|
||||
|
||||
A step moves on when no refusal is anchored to one of its shown fields
|
||||
(`uipage.step_problems`). A refusal on a hidden field, a field on no step or
|
||||
no field at all blocks the last step, which lists every refusal.
|
||||
|
||||
## 2. When it opens by itself (`uipage.setup_needed`)
|
||||
|
||||
The page opens `#setup` only when it is opened with no route, and only when
|
||||
`/config/model` gives a reason:
|
||||
|
||||
- **`no_power_request`**: the stored options name no inverter to control.
|
||||
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
|
||||
Store has no `setup_done` and no `auto` key, and control is not started.
|
||||
`auto` is the key Start/Stop writes. Neither the startup gate's exit 78 nor
|
||||
setup-only mode (UX-15) writes the Store, so a new install's first good boot
|
||||
is fresh.
|
||||
|
||||
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
|
||||
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
|
||||
R1). So a Store read from a file is an old site, and it is never sent to the
|
||||
wizard. A new install that restarts before the page is opened is not sent
|
||||
either, and its Setup tab still works. A site that runs control, or ever
|
||||
started or stopped it, is never sent. Without a Store the answer is no. The wizard's save writes
|
||||
`setup_done` (an ISO time) to the Store. A save from the Installer view does
|
||||
not.
|
||||
|
||||
**Decided since, in [[UX-15-setup-only-mode]] (owner, 2026-09-28):** a
|
||||
brand-new install ships `setpoint_entity: ""`, and it no longer exits 78. When
|
||||
naming no inverter is the gate's only refusal, the add-on serves the page in
|
||||
setup-only mode, which commands nothing, and the wizard opens with
|
||||
`no_power_request`. Setup-only mode writes no Store file, so the first-run
|
||||
rule above still holds for an install set up on the Configuration tab.
|
||||
|
||||
## 3. The kettle test and the sign checks
|
||||
|
||||
Step 1 reads the meter's entities now (`GET ha/entities?entity=`, one
|
||||
`hass.state()` read, in W) before and after a kettle is switched on. The
|
||||
change, not the sign, decides: a rise of at least 500 W keeps `meter_invert`
|
||||
off, a fall turns it on. For a net entity or import and export, a fall cannot
|
||||
be fixed by an option, so the step is not passed and the page says what to
|
||||
check. A meter the add-on reads itself (HomeWizard, MQTT) cannot be read by
|
||||
the page, so the test is skipped and Home shows the direction after the
|
||||
restart. The installer may skip the test, because a cloud can fake a fall;
|
||||
the last step then warns, and after a fall it says the reading went down.
|
||||
|
||||
The battery and solar signs cannot be forced from the page, which never
|
||||
commands an inverter. The last step reports each battery's reading with its
|
||||
direction ("reads 800 W charging now, so check the inverter shows the same")
|
||||
and warns when a solar entity reads below zero without its "reads negative"
|
||||
switch. It also warns when meter + solar + battery, all known, add up to a
|
||||
house using less than -300 W: a battery sensor reversed while giving power.
|
||||
An idle or charging battery cannot be judged that way.
|
||||
|
||||
## 4. The last step
|
||||
|
||||
`validate.setup_checks()` sits beside the rules because it names option keys.
|
||||
It reads nothing itself: `web.py` passes a Home Assistant entity list at most
|
||||
5 s old, the running options, the running meter reading and today's price
|
||||
count. Tiers are ok, info, warn and bad. A power request entity missing from
|
||||
Home Assistant is **bad**, because a write to it is answered 200 (SAFETY-77)
|
||||
and only the entity list can tell. So is Home Assistant not answering, since
|
||||
the list is then unknown. The wizard does not save while anything is
|
||||
bad. It then saves through P2's path (`setup: "done"`) and asks for the
|
||||
restart as the second, confirmed request, with a dialog that names the 0 W.
|
||||
The shadow switch is on by default and writes `shadow_mode: on`, which holds
|
||||
every inverter at 0 W after a restart even if control is started. Off keeps the
|
||||
stored value, or `auto` if that was `on`. The switch goes only on the model the
|
||||
wizard posts, never into the page model the Installer view also saves.
|
||||
|
||||
## 5. The stale save lists only what moved (P2 test, finding a)
|
||||
|
||||
`/config/model` remembers the last 16 blobs it handed out, by fingerprint. On a
|
||||
stale save the add-on compares that blob with what is stored now, so the banner
|
||||
lists only the other surface's changes. `changed` (stored against the posted
|
||||
blob) is unchanged for the classic page's "theirs → mine" list. When the
|
||||
fingerprint was not handed out by this process, it falls back to `changed`.
|
||||
|
||||
## 6. How it is held
|
||||
|
||||
- test_setup.py: the steps and tables, the kettle logic (and the page's copy of
|
||||
it), step blocking, the first-run rule on new and TEST-23 sites, the live
|
||||
checks, the save through the P2 path, the stale fix, and the words.
|
||||
- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new
|
||||
install and three TEST-23 sites (88 accepted by the shipped validator).
|
||||
UX-15 added the shipped defaults as a start (setup-only mode). At P4
|
||||
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
|
||||
keys each.
|
||||
@@ -0,0 +1,114 @@
|
||||
# UX-15 — setup-only mode: a new install serves the wizard
|
||||
|
||||
**Status: DECIDED 2026-09-28 (owner decision; UX-15 dev).** Card
|
||||
6aba0053d27a32cb3f210dcc. Changes the CFG-01 startup gate and closes the open
|
||||
question in [[UX-13-setup-wizard]] §2. Amends [[UX-02-two-config-surfaces]] §4.
|
||||
|
||||
## 1. The rule
|
||||
|
||||
`config.yaml` ships `setpoint_entity: ""`, so a new install's options are
|
||||
refused by rule 1 of `validate_startup_config()`: no inverter to control.
|
||||
Until now that was exit 78 before any page was served, and the installer had
|
||||
to fill in the entity on the Configuration tab before the wizard could open.
|
||||
|
||||
The add-on now starts in **setup-only mode** when, and only when
|
||||
(`setuponly.applies()`):
|
||||
|
||||
- the options name no inverter at all: no flat `setpoint_entity` and no
|
||||
`inverters` entry, and
|
||||
- rule 1's refusal (`no_power_request_problem()`) is the gate's **only**
|
||||
refusal.
|
||||
|
||||
In setup-only mode it serves the page, says it is in setup (banner, `/status`
|
||||
`setup_only: true`, a WARNING log line) and opens the wizard when the page is
|
||||
opened with no route (`setup_needed` is `no_power_request`). After the
|
||||
wizard's save and restart the normal gate applies to the saved options.
|
||||
|
||||
## 2. What still exits 78, and why
|
||||
|
||||
Everything else the gate refuses, including:
|
||||
|
||||
- a complete configuration that is refused, e.g. `maintenance_charge_w` above
|
||||
`max_w`;
|
||||
- no power request entity **and** any other refusal;
|
||||
- an `inverters` list whose entry has a blank power request entity.
|
||||
|
||||
These are configurations somebody edited. The Configuration tab stored them,
|
||||
the log's refusal banner names each problem, and the tab is where they are
|
||||
fixed (UX-02 §4). Serving the wizard there would put a six-step setup in
|
||||
front of someone who needs to change one field. A narrow rule also keeps the
|
||||
setup-only decision to one comparison against one known sentence, so a new
|
||||
validator rule cannot widen it by accident: any other refusal fails the
|
||||
equality and exits 78.
|
||||
|
||||
The cost: an installer who first sets, say, a DSMR meter with no entities on
|
||||
the Configuration tab and leaves the power request entity empty gets exit 78,
|
||||
not the wizard. The log then names both fields.
|
||||
|
||||
## 3. Why it cannot command
|
||||
|
||||
Setup-only mode builds nothing that commands. `amain()` branches to
|
||||
`setuponly.serve()` before the MQTT broker, the controller, the meter task and
|
||||
the tariff task are built, and it returns from there when the Supervisor
|
||||
stops it. What serves the page is `SetupOnly`, which holds no inverter.
|
||||
|
||||
Three checks make it structural rather than a convention:
|
||||
|
||||
1. **The HomeAssistant is read-only** (`HomeAssistant(read_only=True)`).
|
||||
`call()` refuses before it sends anything, and every service call goes
|
||||
through it: `number.set_value` (`set_number()`), notifications, all of
|
||||
them. The flag is fixed at construction, and there is no setter.
|
||||
2. **`SetupOnly` refuses a HomeAssistant that can write, and `Controller`
|
||||
refuses one that cannot.** So neither can be built on the wrong one.
|
||||
3. **`/action` answers 409** in setup-only mode, before
|
||||
`handle_action()`. Start, Stop and the battery cycle do nothing.
|
||||
|
||||
No MQTT client is created, so nothing is published. The page's other routes
|
||||
already read only, or write the add-on's own options (UX-02 §6).
|
||||
|
||||
Setup-only mode writes no Store file. The wizard's save writes `setup_done`,
|
||||
as in any mode. So a new install that is set up on the Configuration tab
|
||||
instead still has a fresh Store on its first normal boot, and the first-run
|
||||
rule (UX-13 §2) sends it to the wizard as before.
|
||||
|
||||
## 4. How it is held
|
||||
|
||||
- `test_setup_only.py` part 1: when it applies, including the three exit-78
|
||||
cases above. Part 2: the read-only write path refuses with nothing sent,
|
||||
and the two constructors refuse. Part 3 boots the real `main()` on the
|
||||
shipped defaults against a fake Supervisor and Home Assistant over HTTP and
|
||||
logs every request: the page is served, `/status` says setup-only, Start
|
||||
and the cycle get 409, and there are 0 service calls and no MQTT request,
|
||||
also with `auto_start` on and a Store that says control was started. The
|
||||
wizard's save is then booted: it passes the normal gate, does not open the
|
||||
wizard again, and every write is 0 W (shadow). Part 4: the refused
|
||||
configurations exit 78 and serve nothing.
|
||||
- Mutations, each red: `applies()` always false or always true, the
|
||||
read-only refusal removed, the Controller's check removed, `amain()` giving
|
||||
setup-only mode a writable HomeAssistant, `/action`'s 409 removed, and the
|
||||
`return` after `serve()` removed.
|
||||
- TEST-23 (PR #147 review, 2026-09-28): the add-on image on the shipped
|
||||
defaults and an empty `/data` made 0 service calls and no MQTT request in
|
||||
3.5 minutes of setup-only mode, counted in both the stand-in and the rig
|
||||
shim logs. The wizard save then passed Supervisor 2026.08.0's `AppOptions`,
|
||||
and after the restart it ran in shadow: 11 of 11 writes were
|
||||
`set_value 0.0`.
|
||||
|
||||
## 5. The wizard fixes in the same card
|
||||
|
||||
- **F1.** `/config/validate` converts what it can of a page model
|
||||
(`uimodel.ui_to_options_partial()`), so a conversion error blocks its own
|
||||
step beside its field and the live checks still run on the rest. A refusal
|
||||
about a key the failed conversion writes is left out, because it would
|
||||
judge the stored value. The save still refuses such a model (422, with
|
||||
`steps`).
|
||||
- **F2.** A save from the page model sends `base`, the model it opened. The
|
||||
add-on writes only the keys whose value differs from `base`, onto the blob
|
||||
stored now (`uipage.only_edits()`). So "save again to keep yours" after a
|
||||
409 keeps the other surface's changes. A key both surfaces changed takes
|
||||
this page's value, which is what the second press after the banner means.
|
||||
- **Meter entity.** On the last step a meter entity Home Assistant does not
|
||||
have is red, like a missing power request entity, so a skipped kettle test
|
||||
cannot let it through. In the Installer view it stays a warning.
|
||||
- **Step 1 after a skip.** When the kettle test saw the reading fall and was
|
||||
then skipped, step 1 says the reading went down. The skip stays (UX-13 §3).
|
||||
@@ -98,6 +98,12 @@ DROP_STALE = "stale link"
|
||||
DROP_CONTRADICTION = "contradicts itself"
|
||||
DROP_NO_INTEGER = "no integer setpoint in its interval"
|
||||
|
||||
# OPT-11: why a unit's MODE layer was not applied this tick. Only the first is
|
||||
# a fault; the other two are the rule working.
|
||||
MODE_FAULT = "mode range leaves out 0 W"
|
||||
MODE_RELEASED = "mode released by a higher claim"
|
||||
MODE_OUTRANKED = "hard limit leaves no room for the mode"
|
||||
|
||||
REFUSE_SCOPE = "mis-routed claim"
|
||||
REFUSE_EMPTY = "no unit available"
|
||||
REFUSE_SIGN = "opposite directions"
|
||||
@@ -213,6 +219,10 @@ class Allocation:
|
||||
# that calls `plan()` for its arithmetic gets None and does not care;
|
||||
# main.py stamps every record it puts on the write path.
|
||||
tick: int | None = None
|
||||
# OPT-11: ((unit, why), ...) for each unit whose mode range left out 0 W
|
||||
# this tick. That mode was dropped (see `unit_interval()`); main.py logs it
|
||||
# as a software fault. Empty on every tick without a mode.
|
||||
mode_faults: tuple = ()
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -246,11 +256,11 @@ class Allocation:
|
||||
block the failsafe is worse than the hole it closes.
|
||||
"""
|
||||
if tick is None or self.tick is None or self.tick != tick:
|
||||
return (f"the allocation record on hand was made on tick "
|
||||
f"{self.tick!r} and this is tick {tick!r} - a record whose "
|
||||
f"envelopes have moved cannot authorise a write")
|
||||
return (f"the power split on hand is from tick {self.tick!r}, "
|
||||
f"not this tick {tick!r}, and an old split cannot allow a "
|
||||
f"write")
|
||||
if not self.ok:
|
||||
return f"the allocator refused this tick ({self.explain()})"
|
||||
return f"no power split was possible this tick ({self.explain()})"
|
||||
if unit not in self.shares:
|
||||
return (f"{unit} has no share in this tick's allocation "
|
||||
f"({self.explain()})")
|
||||
@@ -290,6 +300,99 @@ class Allocation:
|
||||
return out
|
||||
|
||||
|
||||
def unit_interval(b, deciding_priority=None):
|
||||
"""OPT-11. `(lo, hi, note)`: the interval a unit is allocated into.
|
||||
|
||||
The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range
|
||||
(`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is
|
||||
above the mode's own priority (arbiter._unit_modes() passes None when the
|
||||
modes hold, and the yielding limit's priority when they give way). `note` says why the
|
||||
mode did not apply, or is None when it did or there is none:
|
||||
|
||||
* MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction
|
||||
and never force one, so this is a software fault and the mode is dropped.
|
||||
It is what keeps REFUSE_SIGN unreachable from a mode: the fill below
|
||||
moves every free unit away from 0 in ONE direction, and it starts from 0
|
||||
only while every interval holds 0.
|
||||
* MODE_RELEASED: a higher claim (the peak floor) decided the target.
|
||||
* MODE_OUTRANKED: the hard limits and the mode do not overlap. The hard
|
||||
limits win, as they always do.
|
||||
|
||||
A unit without mode fields (`mode_priority` absent or None) gets its hard
|
||||
limits, unchanged: that is every unit when battery modes are off.
|
||||
"""
|
||||
lo, hi = b.lo, b.hi
|
||||
prio = getattr(b, "mode_priority", None)
|
||||
if prio is None:
|
||||
return lo, hi, None
|
||||
mlo, mhi = b.mode_lo, b.mode_hi
|
||||
if not (mlo <= 0.0 <= mhi):
|
||||
return lo, hi, MODE_FAULT
|
||||
if deciding_priority is not None and deciding_priority > prio:
|
||||
return lo, hi, MODE_RELEASED
|
||||
nlo, nhi = max(lo, mlo), min(hi, mhi)
|
||||
if nlo > nhi:
|
||||
return lo, hi, MODE_OUTRANKED
|
||||
return nlo, nhi, None
|
||||
|
||||
|
||||
class _Narrowed:
|
||||
"""A unit's bounds with its interval replaced by `unit_interval()`'s."""
|
||||
__slots__ = ("lo", "hi")
|
||||
|
||||
def __init__(self, lo, hi):
|
||||
self.lo, self.hi = lo, hi
|
||||
|
||||
|
||||
def pin_w(b):
|
||||
"""The watts a unit's own SET places once held inside its hard limits,
|
||||
as `plan()` places a standing pin, or None when the unit has no SET."""
|
||||
pv = getattr(b, "pinned_w", None)
|
||||
if getattr(b, "gate", None) is None or pv is None:
|
||||
return None
|
||||
lo, hi = _int_envelope(b)
|
||||
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
|
||||
|
||||
|
||||
def hi_w(b):
|
||||
"""A unit's upper hard limit as `plan()` narrows it to whole W (SAFETY-84)."""
|
||||
return float(_int_envelope(b)[1])
|
||||
|
||||
|
||||
def loop_bound(bounds, live=None):
|
||||
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
|
||||
|
||||
The sum, over live units that do not contradict themselves, of each unit's
|
||||
interval with its mode applied (`unit_interval()` with no higher claim). A
|
||||
pinned unit adds the span from 0 to its pin, so a probe or a maintenance
|
||||
pin stays reachable. Every term holds 0, so the sum does too.
|
||||
|
||||
`compute()` keeps the site target inside this, so the loop never asks for
|
||||
watts no battery may give: the allocator would report them as unmet and the
|
||||
saturation freeze would park the target on them (SAFETY-73 section 1).
|
||||
Pure, like `plan()`: it reads numbers and intervals and nothing else.
|
||||
"""
|
||||
lo_sum, hi_sum = 0.0, 0.0
|
||||
for u in sorted(bounds):
|
||||
b = bounds[u]
|
||||
if live is not None and u not in live:
|
||||
continue
|
||||
if getattr(b, "contradiction", False):
|
||||
continue
|
||||
p = pin_w(b)
|
||||
if p is not None:
|
||||
lo_sum += min(0.0, p)
|
||||
hi_sum += max(0.0, p)
|
||||
continue
|
||||
lo, hi, _ = unit_interval(b)
|
||||
lo, hi = _int_envelope(_Narrowed(lo, hi))
|
||||
if lo > hi:
|
||||
continue
|
||||
lo_sum += min(0.0, lo)
|
||||
hi_sum += max(0.0, hi)
|
||||
return lo_sum, hi_sum
|
||||
|
||||
|
||||
def _int_envelope(b):
|
||||
"""A unit's interval, narrowed to the integers a 1 W register can hold.
|
||||
|
||||
@@ -385,7 +488,8 @@ def _fill(order, weights, cap, amount):
|
||||
|
||||
|
||||
def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
site_priority=None, site_key="site", tick=None) -> Allocation:
|
||||
site_priority=None, site_key="site", tick=None,
|
||||
deciding_priority=None) -> Allocation:
|
||||
"""Divide the site grid target `target_w` across the units in `bounds`.
|
||||
|
||||
`bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1,
|
||||
@@ -411,6 +515,9 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
`site_priority` the priority of the SITE claim that chose `target_w`. It is
|
||||
what a unit's own winning SET is weighed against - see PINS.
|
||||
`site_key` the name to use when there are no units at all.
|
||||
`deciding_priority` OPT-11: the priority that decided `target_w`
|
||||
(`Resolution.deciding_priority`). Each unit's mode range applies
|
||||
only at or below it - see `unit_interval()`. None applies them.
|
||||
`tick` an opaque token identifying the tick this record is FOR, handed
|
||||
straight back on the record. SAFETY-28's write gate compares it
|
||||
against the tick it is writing on, so that a record cannot be
|
||||
@@ -497,6 +604,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
dropped = []
|
||||
ilo, ihi = {}, {}
|
||||
considered = []
|
||||
mode_faults = []
|
||||
for u in units:
|
||||
b = bounds[u]
|
||||
if live is not None and u not in live:
|
||||
@@ -512,7 +620,12 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
# has no interval to allocate into.
|
||||
dropped.append((u, DROP_CONTRADICTION))
|
||||
continue
|
||||
lo, hi = _int_envelope(b)
|
||||
# OPT-11: the mode layer, when it applies, narrows the hard limits.
|
||||
# A unit with no mode gets exactly its hard limits, as before.
|
||||
nlo, nhi, note = unit_interval(b, deciding_priority)
|
||||
if note == MODE_FAULT:
|
||||
mode_faults.append((u, MODE_FAULT))
|
||||
lo, hi = _int_envelope(_Narrowed(nlo, nhi))
|
||||
if lo > hi:
|
||||
# An interval that contains no integer. The register is 1 W, so
|
||||
# there is no number this unit could be commanded that both bounds
|
||||
@@ -526,7 +639,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
return Allocation(
|
||||
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
|
||||
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
|
||||
refused=why, tick=tick)
|
||||
refused=why, tick=tick, mode_faults=tuple(mode_faults))
|
||||
|
||||
# --- REFUSAL 2: nobody is available ------------------------------------
|
||||
if not considered:
|
||||
@@ -659,4 +772,5 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
|
||||
|
||||
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
|
||||
plans=tuple(plans), dropped=tuple(dropped),
|
||||
unmet_w=int(unmet), quantisation_w=quant, tick=tick)
|
||||
unmet_w=int(unmet), quantisation_w=quant, tick=tick,
|
||||
mode_faults=tuple(mode_faults))
|
||||
|
||||
@@ -76,6 +76,12 @@ from . import allocator
|
||||
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
|
||||
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
|
||||
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
||||
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
|
||||
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
|
||||
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
|
||||
# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86).
|
||||
# A cheap-window charge is suspended while the peak guard holds and, at N>=2,
|
||||
# sized to the quarter's headroom (SAFETY-84).
|
||||
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
||||
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
|
||||
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
|
||||
@@ -86,8 +92,17 @@ P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
||||
# because trading against the meter is the entire point - the grid-following
|
||||
# loop would otherwise cancel every charge the moment it created import.
|
||||
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
|
||||
# OPT-11. A battery's mode (Solar first, Leftover solar, Off): a SOFT unit
|
||||
# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside
|
||||
# each battery's mode; below the peak guard, to which a mode gives way only as
|
||||
# far as the floor needs (`_unit_modes()`, `_site_mode()`).
|
||||
# DECISIONS/OPT-11-battery-modes.md.
|
||||
P_MODE = 35
|
||||
P_LOOP = 10 # ordinary grid-following
|
||||
|
||||
# The one source filed at P_MODE. pre_resolve() keeps it out of the hard limits.
|
||||
MODE_SOURCE = "mode"
|
||||
|
||||
SET = "set"
|
||||
LIMIT = "limit"
|
||||
|
||||
@@ -175,6 +190,22 @@ CLAIM_SCOPE = {
|
||||
# the banner and the claim an install has had since SAFETY-02 shipped.
|
||||
# `Controller.cycle()` does not even arm the site detector there.
|
||||
"sign_site": SCOPE_SITE,
|
||||
# main.py: `Claim.limit("drain", P_MAINTENANCE, lo=...)`, SAFETY-78. At
|
||||
# N>=2, while a pack's maintenance drain is pinned, the site target may
|
||||
# not go below what that pin places: below it a sibling would have to
|
||||
# charge against the drain (circulation, refused, 0 W). A site `lo`,
|
||||
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
|
||||
"drain": SCOPE_SITE,
|
||||
# main.py: `Claim.limit("charge", P_MAINT_SHAPE, hi=...)`, SAFETY-84. The
|
||||
# mirror of `drain` for a cheap-window maintenance charge at N>=2: the site
|
||||
# target may not go above what the charging pack's own `hi` asks, or a
|
||||
# sibling would have to give against it. Never filed at N=1.
|
||||
# DECISIONS/SAFETY-84-charge-at-two.md.
|
||||
"charge": SCOPE_SITE,
|
||||
# main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a
|
||||
# holding pack gives the peak shortfall at N>=2, the site target stays at
|
||||
# the peak floor, what the pin and the others place. Never filed at N=1.
|
||||
"hold": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
@@ -226,12 +257,25 @@ CLAIM_SCOPE = {
|
||||
# reaches the allocator, so a unit-scoped claim is the shape that sentence
|
||||
# asks for rather than an evasion of it.
|
||||
"pack": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("refused", P_SAFETY, ...)` and, during a probe,
|
||||
# `Claim.set("refused", P_SAFETY, +-PROBE_W)`. SAFETY-73: THIS battery gives
|
||||
# nothing in one direction, measured on THIS unit's own reading. A hard unit
|
||||
# limit that always intersects, so the share goes to the siblings. Filed at
|
||||
# N>1 only; at N=1 SAFETY-52's law in compute() does the same job.
|
||||
"refused": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
|
||||
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
|
||||
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
|
||||
# this pack to its floor, charge this pack to full, recalibrate this pack's
|
||||
# coulomb counter. Two packs recalibrate on their own schedules.
|
||||
"maintenance": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
|
||||
# battery's mode, chosen per battery by its owner. A unit claim that never
|
||||
# enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the
|
||||
# hard limits, and it gives way to a site limit above P_MODE only as far as
|
||||
# that limit needs (`_unit_modes()`). At N=1 it routes to the site, and
|
||||
# `resolve()` takes it out of stage 2 for the same reason.
|
||||
"mode": SCOPE_UNIT,
|
||||
}
|
||||
|
||||
|
||||
@@ -359,6 +403,13 @@ class UnitBounds:
|
||||
pinned_w: float | None = None
|
||||
pinned_by: str | None = None
|
||||
contradiction: bool = False
|
||||
# OPT-11: the mode layer, APART from `lo`/`hi` above, which stay the hard
|
||||
# limits. `mode_priority` is None when no mode claim applies. Whether the
|
||||
# mode binds is decided at stage 3 (`allocator.unit_interval()`), because it
|
||||
# depends on the priority that decided the SITE target.
|
||||
mode_lo: float = -inf
|
||||
mode_hi: float = inf
|
||||
mode_priority: int | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.contradiction:
|
||||
@@ -404,6 +455,15 @@ class Resolution:
|
||||
# rules drifting apart: this is set by the same line that picks the winner
|
||||
# `explain()` names.
|
||||
winner_priority: int | None = None
|
||||
# OPT-11. The higher of the winning set's priority and the priority of the
|
||||
# limit that bound `target_w`. Whether the modes hold is decided from the
|
||||
# limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way
|
||||
# only as far as a limit above it (the peak floor) needs, so a mode `hi`
|
||||
# never meets a site `lo`.
|
||||
deciding_priority: int | None = None
|
||||
# OPT-11 at N=1: why the site's mode layer was dropped this tick, or None.
|
||||
# At N>1 the same fact is on `allocation.mode_faults`, per unit.
|
||||
mode_fault: str | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""One line an operator can act on - this is the point of the module."""
|
||||
@@ -427,7 +487,8 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
|
||||
of the fault this card removes, and the reason `resolve()` partitions rather
|
||||
than filters twice.
|
||||
"""
|
||||
sets = sorted((c for c in unit_claims if c.kind == SET),
|
||||
sets = sorted((c for c in unit_claims
|
||||
if c.kind == SET and c.source != MODE_SOURCE),
|
||||
key=lambda c: c.priority, reverse=True)
|
||||
gate = pinned_w = pinned_by = None
|
||||
if sets:
|
||||
@@ -435,19 +496,30 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
|
||||
|
||||
lo, hi = -inf, inf
|
||||
lo_src = hi_src = None
|
||||
# OPT-11: a mode claim never joins the hard limits, so a mode cannot make a
|
||||
# unit contradict itself or narrow a pin. A mode SET is not a thing: only
|
||||
# limits are read. The lowest mode priority is kept (the first to release).
|
||||
mode_lo, mode_hi, mode_priority = -inf, inf, None
|
||||
for c in unit_claims:
|
||||
if c.kind != LIMIT or (gate is not None and c.priority < gate):
|
||||
continue
|
||||
if c.source == MODE_SOURCE:
|
||||
mode_lo, mode_hi = max(mode_lo, c.lo), min(mode_hi, c.hi)
|
||||
mode_priority = (c.priority if mode_priority is None
|
||||
else min(mode_priority, c.priority))
|
||||
continue
|
||||
if c.lo > lo:
|
||||
lo, lo_src = c.lo, c.source
|
||||
if c.hi < hi:
|
||||
hi, hi_src = c.hi, c.source
|
||||
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
|
||||
contradiction=lo > hi)
|
||||
contradiction=lo > hi, mode_lo=mode_lo, mode_hi=mode_hi,
|
||||
mode_priority=mode_priority)
|
||||
|
||||
|
||||
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None) -> tuple:
|
||||
scope_errors=(), site_priority=None,
|
||||
deciding_priority=None) -> tuple:
|
||||
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
|
||||
|
||||
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
|
||||
@@ -485,11 +557,13 @@ def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
"""
|
||||
return allocate_record(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority)[:2]
|
||||
site_priority=site_priority,
|
||||
deciding_priority=deciding_priority)[:2]
|
||||
|
||||
|
||||
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None, tick=None) -> tuple:
|
||||
scope_errors=(), site_priority=None, tick=None,
|
||||
deciding_priority=None) -> tuple:
|
||||
"""`allocate()` plus the record - `(shares, allocated, allocation)`.
|
||||
|
||||
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
|
||||
@@ -501,7 +575,7 @@ def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority, site_key=ACTUATOR_SITE,
|
||||
tick=tick)
|
||||
tick=tick, deciding_priority=deciding_priority)
|
||||
return rec.shares, rec.ok, rec
|
||||
|
||||
|
||||
@@ -538,23 +612,30 @@ def site_resolve(claims: list) -> Resolution:
|
||||
considered = [f"{c.source}:{c.kind}" for c in claims]
|
||||
|
||||
if lo > hi:
|
||||
# OPT-11: 0 W here, and every limit that took part counts as deciding.
|
||||
return Resolution(0.0, winner.source, bound_by=None,
|
||||
contradiction=True, considered=considered,
|
||||
winner_priority=winner.priority)
|
||||
winner_priority=winner.priority,
|
||||
deciding_priority=max(winner.priority,
|
||||
lo_src.priority,
|
||||
hi_src.priority))
|
||||
|
||||
target = max(lo, min(hi, winner.value))
|
||||
bound_by = None
|
||||
deciding = winner.priority
|
||||
if target != winner.value:
|
||||
binder = lo_src if target == lo else hi_src
|
||||
if binder is not None:
|
||||
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
|
||||
deciding = max(deciding, binder.priority)
|
||||
|
||||
return Resolution(float(target), winner.source, bound_by, False, considered,
|
||||
winner_priority=winner.priority)
|
||||
winner_priority=winner.priority,
|
||||
deciding_priority=deciding)
|
||||
|
||||
|
||||
def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
tick=None) -> Resolution:
|
||||
tick=None, reserve=()) -> Resolution:
|
||||
"""The three stages, in order, over one cycle's claims.
|
||||
|
||||
`units` is the site's roster of inverters. Left None it is derived from the
|
||||
@@ -600,6 +681,12 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
written down.
|
||||
"""
|
||||
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
|
||||
# OPT-11: a mode claim on the site (N=1, where every source routes there)
|
||||
# never enters stage 2's `lo > hi` test. It is applied after it, below, by
|
||||
# the same release rule stage 3 uses.
|
||||
site_modes = [c for c in site_claims if c.source == MODE_SOURCE]
|
||||
if site_modes:
|
||||
site_claims = [c for c in site_claims if c.source != MODE_SOURCE]
|
||||
if units is None:
|
||||
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
|
||||
units = tuple(sorted(units))
|
||||
@@ -611,6 +698,8 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
|
||||
# `claims`, so this is the shipped arbiter on the shipped list.
|
||||
out = site_resolve(site_claims)
|
||||
if site_modes:
|
||||
_site_mode(out, site_modes, site_claims)
|
||||
|
||||
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
|
||||
#
|
||||
@@ -643,10 +732,132 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
|
||||
# own direction can differ from the target's sign under a pin, in which case
|
||||
# the weighting is a worse preference and still cannot be unsafe).
|
||||
# OPT-11: whether each unit's mode holds this tick (`_unit_modes()`).
|
||||
# Before the weights, because a limit above the modes may move the target.
|
||||
gate, held = _unit_modes(out, site_claims, bounds, live)
|
||||
if callable(weights):
|
||||
weights = weights(out.target_w)
|
||||
# SAFETY-87/91: units under their peak reserve weigh 0 while the site
|
||||
# gives, so the others carry the target first. An Off battery gives the
|
||||
# peak's shortfall, so it goes before them when no other unit is left.
|
||||
reserve = set(reserve) if out.target_w > 0.0 else set()
|
||||
if held and reserve and not set(bounds) - held - reserve:
|
||||
held = set()
|
||||
held = held | reserve
|
||||
if held:
|
||||
weights = {u: (0.0 if u in held else
|
||||
(1.0 if weights is None else weights.get(u, 1.0)))
|
||||
for u in bounds}
|
||||
out.shares, out.allocated, out.allocation = allocate_record(
|
||||
out.target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=out.scope_errors, site_priority=out.winner_priority,
|
||||
tick=tick)
|
||||
tick=tick, deciding_priority=gate)
|
||||
return out
|
||||
|
||||
|
||||
def _limits_above(site_claims: list, prio: int) -> tuple:
|
||||
"""OPT-11: `(lo, lo_src, hi, hi_src)` over the site LIMITs above `prio`.
|
||||
|
||||
These are what a mode may never take the site target outside of: the peak
|
||||
guard (P_PEAK), maintenance shaping (P_MAINT_SHAPE) and the hard limits.
|
||||
"""
|
||||
lo, hi = -inf, inf
|
||||
lo_src = hi_src = None
|
||||
for c in site_claims:
|
||||
if c.kind != LIMIT or c.priority <= prio:
|
||||
continue
|
||||
if c.lo > lo:
|
||||
lo, lo_src = c.lo, c
|
||||
if c.hi < hi:
|
||||
hi, hi_src = c.hi, c
|
||||
return lo, lo_src, hi, hi_src
|
||||
|
||||
|
||||
def _yield_to(out: Resolution, t1: float, above: tuple):
|
||||
"""OPT-11: the target `t1` the modes allow, moved only as far as the limits
|
||||
above the mode need (`_limits_above()`). Returns the claim that moved it,
|
||||
or None when `t1` already satisfies them, and sets `out.target_w`."""
|
||||
lo, lo_src, hi, hi_src = above
|
||||
t2 = max(lo, min(hi, t1))
|
||||
out.target_w = float(t2)
|
||||
if t2 == t1:
|
||||
return None
|
||||
binder = lo_src if t2 == lo else hi_src
|
||||
out.bound_by = (f"{binder.source}({binder.reason})" if binder.reason
|
||||
else binder.source)
|
||||
out.deciding_priority = max(out.deciding_priority or binder.priority,
|
||||
binder.priority)
|
||||
return binder
|
||||
|
||||
|
||||
def _site_mode(out: Resolution, modes: list, site_claims: list) -> None:
|
||||
"""OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2.
|
||||
|
||||
A SET above the mode (a safety stop) decides alone. Otherwise the target is
|
||||
clamped into the mode range and then back into the site limits above the
|
||||
mode, so the mode gives way only as far as those limits need: a peak floor
|
||||
of 2000 W over an Off battery asks it for 2000 W, whether a trade or the
|
||||
loop won stage 2, and a charge the peak guard merely caps stays at 0 W. A
|
||||
range without 0 W is a software fault that drops the mode
|
||||
(`out.mode_fault`). Clamping one number cannot contradict anything.
|
||||
"""
|
||||
out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes]
|
||||
lims = [c for c in modes if c.kind == LIMIT]
|
||||
if not lims or out.contradiction:
|
||||
return
|
||||
lo = max(c.lo for c in lims)
|
||||
hi = min(c.hi for c in lims)
|
||||
prio = min(c.priority for c in lims)
|
||||
if not (lo <= 0.0 <= hi):
|
||||
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
|
||||
return
|
||||
if out.winner_priority is not None and out.winner_priority > prio:
|
||||
return
|
||||
before = out.target_w
|
||||
t1 = max(lo, min(hi, before))
|
||||
if _yield_to(out, t1, _limits_above(site_claims, prio)) is None \
|
||||
and t1 != before:
|
||||
c = lims[0]
|
||||
out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source
|
||||
|
||||
|
||||
def _unit_modes(out: Resolution, site_claims: list, bounds: dict, live):
|
||||
"""OPT-11 at N>1: `(gate, held)` for stage 3.
|
||||
|
||||
`gate` is the `deciding_priority` handed to the allocator: None applies
|
||||
every unit's mode, a number above P_MODE releases them. `held` is the set
|
||||
of units whose mode holds them back in the target's direction; while the
|
||||
modes are released they weigh 0, so they carry only what the other units
|
||||
cannot (the allocator's last-resort pass).
|
||||
|
||||
The rule matches N=1: a SET above the mode decides alone. Otherwise the
|
||||
target is clamped into what the units can place with their modes
|
||||
(`allocator.loop_bound()`) and back into the site limits above the mode.
|
||||
If that point is still placeable with the modes, they hold and the target
|
||||
is unchanged (what they cannot place is reported unmet, as before). If a
|
||||
limit above the mode needs more, the target moves only that far, and the
|
||||
modes are released for it.
|
||||
"""
|
||||
mode_units = [u for u, b in bounds.items()
|
||||
if getattr(b, "mode_priority", None) is not None]
|
||||
if not mode_units or out.contradiction:
|
||||
return out.deciding_priority, set()
|
||||
prio = min(bounds[u].mode_priority for u in mode_units)
|
||||
if out.winner_priority is not None and out.winner_priority > prio:
|
||||
return out.deciding_priority, set()
|
||||
clo, chi = allocator.loop_bound(bounds, live)
|
||||
t1 = max(clo, min(chi, out.target_w))
|
||||
before = out.target_w
|
||||
binder = _yield_to(out, t1, _limits_above(site_claims, prio))
|
||||
if binder is None:
|
||||
out.target_w = float(before)
|
||||
return None, set()
|
||||
held = set()
|
||||
for u in mode_units:
|
||||
b = bounds[u]
|
||||
nlo, nhi, note = allocator.unit_interval(b)
|
||||
if note is not None:
|
||||
continue
|
||||
if (nhi < b.hi) if out.target_w > 0 else (nlo > b.lo):
|
||||
held.add(u)
|
||||
return binder.priority, held
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -325,12 +325,13 @@ class CirculationWatchdog:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
|
||||
self.reason = (
|
||||
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
|
||||
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
|
||||
f"continuously - the meter cannot see it, because it nets to zero "
|
||||
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
|
||||
f"{self.cfg.hold_s:.0f} s)")
|
||||
f"the inverters for {held:.0f} s, peaking at {self.peak_w:.0f} W, "
|
||||
f"over the limits of {self.cfg.min_w:.0f} W and "
|
||||
f"{self.cfg.hold_s:.0f} s. The meter cannot see it, because it "
|
||||
f"nets to zero there")
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
|
||||
@@ -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
|
||||
@@ -141,6 +142,8 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from . import messages
|
||||
|
||||
# The reason code, distinct from any saturation-freeze code by construction:
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
@@ -200,9 +203,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 +236,62 @@ CLEAR_CYCLES = 3
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
# UX-10: the reasons below are quoted by a WARNING or ERROR line, so they
|
||||
# follow STYLE.md's rule for a quoted reason (test_messages.py part 6): no
|
||||
# final full stop, the line adds it. The line around them says "software
|
||||
# fault" for an over-rating, so the reason does not. Each is one sentence, so
|
||||
# the logged line stays within 3 sentences and about 30 words.
|
||||
|
||||
|
||||
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 software fault. Every number is the
|
||||
decision's own, i.e. from the tick that clamped.
|
||||
"""
|
||||
if d.clamped:
|
||||
return (f"{REASON_CODE}: computed {d.requested_w:+.0f} W, cut to the "
|
||||
f"{d.clamped_w:+.0f} W rating, command now {d.target_w:+.0f} W")
|
||||
if d.i_clamped:
|
||||
return (f"{REASON_CODE}: the correction the add-on builds up reached "
|
||||
f"its {d.i_bound_w:+.0f} W limit, command now "
|
||||
f"{d.target_w:+.0f} W")
|
||||
return ""
|
||||
|
||||
|
||||
def shortfall_note(named) -> str:
|
||||
"""SAFETY-85: the alarm text while another owner sets the batteries.
|
||||
`named` is `[(inverter name or None, asked W, given W)]`, one per battery
|
||||
that gave less than asked."""
|
||||
return f"{REASON_CODE}: " + ", ".join(
|
||||
f"{name or 'the battery'} gives {given:+.0f} W of the {asked:+.0f} W "
|
||||
f"asked" for name, asked, given in named)
|
||||
|
||||
|
||||
def delivery_short(asked_w, given_w, margin_w) -> bool:
|
||||
"""SAFETY-85: did a battery asked to discharge give more than `margin_w`
|
||||
less? A charge is never short: a full pack tapers (SAFETY-42 section 7)."""
|
||||
asked, given = float(asked_w), float(given_w)
|
||||
return asked > 0.0 and asked - given > abs(float(margin_w))
|
||||
|
||||
|
||||
def write_clamp_note(named) -> str:
|
||||
"""The write-side alarm text: `named` is `[(inverter name or None,
|
||||
ClampResult)]` for every inverter whose write was cut back this cycle.
|
||||
|
||||
One inverter reads as `describe()`. Several read as one sentence of
|
||||
asked/sent pairs, so the line stays one sentence however many inverters
|
||||
there are. Which limit cut each one back is on its own action-log row
|
||||
(`describe()`) and in the notification."""
|
||||
if len(named) == 1:
|
||||
name, r = named[0]
|
||||
return f"{name}: {r.describe()}" if name else r.describe()
|
||||
return f"{REASON_CODE}: " + ", ".join(
|
||||
f"{name} asked {r.requested_w:+.0f} W and sent {r.applied_w:+.0f} W"
|
||||
for name, r in named)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
"""What the guard did, with the RAW value preserved.
|
||||
@@ -250,10 +308,12 @@ class ClampResult:
|
||||
bound: str = "" # "" | "max_w" | "device" - which stage bit
|
||||
|
||||
def describe(self) -> str:
|
||||
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
|
||||
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} "
|
||||
f"bound (rating {self.limit_w:.0f} W) - something upstream is "
|
||||
f"winding")
|
||||
# The sent value IS the limit that bound, so the rating is not
|
||||
# repeated: on "max_w" it equals the rating, and on "device" the
|
||||
# rating was not what cut it back.
|
||||
return (f"{REASON_CODE}: asked {self.requested_w:+.0f} W and sent "
|
||||
f"{self.applied_w:+.0f} W, "
|
||||
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}")
|
||||
|
||||
|
||||
def is_commandable(value) -> bool:
|
||||
@@ -333,32 +393,80 @@ 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
|
||||
# SAFETY-85: episodes with a bug=False engagement. The status row
|
||||
# counts these; counting cycles read 861 on one rig run.
|
||||
self.bound_episodes = 0
|
||||
self._bound_counted = False
|
||||
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
|
||||
self._bound_counted = False
|
||||
if not bug and not self._bound_counted:
|
||||
self._bound_counted = True
|
||||
self.bound_episodes += 1
|
||||
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 +492,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 +501,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 +520,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
|
||||
|
||||
@@ -221,6 +221,11 @@ function makeInput(field, value, onchange){
|
||||
input.step = field.type === 'integer' ? '1' : 'any';
|
||||
if(field.min !== undefined) input.min = field.min;
|
||||
if(field.max !== undefined) input.max = field.max;
|
||||
} else if(field.type === 'password'){
|
||||
input.type = 'password';
|
||||
input.autocomplete = 'new-password';
|
||||
input.autocapitalize = 'none';
|
||||
input.spellcheck = false;
|
||||
} else {
|
||||
input.type = 'text';
|
||||
input.autocapitalize = 'none';
|
||||
@@ -413,10 +418,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');
|
||||
|
||||
@@ -206,6 +206,8 @@ def _one_field(node, translations, lang, path=()):
|
||||
"multiple": bool(node.get("multiple")),
|
||||
"unit": unit_of(key),
|
||||
}
|
||||
if node.get("format") == "password":
|
||||
field["type"] = "password"
|
||||
if node.get("lengthMin") is not None:
|
||||
field["min"] = node["lengthMin"]
|
||||
if node.get("lengthMax") is not None:
|
||||
@@ -289,7 +291,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:
|
||||
@@ -303,6 +308,52 @@ def problems_by_field(problems, keys):
|
||||
return by_key, unanchored
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SECRETS (PRICE-01 F1). A `password` option never leaves this process in a page
|
||||
# or a JSON answer. The pages get SECRET_SHOWN when one is set and "" when not.
|
||||
# A save that sends SECRET_SHOWN or nothing keeps the stored value; any other
|
||||
# value replaces it. The names come from the Supervisor's own schema, so a
|
||||
# password option added later is covered without an edit here.
|
||||
# --------------------------------------------------------------------------- #
|
||||
SECRET_SHOWN = "********"
|
||||
|
||||
|
||||
def secret_keys(schema, extra=()):
|
||||
"""Names of the options whose schema type is `password`, plus `extra`."""
|
||||
# The Supervisor may spell it `type: password` or `type: string` with
|
||||
# `format: password`; both count.
|
||||
return frozenset(str(n.get("name")) for n in schema or ()
|
||||
if isinstance(n, dict) and (
|
||||
n.get("type") == "password"
|
||||
or n.get("format") == "password")
|
||||
) | frozenset(extra)
|
||||
|
||||
|
||||
def hide_secrets(value, keys):
|
||||
"""A copy of `value` with every secret key's value replaced by
|
||||
SECRET_SHOWN (set) or "" (empty), at any depth."""
|
||||
if isinstance(value, dict):
|
||||
return {k: ((SECRET_SHOWN if v else "") if k in keys
|
||||
else hide_secrets(v, keys))
|
||||
for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [hide_secrets(v, keys) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def keep_secrets(proposed, stored, keys):
|
||||
"""`proposed` with each secret the page did not change put back from
|
||||
`stored`: absent, empty or SECRET_SHOWN means 'leave it'."""
|
||||
out = dict(proposed)
|
||||
for key in keys:
|
||||
if out.get(key) in (None, "", SECRET_SHOWN):
|
||||
if isinstance(stored, dict) and key in stored:
|
||||
out[key] = stored[key]
|
||||
elif key in out and out[key] == SECRET_SHOWN:
|
||||
out[key] = ""
|
||||
return out
|
||||
|
||||
|
||||
def changed_keys(left, right):
|
||||
"""Sorted keys whose values differ between two option blobs.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure.
|
||||
"""
|
||||
|
||||
import math
|
||||
from math import inf
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -265,6 +266,38 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||
# `slew_limit_w()` and therefore with the rail.
|
||||
SLEW_LEAD_STEPS = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
|
||||
#
|
||||
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
|
||||
# saturation freeze below holds the command where it stood (the recorded
|
||||
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
|
||||
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
|
||||
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
|
||||
# carried between cycles like `sat_count`, and left again when the loop wants
|
||||
# the other direction, when a small probe is answered, or when the caller
|
||||
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
|
||||
#
|
||||
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
|
||||
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
|
||||
# test_refusal.py pins the inequality. The cadence and length are the owner's
|
||||
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
|
||||
# (17 s is how long a healthy reading needs to show a command).
|
||||
REFUSED = "battery refuses"
|
||||
PROBING = "battery probe"
|
||||
PROBE_W = 200.0
|
||||
PROBE_ANSWER_W = PROBE_W / 2
|
||||
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
|
||||
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
|
||||
# (review of #141). The capture's refusals read -4 W.
|
||||
REFUSED_BELOW_W = PROBE_ANSWER_W
|
||||
PROBE_EVERY_S = 900.0
|
||||
PROBE_S = 34.0
|
||||
|
||||
# OPT-11. The loop's reason when the battery modes, not the meter, set the
|
||||
# command: every battery that may move this way is at its mode's limit.
|
||||
MODE_BOUND = "mode-bound"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -340,6 +373,18 @@ 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
|
||||
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
|
||||
# charge, 0 none), or None when the caller does not carry it.
|
||||
refused: int | None = None
|
||||
# SAFETY-85. The `bound` held the integrator or the command this cycle,
|
||||
# so the batteries' limits, not the loop, decided it.
|
||||
held: bool = False
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -436,6 +481,11 @@ def compute(
|
||||
tuning: Tuning,
|
||||
sat_count: int = 0,
|
||||
i_w: float | None = None,
|
||||
refused: int | None = None,
|
||||
probe: bool = False,
|
||||
bound: tuple | None = None,
|
||||
gave_nothing: bool | None = None,
|
||||
idle_w: float = 0.0,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -443,8 +493,34 @@ def compute(
|
||||
`grid_w` net grid power, + = importing
|
||||
`actual_w` what the inverter reports it is doing, + = discharging
|
||||
`i_w` the carried integrator, or None to seed it from `prev_w`
|
||||
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
||||
(the `i_w=None` equivalence path does)
|
||||
`probe` the caller's clock says a refused direction may try PROBE_W now
|
||||
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
|
||||
their modes applied (`allocator.loop_bound()`), or None. See
|
||||
MODE_BOUND below. None is the law as it was.
|
||||
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
|
||||
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
|
||||
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
|
||||
out (compute() alone, as before).
|
||||
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
|
||||
nothing (app/idle.py). The refusal's band and its end are
|
||||
centred on it. 0 is the law as it was.
|
||||
"""
|
||||
reason = "tracking"
|
||||
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||
# mode may do. It is dropped rather than obeyed (a software fault, and
|
||||
# main.py never builds one: every term of `loop_bound()` holds 0).
|
||||
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
|
||||
bound = None
|
||||
# ...and a side of it that reaches the rail is left to the rail, so that
|
||||
# SAFETY-03's `i_clamped` still fires when the loop winds into it. With
|
||||
# every battery on, the bound IS the rail, and the law is exactly as it was.
|
||||
if bound is not None:
|
||||
_rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None
|
||||
else tuning.integrator_max_w)
|
||||
bound = (float(bound[0]) if bound[0] > -_rail else -inf,
|
||||
float(bound[1]) if bound[1] < _rail else inf)
|
||||
|
||||
# --- saturation, WITH the duration term -------------------------------
|
||||
# Command and actual diverging means the inverter cannot follow - it is at
|
||||
@@ -547,6 +623,17 @@ def compute(
|
||||
else:
|
||||
i_w = max(moved, i_w)
|
||||
|
||||
# OPT-11: the integrator does not wind past what the modes let the
|
||||
# batteries place. Only a side tighter than the rail is left in `bound`
|
||||
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
|
||||
held_by_bound = False
|
||||
if bound is not None:
|
||||
held = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||
if held != i_w:
|
||||
i_w = held
|
||||
reason = MODE_BOUND
|
||||
held_by_bound = True
|
||||
|
||||
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
||||
# bound is not. It is what makes the worst-case unwind time finite and
|
||||
# knowable instead of a function of how long the error happened to stand.
|
||||
@@ -563,6 +650,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 +662,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"
|
||||
|
||||
@@ -731,6 +820,49 @@ def compute(
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
|
||||
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
|
||||
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
|
||||
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
|
||||
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# limit because it only removes an ask the battery is not meeting.
|
||||
if refused is not None:
|
||||
# SAFETY-94: and only when the reading has given nothing for the
|
||||
# whole span, not just now. `sat_count` also counts the cycles a
|
||||
# healthy battery spends reversing through its lag (-2000 W towards
|
||||
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
|
||||
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||
and abs(prev_w) > tuning.saturation_w
|
||||
and abs(actual_w - idle_w) < REFUSED_BELOW_W
|
||||
and gave_nothing is not False):
|
||||
refused = 1 if prev_w > 0 else -1
|
||||
if refused and refused * i_w < 0:
|
||||
refused = 0 # the loop wants the other way
|
||||
elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W:
|
||||
refused = 0 # it delivers: a probe answered,
|
||||
# or a slow reading arriving
|
||||
elif refused:
|
||||
wants = refused * i_w > 0
|
||||
i_w = 0.0
|
||||
target = refused * PROBE_W if probe and wants else 0.0
|
||||
reason = PROBING if probe and wants else REFUSED
|
||||
|
||||
# --- OPT-11: the command stays inside what the modes allow -------------
|
||||
# Applied after the slew limit and the freeze, so a mode that closes drops
|
||||
# the ask at once rather than one slew step per cycle. That moves no
|
||||
# battery further than the allocator would: it places at most this bound
|
||||
# when the loop decides. What it removes is the unmet part, which would
|
||||
# otherwise hold `prev_w` away from `actual_w` and trip the saturation
|
||||
# freeze on watts no battery was allowed to give.
|
||||
if bound is not None:
|
||||
inside = max(float(bound[0]), min(float(bound[1]), target))
|
||||
if inside != target:
|
||||
target = inside
|
||||
reason = MODE_BOUND
|
||||
held_by_bound = True
|
||||
# SAFETY-48's lead cap above may pull the integrator back toward the
|
||||
# slewed command; what is CARRIED stays inside the bound too.
|
||||
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||
|
||||
# --- quantise ----------------------------------------------------------
|
||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
||||
@@ -738,9 +870,17 @@ def compute(
|
||||
# 10 W simply avoids a visible staircase on dashboards.
|
||||
step = max(1, int(tuning.step_w))
|
||||
target = round(target / step) * step
|
||||
if bound is not None:
|
||||
# OPT-11: rounding must not step outside the bound. The bound holds
|
||||
# 0 W, so one step toward 0 is always inside it.
|
||||
if target > bound[1]:
|
||||
target -= step
|
||||
elif target < bound[0]:
|
||||
target += 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), refused, held_by_bound)
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""OPT-11 P4: the day plan. Will the sun fill each battery before the
|
||||
expensive hours? If not, how much should it take from the grid?
|
||||
|
||||
Pure: the forecast (`solarfc`), the house profile, the price runs and the
|
||||
batteries in; a level per battery (`Plan.targets`) out. `modetrade.charge_to()`
|
||||
turns the level into permission to charge from the grid; main.py rebuilds the
|
||||
plan with the price runs (at most every 15 minutes) and keeps a level unless
|
||||
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
|
||||
|
||||
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
|
||||
* `sun`: trust x the solar the house will not use, between now and the
|
||||
expensive run the cheap run is for, x the charge efficiency:
|
||||
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
|
||||
* Solar first batteries take it first, up to the ceiling. What is left is
|
||||
shared by the Leftover solar batteries by their room.
|
||||
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
|
||||
the design (section 1). Skipping the sun's share cost money in the replay
|
||||
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
|
||||
* A Leftover solar battery charges only up to what the house will draw from
|
||||
it in the expensive runs before the next cheap run, less its share:
|
||||
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
|
||||
shortfall, not up to the ceiling.
|
||||
* Solar first never charges from the grid (owner, 2026-09-29).
|
||||
|
||||
The house profile is the add-on's own measurement, grid + battery power, per
|
||||
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
|
||||
median of that hour. It is net of the solar the house used itself, so for
|
||||
the solar sum it is never taken below the night-time median (`base()`).
|
||||
The site's own solar readings are not used: TEL-11 keeps them out of every
|
||||
control decision.
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .tariff import BILLING_TZ
|
||||
|
||||
EFF = 0.95 # charge (and discharge) efficiency
|
||||
HYST_KWH = 1.0 # a kept level moves only by this much...
|
||||
HYST_FRAC = 0.10 # ...or this share of the battery's shortfall
|
||||
PROFILE_DAYS = 7
|
||||
MIN_HOURS = 20 # hours of a day recorded before it counts
|
||||
NIGHT_HOURS = (0, 1, 2, 3, 4, 5, 22, 23)
|
||||
DEFAULT_TRUST_PCT = 70
|
||||
|
||||
# Site verdicts (the page's forecast line).
|
||||
SUNNY, PARTLY, DARK = "sunny", "partly", "dark"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The house profile
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _local(t: datetime):
|
||||
return t.astimezone(BILLING_TZ)
|
||||
|
||||
|
||||
class HouseProfile:
|
||||
"""Hourly means of the house's net use (grid + battery power), kept per
|
||||
local day and hour: `{"YYYY-MM-DD": {hour: [mean_w, samples]}}`. The
|
||||
store holds `dump()`; `load()` takes it back and drops what is bad."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.days = {}
|
||||
self._cur = None # (day, hour, sum, n)
|
||||
if isinstance(data, dict):
|
||||
self.load(data)
|
||||
|
||||
def load(self, data):
|
||||
for day, hours in data.items():
|
||||
try:
|
||||
datetime.strptime(str(day), "%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(hours, dict):
|
||||
continue
|
||||
row = {}
|
||||
for h, v in hours.items():
|
||||
try:
|
||||
hi, w = int(h), float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 <= hi < 24 and abs(w) < 1e6:
|
||||
row[hi] = w
|
||||
if row:
|
||||
self.days[str(day)] = row
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {d: {str(h): round(w, 1) for h, w in sorted(r.items())}
|
||||
for d, r in sorted(self.days.items())}
|
||||
|
||||
def add(self, now: datetime, house_w) -> bool:
|
||||
"""One reading. Returns True when an hour closed (then store it)."""
|
||||
lt = _local(now)
|
||||
key = (lt.strftime("%Y-%m-%d"), lt.hour)
|
||||
closed = False
|
||||
if self._cur is not None and self._cur[:2] != key:
|
||||
day, hour, s, n = self._cur
|
||||
if n >= 60: # about 5 minutes of readings
|
||||
self.days.setdefault(day, {})[hour] = s / n
|
||||
closed = True
|
||||
self._cur = None
|
||||
self._prune(lt)
|
||||
if house_w is None:
|
||||
return closed
|
||||
if self._cur is None:
|
||||
self._cur = (key[0], key[1], 0.0, 0)
|
||||
d, h, s, n = self._cur
|
||||
self._cur = (d, h, s + float(house_w), n + 1)
|
||||
return closed
|
||||
|
||||
def _prune(self, lt):
|
||||
"""Keep the last `PROFILE_DAYS` days; a day after today (the clock
|
||||
was wrong once) would never age out, so it goes too."""
|
||||
keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat()
|
||||
today = lt.date().isoformat()
|
||||
for d in [d for d in self.days if d < keep or d > today]:
|
||||
del self.days[d]
|
||||
|
||||
def ready(self) -> bool:
|
||||
"""At least one day with `MIN_HOURS` hours recorded."""
|
||||
return any(len(r) >= MIN_HOURS for r in self.days.values())
|
||||
|
||||
def hour_w(self, hour: int):
|
||||
vals = [r[hour] for r in self.days.values() if hour in r]
|
||||
return statistics.median(vals) if vals else None
|
||||
|
||||
def base(self):
|
||||
vals = [w for h in NIGHT_HOURS if (w := self.hour_w(h)) is not None]
|
||||
return statistics.median(vals) if vals else None
|
||||
|
||||
def estimator(self):
|
||||
"""`f(hour) -> W`, or None when not ready. Fixed at call time."""
|
||||
if not self.ready():
|
||||
return None
|
||||
base = self.base() or 0.0
|
||||
table = {}
|
||||
for h in range(24):
|
||||
w = self.hour_w(h)
|
||||
table[h] = base if w is None else w
|
||||
return lambda h: table[h], max(0.0, base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The plan
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class Plan:
|
||||
verdict: str # SUNNY / PARTLY / DARK
|
||||
sun_kwh: float # solar the batteries can count on
|
||||
fc_kwh: float # the forecast over the same span
|
||||
until: datetime # the expensive run the plan is for
|
||||
targets: dict # name -> level % (Leftover solar only)
|
||||
need_kwh: dict # name -> kWh from the grid (0: none)
|
||||
share_kwh: dict # name -> kWh of solar counted for it
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = _local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
return out
|
||||
|
||||
|
||||
def _room(p, to_pct):
|
||||
if p.capacity_kwh is None or p.soc is None:
|
||||
return 0.0
|
||||
return max(0.0, float(to_pct) - float(p.soc)) / 100.0 * float(
|
||||
p.capacity_kwh)
|
||||
|
||||
|
||||
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
ceiling_pct):
|
||||
"""The day plan, or None when there is none to make: no forecast, no
|
||||
house profile yet, no price plan, or no expensive run after the cheap
|
||||
run. `house` is `HouseProfile.estimator()`'s answer. `packs` are
|
||||
`modetrade.Pack`s."""
|
||||
if forecast is None or house is None or runs is None or not runs.on \
|
||||
or runs.cheap is None:
|
||||
return None
|
||||
house_w, base_w = house
|
||||
until = next((g for g in runs.give if g.start >= runs.cheap.end), None)
|
||||
if until is None:
|
||||
return None
|
||||
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
|
||||
sun = fc_sum = 0.0
|
||||
for s, e, h in _hour_slices(now, until.start):
|
||||
pv = forecast.between(s, e)
|
||||
if pv is None:
|
||||
return None # the span reaches a day with no forecast
|
||||
hrs = (e - s).total_seconds() / 3600.0
|
||||
use = max(house_w(h), base_w) * hrs / 1000.0
|
||||
sun += max(0.0, pv - use)
|
||||
fc_sum += pv
|
||||
sun *= trust * EFF
|
||||
|
||||
usable = [p for p in packs if p.use and p.live and p.idle
|
||||
and p.soc is not None and p.capacity_kwh]
|
||||
sf = [p for p in usable if p.mode == SOLAR_FIRST]
|
||||
price = [p for p in usable if p.mode == LEFTOVER_SOLAR]
|
||||
sf_room = sum(_room(p, ceiling_pct) for p in sf)
|
||||
excess = max(0.0, sun - sf_room)
|
||||
rooms = {p.name: _room(p, ceiling_pct) for p in price}
|
||||
total_room = sum(rooms.values())
|
||||
|
||||
# What the house draws in the expensive runs from the one this plan is
|
||||
# for until the next cheap run (or a day), from the profile.
|
||||
horizon = until.start + timedelta(hours=24)
|
||||
use_kwh = 0.0
|
||||
for g in runs.give:
|
||||
if g.start < until.start or g.start >= horizon:
|
||||
continue
|
||||
for s, e, h in _hour_slices(g.start, g.end):
|
||||
use_kwh += max(0.0, house_w(h)) * (
|
||||
e - s).total_seconds() / 3600.0 / 1000.0
|
||||
caps = {p.name: float(p.capacity_kwh) for p in price}
|
||||
cap_sum = sum(caps.values()) or 1.0
|
||||
|
||||
targets, need, share = {}, {}, {}
|
||||
for p in price:
|
||||
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
|
||||
sh_pct = sh / caps[p.name] * 100.0
|
||||
want = use_kwh * caps[p.name] / cap_sum / EFF
|
||||
top = min(float(ceiling_pct),
|
||||
float(floor_pct) + want / caps[p.name] * 100.0)
|
||||
level = max(0.0, top - sh_pct)
|
||||
targets[p.name] = round(level, 2)
|
||||
need[p.name] = round(_room(p, level), 3)
|
||||
share[p.name] = round(sh, 3)
|
||||
if sun < sf_room:
|
||||
verdict = DARK
|
||||
elif any(v > 0.05 for v in need.values()):
|
||||
verdict = PARTLY
|
||||
else:
|
||||
verdict = SUNNY
|
||||
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
|
||||
targets, need, share)
|
||||
|
||||
|
||||
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
|
||||
"""Hysteresis: a battery keeps its old level unless the new one differs
|
||||
by at least `HYST_KWH`, or by `HYST_FRAC` of the larger level's energy
|
||||
(whichever is less). A battery new to the plan takes its level at once;
|
||||
one that left the plan is dropped at once."""
|
||||
out = {}
|
||||
for name, level in new.items():
|
||||
was = (old or {}).get(name)
|
||||
cap = float(capacity.get(name) or 0.0)
|
||||
if was is None or cap <= 0:
|
||||
out[name] = level
|
||||
continue
|
||||
moved = abs(level - was) / 100.0 * cap
|
||||
step = min(HYST_KWH, HYST_FRAC * max(was, level) / 100.0 * cap)
|
||||
out[name] = level if moved >= step else was
|
||||
return out
|
||||
@@ -424,15 +424,17 @@ class FreezeWatchdog:
|
||||
# page was telling, printed into the log and the HA notification where
|
||||
# it would outlive the page.
|
||||
ceiling = rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max)
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
|
||||
capped = ("" if ceiling >= abs(float(max_w))
|
||||
else f", capped from max_w {float(max_w):.0f} W by the "
|
||||
f"setpoint device's own rating")
|
||||
f"inverter's own rating")
|
||||
self.reason = (
|
||||
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
|
||||
f"without its measurement changing while the command stands at "
|
||||
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W rail{capped} "
|
||||
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
|
||||
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)")
|
||||
f"METER_FROZEN: the meter has not changed for "
|
||||
f"{float(unchanged_s):.0f} s while the command is "
|
||||
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W limit"
|
||||
f"{capped}. Limits: unchanged over {self.cfg.hold_s:.0f} s, "
|
||||
f"command over "
|
||||
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W")
|
||||
if self.active:
|
||||
return False
|
||||
self.active = True
|
||||
|
||||
@@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
SAFETY-94: "unchanged for" counts only the reads at which both command terms
|
||||
held, so a register that rested is not judged before it could answer.
|
||||
DECISIONS/SAFETY-94-lagging-battery.md.
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
@@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
|
||||
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
|
||||
property of this battery's firmware. It is not a property of this code, and the
|
||||
add-on must not depend on it.
|
||||
SAFETY-94: many were the gate judging a rested register on its first read
|
||||
(SAFETY-58). Counted from the ask, the same days give 59 trips.
|
||||
|
||||
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
|
||||
exit, not how long the block lasts":
|
||||
@@ -372,7 +378,9 @@ BOUNDS = {
|
||||
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
|
||||
"standing command below 250 W, because at 0 W the register "
|
||||
"is obliged to nothing and the verdict cannot be tested "
|
||||
"again from inside the state it creates"),
|
||||
"again from inside the state it creates. SAFETY-94: the "
|
||||
"35 s counts only reads at which the command asks the "
|
||||
"register to move"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
@@ -416,11 +424,9 @@ class Freshness:
|
||||
new grading window at that instant. The 1 Hz oscillation the latch exists to
|
||||
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
|
||||
no re-trip can land sooner than 35 s after a release, rather than on the
|
||||
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
|
||||
the site rests for longer than the window before it asks for anything, the
|
||||
window is already old and the re-trip lands on the first read at which the
|
||||
command travels 250 W again. That is survivor 5's shape, it predates this
|
||||
card, and its cost is one `stale_input_s` hold.
|
||||
next read. SAFETY-94: the 35 s counts only while the command asks the
|
||||
register to move, so a site that rests after a release is not re-tripped
|
||||
on its first ask.
|
||||
|
||||
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
|
||||
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
|
||||
@@ -451,6 +457,11 @@ class Freshness:
|
||||
# the change-stamps; `_release()` is where they diverge.
|
||||
self.graded_from = None
|
||||
self.standing_at_grade = None
|
||||
# SAFETY-94. Seconds of this window during which the register was
|
||||
# obliged to move (the command at least `move_w` and `move_w` away
|
||||
# from `standing_at_grade`), and the instant of the last read.
|
||||
self.obliged_acc = 0.0
|
||||
self.last_read = None
|
||||
# The instant the standing command last FELL below `move_w`, or None
|
||||
# while an obligation is standing. The release timer runs off this.
|
||||
self.rested_since = None
|
||||
@@ -499,6 +510,7 @@ class Freshness:
|
||||
# it reads in the order the state machine moves - withdraw, then
|
||||
# re-grade - not because the code depends on it.
|
||||
self._release(standing_w, now_mono)
|
||||
self._oblige(standing_w, now_mono)
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _open_window(self, standing_w, now_mono) -> None:
|
||||
@@ -510,6 +522,35 @@ class Freshness:
|
||||
self.graded_from = float(now_mono)
|
||||
self.standing_at_grade = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# The read that opens a window is never an ask (no travel yet).
|
||||
self.obliged_acc = 0.0
|
||||
|
||||
def _obliged(self, standing_w) -> bool:
|
||||
"""Is the register asked to move: the command is at least `move_w` and
|
||||
has travelled `move_w` since the window opened?"""
|
||||
if standing_w is None or self.standing_at_grade is None:
|
||||
return False
|
||||
standing_w = float(standing_w)
|
||||
return (abs(standing_w) >= self.bound.move_w
|
||||
and abs(standing_w - self.standing_at_grade)
|
||||
>= self.bound.move_w)
|
||||
|
||||
def _oblige(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-94. Count only the time the register was asked to move.
|
||||
|
||||
A register that held still while nothing asked it to move has not
|
||||
failed, so time before the command moved is not evidence. The time
|
||||
since the previous read counts when the obligation stands at this
|
||||
read; a command flapping in and out of the obligation still adds up.
|
||||
"""
|
||||
now_mono = float(now_mono)
|
||||
if self.last_read is not None and self._obliged(standing_w):
|
||||
self.obliged_acc += max(0.0, now_mono - self.last_read)
|
||||
self.last_read = now_mono
|
||||
|
||||
def obliged_s(self) -> float:
|
||||
"""Seconds of the current window the register was obliged to move."""
|
||||
return self.obliged_acc
|
||||
|
||||
def _release(self, standing_w, now_mono) -> None:
|
||||
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
|
||||
@@ -543,7 +584,12 @@ class Freshness:
|
||||
if self.stale or self.graded_from is None:
|
||||
return
|
||||
held_s = self.graded_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
|
||||
# command asked the register to move, not the time since it last
|
||||
# changed. A register that rested for minutes was otherwise judged on
|
||||
# the first read after a new command, before it could answer (live,
|
||||
# 2026-10-03: 1 s after the command).
|
||||
if self.obliged_s() < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
@@ -594,31 +640,26 @@ class Freshness:
|
||||
# ("what did the register do" against "can I trust this entity"). One
|
||||
# helper serving both would have to drop whichever half the other does
|
||||
# not need, and the half it dropped would be the operator's next step.
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
|
||||
f"CANNOT TELL THEM APART: the register may have stopped reporting "
|
||||
f"reality, or the source may be unable to comply with the command "
|
||||
f"- a pack the BMS is refusing holds still under any setpoint, and "
|
||||
f"an honest register then reads exactly like a frozen one. Either "
|
||||
f"way the number cannot be read as this command's effect, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
# UX-10: two plain sentences (test_messages.py part 6). SAFETY-56's
|
||||
# two explanations stay, in this order, and so does the admission
|
||||
# that this check cannot tell them apart. The reading is treated as
|
||||
# missing either way, which the log line around this says. The
|
||||
# limits (`self.bound`) are on main.py's DEBUG line beside it, so the
|
||||
# logged line stays within 3 sentences.
|
||||
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
|
||||
# in its own slot: the register's unchanged time is in the sentence
|
||||
# above, the WINDOW this verdict actually rests on is here, and so is
|
||||
# how many times this unit has been round the loop. Without the second
|
||||
# one the sentence reads as though 92 s of unchanged register were what
|
||||
# was compared against the 35 s limit, and it was not.
|
||||
if self.releases:
|
||||
self.reason += (
|
||||
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
|
||||
f"opened when the previous one was withdrawn, not when the "
|
||||
f"register last moved - this unit has now been graded "
|
||||
f"{self.releases + 1} times]")
|
||||
# in its own slot: the register's unchanged time, the WINDOW this
|
||||
# verdict actually rests on after a release (`regraded`), and how many
|
||||
# times this unit has been round the loop. Without the second one the
|
||||
# sentence reads as though 92 s of unchanged register were what was
|
||||
# compared against the 35 s limit, and it was not.
|
||||
regraded = (f", checked {self.releases + 1} times over a {held_s:.0f} s "
|
||||
f"window since the last stop was withdrawn"
|
||||
if self.releases else "")
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} W unchanged for {shown_s:.0f} s "
|
||||
f"while the command moved {moved_w:.0f} W to {standing_w:+.0f} W"
|
||||
f"{regraded}. The reading may have stopped, or the battery may be "
|
||||
f"unable to follow the command, and this check cannot tell which")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""UX-13: Home Assistant's entities, for the settings page's entity picker.
|
||||
|
||||
A read of `GET /api/states` through the add-on's own session (hass.states()),
|
||||
cached for 30 s and trimmed to what a picker shows. Nothing here writes, so
|
||||
UX-02 §6 holds. When Home Assistant does not answer, the last good list is
|
||||
served with its age, or an empty one that says why: the route never fails.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
CACHE_S = 30.0
|
||||
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
|
||||
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
|
||||
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
|
||||
_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6}
|
||||
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
|
||||
|
||||
|
||||
def row(state: dict) -> dict | None:
|
||||
"""One state object -> {entity_id, name, state, unit, device_class}."""
|
||||
if not isinstance(state, dict) or not isinstance(state.get("entity_id"), str):
|
||||
return None
|
||||
attrs = state.get("attributes") if isinstance(state.get("attributes"), dict) else {}
|
||||
return {"entity_id": state["entity_id"],
|
||||
"name": str(attrs.get("friendly_name") or state["entity_id"]),
|
||||
"state": state.get("state"),
|
||||
"unit": attrs.get("unit_of_measurement"),
|
||||
"device_class": attrs.get("device_class")}
|
||||
|
||||
|
||||
def watts(r: dict | None):
|
||||
"""A power entity's reading in W, or None when it is not a number in a
|
||||
power unit (the setup wizard's kettle test)."""
|
||||
if not r or r.get("unit") not in _TO_W:
|
||||
return None
|
||||
try:
|
||||
value = float(r.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value * _TO_W[r["unit"]] if value == value else None
|
||||
|
||||
|
||||
def matches(r: dict, kind: str) -> bool:
|
||||
"""Is `r` what a picker of this kind offers first? `kind` is one of
|
||||
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
|
||||
unit, cls = r.get("unit"), r.get("device_class")
|
||||
if kind in ("power", "power_w"):
|
||||
return cls == "power" or unit in _POWER_UNITS
|
||||
if kind == "energy":
|
||||
return cls == "energy" or unit in _ENERGY_UNITS
|
||||
if kind == "pct":
|
||||
return unit == "%"
|
||||
if kind == "number":
|
||||
return r["entity_id"].startswith(("number.", "input_number."))
|
||||
return True
|
||||
|
||||
|
||||
class EntityCache:
|
||||
"""The last entity list read from Home Assistant, at most CACHE_S old."""
|
||||
|
||||
def __init__(self, hass, clock=time.monotonic):
|
||||
self.hass = hass
|
||||
self.clock = clock
|
||||
self.rows: dict | None = None
|
||||
self.read_at: float | None = None
|
||||
self.tried_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, max_age: float = CACHE_S
|
||||
) -> tuple[dict | None, float | None]:
|
||||
"""(rows by entity id, age in s), or (None, None) if never read.
|
||||
`max_age` is how old a try may be before HA is asked again: the setup
|
||||
wizard's last step asks for a list at most a few seconds old."""
|
||||
async with self._lock: # one HA read however many pickers ask
|
||||
now = self.clock()
|
||||
# A failed read also waits CACHE_S, so polls while HA is down do
|
||||
# not queue one 10 s read each behind this lock.
|
||||
if self.tried_at is None or now - self.tried_at >= max_age:
|
||||
self.tried_at = now
|
||||
states = await self.hass.states()
|
||||
if states is not None:
|
||||
rows = (row(s) for s in states)
|
||||
self.rows = {r["entity_id"]: r for r in rows if r}
|
||||
self.read_at = now
|
||||
if self.read_at is None:
|
||||
return None, None
|
||||
return self.rows, now - self.read_at
|
||||
|
||||
async def reading(self, entity_id: str) -> dict:
|
||||
"""One entity read now, not from the list: the kettle test compares
|
||||
two readings seconds apart, which a 30 s list cannot. A read only."""
|
||||
r = row(await self.hass.state(entity_id)) if entity_id else None
|
||||
return {"entity_id": entity_id, "found": r is not None,
|
||||
"state": r and r["state"], "unit": r and r["unit"],
|
||||
"watts": watts(r)}
|
||||
|
||||
def peek(self) -> dict | None:
|
||||
"""The cached rows if they are fresh, without asking Home Assistant."""
|
||||
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
|
||||
return None
|
||||
return self.rows
|
||||
|
||||
async def payload(self, kind: str = "") -> dict:
|
||||
"""The route's JSON: matching entities first, then the rest."""
|
||||
rows, age = await self.get()
|
||||
if rows is None:
|
||||
return {"available": False, "text": DOWN_TEXT, "entities": []}
|
||||
ordered = sorted(rows.values(), key=lambda r: r["entity_id"])
|
||||
first = [dict(r, match=True) for r in ordered if matches(r, kind)]
|
||||
rest = [dict(r, match=False) for r in ordered if not matches(r, kind)]
|
||||
out = {"available": True, "age_s": round(age, 1), "entities": first + rest}
|
||||
if age >= CACHE_S:
|
||||
out["text"] = STALE_TEXT
|
||||
return out
|
||||
@@ -11,17 +11,82 @@ import time
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
|
||||
_LOG = logging.getLogger("goodwe.hass")
|
||||
|
||||
CORE_API = "http://supervisor/core/api"
|
||||
SUPERVISOR_API = "http://supervisor"
|
||||
BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
# What the Supervisor's proxy answers while Home Assistant restarts.
|
||||
UNAVAILABLE = (502, 503, 504)
|
||||
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
|
||||
STILL_WAITING_S = 300 # SAFETY-112: one reminder line per 5 minutes down
|
||||
|
||||
|
||||
class Outage:
|
||||
"""UX-10: Home Assistant not answering, said in one plain line per outage.
|
||||
|
||||
A restart takes the REST API and the meter websocket down together, and
|
||||
each used to log the library's own text for every entity and service
|
||||
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
|
||||
not str"). Now the first channel to fail says it once at WARNING, the raw
|
||||
text goes to DEBUG, and the last channel to recover says so at INFO.
|
||||
|
||||
SAFETY-112: the Supervisor answers 502 for every read, write and
|
||||
websocket until Home Assistant has finished starting, however live its
|
||||
entities already are (DECISIONS/SAFETY-112-waiting-for-home-assistant.md).
|
||||
So an outage also says, every STILL_WAITING_S, that it is still on, and
|
||||
`waiting()` puts it on the status page.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.down_on = set()
|
||||
self.since = None # monotonic start of this outage
|
||||
self._told = None # when the last "still waiting" line went
|
||||
|
||||
def down(self, channel: str, what: str, detail) -> None:
|
||||
now = time.monotonic()
|
||||
if not self.down_on:
|
||||
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
|
||||
"still starting. Control waits and resumes by itself.",
|
||||
what)
|
||||
self.since = self._told = now
|
||||
elif now - self._told >= STILL_WAITING_S:
|
||||
self._told = now
|
||||
_LOG.info("Still waiting for Home Assistant to finish starting, "
|
||||
"%s so far.", messages.duration(now - self.since))
|
||||
self.down_on.add(channel)
|
||||
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
|
||||
|
||||
def up(self, channel: str) -> None:
|
||||
if channel in self.down_on:
|
||||
self.down_on.discard(channel)
|
||||
if not self.down_on:
|
||||
_LOG.info("Home Assistant answers again after %s.",
|
||||
messages.duration(time.monotonic() - self.since))
|
||||
|
||||
def waiting(self) -> bool:
|
||||
"""True while Home Assistant's API does not answer. The API, not the
|
||||
meter websocket: every battery reading and every write rides on it."""
|
||||
return "api" in self.down_on
|
||||
|
||||
|
||||
OUTAGE = Outage()
|
||||
|
||||
|
||||
class ReadOnly(RuntimeError):
|
||||
"""A service call on a read-only HomeAssistant (UX-15 setup-only mode)."""
|
||||
|
||||
|
||||
class HomeAssistant:
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None,
|
||||
*, read_only: bool = False):
|
||||
self.session = session
|
||||
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
|
||||
# UX-15: setup-only mode reads Home Assistant and calls no service.
|
||||
# Fixed at construction; `call()` refuses before it sends anything.
|
||||
self._read_only = bool(read_only)
|
||||
self._last_moan: dict[str, float] = {}
|
||||
if not self.token:
|
||||
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
|
||||
@@ -58,10 +123,18 @@ class HomeAssistant:
|
||||
async with self.session.get(
|
||||
f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"read {entity_id}: {resp.status}")
|
||||
return None
|
||||
OUTAGE.up("api")
|
||||
if resp.status == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
obj = await resp.json()
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection", f"read {entity_id}: {err}")
|
||||
return None
|
||||
# SAFETY-61: an undecodable body is an absence like any other failed
|
||||
# read, never an exception into the control loop.
|
||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
||||
@@ -73,6 +146,39 @@ class HomeAssistant:
|
||||
return None
|
||||
return obj
|
||||
|
||||
async def states(self) -> list | None:
|
||||
"""Every entity's state object (`GET /api/states`), or None when Home
|
||||
Assistant does not answer. A read only (UX-13 entity picker)."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{CORE_API}/states", headers=self._headers,
|
||||
timeout=STATES_TIMEOUT_S
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"read all states: {resp.status}")
|
||||
return None
|
||||
OUTAGE.up("api")
|
||||
resp.raise_for_status()
|
||||
obj = await resp.json()
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection", f"read all states: {err}")
|
||||
return None
|
||||
# A timeout's own text is empty, so the line names the wait instead
|
||||
# (UX-13 P1 test, c).
|
||||
except TimeoutError:
|
||||
self._moan("read:*", "Reading the entity list failed: no answer "
|
||||
"within %s seconds.", STATES_TIMEOUT_S)
|
||||
return None
|
||||
except (aiohttp.ClientError, ValueError) as err:
|
||||
self._moan("read:*", "Reading the entity list failed: %s",
|
||||
str(err) or type(err).__name__)
|
||||
return None
|
||||
if not isinstance(obj, list):
|
||||
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
|
||||
return None
|
||||
return obj
|
||||
|
||||
async def number(self, entity_id: str, invert: bool = False) -> float | None:
|
||||
"""Numeric state, or None. Never substitutes a default."""
|
||||
obj = await self.state(entity_id)
|
||||
@@ -107,23 +213,53 @@ class HomeAssistant:
|
||||
return None
|
||||
return (_f("min"), _f("max"), _f("step"))
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return self._read_only
|
||||
|
||||
async def call(self, domain: str, service: str, data: dict) -> bool:
|
||||
if self._read_only:
|
||||
# Every service call, `number.set_value` included, goes through
|
||||
# here, so setup-only mode has no way to command (DECISIONS UX-15).
|
||||
_LOG.error("Setup-only mode called no service, because it may not "
|
||||
"command anything.")
|
||||
raise ReadOnly(f"{domain}.{service} refused: setup-only mode")
|
||||
try:
|
||||
async with self.session.post(
|
||||
f"{CORE_API}/services/{domain}/{service}",
|
||||
headers=self._headers, json=data, timeout=10,
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"service {domain}.{service}: {resp.status}")
|
||||
return False
|
||||
OUTAGE.up("api")
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
# 400 here usually means out-of-range for the entity - the
|
||||
# value is rejected outright, not clamped. Always log it:
|
||||
# silently dropped commands are how a controller ends up
|
||||
# believing something the hardware never did.
|
||||
# UX-10: plain words. While Home Assistant loads its
|
||||
# entities it answers 400 with only "400: Bad Request",
|
||||
# which says nothing, so only a body with more is quoted.
|
||||
said = body.strip()[:200]
|
||||
if said.lower() in (f"{resp.status}: {resp.reason}".lower(),
|
||||
""):
|
||||
said = "no details"
|
||||
entity = (data.get("entity_id", "no entity")
|
||||
if isinstance(data, dict) else "no entity")
|
||||
self._moan(f"svc:{domain}.{service}",
|
||||
"service %s.%s rejected (%s): %s",
|
||||
domain, service, resp.status, body[:200])
|
||||
"Home Assistant refused %s.%s for %s (error %s, "
|
||||
"%s). The entity may still be loading, or the "
|
||||
"value is out of range.", domain, service,
|
||||
entity, resp.status, said)
|
||||
return False
|
||||
return True
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection",
|
||||
f"service {domain}.{service}: {err}")
|
||||
return False
|
||||
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
|
||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
||||
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""SAFETY-97: what a battery's power reading shows while it gives nothing.
|
||||
|
||||
B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live
|
||||
2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded
|
||||
against this idle reading, not against 0 W, or a refusing B never latches.
|
||||
DECISIONS/SAFETY-97-idle-offset.md.
|
||||
|
||||
Learned per unit, never configured:
|
||||
- only while control runs, the unit is not refused, stale or tripped, and
|
||||
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
|
||||
is asked more than that, so it cannot teach its refusal as idle);
|
||||
- a reading beyond IDLE_SAMPLE_W is not a sample;
|
||||
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow
|
||||
(IDLE_TAU_S);
|
||||
- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start.
|
||||
The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until
|
||||
learned the idle reading is 0 W, the rule as it was.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W
|
||||
IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample
|
||||
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
|
||||
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
|
||||
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
|
||||
IDLE_TAU_S = 1800.0 # then follow drift this slowly
|
||||
IDLE_SAVE_W = 5.0 # persist when the value moved this far
|
||||
IDLE_GAP_S = 10.0 # a longer gap between reads counts as this
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
v = float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return v if math.isfinite(v) else None
|
||||
|
||||
|
||||
class IdleReading:
|
||||
"""One unit's idle reading. `w` is what "gives nothing" is centred on."""
|
||||
|
||||
def __init__(self, saved=None):
|
||||
v = _num(saved)
|
||||
self.value = (None if v is None
|
||||
else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v)))
|
||||
self.saved = self.value
|
||||
self.quiet_since = None
|
||||
self.last = None
|
||||
self.acc_w = 0.0
|
||||
self.acc_s = 0.0
|
||||
|
||||
@property
|
||||
def w(self) -> float:
|
||||
"""The centre used: the learned value clamped to IDLE_MAX_W."""
|
||||
if self.value is None:
|
||||
return 0.0
|
||||
return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
|
||||
|
||||
def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
|
||||
"""One read. Returns True when the value should be persisted."""
|
||||
now = float(now_mono)
|
||||
dt = (0.0 if self.last is None
|
||||
else max(0.0, min(IDLE_GAP_S, now - self.last)))
|
||||
self.last = now
|
||||
standing = _num(standing_w)
|
||||
if not may_learn or standing is None or abs(standing) > IDLE_ASK_W:
|
||||
self.quiet_since = None
|
||||
return False
|
||||
if self.quiet_since is None:
|
||||
self.quiet_since = now
|
||||
b = _num(reading)
|
||||
if (now - self.quiet_since < IDLE_QUIET_S or b is None
|
||||
or abs(b) > IDLE_SAMPLE_W or dt <= 0.0):
|
||||
return False
|
||||
if self.value is None:
|
||||
self.acc_w += b * dt
|
||||
self.acc_s += dt
|
||||
if self.acc_s < IDLE_FIRST_S:
|
||||
return False
|
||||
self.value = self.acc_w / self.acc_s
|
||||
else:
|
||||
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
|
||||
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
|
||||
self.saved = self.value
|
||||
return True
|
||||
return False
|
||||
@@ -123,9 +123,11 @@ 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
|
||||
from .idle import IdleReading
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
@@ -316,6 +318,26 @@ class Inverter:
|
||||
tripped=bool(data.get(self.key("delivery_tripped"), False)),
|
||||
reason=str(data.get(self.key("delivery_reason"), "") or ""))
|
||||
|
||||
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
|
||||
# take, 0 none), when that refusal or its last probe started, the
|
||||
# current probe's start, and the cycles of evidence so far and when
|
||||
# they began. Memory only, like SAFETY-52's: stop and start end it.
|
||||
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
|
||||
self.refused = 0
|
||||
self.refused_at = None
|
||||
self.probe_from = None
|
||||
self.ref_count = 0
|
||||
self.ref_since = None
|
||||
self.ref_sign = 0
|
||||
self.ref_from = None
|
||||
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
|
||||
# is centred on it. Learned, persisted, restored (app/idle.py).
|
||||
self.idle = IdleReading(data.get(self.key("idle_w")))
|
||||
|
||||
@property
|
||||
def idle_w(self) -> float:
|
||||
return self.idle.w
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
@@ -407,21 +429,31 @@ 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 refusal_note(self) -> str:
|
||||
"""SAFETY-73's status row while this unit's battery refuses."""
|
||||
if not self.refused:
|
||||
return ""
|
||||
return messages.row_battery_refuses(self.name, self.refused,
|
||||
self.probe_from is not None)
|
||||
|
||||
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:
|
||||
|
||||
@@ -34,7 +34,7 @@ simply not do.
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
|
||||
from .store import MAINT_UNIT_KEYS, unit_key
|
||||
|
||||
@@ -65,10 +65,30 @@ POWER_MOVING = (DRAIN, CHARGE, HOLD)
|
||||
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||
# it is expected to span a night; 36 h still bounds it.
|
||||
DRAIN_TIMEOUT_MIN = 720
|
||||
# MAINT-13: minutes the pack was ALLOWED to charge (the price gate open), not
|
||||
# wall-clock minutes since the charge began. See CHARGE_WALL_TIMEOUT_MIN.
|
||||
CHARGE_TIMEOUT_MIN = 2160
|
||||
# After an abort, do not immediately re-trigger in the same start hour.
|
||||
RESTART_COOLDOWN_MIN = 90
|
||||
|
||||
# MAINT-13 (DECISIONS/MAINT-13-full-pack-is-complete.md). Constants, not options.
|
||||
# A pack that reaches target - 3 and then takes no more charge for this many
|
||||
# minutes of allowed charging counts as full.
|
||||
CHARGE_NEAR_FULL_PCT = 3
|
||||
CHARGE_STALL_MIN = 60
|
||||
# Wall-clock backstop for a charge whose gate never opens (no cheap hours, no sun).
|
||||
CHARGE_WALL_TIMEOUT_MIN = 10080
|
||||
# A failed cycle (stuck drain or charge) is not retried before this many days.
|
||||
FAIL_BACKOFF_DAYS = 7
|
||||
# One tick counts at most this many minutes: a gap means the add-on was down.
|
||||
CHARGE_TICK_MAX_MIN = 5
|
||||
# The charge accounting is written to the state file at most this often.
|
||||
CHARGE_SAVE_MIN = 5
|
||||
# MAINT-14 (DECISIONS/MAINT-14-no-soc-in-maintenance.md): with no SoC reading
|
||||
# the timeouts still run, a drain holds at 0 W, and the log says once, after this
|
||||
# many minutes, that the level has been unknown for long.
|
||||
NOSOC_NOTE_MIN = 15
|
||||
|
||||
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*`
|
||||
# OPTION, and the only place in the add-on where one of these numbers is typed.
|
||||
#
|
||||
@@ -112,7 +132,9 @@ RESTART_COOLDOWN_MIN = 90
|
||||
MAINT_OPTION_DEFAULTS = {
|
||||
"maintenance_enabled": False,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_start_hour": 10,
|
||||
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
|
||||
# into the night rather than through the solar day.
|
||||
"maintenance_start_hour": 19,
|
||||
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
|
||||
# argument: both rates must stay at or below `max_w` (2000) or the
|
||||
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
|
||||
@@ -177,6 +199,69 @@ class MaintConfig:
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _load_zone(name):
|
||||
"""A tzinfo for an IANA name, or `(None, why)`."""
|
||||
if not name or not isinstance(name, str):
|
||||
return None, "none given"
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(name.strip()), ""
|
||||
except Exception as err: # noqa: BLE001 - any failure
|
||||
return None, f"{name!r} is not a known zone ({type(err).__name__})"
|
||||
|
||||
|
||||
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
|
||||
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
|
||||
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
|
||||
tz, why = _load_zone(ha_zone)
|
||||
if tz is not None:
|
||||
return tz, ha_zone, "ha", ""
|
||||
ha_why = ha_why or why
|
||||
tz, env_why = _load_zone(env_tz)
|
||||
if tz is not None:
|
||||
return tz, env_tz, "tz", ha_why
|
||||
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
|
||||
|
||||
|
||||
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
|
||||
"""Is `now` inside today's one-hour start slot, read on the local clock?
|
||||
|
||||
Normally the slot is the local hour `start_hour`. On the day the clocks go
|
||||
forward and that hour does not exist, it is the first hour after the jump.
|
||||
On the day they go back and it happens twice, only the first one counts.
|
||||
Worked from UTC instants only (`astimezone`), so it does not depend on how
|
||||
a tzinfo resolves a wall time that is missing or doubled.
|
||||
"""
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
here = now.astimezone(tz).replace(tzinfo=None)
|
||||
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
|
||||
if here.hour == start_hour:
|
||||
# the second pass through a repeated hour: an hour ago read the same
|
||||
return not (before.date() == here.date() and before.hour == start_hour)
|
||||
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
|
||||
# the clock jumped over the start of the slot within the last hour
|
||||
return before < slot and here >= slot + timedelta(hours=1)
|
||||
|
||||
|
||||
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
|
||||
"""The start of the slot `now` is in, or else of the next one; None if
|
||||
there is none within 50 h (no real zone does that)."""
|
||||
step = timedelta(minutes=15)
|
||||
t = now.replace(minute=now.minute - now.minute % 15, second=0,
|
||||
microsecond=0) - timedelta(hours=1)
|
||||
for _ in range(4 * 50):
|
||||
if (in_start_slot(t, start_hour, tz)
|
||||
and not in_start_slot(t - step, start_hour, tz)
|
||||
and t + timedelta(hours=1) > now):
|
||||
return t
|
||||
t += step
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaintResult:
|
||||
phase: str
|
||||
@@ -192,6 +277,9 @@ class MaintResult:
|
||||
# showing an idle machine and a due date in the past. Empty on every path
|
||||
# that is not deferring, so a roster of one never sees it.
|
||||
deferred: str = ""
|
||||
# MAINT-13: "drain" or "charge" on the one tick where a cycle FAILED, so
|
||||
# the Controller can tell the operator once. Empty on every other tick.
|
||||
failed: str = ""
|
||||
|
||||
|
||||
class UnitHistory:
|
||||
@@ -256,8 +344,12 @@ class Maintenance:
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
|
||||
name: str = "", mirror_flat: bool = False):
|
||||
name: str = "", mirror_flat: bool = False,
|
||||
tz: tzinfo | None = None):
|
||||
self.cfg = cfg
|
||||
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
|
||||
# once at startup (`site_zone()`). UTC when nobody says, as before.
|
||||
self.tz = tz or timezone.utc
|
||||
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
|
||||
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
|
||||
# the one place that turns it into the key this pack owns. A raw
|
||||
@@ -275,6 +367,14 @@ class Maintenance:
|
||||
# per cycle would flush the very context that explains it. Same pattern
|
||||
# as `Controller._arb_action` and `_maint_shape_suspended`.
|
||||
self._deferred = ""
|
||||
# MAINT-13: the charge accounting (see _charge_account), in memory and
|
||||
# written to the state file every CHARGE_SAVE_MIN minutes.
|
||||
self._trk = None
|
||||
self._trk_saved = None
|
||||
# MAINT-14: when the SoC reading went missing (None while it is there),
|
||||
# and whether the "unknown for long" line was said in this episode.
|
||||
self._nosoc_since = None
|
||||
self._nosoc_said = False
|
||||
|
||||
# -- state ---------------------------------------------------------------
|
||||
@property
|
||||
@@ -282,6 +382,11 @@ class Maintenance:
|
||||
return self.store.get("phase", IDLE)
|
||||
|
||||
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
|
||||
self._nosoc_since = None
|
||||
self._trk = None
|
||||
self._trk_saved = None
|
||||
if self.store.get("charge_track") is not None:
|
||||
self.store.set("charge_track", None)
|
||||
self.store.set("phase", phase)
|
||||
self.store.set_time("phase_started", now)
|
||||
_LOG.info("%s", msg)
|
||||
@@ -322,6 +427,21 @@ class Maintenance:
|
||||
return (last_try is None
|
||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
|
||||
|
||||
def backoff_until(self, now: datetime):
|
||||
"""MAINT-13: when a failed cycle may be tried again, or None.
|
||||
|
||||
None when nothing failed, when the back-off is over, or when a cycle
|
||||
completed after the failure (a forced start that worked ends it).
|
||||
"""
|
||||
failed = self.store.get_time("last_failed")
|
||||
if failed is None:
|
||||
return None
|
||||
done = self.store.get_time("last_completed")
|
||||
if done is not None and done >= failed:
|
||||
return None
|
||||
until = failed + timedelta(days=FAIL_BACKOFF_DAYS)
|
||||
return until if now < until else None
|
||||
|
||||
def wants_start(self, now: datetime) -> bool:
|
||||
"""Would this pack begin a cycle on this tick if the site let it?
|
||||
|
||||
@@ -331,8 +451,10 @@ class Maintenance:
|
||||
about who was asking - so the IDLE branch of `tick()` calls this one.
|
||||
"""
|
||||
return (self.phase == IDLE and self.cfg.enabled
|
||||
and now.hour == self.cfg.start_hour and self.due(now)
|
||||
and self._cooled(now))
|
||||
and in_start_slot(now, self.cfg.start_hour, self.tz)
|
||||
and self.due(now)
|
||||
and self._cooled(now)
|
||||
and self.backoff_until(now) is None)
|
||||
|
||||
# -- operations ----------------------------------------------------------
|
||||
def force_start(self, now: datetime, blocked_by: str = "") -> list:
|
||||
@@ -351,7 +473,7 @@ class Maintenance:
|
||||
the action log rather than watching nothing happen.
|
||||
"""
|
||||
if blocked_by:
|
||||
msg = (f"maintenance: forced start REFUSED on {self.name} - "
|
||||
msg = (f"maintenance: {self.name} cannot start now, because "
|
||||
f"{blocked_by}")
|
||||
_LOG.warning("%s", msg)
|
||||
return [msg]
|
||||
@@ -360,6 +482,140 @@ class Maintenance:
|
||||
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
||||
return events
|
||||
|
||||
def _charge_account(self, now: datetime, soc: float | None,
|
||||
gate_open: bool) -> dict:
|
||||
"""MAINT-13: what this charge has used so far, as minutes of ALLOWED time.
|
||||
|
||||
`open_min` counts only ticks with the price gate open; `flat_min` counts
|
||||
gate-open minutes since the SoC last beat its best; a closed gate pauses
|
||||
both. One tick adds at most CHARGE_TICK_MAX_MIN, so a gap (add-on down)
|
||||
counts for nothing. A state file from before MAINT-13, or from another
|
||||
charge, starts from the wall-clock minutes so far: the old behaviour.
|
||||
|
||||
MAINT-14: `soc` may be None (no reading). Time still counts, the stall
|
||||
wait pauses, and `last` keeps the last SoC seen (saved with the rest).
|
||||
"""
|
||||
since = self.store.get("phase_started")
|
||||
trk = self._trk
|
||||
if trk is None:
|
||||
raw = self.store.get("charge_track")
|
||||
if isinstance(raw, dict) and raw.get("since") == since:
|
||||
trk = dict(raw)
|
||||
else:
|
||||
trk = {"since": since, "open_min": self._elapsed_min(now),
|
||||
"flat_min": 0.0,
|
||||
"best": None if soc is None else float(soc),
|
||||
"last": None if soc is None else float(soc), "at": None}
|
||||
self._trk = trk
|
||||
at = trk.get("at")
|
||||
try:
|
||||
last = datetime.fromisoformat(at) if at else None
|
||||
except (TypeError, ValueError):
|
||||
last = None
|
||||
dt = 0.0 if last is None else min(
|
||||
CHARGE_TICK_MAX_MIN, max(0.0, (now - last).total_seconds() / 60.0))
|
||||
trk["at"] = now.isoformat()
|
||||
if soc is None:
|
||||
pass
|
||||
elif trk.get("best") is None or soc > trk["best"]:
|
||||
trk["best"], trk["flat_min"] = float(soc), 0.0
|
||||
elif gate_open:
|
||||
trk["flat_min"] += dt
|
||||
if soc is not None:
|
||||
trk["last"] = float(soc)
|
||||
if gate_open:
|
||||
trk["open_min"] += dt
|
||||
saved = self._trk_saved
|
||||
if saved is None or (now - saved).total_seconds() >= CHARGE_SAVE_MIN * 60:
|
||||
self.store.set("charge_track", dict(trk))
|
||||
self._trk_saved = now
|
||||
return trk
|
||||
|
||||
def _fail(self, now: datetime, kind: str, why: str) -> MaintResult:
|
||||
"""MAINT-13: stand down, remember the failure, and tell the Controller."""
|
||||
events = self.abort(now, why)
|
||||
self.store.set_time("last_failed", now)
|
||||
msg = (f"maintenance: the cycle did not finish. It waits "
|
||||
f"{FAIL_BACKOFF_DAYS} days before it tries again.")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
return MaintResult(IDLE, True, 0.0, False, events, failed=kind)
|
||||
|
||||
def _hold(self, now: datetime, events: list) -> MaintResult:
|
||||
"""HOLD for one tick, with or without a SoC reading (MAINT-15).
|
||||
|
||||
HOLD needs no level to end: it is reached only after the charge counted
|
||||
as done (MAINT-13/14), so its end is a time check and nothing else.
|
||||
"""
|
||||
elapsed = self._elapsed_min(now)
|
||||
if elapsed >= self.cfg.hold_min:
|
||||
self.store.set_time("last_completed", now)
|
||||
self._enter(IDLE, now, events,
|
||||
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop")
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
# 0 W, not "do nothing": commanding zero is what stops the loop
|
||||
# pulling the pack straight back down the moment it is full, which
|
||||
# is exactly when the BMS needs it parked at the top.
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
|
||||
def _no_soc(self, now: datetime, phase: str, gate_open: bool,
|
||||
events: list) -> MaintResult:
|
||||
"""MAINT-14: one tick of a pack in a moving phase with no SoC reading.
|
||||
|
||||
HOLD stays at 0 W and ends at its normal time (MAINT-15). DRAIN holds at 0 W, like HOLD, and its 12 h
|
||||
timeout still runs. CHARGE keeps its timeout and wall backstop: out of
|
||||
time is a failure, except when the last SoC seen was within
|
||||
CHARGE_NEAR_FULL_PCT of the target, which counts as done (MAINT-13).
|
||||
Nothing here may start a phase change that needs the level.
|
||||
"""
|
||||
if self._nosoc_since is None:
|
||||
self._nosoc_since = now
|
||||
self._nosoc_said = False
|
||||
self._trk_saved = None # save the last SoC seen, now
|
||||
if phase == DRAIN:
|
||||
msg = ("maintenance: the battery level is unknown, so the "
|
||||
"drain holds at 0 W until it is back.")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
if (not self._nosoc_said and (now - self._nosoc_since).total_seconds()
|
||||
>= NOSOC_NOTE_MIN * 60):
|
||||
self._nosoc_said = True
|
||||
tail = ("The hold still ends at its normal time."
|
||||
if phase == HOLD else
|
||||
f"The time limit of the {phase} still applies.")
|
||||
msg = (f"maintenance: no battery level for {NOSOC_NOTE_MIN} min. "
|
||||
f"{tail}")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
if phase == HOLD:
|
||||
return self._hold(now, events)
|
||||
elapsed = self._elapsed_min(now)
|
||||
if phase == DRAIN:
|
||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||
return self._fail(now, "drain",
|
||||
f"drain stuck, battery level unknown, after "
|
||||
f"{elapsed / 60:.0f} h")
|
||||
return MaintResult(DRAIN, True, 0.0, False, events)
|
||||
trk = self._charge_account(now, None, gate_open)
|
||||
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||
last = trk.get("last")
|
||||
if last is not None and last >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: charge time is up and the last level "
|
||||
f"seen was {last:.0f} %, close enough to full, so the "
|
||||
f"charge counts as done -> holding "
|
||||
f"{self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if elapsed > CHARGE_WALL_TIMEOUT_MIN:
|
||||
lim = f"the {CHARGE_WALL_TIMEOUT_MIN // 1440}-day limit"
|
||||
else:
|
||||
lim = f"{trk['open_min'] / 60:.0f} h of charging time"
|
||||
return self._fail(now, "charge",
|
||||
f"charge stuck, battery level unknown, after {lim}")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
def abort(self, now: datetime, why: str) -> list:
|
||||
events = []
|
||||
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
||||
@@ -378,23 +634,25 @@ class Maintenance:
|
||||
"""
|
||||
if self._deferred != why:
|
||||
self._deferred = why
|
||||
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
|
||||
f"{why}. It is idle under the control loop and will "
|
||||
f"recalibrate at the next start hour once the slot is free; "
|
||||
f"this is not a stalled phase.")
|
||||
msg = (f"maintenance: {self.name} is due and waits its turn, "
|
||||
f"because {why}. It starts at the next start hour once the "
|
||||
f"other battery is done. This is not a stalled phase.")
|
||||
_LOG.info("%s", msg)
|
||||
events.append(msg)
|
||||
return events
|
||||
|
||||
# -- the machine ---------------------------------------------------------
|
||||
def tick(self, now: datetime, soc: float | None,
|
||||
blocked_by: str = "") -> MaintResult:
|
||||
blocked_by: str = "", gate_open: bool = True) -> MaintResult:
|
||||
"""One cycle of this pack's state machine.
|
||||
|
||||
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
|
||||
site's one maintenance slot is available to it, otherwise the reason it
|
||||
is not. It gates two things and nothing else - a start, and a phase that
|
||||
is already moving power on a pack that does not hold the slot.
|
||||
|
||||
`gate_open` (MAINT-13) is whether the pack may charge now (the price
|
||||
gate); only those minutes count towards the charge timeout.
|
||||
"""
|
||||
events: list = []
|
||||
phase = self.phase
|
||||
@@ -445,13 +703,13 @@ class Maintenance:
|
||||
# transitions below mean anything, and a stale "100 %" would end the
|
||||
# charge phase having charged nothing. Hold the current phase's command
|
||||
# and wait; the phase timeout is the backstop.
|
||||
#
|
||||
# MAINT-14: "hold and wait" is not "wait for ever". The timeouts run
|
||||
# here too, and a drain holds at 0 W (an unknown level never allows
|
||||
# discharge, TEL-08). See `_no_soc`.
|
||||
if soc is None:
|
||||
_LOG.warning("maintenance: no SoC reading, holding phase %s", phase)
|
||||
if phase == DRAIN:
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
if phase == HOLD:
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
return self._no_soc(now, phase, gate_open, events)
|
||||
self._nosoc_since = None
|
||||
|
||||
elapsed = self._elapsed_min(now)
|
||||
|
||||
@@ -462,8 +720,8 @@ class Maintenance:
|
||||
f"after {elapsed:.0f} min -> charge")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||
events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
return self._fail(now, "drain", f"drain stuck at {soc:.0f} % "
|
||||
f"after {elapsed:.0f} min")
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
|
||||
if phase == CHARGE:
|
||||
@@ -472,22 +730,31 @@ class Maintenance:
|
||||
f"maintenance: charge complete at {soc:.0f} % after "
|
||||
f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if elapsed > CHARGE_TIMEOUT_MIN:
|
||||
events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
trk = self._charge_account(now, soc, gate_open)
|
||||
near = soc >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT
|
||||
# MAINT-13 rule 1: close to full and no longer taking charge.
|
||||
if near and trk["flat_min"] >= CHARGE_STALL_MIN:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: the battery takes no more charge at "
|
||||
f"{soc:.0f} %, so the charge counts as done -> holding "
|
||||
f"{self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||
# MAINT-13 rule 2: out of time close to full is complete.
|
||||
if near:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: charge time is up at {soc:.0f} %, "
|
||||
f"close enough to full, so the charge counts as "
|
||||
f"done -> holding {self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return self._fail(now, "charge",
|
||||
f"charge stuck at {soc:.0f} % after "
|
||||
f"{trk['open_min']:.0f} min of charging time")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
if phase == HOLD:
|
||||
if elapsed >= self.cfg.hold_min:
|
||||
self.store.set_time("last_completed", now)
|
||||
self._enter(IDLE, now, events,
|
||||
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop")
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
# 0 W, not "do nothing": commanding zero is what stops the loop
|
||||
# pulling the pack straight back down the moment it is full, which
|
||||
# is exactly when the BMS needs it parked at the top.
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return self._hold(now, events)
|
||||
|
||||
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
|
||||
events += self.abort(now, f"unknown phase {phase!r}")
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""OPT-11 P3: each battery's role this tick, from its mode and the price runs.
|
||||
|
||||
Pure: no I/O, no clock. main.py hands in the run plan (`priceruns`), each
|
||||
battery's state and the peak guard's answers, files each battery's range as
|
||||
its `mode` claim (P_MODE), and files the site trade as the `arbitrage` claim
|
||||
(P_ARBITRAGE), sized inside the modes (`size()`), only with battery modes on.
|
||||
With them off, `arbitrage.decide()` runs exactly as before.
|
||||
|
||||
Roles (the page's Why line names them):
|
||||
off Off: [0, 0].
|
||||
house follows the house: charges from spare solar, gives power at any
|
||||
hour (Solar first always; the other modes when there is no plan).
|
||||
hold Leftover solar / Cheap hours outside a give run: may charge from
|
||||
spare solar, gives nothing, so its energy is kept for the run.
|
||||
give the same inside a give run: covers the house.
|
||||
sell inside the sell run, with selling on: gives its rating, to the
|
||||
house first and the rest to the grid.
|
||||
charge inside the cheap run: charges from the grid.
|
||||
rest another battery charges from the grid, so this one may not charge
|
||||
(the owner's 2026-09-27 answer 3: while one battery charges from the
|
||||
grid the others cannot give power either; the allocator never
|
||||
splits a target both ways).
|
||||
|
||||
Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
* every range holds 0 W (a mode may forbid a direction, never force one);
|
||||
* a Solar first battery never charges from the grid;
|
||||
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
||||
the grid only up to its planned level, never above the ceiling; with
|
||||
none it takes solar only (P3 option A). Cheap hours only fills to the
|
||||
ceiling either way (`charge_to()`);
|
||||
* grid charging needs a measured peak guard that is clear and a measured
|
||||
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
|
||||
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
||||
more, and once the guard or a measured headroom stops it, it stays
|
||||
stopped for the rest of that 15-minute period (`blocked`): a charge
|
||||
makes the other batteries stop giving power, so a few watts of charge
|
||||
flipping on and off would swing the house between them and the grid
|
||||
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||
it must only have measured the quarter;
|
||||
* a battery out of a run (full, or at the floor) stays out until the run
|
||||
ends, so the ceiling and the floor do not re-arm within one run;
|
||||
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
|
||||
run in that direction: the refusal wins over the run. The one exception
|
||||
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
|
||||
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
|
||||
card's "tries again every 15 minutes" holds in the run too;
|
||||
* OPT-20: a probe asks PROBE_W for the probing battery and nothing more.
|
||||
The rest of the ask is what the other chargers would have had without
|
||||
the probe (none: PROBE_W alone), so a probe never places charge on a
|
||||
battery in a maintenance phase or on one that rests;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .control import PROBE_W
|
||||
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
|
||||
PLAN_CHARGE_MODES, SOLAR_FIRST)
|
||||
|
||||
OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = (
|
||||
"off", "house", "hold", "give", "sell", "charge", "rest")
|
||||
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
|
||||
# The smallest grid charge worth starting, in W. Under it the other batteries
|
||||
# would stop covering the house for less than they give.
|
||||
START_W = 500.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pack:
|
||||
"""One battery as this module needs it."""
|
||||
name: str
|
||||
use: bool
|
||||
mode: str
|
||||
sell: bool
|
||||
rating_w: float
|
||||
soc: float | None
|
||||
capacity_kwh: float | None
|
||||
live: bool = True
|
||||
idle: bool = True # not in a maintenance phase
|
||||
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
|
||||
probing: bool = False # OPT-15: its SAFETY-73 probe window is open
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tick:
|
||||
ranges: dict # name -> (lo, hi), only for idle batteries
|
||||
roles: dict # name -> role
|
||||
action: str | None # "charge" | "sell" | None
|
||||
ask_w: float # signed site ask before sizing (0 if none)
|
||||
why: str | None # why no trade when a run is on, else None
|
||||
probe_w: float = 0.0 # OPT-20: the part of `ask_w` that is a probe
|
||||
|
||||
|
||||
def _room_kwh(p: Pack, pct: float) -> float:
|
||||
"""kWh to `pct` (charging) or above it (selling); a battery with no
|
||||
capacity set counts one hour at its rating, so a run is still sized."""
|
||||
if p.capacity_kwh is None or p.soc is None:
|
||||
return abs(p.rating_w) / 1000.0
|
||||
return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh)
|
||||
|
||||
|
||||
def charge_to(p: Pack, ceiling_pct, targets=None):
|
||||
"""The level this battery may charge to from the grid, or None.
|
||||
|
||||
`targets` is the day plan (`dayplan.plan()`: name -> level), or None
|
||||
when there is none (no forecast, stale, or not configured). With no plan
|
||||
this is P3 option A exactly: Cheap hours to the ceiling, the others never.
|
||||
With a plan, a Leftover solar battery charges only to its planned level
|
||||
(the sun fills the rest); a battery the plan leaves out, Cheap hours only
|
||||
included, gets the no-plan answer. Solar first never charges."""
|
||||
if p.mode in GRID_CHARGE_MODES:
|
||||
return float(ceiling_pct)
|
||||
if targets is None or p.name not in targets \
|
||||
or p.mode not in PLAN_CHARGE_MODES:
|
||||
return None
|
||||
t = targets[p.name]
|
||||
return None if t is None else min(float(ceiling_pct), float(t))
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None, probe=False) -> list:
|
||||
"""Batteries that may charge from the grid in a cheap run: below the
|
||||
level `charge_to()` allows them (P4: the day plan's). With `probe`, also
|
||||
one refusing to charge while its SAFETY-73 probe is open: its own P_SAFETY
|
||||
pin keeps it at PROBE_W (OPT-15)."""
|
||||
out = []
|
||||
for p in packs:
|
||||
to = charge_to(p, ceiling_pct, targets)
|
||||
if (to is not None and p.use and p.idle and p.live
|
||||
and p.soc is not None and p.soc < to
|
||||
and (p.refused >= 0 or (probe and p.probing))
|
||||
and p.name not in done):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def sellers(packs, floor_pct, done=()) -> list:
|
||||
"""Batteries that may sell in the sell run. Solar first never sells."""
|
||||
return [p for p in packs
|
||||
if p.use and p.idle and p.live and p.sell
|
||||
and p.mode in PRICE_MODES
|
||||
and p.soc is not None and p.soc > floor_pct
|
||||
and p.refused <= 0 and p.name not in done]
|
||||
|
||||
|
||||
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
|
||||
targets=None):
|
||||
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`.
|
||||
With a day plan the charge is each battery's shortfall, so the cheap run
|
||||
is only as long as the shortfall needs."""
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets)
|
||||
se = sellers(packs, floor_pct, done_sell)
|
||||
return (sum(_room_kwh(p, charge_to(p, ceiling_pct, targets))
|
||||
for p in ch),
|
||||
sum(abs(p.rating_w) for p in ch) / 1000.0,
|
||||
sum(_room_kwh(p, floor_pct) for p in se),
|
||||
sum(abs(p.rating_w) for p in se) / 1000.0)
|
||||
|
||||
|
||||
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
||||
blocked=False, done_charge=(), done_sell=(),
|
||||
targets=None, draining=False) -> Tick:
|
||||
"""This tick's ranges, roles and site ask.
|
||||
|
||||
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
||||
`charge_ok` is True only when `TariffPlan.arbitrage()` allowed a trade
|
||||
this tick with the MEASURED peak guard clear; `sell_ok` when it allowed
|
||||
one with the guard measured (either state); else `trade_why` says why.
|
||||
`headroom_w` is `peakguard.charge_headroom_w()` (None: unmeasured).
|
||||
`house_w` is the measured house (grid + batteries), or None.
|
||||
`charging_now` says last tick charged (then any headroom above 0 keeps
|
||||
it going); `blocked` that the guard or the headroom stopped a charge in
|
||||
this 15-minute period. `targets` is the day plan (`charge_to()`).
|
||||
`draining` says a battery drains for its monthly cycle, so none may
|
||||
charge from the grid (why "cycle").
|
||||
"""
|
||||
plan_on = runs is not None and runs.on
|
||||
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
||||
in_give = plan_on and runs.give_at(now) is not None
|
||||
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
|
||||
|
||||
why = None
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \
|
||||
if in_cheap else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
charging = False
|
||||
if ch:
|
||||
need = 0.0 if charging_now else START_W
|
||||
if blocked:
|
||||
why = "headroom"
|
||||
elif not charge_ok:
|
||||
why = trade_why
|
||||
elif headroom_w is None or not headroom_w > need:
|
||||
why = "headroom"
|
||||
else:
|
||||
charging = True
|
||||
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
|
||||
else []
|
||||
selling = False
|
||||
if se:
|
||||
if not sell_ok:
|
||||
why = why or trade_why
|
||||
elif price_now is None or price_now < 0.0 or house_w is None:
|
||||
why = why or "negative"
|
||||
else:
|
||||
selling = True
|
||||
|
||||
ranges, roles = {}, {}
|
||||
ch_names = {p.name for p in ch} if charging else set()
|
||||
se_names = {p.name for p in se} if selling else set()
|
||||
for p in packs:
|
||||
r = abs(float(p.rating_w))
|
||||
if not p.use:
|
||||
role, lo, hi = OFF, 0.0, 0.0
|
||||
elif p.name in ch_names:
|
||||
role, lo, hi = CHARGE, -r, 0.0
|
||||
elif charging:
|
||||
# Another battery charges from the grid: this one may not charge
|
||||
# (it would be grid charging too), and cannot give (one sign).
|
||||
role, lo, hi = REST, 0.0, (r if p.mode == SOLAR_FIRST
|
||||
or not plan_on or in_give else 0.0)
|
||||
elif p.name in se_names:
|
||||
role, lo, hi = SELL, -r, r
|
||||
elif p.mode == SOLAR_FIRST or not plan_on:
|
||||
role, lo, hi = HOUSE, -r, r
|
||||
elif in_give:
|
||||
role, lo, hi = GIVE, -r, r
|
||||
else:
|
||||
role, lo, hi = HOLD, -r, 0.0
|
||||
roles[p.name] = role
|
||||
if p.idle:
|
||||
ranges[p.name] = (lo, hi)
|
||||
|
||||
if charging:
|
||||
# OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
|
||||
# there); only the batteries that charge without a probe are asked
|
||||
# for their rating.
|
||||
probe_w = PROBE_W * sum(1 for p in ch if p.refused < 0)
|
||||
ask = -sum(abs(p.rating_w) for p in ch if p.refused >= 0) - probe_w
|
||||
return Tick(ranges, roles, CHARGE, ask, None, probe_w)
|
||||
if selling:
|
||||
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
|
||||
return Tick(ranges, roles, SELL, ask, None)
|
||||
return Tick(ranges, roles, None, 0.0,
|
||||
why if (in_cheap or in_sell) else None)
|
||||
|
||||
|
||||
def size(tick: Tick, bound, headroom_w) -> float:
|
||||
"""The site trade in W, inside what the batteries may place with their
|
||||
modes (`bound` = main's loop bound) and, for a charge, the billed
|
||||
quarter's headroom. 0.0 means no trade (a 0 W trade is a park order and
|
||||
is never filed)."""
|
||||
if tick.action is None or bound is None:
|
||||
return 0.0
|
||||
lo, hi = bound
|
||||
if tick.action == CHARGE:
|
||||
room = float(headroom_w) if headroom_w is not None else 0.0
|
||||
pw = float(tick.probe_w)
|
||||
if pw > 0.0:
|
||||
# OPT-20: the others' share is sized as without the probe; the
|
||||
# probe's PROBE_W is added on top (the grid rises by PROBE_W at
|
||||
# most), and alone it is sized by the bound and the headroom.
|
||||
rest = max(0.0, min(-float(tick.ask_w) - pw, -float(lo) - pw,
|
||||
room))
|
||||
if rest > 0.0:
|
||||
return -(rest + pw)
|
||||
# PROBE_W exactly or no trade: less would void the pin and hand
|
||||
# the remainder to whichever battery the allocator can reach.
|
||||
return -pw if min(-float(lo), room) >= pw else 0.0
|
||||
cap = min(-float(tick.ask_w), -float(lo), room)
|
||||
return -max(0.0, cap)
|
||||
return max(0.0, min(float(tick.ask_w), float(hi)))
|
||||
|
||||
|
||||
def weights(roles: dict, base, target_w: float):
|
||||
"""Who carries the site target first (design 3.5). Selling: the sellers.
|
||||
Giving power: batteries in a give run before Solar first. Charging from
|
||||
the grid: only the chargers. Otherwise `base` unchanged. Comfort only;
|
||||
the ranges above are the permission."""
|
||||
names = sorted(roles)
|
||||
first = None
|
||||
if target_w > 0 and any(r == SELL for r in roles.values()):
|
||||
first = {n for n in names if roles[n] == SELL}
|
||||
elif target_w > 0 and any(r == GIVE for r in roles.values()):
|
||||
first = {n for n in names if roles[n] in (GIVE, SELL)}
|
||||
elif target_w < 0 and any(r == CHARGE for r in roles.values()):
|
||||
first = {n for n in names if roles[n] == CHARGE}
|
||||
if not first:
|
||||
return base
|
||||
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
|
||||
out = {n: (w[n] if n in first else 0.0) for n in names}
|
||||
return out if any(v > 0.0 for v in out.values()) else base
|
||||
@@ -7,8 +7,10 @@ That separation is deliberate; observability must never be able to take down
|
||||
control.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
@@ -375,7 +377,26 @@ def _payload(name, object_id, unique_id, state_topic, device,
|
||||
return cfg
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
def unit_unique_id(slug: str, key: str) -> str:
|
||||
"""One unit's entity `unique_id` (also its config topic's middle part)."""
|
||||
return f"{BASE}_unit_{slug}_{key}"
|
||||
|
||||
|
||||
def _unit_places(names):
|
||||
"""`(slug, device, prefix)` for each roster name, in order."""
|
||||
slugs = unit_slugs(names)
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
yield slug, device, prefix
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=(), include=(), modes=()) -> list:
|
||||
"""Every `(topic, payload)` this add-on announces, in announcement order.
|
||||
|
||||
Split out of `_announce()` so that the acceptance criterion can be graded as
|
||||
@@ -389,6 +410,8 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
|
||||
ordered LIST precisely because a re-order is a zero-byte edit that
|
||||
re-announces every entity on a live install).
|
||||
|
||||
`modes` (OPT-11 P2) is the battery modes offered, empty when they are off.
|
||||
"""
|
||||
omit = set(omit)
|
||||
include = [k for k in include if k not in omit]
|
||||
@@ -400,19 +423,10 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
names = announced_units(units)
|
||||
slugs = unit_slugs(names)
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` below can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
for slug, device, prefix in _unit_places(announced_units(units)):
|
||||
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
|
||||
unique_id = f"{BASE}_unit_{slug}_{key}"
|
||||
unique_id = unit_unique_id(slug, key)
|
||||
out.append((
|
||||
f"homeassistant/sensor/{unique_id}/config",
|
||||
_payload(prefix + label,
|
||||
@@ -427,12 +441,135 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
# OPT-11 P2: last, and only when battery modes are on.
|
||||
out.extend(mode_payloads(units, modes))
|
||||
return out
|
||||
|
||||
|
||||
# --- OPT-11 P2: each battery's switch and mode, as HA entities ---------------
|
||||
# Announced only when battery modes are on, after every other row, so no
|
||||
# existing row moves. A command goes through Controller.handle_action(), the
|
||||
# page's /action path. DECISIONS/OPT-11-battery-modes.md has the record.
|
||||
#
|
||||
# The select's options are these labels, in `packmode.MODES` order. A new mode
|
||||
# (P3) is a new entry here and a new option at the end; no id changes.
|
||||
MODE_LABELS = {"solar_first": "Solar first", "leftover_solar": "Leftover solar",
|
||||
"cheap_hours": "Cheap hours only"} # OPT-11 P3
|
||||
USE_ON, USE_OFF = "ON", "OFF"
|
||||
# A longer command payload is refused before it is decoded.
|
||||
MAX_COMMAND_BYTES = 64
|
||||
# The store key holding the mode config topics last announced, so that turning
|
||||
# modes off can remove exactly those (an empty retained config) and an install
|
||||
# that never turned them on publishes nothing new.
|
||||
MODE_CONFIGS_KEY = "mqtt_mode_configs"
|
||||
_MODE_CONFIG_RE = re.compile(
|
||||
rf"homeassistant/(select|switch)/{BASE}_unit_[a-z0-9_]+_(mode|use)/config")
|
||||
_COMMAND_RE = re.compile(rf"{BASE}/unit/([a-z0-9_]+)/(mode|use)/set")
|
||||
# (entity key, HA component, entity name, icon)
|
||||
MODE_ENTITIES = [
|
||||
("mode", "select", "Mode", "mdi:battery-sync"),
|
||||
("use", "switch", "Use", "mdi:power"),
|
||||
]
|
||||
|
||||
|
||||
def mode_payloads(units, modes) -> list:
|
||||
"""The `(topic, payload)` for each battery's select and switch.
|
||||
|
||||
Every roster entry gets them, also at a roster of one. The device rule is
|
||||
TEL-07's: the first battery's rows go on the site device with its name in
|
||||
front, each later battery's on its own device. Empty when `modes` is empty.
|
||||
"""
|
||||
if not modes:
|
||||
return []
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
options = [MODE_LABELS[m] for m in modes]
|
||||
out = []
|
||||
for slug, device, prefix in _unit_places([str(u) for u in units]):
|
||||
for key, component, label, icon in MODE_ENTITIES:
|
||||
unique_id = unit_unique_id(slug, key)
|
||||
state = f"{BASE}/{unit_state_key(slug, key)}"
|
||||
cfg = {
|
||||
"name": prefix + label,
|
||||
"object_id": f"{site_slug}_{slug}_{key}",
|
||||
"unique_id": unique_id,
|
||||
"state_topic": state,
|
||||
"command_topic": f"{state}/set",
|
||||
}
|
||||
if component == "select":
|
||||
cfg["options"] = options
|
||||
else:
|
||||
cfg["payload_on"] = USE_ON
|
||||
cfg["payload_off"] = USE_OFF
|
||||
# HA sends commands unretained; a retained one is ignored anyway.
|
||||
cfg["retain"] = False
|
||||
cfg["availability_topic"] = AVAILABILITY
|
||||
cfg["device"] = device
|
||||
cfg["icon"] = icon
|
||||
out.append((f"homeassistant/{component}/{unique_id}/config", cfg))
|
||||
return out
|
||||
|
||||
|
||||
def mode_retractions(stored, current) -> list:
|
||||
"""Config topics announced before and not now: each gets an empty
|
||||
retained config, which removes the entity from HA. Anything in the store
|
||||
that is not one of our mode config topics is ignored."""
|
||||
if not isinstance(stored, list):
|
||||
return []
|
||||
keep = set(current)
|
||||
return list(dict.fromkeys(t for t in stored if isinstance(t, str)
|
||||
and _MODE_CONFIG_RE.fullmatch(t)
|
||||
and t not in keep))
|
||||
|
||||
|
||||
def mode_configs(units, modes, stored) -> tuple:
|
||||
"""`(keep, retract)` at start-up: the mode config topics to store, and the
|
||||
ones to remove now. A removal stays in `keep`, so a start with no broker
|
||||
does not forget it; resending an empty config is harmless."""
|
||||
current = [t for t, _ in mode_payloads(units, modes)]
|
||||
retract = mode_retractions(stored, current)
|
||||
return current + retract, retract
|
||||
|
||||
|
||||
def parse_command(topic, payload, retained, modes):
|
||||
"""`(slug, action, value, reject)` for one message on a command topic.
|
||||
|
||||
`action` is `pack_use` or `pack_mode`. `value` is what /action would be
|
||||
sent: a bool for the switch, a mode key for the select, or None for a
|
||||
payload that is neither (the store path then refuses it). `reject` is a
|
||||
reason when the message is dropped here: replayed by the broker (retained),
|
||||
too long, not UTF-8, or not a command topic.
|
||||
"""
|
||||
m = _COMMAND_RE.fullmatch(topic) if isinstance(topic, str) else None
|
||||
if m is None:
|
||||
return None, None, None, "topic"
|
||||
slug, key = m.group(1), m.group(2)
|
||||
action = "pack_use" if key == "use" else "pack_mode"
|
||||
if retained:
|
||||
return slug, action, None, "retained"
|
||||
if not isinstance(payload, (bytes, bytearray)) \
|
||||
or len(payload) > MAX_COMMAND_BYTES:
|
||||
return slug, action, None, "size"
|
||||
try:
|
||||
text = bytes(payload).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return slug, action, None, "encoding"
|
||||
if key == "use":
|
||||
value = {USE_ON: True, USE_OFF: False}.get(text)
|
||||
else:
|
||||
value = next((k for k in modes if MODE_LABELS.get(k) == text), None)
|
||||
return slug, action, value, None
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
# OPT-11 P2. Class defaults, so a publisher built without them (and every
|
||||
# test fixture that builds one with __new__) behaves as before.
|
||||
modes = ()
|
||||
retract = ()
|
||||
_loop = None
|
||||
_handler = None
|
||||
|
||||
def __init__(self, host, port, username=None, password=None, omit=(),
|
||||
units=(), include=()):
|
||||
units=(), include=(), modes=(), retract=()):
|
||||
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||
# sensor that nothing will ever publish to is not harmless here:
|
||||
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||
@@ -448,6 +585,10 @@ class MqttPublisher:
|
||||
# second reading of the option. Defaults to empty, so a caller that has
|
||||
# not been taught about it announces exactly what it announced before.
|
||||
self.include = [str(k) for k in include]
|
||||
# OPT-11 P2: the modes offered (empty when battery modes are off), and
|
||||
# the mode config topics to remove (`mode_retractions()`).
|
||||
self.modes = tuple(modes)
|
||||
self.retract = [str(t) for t in retract]
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -470,7 +611,8 @@ class MqttPublisher:
|
||||
# paho DROPS QoS-0 publishes when disconnected, silently. The result is
|
||||
# an add-on that logs "MQTT connected" and creates no entities at all.
|
||||
# As a bonus, this also re-announces after every reconnect.
|
||||
self.client.on_connect = lambda *_args, **_kw: self._announce()
|
||||
self.client.on_connect = lambda *_args, **_kw: self._on_connect()
|
||||
self.client.on_message = self._on_message
|
||||
try:
|
||||
self.client.connect(host, int(port), keepalive=60)
|
||||
self.client.loop_start()
|
||||
@@ -485,10 +627,35 @@ class MqttPublisher:
|
||||
# diffing what actually goes on the wire - and a payload built here
|
||||
# instead would be one this add-on publishes and no check has ever seen.
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit,
|
||||
self.include):
|
||||
self.include, self.modes):
|
||||
self.client.publish(topic, json.dumps(cfg), retain=True)
|
||||
for topic in self.retract:
|
||||
self.client.publish(topic, "", retain=True)
|
||||
self.client.publish(AVAILABILITY, "online", retain=True)
|
||||
|
||||
def _on_connect(self) -> None:
|
||||
self._announce()
|
||||
if self.modes:
|
||||
for key in ("mode", "use"):
|
||||
self.client.subscribe(f"{BASE}/unit/+/{key}/set")
|
||||
|
||||
def bind(self, loop, handler) -> None:
|
||||
"""OPT-11 P2: hand each command to `handler(topic, payload, retained)`,
|
||||
a coroutine function run on `loop` (the controller's)."""
|
||||
self._loop, self._handler = loop, handler
|
||||
|
||||
def _on_message(self, _client, _userdata, msg) -> None:
|
||||
# paho's network thread: nothing is done here but the handoff.
|
||||
loop, handler = self._loop, self._handler
|
||||
if loop is None or handler is None:
|
||||
_LOG.debug("MQTT command before start-up ignored: %s", msg.topic)
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
handler(msg.topic, bytes(msg.payload), bool(msg.retain)), loop)
|
||||
except RuntimeError as err: # the loop has closed
|
||||
_LOG.debug("MQTT command dropped: %s", err)
|
||||
|
||||
def publish(self, values: dict) -> None:
|
||||
if not self.enabled or self.client is None:
|
||||
return
|
||||
|
||||
@@ -47,6 +47,11 @@ from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
from .hass import OUTAGE
|
||||
|
||||
_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 +185,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 +194,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)
|
||||
@@ -306,6 +312,19 @@ class QuarterAverager:
|
||||
"""
|
||||
return self._last_net
|
||||
|
||||
@property
|
||||
def held_t(self) -> float | None:
|
||||
"""Epoch seconds of the held sample. OPT-15: `PeakTracker.project()`
|
||||
with `roll=True` credits it into the next block as `add()` will."""
|
||||
return self._last_t
|
||||
|
||||
@property
|
||||
def held_since(self) -> datetime | None:
|
||||
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
|
||||
if self._last_t is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(self._last_t, timezone.utc)
|
||||
|
||||
@property
|
||||
def offtake_avg_w(self) -> float:
|
||||
"""Average offtake over the part of the open block seen so far."""
|
||||
@@ -467,7 +486,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:
|
||||
@@ -519,6 +538,16 @@ WS_URL = "ws://supervisor/core/websocket"
|
||||
BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
|
||||
|
||||
async def _receive_json(ws):
|
||||
"""The next JSON message. A socket Home Assistant closed (a restart) is a
|
||||
ConnectionResetError, not aiohttp's "Received message 8:1000 is not str"."""
|
||||
msg = await ws.receive()
|
||||
if msg.type is not aiohttp.WSMsgType.TEXT:
|
||||
raise ConnectionResetError(
|
||||
f"Home Assistant closed the connection ({msg.type.name})")
|
||||
return json.loads(msg.data)
|
||||
|
||||
|
||||
class HaDsmrSource:
|
||||
"""Subscribes to state_changed for the configured DSMR entities.
|
||||
|
||||
@@ -569,8 +598,12 @@ class HaDsmrSource:
|
||||
backoff = 1.0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (aiohttp.ClientError, ConnectionError) as err:
|
||||
# UX-10: Home Assistant restarting, said once (hass.Outage).
|
||||
OUTAGE.down("websocket", "the meter connection closed", err)
|
||||
except Exception as err: # noqa: BLE001 - any transport fault retries
|
||||
_LOG.warning("P1 HA websocket: %s - reconnecting in %.0fs", err, backoff)
|
||||
_LOG.warning("The meter connection to Home Assistant failed "
|
||||
"(%s), trying again in %.0f s.", err, backoff)
|
||||
finally:
|
||||
self.connected = False
|
||||
await asyncio.sleep(backoff)
|
||||
@@ -578,16 +611,17 @@ class HaDsmrSource:
|
||||
|
||||
async def _session_once(self) -> None:
|
||||
async with self.session.ws_connect(WS_URL, heartbeat=30) as ws:
|
||||
hello = await ws.receive_json()
|
||||
hello = await _receive_json(ws)
|
||||
if hello.get("type") == "auth_required":
|
||||
await ws.send_json({"type": "auth", "access_token": self.token})
|
||||
reply = await ws.receive_json()
|
||||
reply = await _receive_json(ws)
|
||||
if reply.get("type") != "auth_ok":
|
||||
raise RuntimeError(f"auth rejected: {reply.get('message', reply)}")
|
||||
await ws.send_json({"id": 1, "type": "subscribe_events",
|
||||
"event_type": "state_changed"})
|
||||
await ws.send_json({"id": 2, "type": "get_states"})
|
||||
self.connected = True
|
||||
OUTAGE.up("websocket")
|
||||
_LOG.info("P1 ingest: subscribed to %s", ", ".join(sorted(self.ids)))
|
||||
|
||||
async for msg in ws:
|
||||
@@ -613,7 +647,7 @@ class HaDsmrSource:
|
||||
new = data.get("new_state") or {}
|
||||
self._absorb(data.get("entity_id"), new.get("state"))
|
||||
self._schedule()
|
||||
raise RuntimeError("websocket closed")
|
||||
raise ConnectionResetError("Home Assistant closed the connection")
|
||||
|
||||
def _absorb(self, entity_id: str | None, state) -> None:
|
||||
if not entity_id or entity_id not in self.ids:
|
||||
@@ -1160,37 +1194,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 +1232,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 +1298,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:
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""OPT-11: each battery's mode and on/off switch, as limits and weights.
|
||||
|
||||
Pure: no I/O, no clock. main.py reads the store, calls these, and files the
|
||||
result as a `mode` claim (arbiter.P_MODE) and as the charge weights.
|
||||
DECISIONS/OPT-11-battery-modes.md has the record; OPT-11-design section 1 has
|
||||
the modes.
|
||||
|
||||
Two rules hold for every mode, and they are what keeps modes safe:
|
||||
|
||||
1. A mode range always holds 0 W. A mode may forbid a direction, never force
|
||||
one, so batteries never move power in opposite directions because of a mode
|
||||
(allocator.unit_interval() drops a range without 0 as a software fault).
|
||||
2. A mode is a limit, not a weight. A weight of 0 does not keep a battery out:
|
||||
allocator._fill()'s last-resort pass still uses it. So "may not" is a range,
|
||||
and weights only set the order among batteries that may.
|
||||
|
||||
`mode_range()` is the switch alone (Off is [0, 0]). P3 adds the price runs:
|
||||
`modetrade.decide()` narrows each battery's range by its role this tick
|
||||
(hold for the expensive hours, charge from the grid, sell), and main.py files
|
||||
the intersection. Selling is a per-battery choice (`pack_sell`, the owner's
|
||||
2026-09-27 answer), for Leftover solar and Cheap hours only.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .store import unit_key
|
||||
|
||||
SOLAR_FIRST = "solar_first"
|
||||
LEFTOVER_SOLAR = "leftover_solar"
|
||||
CHEAP_HOURS = "cheap_hours" # OPT-11 P3
|
||||
|
||||
# The modes a battery may be set to, in the order the page and the HA select
|
||||
# list them. The first is the default when the store has no value. A new mode
|
||||
# is appended, never inserted: automations send the HA labels (P2).
|
||||
MODES = (SOLAR_FIRST, LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
DEFAULT_MODE = SOLAR_FIRST
|
||||
|
||||
# Store keys, per battery: `unit/<name>/<key>` (store.unit_key()).
|
||||
USE_KEY = "pack_use"
|
||||
MODE_KEY = "pack_mode"
|
||||
SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run
|
||||
|
||||
# Owner decision 2026-09-29 (option A): selling is off until it can be judged
|
||||
# on the price the grid pays. While False, `read()` answers `sell=False`
|
||||
# whatever the store holds, the page shows no switch and `pack_sell` is
|
||||
# refused; the sell code in modetrade/priceruns stays, unreachable.
|
||||
SELLING_ENABLED = False
|
||||
|
||||
# The modes that may charge from the grid in a cheap run with no day plan.
|
||||
# Leftover solar takes solar only then (owner decision 2026-09-29, option A).
|
||||
GRID_CHARGE_MODES = (CHEAP_HOURS,)
|
||||
# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) lets
|
||||
# Leftover solar charge from the grid, by the shortfall only. Cheap hours only
|
||||
# ignores the plan (fills to the ceiling); Solar first never grid-charges.
|
||||
PLAN_CHARGE_MODES = (LEFTOVER_SOLAR,)
|
||||
|
||||
|
||||
def enabled(opts) -> bool:
|
||||
"""Battery modes are on only when the option is exactly true. One answer,
|
||||
read by the controller and by the MQTT announcement."""
|
||||
return opts.get("battery_modes_enabled") is True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackMode:
|
||||
"""One battery's choice: on or off, its mode, and selling."""
|
||||
use: bool = True
|
||||
mode: str = DEFAULT_MODE
|
||||
sell: bool = False
|
||||
|
||||
@property
|
||||
def can_sell(self) -> bool:
|
||||
"""Selling applies to the price modes only; Solar first never sells.
|
||||
False for every battery while SELLING_ENABLED is False."""
|
||||
return SELLING_ENABLED and self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
"""The page's word for this state: a mode, or `off`."""
|
||||
return self.mode if self.use else "off"
|
||||
|
||||
|
||||
def read(data: dict, name: str) -> PackMode:
|
||||
"""Battery `name`'s choice from the store's data. A missing or unknown
|
||||
value falls back to the default (on, Solar first), never to Off: a store
|
||||
written by a later version must not switch a battery off here."""
|
||||
use = data.get(unit_key(name, USE_KEY), True)
|
||||
mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE)
|
||||
return PackMode(use=use is not False,
|
||||
mode=mode if mode in MODES else DEFAULT_MODE,
|
||||
sell=SELLING_ENABLED
|
||||
and data.get(unit_key(name, SELL_KEY)) is True)
|
||||
|
||||
|
||||
def mode_range(pm: PackMode, rating_w: float) -> tuple:
|
||||
"""`(lo, hi)` in W for this battery's switch. Always holds 0 W.
|
||||
|
||||
Off asks for 0 W both ways; On allows the rating both ways. main.py files
|
||||
this intersected with the role range from `modetrade.decide()`.
|
||||
"""
|
||||
if not pm.use:
|
||||
return 0.0, 0.0
|
||||
r = abs(float(rating_w))
|
||||
return -r, r
|
||||
|
||||
|
||||
def charge_weights(base, modes: dict, target_w: float):
|
||||
"""The allocator's weights with the solar order applied (design 3.5).
|
||||
|
||||
`base` is packshare.weights()' answer (None for "all 1.0"). While the site
|
||||
charges (`target_w < 0`), a Leftover solar battery weighs 0 and a Solar
|
||||
first battery keeps its weight, so the fill gives the Solar first batteries
|
||||
everything they can take before the last-resort pass reaches the others.
|
||||
Giving power keeps `base`; `modetrade.weights()` adds the run order.
|
||||
|
||||
When no battery that is on would keep a positive weight (all Leftover
|
||||
solar, or every Solar first one at 0), `base` is returned unchanged, so the
|
||||
order among equals stays OPT-09's rather than an equal split.
|
||||
"""
|
||||
if float(target_w) >= 0.0 or not modes:
|
||||
return base
|
||||
names = sorted(modes)
|
||||
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
|
||||
out = {n: (0.0 if modes[n].mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
else w[n])
|
||||
for n in names}
|
||||
if not any(out[n] > 0.0 for n in names if modes[n].use):
|
||||
return base
|
||||
return out
|
||||
@@ -143,6 +143,10 @@ because the money is spent on `max(month's peak so far, this quarter)`:
|
||||
|
||||
ceiling_w = max(peak_cap_w, the month's banked peak)
|
||||
|
||||
SAFETY-79: the guard AIMS `peak_margin_w` under that ceiling
|
||||
(`quarter_target_w()`), so a quarter that lands a little high still stays under
|
||||
it. `ceiling_w` stays the real limit for reports and the bill.
|
||||
|
||||
Once a month's peak already stands at 5013 W, holding every later quarter to
|
||||
3500 W buys nothing - the bill for that month is already 5013 W - while the
|
||||
holding itself costs real charging the site wanted. Raising the ceiling to a peak
|
||||
@@ -179,6 +183,8 @@ guard passes its own tests while being switched off:
|
||||
record). Those seconds make the projection read LOW, which is the
|
||||
under-guarding direction. The threshold is `max_hold_s` itself - the point
|
||||
p1.py already stops trusting a held sample - not a new number.
|
||||
SAFETY-90: once readings are back, main.py counts those seconds
|
||||
(`fill_gaps()`), so this holds only while the gap lasts.
|
||||
|
||||
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
|
||||
rather than re-written here. A frozen meter is the worst case of all: the
|
||||
@@ -393,35 +399,36 @@ function, so whichever way the owner answers, this file does not change and does
|
||||
not need re-testing. Nothing here indexes a phase.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from . import messages
|
||||
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
||||
from .control import peak_at_risk
|
||||
from .tariff import QUARTER_S
|
||||
|
||||
# Why the projection may not be graded. Reasons, not booleans: the status page
|
||||
# and the action log both have to say WHICH, and "blind" alone is unactionable.
|
||||
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
|
||||
BLIND_NO_BLOCK = "no open 15-minute period, the meter has given no reading yet"
|
||||
BLIND_COVERAGE = "meter gap"
|
||||
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
||||
BLIND_FROZEN = "the meter is answering without changing"
|
||||
|
||||
# Where the ceiling came from. Only the second is a relaxation, and only an
|
||||
# OBSERVED reading may produce it.
|
||||
CEILING_CAP = "peak_cap_w"
|
||||
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
CEILING_MONTH_PEAK = "this month's peak"
|
||||
|
||||
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
|
||||
# for BLIND_*'s reason: the status page, the action log and the operator
|
||||
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
|
||||
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
|
||||
"cycle, and an unreadable SoC is not a full battery")
|
||||
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
|
||||
# possessive is load-bearing: this string is what the status page, the action log
|
||||
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
|
||||
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
|
||||
# reads at the moment the battery stops shaving into a claim about the pack that
|
||||
# the grid-following loop is at that instant disproving.
|
||||
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
|
||||
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
|
||||
"unread is not full")
|
||||
# ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
|
||||
# load-bearing: this string is what the action log and the add-on log print,
|
||||
# and `peak_soc_floor` (“Peak reserve”) bounds THIS GUARD ONLY. Widening it to
|
||||
# "the SoC floor" would make the one sentence an operator reads at the moment
|
||||
# the battery stops shaving into a claim about the pack that the
|
||||
# grid-following loop is at that instant disproving.
|
||||
FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
|
||||
|
||||
SOURCE = "peak"
|
||||
|
||||
@@ -452,7 +459,13 @@ PEAK_OPTION_DEFAULTS = {
|
||||
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
|
||||
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
|
||||
"bms_soc_floor": 10,
|
||||
# SAFETY-79. Watts the guard aims under `peak_cap_w`, so a quarter that
|
||||
# lands a little high still stays under the cap. Owner decision 2026-09-28.
|
||||
"peak_margin_w": 50,
|
||||
}
|
||||
# SAFETY-79. config.yaml's schema range for `peak_margin_w`, mirrored so
|
||||
# validate.py can refuse it in words; test_peakguard.py pins it to the file.
|
||||
PEAK_MARGIN_RANGE_W = (0, 500)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -473,29 +486,49 @@ class PeakDecision:
|
||||
projected_w: float | None = None
|
||||
excess_w: float = 0.0
|
||||
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
||||
target_w: float | None = None # SAFETY-79: what the guard aims at
|
||||
saturated: bool = False
|
||||
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
|
||||
# floor refused to size it. Mutually exclusive with `saturated` by
|
||||
# construction: this branch asks for nothing, so it cannot have run out of
|
||||
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
|
||||
# SAFETY-87: at N>=2 it may ask the batteries above their reserve for
|
||||
# `able_w`, all they have, when that is not enough (`place_on_able()`).
|
||||
floor_limited: bool = False
|
||||
soc: float | None = None
|
||||
soc_floor: float | None = None
|
||||
soc_floor_reason: str | None = None
|
||||
reason: str = ""
|
||||
# SAFETY-87, N>=2 only: the batteries at or under their own reserve,
|
||||
# which give nothing for the peak, and what the others can give (None
|
||||
# when they are not asked). See `place_on_able()`.
|
||||
held: tuple = ()
|
||||
able_w: float | None = None
|
||||
|
||||
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.
|
||||
|
||||
@@ -511,6 +544,15 @@ def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||
return cap, CEILING_CAP
|
||||
|
||||
|
||||
def quarter_target_w(cap_w, margin_w, reading) -> float:
|
||||
"""SAFETY-79. The average the guard AIMS at: `margin_w` under
|
||||
`quarter_ceiling_w()`, so under a month peak already banked as well as
|
||||
under the cap. Aiming AT a banked peak can bank a few W over it, which
|
||||
raises the bill as surely as going over the cap. Reports and the bill keep
|
||||
the real limit (`ceiling_w`)."""
|
||||
return quarter_ceiling_w(cap_w, reading)[0] - float(margin_w)
|
||||
|
||||
|
||||
def excess_w(projection, ceiling_w) -> float:
|
||||
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
|
||||
|
||||
@@ -553,11 +595,63 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
return BLIND_NO_BLOCK
|
||||
gap = uncovered_s(projection)
|
||||
if max_hold_s is not None and gap > float(max_hold_s):
|
||||
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
|
||||
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
|
||||
return (f"{BLIND_COVERAGE}, {gap:.0f} s of this 15-minute period not "
|
||||
f"covered, past the {float(max_hold_s):.0f} s a reading is "
|
||||
f"held")
|
||||
return None
|
||||
|
||||
|
||||
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
|
||||
# noise between the averager's sums and the wall clock, never a real gap.
|
||||
GAP_TOL_S = 1e-3
|
||||
|
||||
|
||||
def gap_w(ceiling_w, *seen_w) -> float:
|
||||
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
|
||||
limit and `seen_w`, the import with the batteries at 0 W (grid + their
|
||||
discharge) just before and just after the gap; None entries are skipped.
|
||||
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
|
||||
|
||||
|
||||
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
|
||||
held_ws=0.0):
|
||||
"""SAFETY-90: `projection` with the seconds the averager did not count
|
||||
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
|
||||
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
|
||||
too: what the gap's first `max_hold_s`, credited at the reading before
|
||||
it, lacks to reach `gap_w`. Returns
|
||||
`(projection, gap seconds filled, unseen seconds filled)`.
|
||||
|
||||
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
|
||||
as before (the averager credits it once the next reading lands). Past
|
||||
that the gap is still open, nothing is filled and `blindness()` answers
|
||||
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
if projection.block_start is None or max_hold_s is None:
|
||||
return projection, 0.0, 0.0
|
||||
wall = float(projection.elapsed_s)
|
||||
tail = min(max(0.0, float(tail_s)), wall)
|
||||
if tail > float(max_hold_s):
|
||||
return projection, 0.0, 0.0
|
||||
covered = wall - uncovered_s(projection)
|
||||
missing = wall - covered - tail
|
||||
if missing <= GAP_TOL_S:
|
||||
missing = 0.0
|
||||
if held_ws <= 0.0:
|
||||
return projection, 0.0, 0.0
|
||||
unseen = min(missing, max(0.0, float(unseen_s)))
|
||||
gap = missing - unseen
|
||||
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
|
||||
done = covered + missing
|
||||
return (replace(
|
||||
projection,
|
||||
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
|
||||
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
|
||||
if done > 0.0 else float(projection.elapsed_w)),
|
||||
covered_frac=(done / wall if wall > 0.0
|
||||
else float(projection.covered_frac))), gap, unseen)
|
||||
|
||||
|
||||
def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
|
||||
|
||||
@@ -699,8 +793,45 @@ def binding_soc_floor(packs, soc_floor):
|
||||
return max(floors)
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
def reserve_split(packs, soc_floor) -> tuple[list, tuple]:
|
||||
"""SAFETY-87: `(packs the guard may spend, names of those it may not)`,
|
||||
each pack graded on its own floor by `pack_permissions()`."""
|
||||
perms = pack_permissions(packs, soc_floor)
|
||||
return ([p for p, (_n, why) in zip(packs, perms) if why is None],
|
||||
tuple(n for n, why in perms if why is not None))
|
||||
|
||||
|
||||
def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision:
|
||||
"""SAFETY-87: `decision` with the batteries in `held` (at or under their
|
||||
own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor`
|
||||
describe the others, as `decide()`'s arguments do for the roster. A
|
||||
floor-limited decision is re-taken on them: they are asked for the need
|
||||
up to `able_w`, and it stays floor-limited only if that is not enough.
|
||||
`soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it.
|
||||
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
|
||||
held = tuple(held)
|
||||
if not decision.floor_limited or decision.wanted_w is None \
|
||||
or discharge_permitted(soc, soc_floor) is not None:
|
||||
return replace(decision, held=held)
|
||||
wanted = float(decision.wanted_w)
|
||||
floor = min(wanted, float(able_w))
|
||||
head = (f"this 15-minute period heads for {decision.projected_w:.0f} W "
|
||||
f"against a {decision.ceiling_w:.0f} W limit "
|
||||
f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. "
|
||||
f"At the peak reserve, so giving nothing for the peak: "
|
||||
f"{messages.names(held)}. The others are asked for {floor:+.0f} W")
|
||||
if floor >= wanted:
|
||||
return replace(decision, floor_limited=False, floor_w=floor,
|
||||
soc_floor_reason=None, held=held, able_w=float(able_w),
|
||||
reason=f"PEAK_GUARD: {head}")
|
||||
return replace(decision, floor_w=floor, held=held, able_w=float(able_w),
|
||||
reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can "
|
||||
f"give, but {wanted:+.0f} W was needed, so this "
|
||||
f"period may go over the limit"))
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
|
||||
soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
`batt_w` MEASURED battery power, positive = discharge. None when the
|
||||
@@ -718,8 +849,13 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
the pack for money. REQUIRED, and deliberately without a
|
||||
default: a default here would be CFG-03's fourth defaults
|
||||
site and TEST-14's quiet fallback in one line.
|
||||
`margin_w` SAFETY-79. Watts to aim under `cap_w` (`peak_margin_w`).
|
||||
REQUIRED for the same reason. `ceiling_w` stays the real
|
||||
limit for every report; only `excess_w` uses the target.
|
||||
"""
|
||||
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
||||
target = quarter_target_w(cap_w, margin_w, reading)
|
||||
aim = "" if target == ceiling else f", aiming at {target:.0f} W"
|
||||
ceil_top = abs(float(achievable_w))
|
||||
|
||||
blind = blindness(projection, max_hold_s, frozen)
|
||||
@@ -729,10 +865,10 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# see", which is one-directional and cannot cost pack energy.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=blind, projected_w=None,
|
||||
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
|
||||
f"while the billed quarter cannot be measured; commanding no "
|
||||
f"charging (floor 0 W)"))
|
||||
target_w=target, blind=blind, projected_w=None,
|
||||
reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
|
||||
f"while this 15-minute period cannot be measured, so it "
|
||||
f"is asked for no charging, floor 0 W"))
|
||||
|
||||
projected = float(projection.projected_w)
|
||||
if batt_w is None:
|
||||
@@ -741,7 +877,7 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# measurement that is not there is a made-up number.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
ceiling_from=from_where, target_w=target, projected_w=projected,
|
||||
reason="no battery measurement to size a floor from")
|
||||
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
|
||||
# A block with no seconds left cannot be influenced. This is degenerate
|
||||
@@ -749,10 +885,10 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# the battery's own current output would be a claim that says nothing.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
ceiling_from=from_where, target_w=target, projected_w=projected,
|
||||
reason="the quarter has no seconds left to influence")
|
||||
|
||||
ex = excess_w(projection, ceiling)
|
||||
ex = excess_w(projection, target)
|
||||
wanted = float(batt_w) + ex
|
||||
if wanted <= 0.0:
|
||||
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
|
||||
@@ -773,7 +909,8 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# the quantity being thresholded is simply the right one.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
ceiling_from=from_where, target_w=target, projected_w=projected,
|
||||
excess_w=ex,
|
||||
wanted_w=wanted,
|
||||
reason="the load is under the level this quarter can afford")
|
||||
|
||||
@@ -791,35 +928,73 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# not the grid-following loop, which still has no SoC guard.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
ceiling_from=from_where, target_w=target, projected_w=projected,
|
||||
excess_w=ex,
|
||||
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
|
||||
soc_floor_reason=denied,
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py
|
||||
# part 6). The code is one token, so it is not read as shouting.
|
||||
reason=(
|
||||
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
|
||||
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
|
||||
f"it, but {denied}"
|
||||
+ ("" if soc is None else f" ({float(soc):.0f} %")
|
||||
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W limit "
|
||||
f"({from_where}){aim}, and {wanted:+.0f} W from the battery "
|
||||
f"would hold it. But {denied}"
|
||||
+ ("" if soc is None else f", at {float(soc):.0f} %")
|
||||
+ ("" if soc is None or soc_floor is None
|
||||
else f" vs {float(soc_floor):.0f} %")
|
||||
+ ("" if soc is None else ")")
|
||||
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
|
||||
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
|
||||
else f" against {float(soc_floor):.0f} %")
|
||||
+ ", so no forced discharge and no charging, and this period "
|
||||
"will go over the limit"))
|
||||
|
||||
floor = min(wanted, ceil_top)
|
||||
saturated = floor < wanted
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||
saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||
target_w=target, blind=None, projected_w=projected, excess_w=ex,
|
||||
wanted_w=wanted, saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||
reason=(
|
||||
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||
f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
|
||||
f"against a {ceiling:.0f} W limit ({from_where}){aim}, with "
|
||||
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
|
||||
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
|
||||
f"floor {floor:+.0f} W"
|
||||
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
|
||||
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
|
||||
f"and is asked for {floor:+.0f} W"
|
||||
# Saturated means floor == ceil_top: the ask IS the most the
|
||||
# inverters give, so one clause says both.
|
||||
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
|
||||
if saturated else "")))
|
||||
|
||||
|
||||
# SAFETY-81. A quarter must end this many W over the real limit before
|
||||
# "Load too high for battery" fires. Above the guard's own landing error at
|
||||
# margin 0 (up to +5.7 W, SAFETY-79 test check 5), which margin 0 accepts.
|
||||
ALARM_MISS_W = 10.0
|
||||
# ...and for this long without a break, so a load that peaks for a few seconds
|
||||
# (a hob or heater cycling) is not read as the rest of the quarter.
|
||||
ALARM_HOLD_S = 10.0
|
||||
|
||||
|
||||
def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
|
||||
"""SAFETY-81. May "Load too high for battery" fire? Only if, with the
|
||||
battery at full power from now on, the quarter still ends more than
|
||||
`ALARM_MISS_W` over the real limit (`ceiling_w`, not the aim).
|
||||
|
||||
This is the miss itself, in W of the quarter average, so it does not have
|
||||
`decide()`'s gain (900 / remaining): near a quarter's end that gain turns
|
||||
a watt over the aim into thousands of W wanted.
|
||||
|
||||
The load is the held meter reading plus `batt_w`. The caller passes the
|
||||
lower of this cycle's and the last cycle's battery reading, so a meter
|
||||
sample taken before the battery's last increase is not counted twice
|
||||
(DECISIONS/SAFETY-81-load-too-high.md). A real overload shows on the meter
|
||||
before the battery moves, so that filter costs it no cycle.
|
||||
"""
|
||||
remaining = QUARTER_S - float(projection.elapsed_s)
|
||||
if batt_w is None or remaining <= 0.0:
|
||||
return False
|
||||
rest = float(projection.held_w) + float(batt_w) - float(achievable_w)
|
||||
done_ws = (QUARTER_S * float(projection.projected_w)
|
||||
- max(float(projection.held_w), 0.0) * remaining)
|
||||
ends_w = (done_ws + max(rest, 0.0) * remaining) / QUARTER_S
|
||||
return ends_w - float(ceiling_w) > ALARM_MISS_W
|
||||
|
||||
|
||||
def charge_headroom_w(decision) -> float | None:
|
||||
@@ -1019,5 +1194,6 @@ def claim(decision: PeakDecision) -> Claim | None:
|
||||
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
||||
reason=("blind" if decision.blind
|
||||
else "SoC floor" if decision.floor_limited
|
||||
and not decision.floor_w
|
||||
else "quarter peak"),
|
||||
actuator=ACTUATOR_SITE)
|
||||
|
||||
@@ -150,6 +150,7 @@ from enum import Enum
|
||||
from textwrap import dedent
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from . import messages
|
||||
from .custom_formula import (
|
||||
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
|
||||
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
|
||||
@@ -749,6 +750,18 @@ class _Series(PriceRead):
|
||||
except Absence:
|
||||
return False
|
||||
|
||||
def last_end_before(self, instant: datetime):
|
||||
"""When the last price that has run out by `instant` stopped covering,
|
||||
or None if no price has (PRICE-01: "no prices since 00:00").
|
||||
|
||||
Each point's end is the one `_cover_table()` worked out, so a hole in
|
||||
the series is reported from where the hole starts and not from the end
|
||||
of the day.
|
||||
"""
|
||||
u = instant.astimezone(timezone.utc)
|
||||
ends = [end for _start, end, _p in self._cover if end <= u]
|
||||
return max(ends) if ends else None
|
||||
|
||||
def window(self, since: datetime, until: datetime) -> tuple:
|
||||
"""Points whose START falls in [since, until). May legitimately be empty.
|
||||
|
||||
@@ -905,6 +918,10 @@ PRICE_OPTION_DEFAULTS = {
|
||||
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
# PRICE-01. The bearer token of the owner's price server. A secret: it is
|
||||
# in SECRET_OPTIONS below, so no text built from an error reaches a log,
|
||||
# the status page or a notification with it in.
|
||||
"price_service_token": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_timeout_s": 10,
|
||||
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
|
||||
@@ -991,6 +1008,26 @@ FRAMEWORK_KEYS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
# PRICE-01. Options whose VALUE must never be printed. `scrub()` removes them
|
||||
# from any text the price code builds out of an error.
|
||||
SECRET_OPTIONS = frozenset({"price_service_token"})
|
||||
|
||||
|
||||
def scrub(text, opts) -> str:
|
||||
"""`text` with every secret option's value replaced by `***`.
|
||||
|
||||
The price code builds reasons out of library errors, and a library may put
|
||||
a request header into its error text. Taking the value out here is the
|
||||
last line of defence; the plugin also never puts it in a message itself.
|
||||
"""
|
||||
out = str(text)
|
||||
for key in SECRET_OPTIONS:
|
||||
secret = str((opts or {}).get(key) or "").strip()
|
||||
if len(secret) >= 4:
|
||||
out = out.replace(secret, "***")
|
||||
return out
|
||||
|
||||
|
||||
class PluginOptions:
|
||||
"""The options dict, minus the keys only the framework may read.
|
||||
|
||||
@@ -1215,6 +1252,13 @@ class PriceProvider(ABC):
|
||||
|
||||
id = "?"
|
||||
|
||||
# PRICE-01. The ids to try, in order, when this source cannot answer, and
|
||||
# whether the last answer is kept for a day without any source. Empty and
|
||||
# False for every plugin that existed before, which is what keeps them
|
||||
# exactly as they were: `price_chain.py` is not used for them at all.
|
||||
FALLBACKS: tuple = ()
|
||||
KEEPS_LAST_GOOD: bool = False
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def capabilities(cls) -> ProviderCapabilities:
|
||||
@@ -1314,8 +1358,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 +1497,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(
|
||||
@@ -1533,19 +1576,58 @@ def assemble(caps: ProviderCapabilities, raw, markup: float,
|
||||
# Transport - one place, HTTPS only, host checked against the declaration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
async def https_get(url: str, timeout_s: float) -> bytes:
|
||||
async def https_get(url: str, timeout_s: float, headers=None) -> bytes:
|
||||
"""The default transport. §16: outbound is allow-listed and TLS'd.
|
||||
|
||||
Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp
|
||||
to be importable at all. AC3 says it works fully offline; that includes a
|
||||
machine where the HTTP stack is missing.
|
||||
|
||||
PRICE-01. `headers` is None for every plugin but `price_service`, and with
|
||||
None this is the code it has always been, byte for byte. With headers it
|
||||
answers in the SAFE words of `status_words()` and never lets a library
|
||||
error through: an error from a request that carried a token is the one
|
||||
place a token could be printed by somebody else's code.
|
||||
"""
|
||||
import aiohttp
|
||||
t = aiohttp.ClientTimeout(total=float(timeout_s))
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
if headers is None:
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
# No redirects: the host is fixed in code, and a redirect would
|
||||
# take the request (and the token) somewhere the allow-list never
|
||||
# saw. A 3xx is reported like any other status.
|
||||
async with s.get(url, headers=headers,
|
||||
allow_redirects=False) as r:
|
||||
if r.status != 200:
|
||||
raise SourceUnavailable(status_words(r.status))
|
||||
return await r.read()
|
||||
except SourceUnavailable:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SourceUnavailable(
|
||||
f"the price service did not answer ({type(e).__name__})")
|
||||
|
||||
|
||||
# PRICE-01. The one phrase a refused token is reported with. The fallback chain
|
||||
# reads it to tell "the owner must act" from "the server is down", so it is a
|
||||
# constant both sides import.
|
||||
AUTH_REFUSED = "the price service refused the token"
|
||||
|
||||
|
||||
def status_words(status: int) -> str:
|
||||
"""What an HTTP status from the price service is called. No header text."""
|
||||
if status in (401, 403):
|
||||
return f"{AUTH_REFUSED} ({status})"
|
||||
if status == 429:
|
||||
return "the price service says too many requests (429)"
|
||||
if status >= 500:
|
||||
return f"the price service has an error ({status})"
|
||||
return f"the price service answered {status}"
|
||||
|
||||
|
||||
def check_egress(url: str, caps: ProviderCapabilities) -> str:
|
||||
@@ -1708,25 +1790,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,12 +1821,11 @@ 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",
|
||||
len(dropped), len(times),
|
||||
_LOG.warning("The price feed gave no price for %d of %s, at index "
|
||||
"%s. Those are left out, not counted as free.",
|
||||
len(dropped), messages.count(len(times), "time"),
|
||||
", ".join(str(i) for i in dropped[:5])
|
||||
+ (", ..." if len(dropped) > 5 else ""))
|
||||
+ (" and more" if len(dropped) > 5 else ""))
|
||||
if not out:
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried {len(times)} instants and a price for none of "
|
||||
@@ -1796,16 +1871,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(),
|
||||
@@ -1877,6 +1952,91 @@ class GenericDayAhead(PriceProvider):
|
||||
provisional_default=False)
|
||||
|
||||
|
||||
@register
|
||||
class PriceService(PriceProvider):
|
||||
"""The owner's own price server (PRICE-01), COLUMNAR, in EUR/MWh.
|
||||
|
||||
SOURCES: energy
|
||||
The framework ADDS: markup, taxes
|
||||
|
||||
The server answers the energy-charts layout (`unix_seconds`, `price`,
|
||||
`unit`) plus `covers`, the complete Brussels days it holds. The price is
|
||||
the wholesale energy price and nothing else, so markup and taxes are added
|
||||
by the framework exactly as for `generic_dayahead`.
|
||||
|
||||
The host is fixed here, HTTPS only, and a bearer token (the option
|
||||
`price_service_token`) goes in the `Authorization` header. The token is not
|
||||
put into any message this class builds.
|
||||
|
||||
`FALLBACKS` names the next source down. `app/price_chain.py` walks it and
|
||||
then the stored prices; this class only fetches.
|
||||
"""
|
||||
|
||||
id = "price_service"
|
||||
|
||||
HOST = "prices.kammenstraatha.duckdns.org"
|
||||
URL = "https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE"
|
||||
FALLBACKS = ("generic_dayahead",)
|
||||
KEEPS_LAST_GOOD = True
|
||||
|
||||
@classmethod
|
||||
def capabilities(cls):
|
||||
return ProviderCapabilities(
|
||||
product=PriceProduct.DAY_AHEAD,
|
||||
sources=frozenset({Component.ENERGY}),
|
||||
egress_hosts=(cls.HOST,))
|
||||
|
||||
async def fetch(self, opts, since, until, transport):
|
||||
token = str(opts.get("price_service_token") or "").strip()
|
||||
if not token:
|
||||
raise SourceUnavailable("no token is set for the price service")
|
||||
if any(c.isspace() or not c.isprintable() for c in token):
|
||||
raise SourceUnavailable(
|
||||
"the price service token has a space or a line break in it")
|
||||
check_egress(self.URL, self.capabilities())
|
||||
try:
|
||||
body = await transport(
|
||||
self.URL, float(opts.get("price_timeout_s")),
|
||||
headers={"Authorization": "Bearer " + token})
|
||||
except SourceUnavailable:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SourceUnavailable(
|
||||
f"the price service did not answer ({type(e).__name__})")
|
||||
points = _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
scale=0.001,
|
||||
provisional_default=False)
|
||||
covers = _covered_days(body)
|
||||
if covers is None:
|
||||
return points
|
||||
whole = [p for p in points
|
||||
if brussels(p.start).date().isoformat() in covers]
|
||||
if not whole:
|
||||
raise SourceUnavailable(
|
||||
"the price service sent no complete day")
|
||||
return whole
|
||||
|
||||
|
||||
def _covered_days(body):
|
||||
"""The `covers` list of a price service answer as a set, or None if absent.
|
||||
|
||||
A day the server does not list as complete is not used: an hour missing
|
||||
inside a day is a hole, and a hole is not a free hour.
|
||||
"""
|
||||
try:
|
||||
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
|
||||
else str(body)
|
||||
covers = json.loads(text).get("covers")
|
||||
except (ValueError, UnicodeDecodeError, AttributeError):
|
||||
return None
|
||||
if covers is None:
|
||||
return None
|
||||
if not isinstance(covers, list) or \
|
||||
not all(isinstance(d, str) for d in covers):
|
||||
raise SourceUnavailable("the price service sent a bad list of days")
|
||||
return set(covers)
|
||||
|
||||
|
||||
@register
|
||||
class ManualCsv(PriceProvider):
|
||||
"""A file on disk. AC3: the documented fallback, fully offline.
|
||||
@@ -2011,11 +2171,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 +2249,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:
|
||||
@@ -2116,12 +2274,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
pid = provider_id(opts)
|
||||
if not provider_configured(opts):
|
||||
return PriceRead.no_data(
|
||||
"no price provider configured (price_provider is 'off'); "
|
||||
"§11.4: static profile, plan provisional, no arbitrage")
|
||||
"no price feed is set (price_provider is 'off'), so the fixed "
|
||||
"tariff is used, the plan is estimated and there is no trading")
|
||||
cls = PROVIDERS.get(pid)
|
||||
if cls is None:
|
||||
return PriceRead.no_data(
|
||||
f"unknown price provider {pid!r}; shipped: "
|
||||
f"unknown price provider {pid!r}. The known ones are "
|
||||
f"{', '.join(sorted(PROVIDERS))}")
|
||||
|
||||
caps = cls.capabilities()
|
||||
@@ -2139,12 +2297,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:
|
||||
@@ -2178,9 +2336,10 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
except FrameworkOption:
|
||||
raise # a plugin bug, not a site condition
|
||||
except SourceUnavailable as e:
|
||||
return PriceRead.no_data(f"{pid}: {e}")
|
||||
return PriceRead.no_data(scrub(f"{pid}: {e}", opts))
|
||||
except Exception as e: # noqa: BLE001 - see the docstring
|
||||
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
|
||||
return PriceRead.no_data(
|
||||
scrub(f"{pid}: {type(e).__name__}: {e}", opts))
|
||||
|
||||
_emit_startup_preview(plugin, pid, points)
|
||||
return PriceRead.series(points, caps.product, pid,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""PRICE-01 - where the prices come from when the first source does not answer.
|
||||
|
||||
A provider that names `FALLBACKS` (app/price.py) is read through this module
|
||||
instead of straight from `load_prices()`. The order is fixed:
|
||||
|
||||
1. the provider itself (the owner's price server)
|
||||
2. each id in its `FALLBACKS` (energy-charts.info)
|
||||
3. the prices the history store kept from the last fetch that worked
|
||||
|
||||
A step counts only if its answer holds a price for NOW. A server that answers
|
||||
200 with yesterday's prices is as useless to a battery as one that is down, so
|
||||
it is skipped, with the same log line.
|
||||
|
||||
Every other provider never comes here (`uses_chain()` is False for it), so
|
||||
`generic_dayahead`, `manual_csv`, `custom_formula` and `off` take the road they
|
||||
always took.
|
||||
|
||||
THE GATE. `gate_for()` is the maintenance charge rule for a site on such a
|
||||
provider: the current 15-minute price is at or below today's average. It is the
|
||||
rule the Home Assistant pair (`price_now_entity`, `price_avg_entity`) gave, read
|
||||
from the provider's own prices, so moving a site from the pair to the provider
|
||||
does not change WHEN the charge runs. DECISIONS/PRICE-01-own-price-server.md
|
||||
says how equal, and where it is not.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .price import (AUTH_REFUSED, PROVIDERS, PriceRead, brussels,
|
||||
price_option, provider_id, scrub)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.chain")
|
||||
|
||||
# The four places a plan can come from, best first. A higher number is further
|
||||
# down the chain.
|
||||
STEP_OWN, STEP_PUBLIC, STEP_STORED, STEP_NONE = 0, 1, 2, 3
|
||||
|
||||
# What a step is called in a status line.
|
||||
SOURCE_WORDS = {
|
||||
"price_service": "the price service",
|
||||
"generic_dayahead": "energy-charts",
|
||||
"price_history": "saved prices",
|
||||
}
|
||||
|
||||
|
||||
def uses_chain(opts: dict) -> bool:
|
||||
"""Does the selected provider ask for the fallback chain?"""
|
||||
cls = PROVIDERS.get(provider_id(opts))
|
||||
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
|
||||
|
||||
|
||||
def fixed_tariff_beside_service(opts: dict) -> bool:
|
||||
"""PRICE-01: a fixed tariff is set beside `price_service`. Not a fault: it
|
||||
stays the plan's last fallback (`resolve_tariff`), and only the cheap window
|
||||
and the gate ignore it. A blank or whitespace-only value is not set."""
|
||||
return (provider_id(opts) == "price_service"
|
||||
and bool(str(price_option(opts, "static_profile")).strip()))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Resolved:
|
||||
"""What the chain found. `published` is the series BEFORE the yesterday
|
||||
fill and the window, which is what the day's average has to be read from:
|
||||
the plan itself starts at the current hour."""
|
||||
|
||||
read: PriceRead
|
||||
step: int
|
||||
reasons: tuple = ()
|
||||
|
||||
@property
|
||||
def published(self):
|
||||
return self.read.match(series=lambda r: r, no_data=lambda _r: None)
|
||||
|
||||
|
||||
class PriceWatch:
|
||||
"""Which step the prices come from, and one log line per change of it.
|
||||
|
||||
The line is written when the step CHANGES and not before. Nothing repeats
|
||||
while a source stays down, and nothing is written for a refresh that
|
||||
changes nothing. The reasons (a status code, a timeout) go on a DEBUG line
|
||||
beside it, with the token taken out.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.step = None
|
||||
self.auth_refused = False
|
||||
|
||||
def note(self, step: int, reasons=(), opts=None) -> None:
|
||||
old, self.step = self.step, step
|
||||
self.auth_refused = any(AUTH_REFUSED in r for r in reasons)
|
||||
if old == step:
|
||||
return
|
||||
if reasons:
|
||||
_LOG.debug("Price sources said: %s.",
|
||||
scrub("; ".join(reasons), opts))
|
||||
if step == STEP_OWN:
|
||||
if old is None:
|
||||
_LOG.info("Prices come from the price service.")
|
||||
else:
|
||||
_LOG.info("The price service answers again.")
|
||||
elif step == STEP_PUBLIC:
|
||||
if old is not None and old > step:
|
||||
_LOG.info("Prices come from energy-charts again.")
|
||||
elif self.auth_refused:
|
||||
_LOG.warning("The price service refused the token, so prices "
|
||||
"come from energy-charts.")
|
||||
else:
|
||||
_LOG.warning("The price service does not answer, so prices "
|
||||
"come from energy-charts.")
|
||||
elif step == STEP_STORED:
|
||||
if old is not None and old > step:
|
||||
_LOG.info("The add-on uses the prices it saved again.")
|
||||
else:
|
||||
_LOG.warning("No price source answers, so the add-on uses "
|
||||
"the prices it saved.")
|
||||
else:
|
||||
_LOG.warning("No price source answers, and no saved price covers "
|
||||
"now.")
|
||||
|
||||
|
||||
async def resolve(opts: dict, since: datetime, until: datetime, transport,
|
||||
history, stamp: datetime, watch: PriceWatch,
|
||||
load) -> Resolved:
|
||||
"""Walk the chain and return the first step that has a price for `stamp`.
|
||||
|
||||
`load` is the caller's `load_prices`, passed in so that `static_tariff.py`
|
||||
stays the one module that fetches prices. Never raises
|
||||
for a source condition: a read that gets nowhere is a `no_data` whose
|
||||
reason names every step, so the §11.4 row applies exactly as for a single
|
||||
provider that failed.
|
||||
"""
|
||||
pid = provider_id(opts)
|
||||
cls = PROVIDERS[pid]
|
||||
reasons = []
|
||||
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
|
||||
# A fallback is asked for ITS OWN feed. The operator's feed override is
|
||||
# for the first provider and would send energy-charts' request to a
|
||||
# URL it was never meant for.
|
||||
o = opts if step == 0 else dict(opts, price_provider=sid,
|
||||
price_feed_url="")
|
||||
read = await load(o, since, until, transport)
|
||||
if read.match(series=lambda r: r.covers(stamp), no_data=lambda _r: False):
|
||||
# One fallback is shipped. Any further one would also be "public".
|
||||
where = STEP_OWN if step == 0 else STEP_PUBLIC
|
||||
watch.note(where, reasons, opts)
|
||||
return Resolved(read, where, tuple(reasons))
|
||||
reasons.append(read.match(
|
||||
series=lambda r: f"{sid}: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
if cls.KEEPS_LAST_GOOD:
|
||||
kept = history.stored(stamp)
|
||||
if kept.match(series=lambda r: r.covers(stamp),
|
||||
no_data=lambda _r: False):
|
||||
watch.note(STEP_STORED, reasons, opts)
|
||||
return Resolved(kept, STEP_STORED, tuple(reasons))
|
||||
reasons.append(kept.match(
|
||||
series=lambda r: "price_history: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
watch.note(STEP_NONE, reasons, opts)
|
||||
# The reason is short on purpose: it is printed on the status page and in
|
||||
# a WARNING every refresh. What each step said is on the DEBUG line.
|
||||
return Resolved(PriceRead.no_data(
|
||||
"no price source has a price for now"), STEP_NONE,
|
||||
tuple(scrub(r, opts) for r in reasons))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The maintenance charge gate, read from the provider's prices
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate:
|
||||
"""What the 30 s tick takes from the standing plan.
|
||||
|
||||
`covered` is whether a published price covers now. When it is False,
|
||||
`cheap` is False and the charge waits: no price is not a cheap price.
|
||||
`since` is when the cover ended, for the status line, or None when nothing
|
||||
was ever published.
|
||||
"""
|
||||
|
||||
cheap: bool
|
||||
covered: bool
|
||||
since: datetime | None
|
||||
words: str
|
||||
|
||||
|
||||
NO_PLAN = Gate(False, False, None, "No prices yet")
|
||||
|
||||
|
||||
def _round5(value: float) -> float:
|
||||
# The Home Assistant template rounded the average to 5 decimals, and a
|
||||
# price to 5 as well. Both sides are compared as it did, so a quarter that
|
||||
# sits within 0.00001 EUR/kWh of the average gets the same answer.
|
||||
return round(float(value), 5)
|
||||
|
||||
|
||||
def gate_for(published, now: datetime) -> Gate:
|
||||
"""The charge gate for `now`, from the series the chain published.
|
||||
|
||||
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or
|
||||
below the average of today's prices, today being the Brussels day. This is
|
||||
what `now_p <= avg_p` was on the Home Assistant pair. It is NOT
|
||||
`TariffPlan.cheap_now()`, which is strictly below the mean of the firm
|
||||
hours of the whole plan, and it is not meant to be.
|
||||
|
||||
`published` is a series or None. With no price for now there is no gate
|
||||
answer: the charge waits.
|
||||
"""
|
||||
if published is None:
|
||||
return NO_PLAN
|
||||
if not published.covers(now):
|
||||
return Gate(False, False, published.last_end_before(now),
|
||||
"No price for now")
|
||||
today = brussels(now).date()
|
||||
todays = [p.eur_per_kwh for p in published.points
|
||||
if not p.provisional and brussels(p.start).date() == today]
|
||||
here = published.at(now)
|
||||
if here.provisional or not todays:
|
||||
return Gate(False, False, published.last_end_before(now),
|
||||
"No price for now")
|
||||
avg = sum(todays) / len(todays)
|
||||
cheap = _round5(here.eur_per_kwh) <= _round5(avg)
|
||||
source = SOURCE_WORDS.get(published.provider, published.provider)
|
||||
return Gate(cheap, True, None,
|
||||
f"Prices from {source}, {here.eur_per_kwh:.4f} EUR/kWh now, "
|
||||
f"{'cheap' if cheap else 'not cheap'} against today's average "
|
||||
f"of {avg:.4f}")
|
||||
@@ -164,6 +164,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
|
||||
from . import messages
|
||||
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
|
||||
from .price import PricePoint, PriceRead, _Outcome, brussels
|
||||
|
||||
@@ -411,13 +412,14 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
if not out:
|
||||
reason = read.match(
|
||||
series=lambda r: (f"the {len(r.points)} published hours all fall "
|
||||
f"outside {since.isoformat()}..{until.isoformat()}"),
|
||||
series=lambda r: (f"all {messages.count(len(r.points), 'published price')} "
|
||||
f"fall outside {messages.when(brussels(since))} to "
|
||||
f"{messages.when(brussels(until))}"),
|
||||
no_data=lambda r: r.reason)
|
||||
return PriceRead.no_data(
|
||||
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
|
||||
f"profile has nothing to fill them with either. §11.4 applies: "
|
||||
f"static profile, plan provisional, no arbitrage")
|
||||
f"no hours could be filled from yesterday's prices: {reason}. So "
|
||||
f"the fixed tariff is used, the plan is estimated and there is no "
|
||||
f"trading")
|
||||
|
||||
_audit(out, published)
|
||||
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
|
||||
@@ -431,10 +433,11 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
|
||||
# feed's is the POINT; nothing derived from one answers for the other.
|
||||
total_h = len({_utc_hour(p.start) for p in out})
|
||||
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
|
||||
"profile and flagged provisional; the feed published %d "
|
||||
"points over the other %d hours",
|
||||
carried, total_h, len(out) - carried, total_h - carried)
|
||||
_LOG.info("%d of %s use yesterday's prices, marked as estimated. "
|
||||
"The feed published %s over the other %s.",
|
||||
carried, messages.count(total_h, "hour"),
|
||||
messages.count(len(out) - carried, "price"),
|
||||
messages.count(total_h - carried, "hour"))
|
||||
|
||||
product = read.match(series=lambda r: r.product,
|
||||
no_data=lambda _r: yesterday.product)
|
||||
@@ -525,8 +528,8 @@ class _Permitted(Gate):
|
||||
"one rule this type has.")
|
||||
self.action = action
|
||||
self.hours = pts
|
||||
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
|
||||
f"({pts[0].start.isoformat()} ..)")
|
||||
self.words = (f"allowed on {messages.count(len(pts), 'published price')} "
|
||||
f"from {messages.when(brussels(pts[0].start))}")
|
||||
|
||||
|
||||
class _Blocked(Gate):
|
||||
@@ -570,8 +573,8 @@ def price_permits(read: PriceRead, now: datetime,
|
||||
return read.match(
|
||||
series=lambda r: _walk(r, hours, action),
|
||||
no_data=lambda r: Gate.block(
|
||||
f"{action.name} is gated on a price and there is no price data: "
|
||||
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
|
||||
f"{action.name} needs a price and there is none: {r.reason}. A "
|
||||
f"missing price is not a cheap one"))
|
||||
|
||||
|
||||
def _walk(series, hours, action) -> Gate:
|
||||
@@ -579,19 +582,18 @@ def _walk(series, hours, action) -> Gate:
|
||||
for u in hours:
|
||||
if not series.covers(u):
|
||||
return Gate.block(
|
||||
f"{action.name} commits {action.commits_h:g} h from "
|
||||
f"{hours[0].isoformat()} and no price covers "
|
||||
f"{u.isoformat()}. An hour outside the series is an hour with "
|
||||
f"no price (§11.4 for that hour), not a free one")
|
||||
f"{action.name} needs prices for {action.commits_h:g} hours "
|
||||
f"from {messages.when(brussels(hours[0]))}, and no price "
|
||||
f"covers {messages.when(brussels(u))}. An hour with no price "
|
||||
f"is not a free one")
|
||||
p = series.at(u)
|
||||
if p.provisional:
|
||||
return Gate.block(
|
||||
f"{action.name} commits {action.commits_h:g} h from "
|
||||
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
|
||||
f"carried from yesterday's profile or standing in for a dead "
|
||||
f"feed. §8.3: no irreversible action may be taken on a "
|
||||
f"provisional price. It becomes available when the day-ahead "
|
||||
f"print lands (~14-16 h CET, §5.4)")
|
||||
f"{action.name} needs prices for {action.commits_h:g} hours "
|
||||
f"from {messages.when(brussels(hours[0]))}, and the price for "
|
||||
f"{messages.when(brussels(u))} is only estimated. It can start "
|
||||
f"once the day-ahead prices are published, around 14:00 to "
|
||||
f"16:00")
|
||||
pts.append(p)
|
||||
return Gate.permit(action, pts)
|
||||
|
||||
@@ -622,5 +624,6 @@ def _go(maint, now, gate) -> Gate:
|
||||
|
||||
|
||||
def _no(gate) -> Gate:
|
||||
_LOG.warning("irreversible action refused - %s", gate.reason)
|
||||
_LOG.warning("An action that cannot be undone does not start: %s.",
|
||||
gate.reason)
|
||||
return gate
|
||||
|
||||
@@ -186,8 +186,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from .price import PricePoint, PriceProduct, PriceRead, brussels
|
||||
from .price_forecast import fill_from_yesterday
|
||||
|
||||
@@ -345,8 +346,19 @@ 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)")
|
||||
# UX-10: the day as the page writes dates ("26 Sep"), not ISO.
|
||||
return (f"{points} price{'' if points == 1 else 's'} over {hours} "
|
||||
f"hour{'' if hours == 1 else 's'} for {day_words(day)}")
|
||||
|
||||
|
||||
def day_words(day) -> str:
|
||||
"""`'2026-09-26'` -> `'26 Sep'`, as the page writes a date. A key that is
|
||||
not a date is shown as it is, because this feeds a status row."""
|
||||
try:
|
||||
d = date.fromisoformat(str(day))
|
||||
except ValueError:
|
||||
return str(day)
|
||||
return f"{d.day} {d:%b}"
|
||||
|
||||
|
||||
def _bad_rows(rec) -> int:
|
||||
@@ -393,9 +405,8 @@ class PriceHistory:
|
||||
self.last_error = f"no durable volume at {parent}"
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong.
|
||||
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
|
||||
"to carry and the horizon stays as short as the feed",
|
||||
self.last_error)
|
||||
_LOG.info("No price history is kept (%s), so missing hours are "
|
||||
"not filled from yesterday's prices.", self.last_error)
|
||||
return
|
||||
self.ok = True
|
||||
self._load()
|
||||
@@ -437,9 +448,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
|
||||
@@ -449,15 +460,15 @@ class PriceHistory:
|
||||
# would not notice a writer this build has never met.
|
||||
if not isinstance(blob, dict) or isinstance(found, bool) \
|
||||
or not isinstance(found, int) or found != SCHEMA:
|
||||
self.last_error = f"schema {found!r}, not {SCHEMA}"
|
||||
_LOG.warning("price history at %s has %s - discarding it rather "
|
||||
"than guessing what its points mean",
|
||||
self.last_error = f"version {found!r}, not {SCHEMA}"
|
||||
_LOG.warning("Price history at %s has %s, so it is not used.",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
days = blob.get("days")
|
||||
if not isinstance(days, dict):
|
||||
self.last_error = "no `days` object"
|
||||
_LOG.warning("price history at %s has %s", self.path, self.last_error)
|
||||
self.last_error = "no days in it"
|
||||
_LOG.warning("Price history at %s has %s, so it is not used.",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
self._days = self._usable_days(days)
|
||||
|
||||
@@ -504,20 +515,22 @@ class PriceHistory:
|
||||
kept[key] = rec
|
||||
notes = []
|
||||
if dropped:
|
||||
notes.append(f"{len(dropped)} unusable day record(s) dropped "
|
||||
f"({', '.join(sorted(dropped))})")
|
||||
notes.append(f"{messages.count(len(dropped), 'unusable day')} "
|
||||
f"dropped, " + ", ".join(day_words(d)
|
||||
for d in sorted(dropped)))
|
||||
if malformed:
|
||||
notes.append(
|
||||
f"{sum(malformed.values())} malformed point row(s) in "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
|
||||
f"{messages.count(sum(malformed.values()), 'bad price')} in "
|
||||
+ ", ".join(f"{day_words(d)} ({n})"
|
||||
for d, n in sorted(malformed.items())))
|
||||
if notes:
|
||||
# Recorded, not silent: `yesterday()` and `words` both report
|
||||
# `last_error`, and a cache that quietly discards half a file reads
|
||||
# to an operator exactly like a normal first day.
|
||||
self.last_error = "; ".join(notes)
|
||||
_LOG.warning("price history at %s: %s - the controller starts "
|
||||
"anyway and the horizon is as short as the feed makes "
|
||||
"it", self.path, self.last_error)
|
||||
self.last_error = " and ".join(notes)
|
||||
_LOG.warning("Price history at %s: %s. The add-on starts anyway, "
|
||||
"and fills in only what the rest holds.",
|
||||
self.path, self.last_error)
|
||||
return kept
|
||||
|
||||
def _save(self) -> bool:
|
||||
@@ -568,7 +581,7 @@ class PriceHistory:
|
||||
|
||||
# -- the one public entry point -----------------------------------------
|
||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||
stamp: datetime) -> PriceRead:
|
||||
stamp: datetime, remember: bool = True) -> PriceRead:
|
||||
"""Remember what this read published, then §8.3-fill the missing hours.
|
||||
|
||||
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
||||
@@ -586,7 +599,11 @@ class PriceHistory:
|
||||
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||
back when that is every hour.
|
||||
"""
|
||||
self._remember(read, stamp)
|
||||
if remember:
|
||||
# PRICE-01: False only for a read that CAME FROM this store
|
||||
# (`stored()`), which is already filed; filing it again would
|
||||
# rewrite the same file for nothing, and nothing else may pass it.
|
||||
self._remember(read, stamp)
|
||||
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
||||
since=since, until=until, stamp=stamp)
|
||||
|
||||
@@ -680,10 +697,11 @@ class PriceHistory:
|
||||
# undone with the rest of the merge, so those rows are still in the
|
||||
# file and the next write that completes drops them again.
|
||||
self.last_error = (
|
||||
f"{sum(junked.values())} malformed point row(s) dropped from "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
|
||||
_LOG.warning("price history at %s: %s - the record is rewritten "
|
||||
"without them and the refresh continues",
|
||||
f"{messages.count(sum(junked.values()), 'bad price')} dropped "
|
||||
f"from " + ", ".join(f"{day_words(d)} ({n})"
|
||||
for d, n in sorted(junked.items())))
|
||||
_LOG.warning("Price history at %s: %s. The file is saved without "
|
||||
"them and the price update goes on.",
|
||||
self.path, self.last_error)
|
||||
self._prune(stamp)
|
||||
if not self._save():
|
||||
@@ -752,21 +770,23 @@ class PriceHistory:
|
||||
if self.last_error and not self._days:
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} is {self.last_error}, so "
|
||||
f"§8.3 has no profile to carry forward for {want}; the plan "
|
||||
f"covers only the hours the feed published")
|
||||
f"there are no prices from yesterday to fill in for "
|
||||
f"{day_words(want)}. The plan covers only the hours the feed "
|
||||
f"published")
|
||||
rec = self._days.get(want)
|
||||
if rec is None:
|
||||
have = ", ".join(sorted(self._days)) or "no days at all"
|
||||
have = (", ".join(day_words(d) for d in sorted(self._days))
|
||||
or "no days at all")
|
||||
return PriceRead.no_data(
|
||||
f"no published prices recorded for {want} (held: {have}), so "
|
||||
f"§8.3 has no profile to carry forward. A record for another "
|
||||
f"day is not a yesterday")
|
||||
f"no published prices are kept for {day_words(want)}, only for "
|
||||
f"{have}, so there are no prices from yesterday to fill in. A "
|
||||
f"record for another day is not a yesterday")
|
||||
points, bad = _parse_points(rec.get("points"))
|
||||
if not points:
|
||||
why = bad or "it is empty"
|
||||
return PriceRead.no_data(
|
||||
f"the {want} record holds no usable point ({why}), and an "
|
||||
f"empty series is not a free day")
|
||||
f"the {day_words(want)} record holds no usable price, {why}, "
|
||||
f"and an empty series is not a free day")
|
||||
try:
|
||||
product = PriceProduct(rec.get("product"))
|
||||
except ValueError:
|
||||
@@ -775,12 +795,44 @@ 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 dropped from the %s record, and "
|
||||
"%s kept.", bad, day_words(want),
|
||||
messages.count(len(points), "price"))
|
||||
return PriceRead.series(points, product,
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
"""PRICE-01: every price this store holds, as one series, or no_data.
|
||||
|
||||
This is the LAST step of the price server's fallback (price_chain.py):
|
||||
the answer of the last fetch that worked, kept on disk, so a site can
|
||||
plan for as long as the days it holds last. It is a read of what was
|
||||
PUBLISHED and filed, in the order they happened, and it is never the
|
||||
carried fill (rule 1 holds: `carry(..., remember=False)` is how this
|
||||
read is filled, and it is not filed again).
|
||||
|
||||
The prices are the all-in ones stored when they were fetched. They are
|
||||
not worked out again, so a change of markup or taxes shows from the
|
||||
next fetch that works, not in these.
|
||||
"""
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError(f"stored({stamp!r}): a naive datetime is not an "
|
||||
f"instant")
|
||||
points = []
|
||||
for day in sorted(self._days):
|
||||
rec = self._days[day]
|
||||
got, _bad = _parse_points(rec.get("points") if isinstance(
|
||||
rec, dict) else None)
|
||||
points.extend(got)
|
||||
if not points:
|
||||
why = self.last_error or "it holds no prices"
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} gives nothing: {why}")
|
||||
points.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return PriceRead.series(points, PriceProduct.DAY_AHEAD,
|
||||
"price_history", stamp)
|
||||
|
||||
# -- observability -------------------------------------------------------
|
||||
@property
|
||||
def words(self) -> str:
|
||||
@@ -805,7 +857,7 @@ class PriceHistory:
|
||||
if not self._days:
|
||||
why = f" - {self.last_error}" if self.last_error else ""
|
||||
return f"empty{why} - nothing to carry forward yet"
|
||||
held = "; ".join(_row_of(d, *_held(self._days[d]))
|
||||
held = ", ".join(_row_of(d, *_held(self._days[d]))
|
||||
for d in sorted(self._days, key=str))
|
||||
# A file that PARTLY loaded says so, or the dropped days read as days
|
||||
# that were simply never written.
|
||||
@@ -833,7 +885,7 @@ def _parse_points(raw) -> tuple:
|
||||
continue
|
||||
out.append(pt)
|
||||
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
|
||||
return tuple(out), (messages.count(dropped, "bad price") if dropped else "")
|
||||
|
||||
|
||||
class NoHistory:
|
||||
@@ -856,10 +908,15 @@ class NoHistory:
|
||||
last_error = "no history is kept by this caller"
|
||||
words = "not kept"
|
||||
|
||||
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
|
||||
def carry(self, read: PriceRead, *, since, until, stamp,
|
||||
remember: bool = True) -> PriceRead:
|
||||
return read
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data("this caller keeps no price history, so "
|
||||
"there are no stored prices")
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data(
|
||||
"this caller keeps no price history (NoHistory), so §8.3 has no "
|
||||
"profile to carry forward")
|
||||
"this caller keeps no price history, so there are no prices from "
|
||||
"yesterday to fill in")
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""OPT-11 P3: price runs. Which consecutive firm price slots are cheap, which
|
||||
are expensive, and which one a battery may sell in.
|
||||
|
||||
Pure: prices, the time and a few energy figures in, a `RunPlan` out. No I/O,
|
||||
no clock. `modetrade.py` turns the plan into each battery's range and the
|
||||
site's trade; main.py rebuilds the plan at most once per 15 minutes and keeps
|
||||
a run that has started until its end (`keep_started()`).
|
||||
|
||||
The owner's SAFETY-46 answer (2026-09-25): a trade is a run of consecutive
|
||||
slots, not one 15-minute interval. OPT-11-design section 2 ("Prices") and
|
||||
DECISIONS/OPT-11-battery-modes.md have the record.
|
||||
|
||||
* The CHEAP run is the cheapest consecutive firm window long enough to put
|
||||
the planned energy in at the chargers' combined rating.
|
||||
* A slot is EXPENSIVE when its price is above the cheap run's mean by at
|
||||
least the required spread (`cycle_cost_threshold_eur`, widened as
|
||||
`TariffPlan.arbitrage()` widens it). Give runs are the maximal consecutive
|
||||
expensive slots. Holding batteries cover the house in them. The mean is
|
||||
the lower of the next cheap run's and `ref_eur_kwh`, the cheap run the
|
||||
batteries last filled in (main.py keeps it for 12 h): energy bought at
|
||||
03:00 is still worth that price at 08:00, when the next cheap run in sight
|
||||
is today's dearer midday (measured: without it a Cheap hours battery spent
|
||||
its night charge on flat morning prices, then had nothing at 19:00).
|
||||
* The SELL run is the dearest consecutive window inside one give run long
|
||||
enough to sell the sellers' energy at their rating, with no slot below 0
|
||||
(section 17 of the spec: never export at a negative price).
|
||||
* A provisional (carried) price is never part of any run (section 8.3).
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .price import _cover_table
|
||||
|
||||
# How far ahead runs are looked for. A day: tonight's cheap hours and the next
|
||||
# evening's expensive ones, without holding a battery for a peak two days out.
|
||||
HORIZON = timedelta(hours=24)
|
||||
|
||||
# No plan, and why (the page words are in homeview / ui_text).
|
||||
NO_PRICES = "no_prices" # fewer than two firm slots ahead
|
||||
NOT_WORTH = "not_worth" # no slot clears the required spread
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Run:
|
||||
start: datetime
|
||||
end: datetime
|
||||
mean_eur_kwh: float
|
||||
|
||||
def holds(self, when: datetime) -> bool:
|
||||
return self.start <= when < self.end
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunPlan:
|
||||
"""What the prices say for the next day. `reason` is None when there is
|
||||
a plan (a cheap run and at least one give run), else why there is none."""
|
||||
cheap: Run | None
|
||||
give: tuple
|
||||
sell: Run | None
|
||||
level_eur_kwh: float | None
|
||||
reason: str | None
|
||||
# A slot after the cheap run clears the spread over the CHEAP RUN'S OWN
|
||||
# price (not the reference): only then is buying in it worth a cycle.
|
||||
charge_worth: bool = False
|
||||
|
||||
@property
|
||||
def on(self) -> bool:
|
||||
return self.reason is None
|
||||
|
||||
def give_at(self, when):
|
||||
return next((g for g in self.give if g.holds(when)), None)
|
||||
|
||||
def next_give(self, when):
|
||||
"""The give run under way at `when`, else the next one, else None."""
|
||||
return next((g for g in self.give if g.end > when), None)
|
||||
|
||||
def charge_useful(self) -> bool:
|
||||
"""Charging in the cheap run pays only if, after it, a slot clears
|
||||
the spread over the cheap run's own price."""
|
||||
return self.cheap is not None and self.charge_worth
|
||||
|
||||
|
||||
def firm_slots(points, now: datetime, horizon: timedelta = HORIZON) -> list:
|
||||
"""`[(start, end, eur_kwh)]`, ascending: the firm slots that have not
|
||||
ended and start within the horizon. A slot's span is TARIFF-14's rule
|
||||
(`price._cover_table()`), so 15-minute and hourly series both work."""
|
||||
out = []
|
||||
for start, end, p in _cover_table(points):
|
||||
if p.provisional or end <= now or start >= now + horizon:
|
||||
continue
|
||||
if end > start:
|
||||
out.append((start, end, float(p.eur_per_kwh)))
|
||||
return out
|
||||
|
||||
|
||||
def _segments(slots) -> list:
|
||||
"""Split slots into runs of consecutive ones (each starts where the last
|
||||
ended). A gap or a provisional slot breaks a run."""
|
||||
segs, cur = [], []
|
||||
for s in slots:
|
||||
if cur and s[0] != cur[-1][1]:
|
||||
segs.append(cur)
|
||||
cur = []
|
||||
cur.append(s)
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
return segs
|
||||
|
||||
|
||||
def _hours(s) -> float:
|
||||
return (s[1] - s[0]).total_seconds() / 3600.0
|
||||
|
||||
|
||||
def _mean(win) -> float:
|
||||
h = sum(_hours(s) for s in win)
|
||||
return sum(_hours(s) * s[2] for s in win) / h
|
||||
|
||||
|
||||
def _windows(segs, need_h):
|
||||
"""Every consecutive window that lasts at least `need_h` hours, shortest
|
||||
such from each start. If no segment is that long, each whole segment."""
|
||||
out = []
|
||||
for seg in segs:
|
||||
for i in range(len(seg)):
|
||||
h, j = 0.0, i
|
||||
while j < len(seg) and h < need_h - 1e-9:
|
||||
h += _hours(seg[j])
|
||||
j += 1
|
||||
if h >= need_h - 1e-9:
|
||||
out.append(seg[i:j])
|
||||
return out or [seg for seg in segs]
|
||||
|
||||
|
||||
def need_hours(kwh, kw, slots) -> float:
|
||||
"""How long a run must last: `kwh` at `kw`, and at least one slot."""
|
||||
one = min((_hours(s) for s in slots), default=1.0)
|
||||
if not (kwh and kw) or kwh <= 0 or kw <= 0:
|
||||
return one
|
||||
return max(one, float(kwh) / float(kw))
|
||||
|
||||
|
||||
def plan(points, now: datetime, *, required_eur_kwh: float, charge_kwh,
|
||||
charge_kw, sell_kwh, sell_kw, horizon: timedelta = HORIZON,
|
||||
ref_eur_kwh=None) -> RunPlan:
|
||||
"""The run plan for the next `horizon`. Ties go to the earliest window."""
|
||||
if not (isinstance(required_eur_kwh, (int, float))
|
||||
and math.isfinite(required_eur_kwh) and required_eur_kwh >= 0):
|
||||
raise ValueError("required_eur_kwh must be a finite number >= 0")
|
||||
slots = firm_slots(points, now, horizon)
|
||||
if len(slots) < 2:
|
||||
return RunPlan(None, (), None, None, NO_PRICES)
|
||||
segs = _segments(slots)
|
||||
wins = _windows(segs, need_hours(charge_kwh, charge_kw, slots))
|
||||
best = min(wins, key=lambda w: (round(_mean(w), 9), w[0][0]))
|
||||
cheap = Run(best[0][0], best[-1][1], _mean(best))
|
||||
base = cheap.mean_eur_kwh if ref_eur_kwh is None else min(
|
||||
cheap.mean_eur_kwh, float(ref_eur_kwh))
|
||||
level = base + float(required_eur_kwh)
|
||||
# Expensive: clears the spread, strictly dearer than the cheap mean, and
|
||||
# not part of the cheap run itself (a threshold of 0 must not overlap).
|
||||
dear = [s for s in slots
|
||||
if s[2] >= level and s[2] > cheap.mean_eur_kwh
|
||||
and not (cheap.start <= s[0] < cheap.end)]
|
||||
give_segs = _segments(dear)
|
||||
give = tuple(Run(g[0][0], g[-1][1], _mean(g)) for g in give_segs)
|
||||
worth = any(s[0] >= cheap.end and s[2] >= cheap.mean_eur_kwh
|
||||
+ float(required_eur_kwh) for s in dear)
|
||||
if not give:
|
||||
return RunPlan(cheap, (), None, level, NOT_WORTH)
|
||||
# Sell: inside one give run, never over a slot priced below 0.
|
||||
sellable = [part for g in give_segs
|
||||
for part in _segments([s for s in g if s[2] >= 0.0])]
|
||||
sell = None
|
||||
if sellable:
|
||||
sw = _windows(sellable, need_hours(sell_kwh, sell_kw, slots))
|
||||
top = max(sw, key=lambda w: (round(_mean(w), 9),
|
||||
-w[0][0].timestamp()))
|
||||
sell = Run(top[0][0], top[-1][1], _mean(top))
|
||||
return RunPlan(cheap, give, sell, level, None, worth)
|
||||
|
||||
|
||||
def keep_started(old: RunPlan | None, new: RunPlan, now: datetime) -> RunPlan:
|
||||
"""A run under way keeps going to its end (no flapping on a new plan): the
|
||||
cheap run, the give run and the sell run each come from `old` while `old`
|
||||
holds `now` in it. The rest is `new`. A safety or peak rule can still stop
|
||||
a run at any tick; that is not decided here."""
|
||||
if old is None or not old.on:
|
||||
return new
|
||||
cheap = old.cheap if old.cheap and old.cheap.holds(now) else new.cheap
|
||||
sell = old.sell if old.sell and old.sell.holds(now) else new.sell
|
||||
g_old = old.give_at(now)
|
||||
give = new.give
|
||||
if g_old is not None:
|
||||
give = (g_old,) + tuple(g for g in new.give
|
||||
if g.start >= g_old.end)
|
||||
if cheap is new.cheap and sell is new.sell and g_old is None:
|
||||
return new
|
||||
worth = old.charge_worth if cheap is old.cheap else new.charge_worth
|
||||
return RunPlan(cheap, give, sell, new.level_eur_kwh if new.on
|
||||
else old.level_eur_kwh, None if give else new.reason,
|
||||
worth)
|
||||
@@ -50,21 +50,10 @@ SITE figure, and it publishes as an absence. A partial sum is a second way of
|
||||
reporting 0-where-there-was-production, one layer up, and it is refused for the
|
||||
same reason.
|
||||
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION. Derived from the two conventions
|
||||
this add-on already ships rather than assumed:
|
||||
|
||||
`meter_entity` positive = IMPORTING from the grid (DOCS.md, Sources)
|
||||
`batt_entity` positive = DISCHARGING the pack (DOCS.md, Sources)
|
||||
|
||||
Both are "positive = power arriving at the house's AC bus", and production
|
||||
arrives there too. There is deliberately NO `pv_invert` to go with
|
||||
`meter_invert` and `batt_invert`: those two quantities are BIPOLAR and are READ
|
||||
BY THE CONTROL PATH - a reversed `batt_entity` feeds SAFETY-30's per-unit sign
|
||||
check and SAFETY-31's circulating-power sensor with a plausible number the site
|
||||
then acts on - whereas production is unipolar and is read by nothing. A reversed
|
||||
PV sensor announces itself as -4 kW at noon in the very history this card
|
||||
creates, and flipping it here would only hide it. See
|
||||
`DECISIONS/TEL-11-pv-production.md` §2.
|
||||
THE SIGN CONVENTION: POSITIVE IS PRODUCTION, as for `meter_entity` and
|
||||
`batt_entity` ("power arriving at the house's AC bus"). TEL-14: a kWh meter or
|
||||
power clamp in the PV circuit reads the other way, so `pv_invert_entities`
|
||||
flips the sign per entity. See `DECISIONS/TEL-14-pv-sign.md`.
|
||||
"""
|
||||
|
||||
from .hass import BAD_STATES
|
||||
@@ -193,7 +182,28 @@ def entity_is_ignored(opts: dict) -> bool:
|
||||
if str(x or "").strip()])
|
||||
|
||||
|
||||
def total(readings) -> tuple:
|
||||
def inverted(opts: dict) -> frozenset:
|
||||
"""The entity ids `pv_invert_entities` flips. TEL-14."""
|
||||
return frozenset(e for e in (str(x or "").strip()
|
||||
for x in (opts.get("pv_invert_entities") or []))
|
||||
if e)
|
||||
|
||||
|
||||
def stray_inverts(opts: dict) -> list:
|
||||
"""`pv_invert_entities` entries naming no entity in the roster - a typo
|
||||
that would otherwise leave a sign unflipped with nothing said. TEL-14."""
|
||||
roster = set(entities(opts))
|
||||
return sorted(e for e in inverted(opts) if e not in roster)
|
||||
|
||||
|
||||
# TEL-14: a reading below this is not standby draw. A correctly signed meter in
|
||||
# the PV circuit reads a few watts NEGATIVE at night (the owner's: 13 W); -50 W
|
||||
# clears that with room for a couple of such devices on one entity, while a
|
||||
# reversed sensor in daylight reads hundreds to thousands of watts below zero.
|
||||
NEGATIVE_W = -50.0
|
||||
|
||||
|
||||
def total(readings, flip=frozenset()) -> tuple:
|
||||
"""`(watts, why, parts)` - the SITE's production, or an ABSENCE. TEL-12.
|
||||
|
||||
`readings` is `[(entity_id, state), ...]` in `entities()` order, one entry
|
||||
@@ -237,8 +247,13 @@ def total(readings) -> tuple:
|
||||
site: shading, a tripped string and nightfall are all real 0s, and a roster
|
||||
that went to `unknown` whenever one member read zero would report an absence
|
||||
on every clear evening and defeat the channel from the other end.
|
||||
|
||||
⚠️ TEL-14: `flip` is `inverted(opts)`; each member's sign is applied HERE,
|
||||
before the sum, so every surface reads the corrected figure. `0.0 - w`
|
||||
rather than `-w`, so a flipped 0 W is `0.0` and never `-0.0` on the wire.
|
||||
"""
|
||||
parts = [(ent, *watts(st)) for ent, st in readings]
|
||||
parts = [(ent, w if w is None or ent not in flip else 0.0 - w, why)
|
||||
for ent, (w, why) in ((e, watts(st)) for e, st in readings)]
|
||||
if not parts:
|
||||
# No roster. `Controller` does not call this in that state - the gate is
|
||||
# `pv_roster` - but a reader of `parts` should never have to wonder.
|
||||
|
||||
@@ -82,6 +82,7 @@ import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
from .configui import changed_keys
|
||||
from .inverter import build_roster
|
||||
from .validate import validate_startup_config, validate_unit_ranges
|
||||
@@ -130,11 +131,16 @@ class SaveRefused(Exception):
|
||||
own sentences, byte for byte; `changed` names the keys that moved.
|
||||
"""
|
||||
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None):
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None,
|
||||
moved=None):
|
||||
self.kind = kind
|
||||
self.problems = list(problems)
|
||||
self.changed = list(changed)
|
||||
self.stored = stored
|
||||
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
|
||||
# against stored-now - when the caller knows what it opened. None
|
||||
# when it does not. `changed` also holds the operator's own edits.
|
||||
self.moved = None if moved is None else list(moved)
|
||||
super().__init__(f"save refused ({kind})")
|
||||
|
||||
|
||||
@@ -287,7 +293,7 @@ async def problems_for(opts: dict, hass=None) -> list:
|
||||
return problems
|
||||
|
||||
|
||||
async def problems_and_ranges(opts: dict, hass=None):
|
||||
async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
|
||||
|
||||
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
|
||||
@@ -302,6 +308,8 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
has just typed a new `setpoint_entity` must be told about THAT entity, and
|
||||
a route that asked the running controller instead would report on the old
|
||||
one. That was the first shape of this and it disagreed with itself.
|
||||
|
||||
UX-13: a list passed as `notes` collects the validator's warnings too.
|
||||
"""
|
||||
flat = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
|
||||
@@ -328,7 +336,7 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
# disagreement is itself the finding - so it is reported rather than
|
||||
# swallowed. Returning `[]` here would turn a crash into a silent
|
||||
# acceptance, which is worse than the 500 this replaced.
|
||||
found = list(validate_startup_config(opts))
|
||||
found = list(validate_startup_config(opts, notes=notes))
|
||||
if not found:
|
||||
found = [f"the configuration could not be composed ({type(exc).__name__}: "
|
||||
f"{exc}), and the validator did not name a reason. This is a "
|
||||
@@ -346,7 +354,8 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
|
||||
dev_min, dev_max = unit.dev_min, unit.dev_max
|
||||
break
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max))
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max,
|
||||
notes=notes))
|
||||
problems += validate_unit_ranges(units, skip_entity=flat)
|
||||
unread = [unit.setpoint_entity for unit in units
|
||||
if unit.setpoint_entity and unit.dev_min is None
|
||||
@@ -355,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
|
||||
|
||||
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
hass=None) -> dict:
|
||||
hass=None, base=None) -> dict:
|
||||
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
|
||||
|
||||
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
|
||||
@@ -378,11 +387,17 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
genuinely wants what the other surface wrote replaced, the page reloads,
|
||||
shows them what changed, and they save again from THAT - which is a
|
||||
decision made in front of the difference rather than through it.
|
||||
|
||||
`base`, when given, is the blob the page opened (the one `expect` is the
|
||||
fingerprint of). A stale refusal then also names the keys that moved
|
||||
underneath it, so the page does not list the operator's own edits among
|
||||
the settings "changed elsewhere" (UX-13 P2 test, finding a).
|
||||
"""
|
||||
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 %s. The "
|
||||
"first: %s", messages.count(len(problems), "problem"),
|
||||
problems[0])
|
||||
raise SaveRefused("invalid", problems=problems)
|
||||
|
||||
info = await selfcfg.info()
|
||||
@@ -390,10 +405,15 @@ 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",
|
||||
", ".join(changed) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||
moved = (changed_keys(base, stored)
|
||||
if isinstance(base, dict) and fingerprint(base) == expect
|
||||
else None)
|
||||
keys = changed if moved is None else moved
|
||||
_LOG.warning("The settings page did not save, because the settings "
|
||||
"changed while it was open (keys: %s).",
|
||||
", ".join(keys) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored,
|
||||
moved=moved)
|
||||
|
||||
await selfcfg.write_options(proposed)
|
||||
_LOG.info("options saved from the ingress panel; a restart is needed "
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""UX-15: setup-only mode. A new install serves the setup wizard and commands
|
||||
nothing. The rule and the argument are in DECISIONS/UX-15-setup-only-mode.md.
|
||||
|
||||
`applies()` says when: the startup gate's only refusal is that no inverter is
|
||||
named. `serve()` then runs the ingress page on a `SetupOnly` stand-in for the
|
||||
controller. Nothing that can command is built: no Controller, no inverter, no
|
||||
MQTT, no meter task. Its HomeAssistant is read-only, so a service call refuses
|
||||
before anything is sent (hass.py), and `/action` refuses too (web.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import signal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import messages, web
|
||||
from .inverter import roster_entries
|
||||
from .validate import actuator_writers, no_power_request_problem
|
||||
|
||||
_LOG = logging.getLogger("goodwe.setup")
|
||||
|
||||
|
||||
def applies(opts: dict, problems) -> bool:
|
||||
"""True when the options name no inverter at all and that is the gate's
|
||||
only refusal. Anything else refused keeps the exit 78 (CFG-01)."""
|
||||
return (not actuator_writers(opts) and not roster_entries(opts)
|
||||
and list(problems) == [no_power_request_problem()])
|
||||
|
||||
|
||||
class SetupOnly:
|
||||
"""What web.build_app() needs from a controller, and nothing that writes.
|
||||
|
||||
It holds no inverter and no publisher. `hass` must be read-only."""
|
||||
|
||||
setup_only = True
|
||||
auto = False # control is never started here
|
||||
grid = None # no meter is read here
|
||||
inverters = ()
|
||||
|
||||
def __init__(self, opts: dict, hass, store):
|
||||
if getattr(hass, "read_only", False) is not True:
|
||||
raise ValueError("setup-only mode needs a read-only HomeAssistant")
|
||||
self.o, self.hass, self.store = opts, hass, store
|
||||
self.events: list[str] = []
|
||||
self.hint = ""
|
||||
|
||||
def log_event(self, msg: str, hint: str | None = None) -> None:
|
||||
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
self.events.insert(0, f"{stamp} {msg}")
|
||||
del self.events[40:]
|
||||
if hint is not None:
|
||||
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
|
||||
|
||||
def status_payload(self, lang: str = "en") -> dict:
|
||||
return {"setup_only": True, "mode": "setup_only",
|
||||
"banner": messages.banner_setup_only(), "level": "warn",
|
||||
"rows": [["Control", "not started, in setup"]],
|
||||
"checks": [{"ok": False, "warn": True,
|
||||
"text": messages.row_setup_only()}],
|
||||
"hint": self.hint, "events": list(self.events)}
|
||||
|
||||
async def handle_action(self, what):
|
||||
# web.py answers 409 before it gets here. Nothing is done either way.
|
||||
_LOG.warning("The page asked for an action in setup, so nothing "
|
||||
"was done.")
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Nothing to wind down: nothing was ever commanded."""
|
||||
|
||||
|
||||
async def serve(opts: dict, hass, store, *, port: int) -> None:
|
||||
"""Serve the page in setup-only mode until the Supervisor stops us."""
|
||||
controller = SetupOnly(opts, hass, store)
|
||||
_LOG.warning("No inverter is named yet, so the add-on is in setup and "
|
||||
"controls nothing. Open its page to finish setup.")
|
||||
controller.log_event("setup-only mode: no inverter named, nothing is "
|
||||
"commanded", hint=messages.hint("setup_only"))
|
||||
runner = await web.start(controller, port=port)
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
try:
|
||||
await stop.wait()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
_LOG.info("stopped")
|
||||
@@ -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]}'."]
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor.
|
||||
|
||||
Pure: HA state dicts and the time in, a `Forecast` (or None and why) out.
|
||||
main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces
|
||||
this later with the add-on's own forecast (owner answer, 2026-09-27).
|
||||
|
||||
Two options name the sensors: `pv_forecast_entity`, the solar energy still
|
||||
to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it
|
||||
is only needed for a cheap run before midnight). Supported, by the entities
|
||||
they create (DOCS.md lists them):
|
||||
|
||||
* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and
|
||||
`sensor.energy_production_tomorrow`. The state only; no hourly split.
|
||||
* Solcast PV Forecast (HACS `solcast_solar`):
|
||||
`sensor.solcast_pv_forecast_forecast_remaining_today` and
|
||||
`..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`):
|
||||
`[{period_start, pv_estimate}]`, kW per period.
|
||||
* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`):
|
||||
`sensor.energy_production_today_remaining` / `..._tomorrow`, with
|
||||
`wh_period`: `{period start: Wh}`.
|
||||
|
||||
Any other sensor whose state is energy in Wh, kWh or MWh works too, as a
|
||||
state only. The unit is read, never guessed (the rule `pv.watts()` has).
|
||||
Without an hourly split, the day's energy is spread over a fixed bell from
|
||||
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
|
||||
|
||||
A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .hass import BAD_STATES
|
||||
from .tariff import BILLING_TZ
|
||||
|
||||
KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0}
|
||||
|
||||
# Stale after 3 h. The integrations above refresh at least hourly, so a
|
||||
# 2 h limit would flag a site whose hourly poll was missed once.
|
||||
FRESH_S = 3 * 3600.0
|
||||
|
||||
# The bell a state-only forecast is spread over, in local hours.
|
||||
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
|
||||
# Why there is no forecast (the page words are in ui_text `home_words`).
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
|
||||
# OPT-17: (single key, list key) per day. A set list replaces the single key;
|
||||
# the validator refuses both set for the same day.
|
||||
TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities")
|
||||
TOMORROW_KEYS = ("pv_forecast_tomorrow_entity",
|
||||
"pv_forecast_tomorrow_entities")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Day:
|
||||
"""One day's forecast: `kwh` still to come (today) or in all (tomorrow),
|
||||
and the hourly split if the sensor has one: `((start, end, kwh), ...)`."""
|
||||
day0: datetime # local midnight, as UTC
|
||||
kwh: float
|
||||
periods: tuple = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Forecast:
|
||||
today: Day
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
|
||||
def between(self, t0: datetime, t1: datetime) -> float | None:
|
||||
"""kWh the sun should give between `t0` (now or later) and `t1`.
|
||||
None when the span reaches the sunny hours of a day with no
|
||||
forecast (tomorrow, when its sensor is not set)."""
|
||||
if t1 <= t0:
|
||||
return 0.0
|
||||
last = self.tomorrow or self.today
|
||||
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
|
||||
return None
|
||||
total = 0.0
|
||||
for day in (self.today, self.tomorrow):
|
||||
if day is None:
|
||||
continue
|
||||
a = max(t0, day.day0)
|
||||
b = min(t1, day.day0 + timedelta(days=1))
|
||||
if b > a:
|
||||
total += _day_between(day, a, b, t0)
|
||||
return total
|
||||
|
||||
|
||||
def _local_hour(t: datetime) -> float:
|
||||
lt = t.astimezone(BILLING_TZ)
|
||||
return lt.hour + lt.minute / 60.0 + lt.second / 3600.0
|
||||
|
||||
|
||||
def _bell(h: float) -> float:
|
||||
"""Cumulative share of a day's sun by local hour `h` (0..1)."""
|
||||
if h <= BELL_FROM_H:
|
||||
return 0.0
|
||||
if h >= BELL_TO_H:
|
||||
return 1.0
|
||||
x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H)
|
||||
return (1.0 - math.cos(math.pi * x)) / 2.0
|
||||
|
||||
|
||||
def _hours_since(day0, t):
|
||||
return (t - day0).total_seconds() / 3600.0
|
||||
|
||||
|
||||
def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
|
||||
if day.periods:
|
||||
out = 0.0
|
||||
for s, e, kwh in day.periods:
|
||||
lo, hi = max(a, s), min(b, e)
|
||||
if hi > lo:
|
||||
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
|
||||
return out
|
||||
# State only: `kwh` is what is left from `max(now, day0)` to the day's
|
||||
# end, spread over the bell.
|
||||
h_from = _hours_since(day.day0, max(now, day.day0))
|
||||
left = 1.0 - _bell(h_from)
|
||||
if left <= 1e-9:
|
||||
return 0.0
|
||||
share = (_bell(_hours_since(day.day0, b))
|
||||
- _bell(_hours_since(day.day0, a))) / left
|
||||
return day.kwh * max(0.0, share)
|
||||
|
||||
|
||||
def _parse_time(v):
|
||||
try:
|
||||
t = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _periods(attrs: dict, day0: datetime) -> tuple:
|
||||
"""The hourly split, if the sensor has one, for the day at `day0`."""
|
||||
d1 = day0 + timedelta(days=1)
|
||||
out = []
|
||||
for key in ("detailedForecast", "detailedHourly"):
|
||||
rows = attrs.get(key)
|
||||
if isinstance(rows, list) and rows:
|
||||
starts = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
t = _parse_time(r.get("period_start"))
|
||||
try:
|
||||
kw = float(r.get("pv_estimate"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if t is not None and math.isfinite(kw) and kw >= 0:
|
||||
starts.append((t, kw))
|
||||
starts.sort()
|
||||
for i, (t, kw) in enumerate(starts):
|
||||
step = (starts[i + 1][0] - t if i + 1 < len(starts)
|
||||
else timedelta(minutes=30 if key ==
|
||||
"detailedForecast" else 60))
|
||||
if step <= timedelta(0) or step > timedelta(hours=1):
|
||||
step = timedelta(minutes=30 if key == "detailedForecast"
|
||||
else 60)
|
||||
if day0 <= t < d1:
|
||||
out.append((t, t + step,
|
||||
kw * step.total_seconds() / 3600.0))
|
||||
if out:
|
||||
return tuple(out)
|
||||
wh = attrs.get("wh_period")
|
||||
if isinstance(wh, dict) and wh:
|
||||
rows = []
|
||||
for k, v in wh.items():
|
||||
t = _parse_time(k)
|
||||
try:
|
||||
e = float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if t is not None and math.isfinite(e) and e >= 0:
|
||||
rows.append((t, e / 1000.0))
|
||||
rows.sort()
|
||||
for i, (t, kwh) in enumerate(rows):
|
||||
nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta(
|
||||
hours=1)
|
||||
if nxt - t > timedelta(hours=1) or nxt <= t:
|
||||
nxt = t + timedelta(hours=1)
|
||||
if day0 <= t < d1:
|
||||
out.append((t, nxt, kwh))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _day(state, day0, now, *, today):
|
||||
"""`(Day, None)` or `(None, why)` from one sensor's state dict."""
|
||||
if state is None:
|
||||
return None, MISSING
|
||||
raw = str(state.get("state", "")).strip()
|
||||
if raw.lower() in BAD_STATES:
|
||||
return None, MISSING
|
||||
try:
|
||||
v = float(raw)
|
||||
except ValueError:
|
||||
return None, BAD
|
||||
attrs = state.get("attributes") or {}
|
||||
unit = str(attrs.get("unit_of_measurement") or "").strip()
|
||||
if unit not in KWH_PER:
|
||||
return None, UNIT
|
||||
kwh = v * KWH_PER[unit]
|
||||
if not math.isfinite(kwh) or kwh < 0:
|
||||
return None, BAD
|
||||
periods = _periods(attrs, day0)
|
||||
if periods and today:
|
||||
# The split is the whole day; keep what is still to come, scaled so
|
||||
# it adds up to the state (the state is what the integration says
|
||||
# is left).
|
||||
left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds()
|
||||
/ (e - s).total_seconds()))
|
||||
for s, e, k in periods)
|
||||
if left > 1e-6:
|
||||
f = kwh / left
|
||||
periods = tuple((s, e, k * f) for s, e, k in periods)
|
||||
return Day(day0, kwh, periods), None
|
||||
|
||||
|
||||
def _reported(state):
|
||||
ts = [_parse_time(state.get(k)) for k in
|
||||
("last_reported", "last_updated", "last_changed")]
|
||||
ts = [t for t in ts if t is not None]
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
dropped, as `pv.entities()` does: it would count one forecast twice."""
|
||||
one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS
|
||||
listed = [e for e in (str(x or "").strip()
|
||||
for x in (opts.get(many) or [])) if e]
|
||||
if listed:
|
||||
return tuple(dict.fromkeys(listed))
|
||||
e = str(opts.get(one) or "").strip()
|
||||
return (e,) if e else ()
|
||||
|
||||
|
||||
def _bounds(now):
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _today(state, day0, now):
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
|
||||
today, why = _day(state, day0, now, today=True)
|
||||
if today is None:
|
||||
return None, None, why
|
||||
at = _reported(state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, None, STALE
|
||||
return today, at, None
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, STALE
|
||||
return t, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
configured=True):
|
||||
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
|
||||
(the option is not set, or the sensor is missing): the forecast then
|
||||
covers today only."""
|
||||
if not configured:
|
||||
return None, NOT_SET
|
||||
day0, day1 = _bounds(now)
|
||||
today, at, why = _today(today_state, day0, now)
|
||||
if today is None:
|
||||
return None, why
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
return Forecast(today, tomorrow, at), None
|
||||
|
||||
|
||||
def _sum(days):
|
||||
"""One Day from several members' Days. The hourly split is kept only if
|
||||
every member has one: the splits are then joined (`_day_between()`
|
||||
adds overlapping periods). Else the total goes on the bell."""
|
||||
if len(days) == 1:
|
||||
return days[0]
|
||||
periods = ()
|
||||
if all(d.periods for d in days):
|
||||
periods = tuple(sorted(p for d in days for p in d.periods))
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
Each member is read exactly as `read()` reads one sensor, in its own
|
||||
unit. A member that is missing, unavailable, stale, not in an energy
|
||||
unit or bad makes that DAY unusable, as one such sensor does today: no
|
||||
today means no forecast, no tomorrow means today only. A partial sum
|
||||
would under-count the sun and mislead the day plan.
|
||||
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays = []
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||