Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 5.5 e0b578756f UX-10 review: the line as logged gets the red-alarm shape, clamp lines at 30 words, plain HA 400
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
Part 6 checked the quoted reason alone, so title + reason + tails logged
as 4+ sentences. It now also checks the whole line (at most 3 sentences,
none over 30 words); 25 lines failed and are rewritten (one-sentence
wrappers, two-sentence circulation and peak reasons, STALE_READING's
fixed limits on a DEBUG line). The clamp lines for one inverter are
pinned at 30 words with OUTPUT_CLAMP and the numbers kept. Home
Assistant's bare '400: Bad Request' while it loads becomes one plain
line; still WARNING, throttled, a failed write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:08:38 +02:00
glenn schrooyenandClaude Opus 5.5 f1a660881c UX-10: two INFO lines read better on TEST-23 (hours count, maintenance history)
checks / unit (pull_request) Successful in 9m2s
infra-pin / fresh (pull_request) Successful in 2s
"11 of 48 hours use yesterday's prices", and the maintenance-history line no
longer says "maintenance history" twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 11:03:30 +02:00
glenn schrooyenandClaude Opus 5.5 23a3a3f5b5 UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
- test_messages.py part 6: every dynamic part of a WARNING+ line is classified
  as data or as a built text, and every built text is produced by the real
  code and checked. This is the gap the 2d scan had: it counted `%s` as one
  word, so reasons built by loop_clamp_note() and friends were never seen.
- Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept):
  OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak
  guard (codes now PEAK_GUARD*), the migration report, price feed, price
  history and gate reasons. Plurals via messages.times()/count().
- Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff
  "one every 15 minutes or every hour", "not final yet" dropped.
- Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO,
  aiohttp access log only at log level debug, settings warnings once per
  start, one plain line per Home Assistant outage (hass.Outage).
- INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE",
  "NOT_OBSERVED", "did NOT land", "AND".
- Display bugs: WHY_WINNER covers every winner and safety reason
  (main.why_key(), part 7 finds them in the code); the meter row is amber
  "waiting for the first reading" inside its limit after a start; the
  missing-reading age is counted when the page asks.
- STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section).

No change to safety behaviour, alarm levels, notification ids, triggers,
cadence or the banner cascade. One row colour changes (meter at start),
justified in DECISIONS.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 08:01:16 +02:00
admin a02cd4b713 Merge pull request 'UX-08 2c: short refusals, error texts and option texts' (#135) from UX-08-2c-refusals into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 07:01:25 +02:00
49 changed files with 2689 additions and 1040 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin"). # The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
7081df4276ad3cf788355b97a3b3f40ea28d72bd ecd3864d9376a990c267dfb399eb2dae4e56c76b
+10 -2
View File
@@ -1,13 +1,15 @@
# STYLE.md - how user-facing text is written (UX-08) # STYLE.md - how user-facing text is written (UX-08)
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Kinds that have not been rewritten yet are on its allowlist, and that allowlist only shrinks. 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 ## 1. Shape
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop. - **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. - **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. - **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. - **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
- **Log line** (WARNING/ERROR): one line, at most 25 words. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated. - **Log line** (WARNING/ERROR): one line, at most 25 words, with the numbers that matter. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated. A reason text a line quotes (`%s`) keeps its reason code (`OUTPUT_CLAMP`, `STALE_READING`), because that is the evidence an installer searches for. The reason itself has 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 reason with the real code and checks it. 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. - **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 ## 2. Numbers
@@ -48,8 +50,14 @@ A setting is named by its label in `translations/en.yaml`, in quotes when it sit
| safety stop (a stop that only starting control clears) | failsafe latch | | safety stop (a stop that only starting control clears) | failsafe latch |
| software fault | BUG SIGNAL | | software fault | BUG SIGNAL |
| control board | master, ESP32 | | 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. | | 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. **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. 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.
@@ -44,3 +44,37 @@ Validator refusals, the parse errors they carry, and every option name and descr
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. 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. 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. 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.
+179 -99
View File
@@ -7,45 +7,73 @@ balance cells and recalibrate its coulomb counter.
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
v0.1.0 and outdated; where it disagrees with this page, this page wins. v0.1.0 and outdated; where it disagrees with this page, this page wins.
## Words used on this page
- **Control board**: the small computer on the inverter's meter cable. It
passes the add-on's power request to the inverter. If the add-on goes quiet
for about 30 seconds, it sets the inverter to 0 W by itself. (For
installers: a T-CAN485 board with an ESP32 chip on the RS485 bus.)
- **Isolator**: the switch that cuts an inverter off from the mains. Switching
it off stops the inverter, whatever the add-on or the control board asks.
- **Developer Tools**: the Home Assistant page that lists every entity and its
current value, on its *States* tab. Use it to check that a sensor the add-on
reads is really there and updating.
- **Shadow mode**: the add-on makes every decision but sends nothing, so every
inverter stays at 0 W. Pages and notifications say what it *would* have done.
- **Safety stop**: the add-on has set one inverter, or all of them, to 0 W and
keeps it there until you start control on the add-on page.
- **Peak limit**: the 15-minute average grid use you want to stay under this
month, because your capacity tariff bills the highest one.
- **Peak guard**: the part of the add-on that uses the battery to keep each
15-minute period under the peak limit.
- **Peak reserve** and **battery cut-out level**: the charge the peak guard
keeps back, and the level where the battery switches itself off.
## Before you start ## Before you start
You need: You need:
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation) - A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
- The vendor's meter-emulating controller **disconnected** from the bus - The vendor's meter-emulating controller **disconnected** from the bus
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml` - A control board: a T-CAN485 (ESP32) flashed with
`firmware/goodwe-master.yaml`
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s - A grid-power sensor already working in Home Assistant, updating every ~5–10 s
After installing, turn on **Watchdog** on the add-on's Info tab. A crashed After installing, turn on **Watchdog** on the add-on's Info tab. If control
control loop stops the add-on at 0 W, and the Supervisor restarts it only when stops by accident, the add-on sets the inverters to 0 W and exits, and Home
Watchdog is on (it is off by default). See Assistant starts it again only when Watchdog is on. It is off by default.
`DECISIONS/SAFETY-61-control-task-death.md`.
## The safety model, in short ## The safety model, in short
The inverter **holds its last command forever** — it has no meter-timeout. So: The inverter **keeps its last power request forever**. It has no timeout of its
own. So:
- The ESP32 commands 0 W if this add-on stops refreshing for ~30 s, and keeps - The control board sets the inverter to 0 W if this add-on stops sending for
commanding it. about 30 seconds, and keeps it there.
- This add-on commands 0 W when its inputs go missing, when you stop control, - This add-on sets the inverters to 0 W when a reading goes missing, when you
and when it shuts down. stop control, and when it shuts down.
- This add-on commands 0 W **and latches there** if what it is commanding and - This add-on sets the inverters to 0 W **and keeps them there** if the power
what the meter reads point opposite ways for three minutes — see "The goes the opposite way to what it asks for three minutes. See "The
sign-disagreement failsafe" below. sign-disagreement failsafe" below.
- The **optional RS485 e-stop** is the only thing that covers this machine - The **optional emergency stop** is the only thing that covers this computer
dying. Without it, a failed host leaves the battery latched at its last failing. Without it, the battery keeps its last power request until someone
command until someone intervenes. steps in.
- This add-on **raises an alarm — and nothing more —** when the meter keeps - This add-on **raises an alarm, and does nothing more,** when the meter keeps
answering with a reading that never changes while the command sits at the answering with a value that never changes while the battery is asked for
rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff full power. See "The frozen-meter alarm" and, before you rely on it,
exposure of the grid-following loop". "Capacity-tariff exposure of the grid-following loop".
**If anything looks wrong: stop the add-on.** That commands 0 W and the **If anything looks wrong: stop the add-on.** That sets the inverters to 0 W,
hardware holds it there. and the control board keeps them there.
*For installers: a crashed control task is SAFETY-61, see
`DECISIONS/SAFETY-61-control-task-death.md`.*
## Configuration ## Configuration
### ⚠️ There are TWO places to configure this add-on (UX-02) ### ⚠️ There are TWO places to configure this add-on
*For installers: card UX-02.*
Since **UX-02** the ingress panel has a **Configuration** page of its own, Since **UX-02** the ingress panel has a **Configuration** page of its own,
beside Home Assistant's *Configuration* tab. Both edit the **same** settings — beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
@@ -108,7 +136,9 @@ One column, big targets, sections collapsed until you open them, and the
refusals pinned where you cannot scroll past them. It is the screen for refusals pinned where you cannot scroll past them. It is the screen for
standing in front of the inverter. standing in front of the inverter.
### What the Configuration tab actually shows you (UX-01) ### What the Configuration tab actually shows you
*For installers: card UX-01.*
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`, Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
so Home Assistant's *Configuration* tab renders human labels and a description so Home Assistant's *Configuration* tab renders human labels and a description
@@ -179,10 +209,10 @@ The label and description each setting shows on the Configuration tab, in tab or
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. | | Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
| Tariff | Distribution, levies and VAT (EUR/kWh) | `price_taxes_eur_kwh` | Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates. | | Tariff | Distribution, levies and VAT (EUR/kWh) | `price_taxes_eur_kwh` | Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates. |
| Tariff | Injection price (EUR/kWh) | `export_price_eur_kwh` | What you are paid per kWh fed into the grid, all in. It may be negative. | | Tariff | Injection price (EUR/kWh) | `export_price_eur_kwh` | What you are paid per kWh fed into the grid, all in. It may be negative. |
| Tariff | Fixed or day/night tariff (JSON) | `static_profile` | Your fixed or day/night contract as one line of JSON, prices all in. Fill it in even on a dynamic contract, as the fallback when the price feed fails. | | Tariff | Fixed or day/night tariff | `static_profile` | Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback. |
| Tariff | Day/night levies and VAT (JSON) | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. | | Tariff | Day/night levies and VAT | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. |
| Tariff | Your own tariff formula (JSON) | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card as one line of JSON, and the add-on checks them against the card. | | Tariff | Your own tariff formula | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card. |
| Tariff | Your own injection formula (JSON) | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. | | Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. | | Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. | | Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. | | Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
@@ -218,9 +248,9 @@ The label and description each setting shows on the Configuration tab, in tab or
| Advanced | Ignore errors below (W) | `deadband_w` | Errors smaller than this are ignored. Keep it small, since any resting point inside it holds. | | Advanced | Ignore errors below (W) | `deadband_w` | Errors smaller than this are ignored. Keep it small, since any resting point inside it holds. |
| Advanced | Meter resting point (W) | `target_grid_w` | What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day. | | Advanced | Meter resting point (W) | `target_grid_w` | What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day. |
| Advanced | Command rounding (W) | `step_w` | The command is rounded to a multiple of this, so noise does not change it every cycle. | | Advanced | Command rounding (W) | `step_w` | The command is rounded to a multiple of this, so noise does not change it every cycle. |
| Advanced | Inverter at limit gap (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. | | Advanced | At-limit shortfall (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. |
| Advanced | Cycles at limit before freezing | `saturation_cycles` | How many cycles in a row at the limit before control stops building up error. Do not set it to 1. | | Advanced | Cycles at limit before freezing | `saturation_cycles` | How many cycles in a row at the limit before control stops building up error. Do not set it to 1. |
| Advanced | Error build-up limit (W) | `integrator_max_w` | Caps the error that control saves up from past cycles. 0 means “Inverter rating”. Do not raise it above the rating. | | Advanced | Catch-up limit (W) | `integrator_max_w` | Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating. |
| Advanced | Command refresh interval (s) | `heartbeat_s` | How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit. | | Advanced | Command refresh interval (s) | `heartbeat_s` | How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit. |
| Advanced | Missing inputs time limit (s) | `stale_input_s` | How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”. | | Advanced | Missing inputs time limit (s) | `stale_input_s` | How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”. |
| Advanced | Longest time between cycles (s) | `cycle_floor_s` | A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s. | | Advanced | Longest time between cycles (s) | `cycle_floor_s` | A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s. |
@@ -246,7 +276,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”. - **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
- **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation. - **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
- **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored. - **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
- **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different-tariff-18). - **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different).
- **Day/night levies and VAT** (`price_taxes_profile`): for Wallonia and Brussels, where distribution has a day and a night rate (ORES 13,27 against 7,39 c/kWh, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10). The figures are levies alone, added to the market price. The add-on refuses to start if it and the flat figure disagree. See [Levies and VAT by time of day](#levies-and-vat-by-time-of-day-price_taxes_profile). - **Day/night levies and VAT** (`price_taxes_profile`): for Wallonia and Brussels, where distribution has a day and a night rate (ORES 13,27 against 7,39 c/kWh, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10). The figures are levies alone, added to the market price. The add-on refuses to start if it and the flat figure disagree. See [Levies and VAT by time of day](#levies-and-vat-by-time-of-day-price_taxes_profile).
- **Injection price** (`export_price_eur_kwh`): one number because no supplier here publishes a dynamic injection curve. In Belgium it is often near zero, and at a sunny midday it can be negative. - **Injection price** (`export_price_eur_kwh`): one number because no supplier here publishes a dynamic injection curve. In Belgium it is often near zero, and at a sunny midday it can be negative.
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile). - **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
@@ -259,19 +289,19 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Draw-down and recharge rate** (`maintenance_discharge_w`, `maintenance_charge_w`): the draw-down rate above the rating is cut back, not refused. A recharge rate above the rating would ask for more than the limit allows and resolve to 0 W, so maintenance would never charge. That is why the add-on refuses it. - **Draw-down and recharge rate** (`maintenance_discharge_w`, `maintenance_charge_w`): the draw-down rate above the rating is cut back, not refused. A recharge rate above the rating would ask for more than the limit allows and resolve to 0 W, so maintenance would never charge. That is why the add-on refuses it.
- **Draw down to** (`maintenance_soc_floor`): the monthly cycle goes this deep on purpose, while “Peak reserve” is a depth peak limiting never reaches. 11 % against 20 % is why they are two settings. - **Draw down to** (`maintenance_soc_floor`): the monthly cycle goes this deep on purpose, while “Peak reserve” is a depth peak limiting never reaches. 11 % against 20 % is why they are two settings.
- **Wrong-direction threshold and time** (`sign_disagree_w`, `sign_disagree_s`): measured, not chosen (SAFETY-02). On the captured healthy day (2026-08-23) signs agree on 93.4 % of gated samples, on the fault day (2026-08-20) 0.0-10.5 %, with opposed runs up to 1752 s. The longest legitimate opposed run is 30 s, and the fault day still trips 13 times at 300 s, so 180 s sits in a wide gap. The threshold must stay below “Inverter rating”. - **Wrong-direction threshold and time** (`sign_disagree_w`, `sign_disagree_s`): measured, not chosen (SAFETY-02). On the captured healthy day (2026-08-23) signs agree on 93.4 % of gated samples, on the fault day (2026-08-20) 0.0-10.5 %, with opposed runs up to 1752 s. The longest legitimate opposed run is 30 s, and the fault day still trips 13 times at 300 s, so 180 s sits in a wide gap. The threshold must stay below “Inverter rating”.
- **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit gap. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit. - **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit shortfall. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
- **Site wrong-direction threshold** (`site_sign_disagree_w`): the single-inverter 300 W carried across deliberately (SAFETY-30), because near zero the sign is noise whatever is behind the one meter. - **Site wrong-direction threshold** (`site_sign_disagree_w`): the single-inverter 300 W carried across deliberately (SAFETY-30), because near zero the sign is noise whatever is behind the one meter.
- **Site wrong-direction time** (`site_sign_disagree_s`): derived from the same 1890 runs (SAFETY-30). The worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, so 180 s would false-trip. 325 s is 1.51 times that and inside the 900 s billing period. - **Site wrong-direction time** (`site_sign_disagree_s`): derived from the same 1890 runs (SAFETY-30). The worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, so 180 s would false-trip. 325 s is 1.51 times that and inside the 900 s billing period.
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W. - **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first. - **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit. - **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`. - **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode-safety-10). - **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode).
- **Control gain** (`gain`): measured on real hardware and already at the limit. - **Control gain** (`gain`): measured on real hardware and already at the limit.
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05. - **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
- **Ignore errors below, Meter resting point** (`deadband_w`, `target_grid_w`): the meter's import counter bills every positive watt, so a resting point at 0 W leaks about half the deadband as billed import, roughly 0.2-0.35 kWh a day at 15 W. -10 W moves that leak to export. - **Ignore errors below, Meter resting point** (`deadband_w`, `target_grid_w`): the meter's import counter bills every positive watt, so a resting point at 0 W leaks about half the deadband as billed import, roughly 0.2-0.35 kWh a day at 15 W. -10 W moves that leak to export.
- **Inverter at limit gap** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too. - **At-limit shortfall** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
- **Error build-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction. - **Catch-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
- **Command refresh interval** (`heartbeat_s`): the control board refuses a link that has been quiet for 30 s. - **Command refresh interval** (`heartbeat_s`): the control board refuses a link that has been quiet for 30 s.
- **Longest time between cycles** (`cycle_floor_s`): keep it under “Reading too old after”, since past that there is no reading left to act on. - **Longest time between cycles** (`cycle_floor_s`): keep it under “Reading too old after”, since past that there is no reading left to act on.
@@ -286,9 +316,9 @@ When the add-on refuses to start, each reason names the setting by its label and
| `batt_invert` | | Flip if needed | | `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below | | `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` | | `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) | | `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity) |
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) | | `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities) |
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities-tel-14) | | `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities) |
Use the ESP32's readings rather than the inverter's cloud or dongle sensors: Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
those serve cached values, and a stale reading here ends the maintenance charge those serve cached values, and a stale reading here ends the maintenance charge
@@ -344,7 +374,7 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
| `soc_entity` | | This pack's state of charge | | `soc_entity` | | This pack's state of charge |
| `batt_entity` | | This unit's battery power, `+` = discharging | | `batt_entity` | | This unit's battery power, `+` = discharging |
| `batt_invert` | | Flip if needed | | `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs-opt-09) | | `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs) |
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC ⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
sensor** — it does *not* fall back to the flat one. That is deliberate: every sensor** — it does *not* fall back to the flat one. That is deliberate: every
@@ -387,7 +417,9 @@ in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.** `setpoint_entity` of the unit you want to keep running.**
#### How the site target is divided between the packs (OPT-09) #### How the site target is divided between the packs
*For installers: card OPT-09.*
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
version before it the split is a fixed 50/50 regardless of what either pack can version before it the split is a fixed 50/50 regardless of what either pack can
@@ -508,8 +540,9 @@ accumulator is doing:
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run | | `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will ⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
often disagree.** `Why` is the *arbiter's* sentence — `loop -> -1200 W` — which often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
names the strategy that won and the watts it won with. It has never carried the near zero: -1 200 W* — which names the strategy that won and the watts it won
with. The log keeps the arbiter's own `loop -> -1200 W`. It has never carried the
strategy's own reason, so a loop that is slew-limited or frozen says so **only** strategy's own reason, so a loop that is slew-limited or frozen says so **only**
here. here.
@@ -567,10 +600,12 @@ and `Inverter B` — are two names to the validator and one id once slugged. The
add-on gives the second one a `_2` suffix so nothing collides, but the ids you add-on gives the second one a `_2` suffix so nothing collides, but the ids you
get then depend on roster order. Give your inverters plainly different names. get then depend on roster order. Give your inverters plainly different names.
### PV production (`pv_entity`, TEL-11) ### PV production (`pv_entity`)
*For installers: card TEL-11.*
> **More than one solar inverter?** Use `pv_entities` instead — see > **More than one solar inverter?** Use `pv_entities` instead — see
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The > [Several PV inverters](#several-pv-inverters-pv_entities) below. The
> rest of this section applies to both. > rest of this section applies to both.
@@ -604,7 +639,9 @@ Only the third is a software defect, and **none of them can be told apart
without a record of production.** This entity is that record. The attribution without a record of production.** This entity is that record. The attribution
itself is OPT-10's, with a week of history behind it. itself is OPT-10's, with a week of history behind it.
#### The sign, and `pv_invert_entities` (TEL-14) #### The sign, and `pv_invert_entities`
*For installers: card TEL-14.*
**Positive must mean producing.** Some sensors — typically a kWh meter or an **Positive must mean producing.** Some sensors — typically a kWh meter or an
ESPHome power clamp in the PV circuit — read negative while producing and a few ESPHome power clamp in the PV circuit — read negative while producing and a few
@@ -642,7 +679,9 @@ pictures, which is exactly the distinction the whole card exists to preserve.
A failed read also **clears** the previous good one: the value never outlives A failed read also **clears** the previous good one: the value never outlives
its own sensor. its own sensor.
### Several PV inverters (`pv_entities`, TEL-12) ### Several PV inverters (`pv_entities`)
*For installers: card TEL-12.*
A multi-string or multi-orientation roof routinely carries **two or three PV A multi-string or multi-orientation roof routinely carries **two or three PV
inverters**, each publishing its own power sensor into Home Assistant. List them inverters**, each publishing its own power sensor into Home Assistant. List them
@@ -1025,7 +1064,9 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share | | `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below | | `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
#### Shadow mode (`shadow_mode`, SAFETY-10) #### Shadow mode (`shadow_mode`)
*For installers: card SAFETY-10.*
Shadow mode runs the **whole** decision path — the price strategy, the capacity Shadow mode runs the **whole** decision path — the price strategy, the capacity
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
@@ -1192,11 +1233,13 @@ If the setpoint this add-on is emitting and the grid figure it is regulating on
point **opposite ways** — charging the battery while the house imports, or point **opposite ways** — charging the battery while the house imports, or
discharging it while the house exports — with both over `sign_disagree_w`, for discharging it while the house exports — with both over `sign_disagree_w`, for
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it `sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
there. The banner reads *Command and meter point opposite ways*, the add-on there. The banner reads *The meter saw power go the wrong way*, the add-on
log says which two numbers disagreed and for how long, and the `Controller log says which two numbers disagreed and for how long, and the `Controller
status` entity reports `failsafe`. status` entity reports `failsafe`.
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30) #### ⚠️ At two or more inverters this is TWO checks, not one
*For installers: card SAFETY-30.*
The paragraph above describes what a **one-inverter** install does, and it is The paragraph above describes what a **one-inverter** install does, and it is
unchanged: there is one meter, one actuator, and comparing the command against unchanged: there is one meter, one actuator, and comparing the command against
@@ -1285,7 +1328,9 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
says so. says so.
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42) #### ⚠️ A unit that stops DELIVERING is a third fault again
*For installers: card SAFETY-42.*
The two checks above both key on **direction**. A unit sitting at **0 W against The two checks above both key on **direction**. A unit sitting at **0 W against
a non-zero share** has no direction: it is not pushing the wrong way, it is a non-zero share** has no direction: it is not pushing the wrong way, it is
@@ -1393,7 +1438,9 @@ correct toward that wrong value instead of holding a stale command. It is bounde
happened to a lying meter whose value merely jittered — bit-exact repetition was never a happened to a lying meter whose value merely jittered — bit-exact repetition was never a
safety property. safety property.
#### When the freeze lets go, and when it deliberately never does (SAFETY-13) #### When the freeze lets go, and when it deliberately never does
*For installers: card SAFETY-13.*
While the loop is frozen the command's **magnitude may fall but never rise**. That is While the loop is frozen the command's **magnitude may fall but never rise**. That is
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
@@ -1592,7 +1639,7 @@ is the design rather than an accident: it is 1.0 at the top of the quarter and
grows, so the guard is gentlest when its projection rests on the least evidence grows, so the guard is gentlest when its projection rests on the least evidence
and becomes aggressive only once the quarter is nearly measured. and becomes aggressive only once the quarter is nearly measured.
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that **The ceiling** is `peak_cap_w`, raised to this month's recorded peak when that
already stands higher — once a month's bill is set at 5013 W, holding later already stands higher — once a month's bill is set at 5013 W, holding later
quarters to 3500 W buys nothing and costs real charging. **That relaxation is quarters to 3500 W buys nothing and costs real charging. **That relaxation is
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
@@ -1668,8 +1715,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
capacity-peak guard will not spend the pack for money below 20 %"*, and capacity-peak guard will not spend the pack for money below 20 %"*, and
**nothing more**. See below for what that costs on a real evening. **nothing more**. See below for what that costs on a real evening.
- **When it binds you are told**, once per episode, with notification - **When it binds you are told**, once per episode, with notification
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD `goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
go over the ceiling, so shedding load is
the only thing left that changes the bill. Failing to shave is never silent — the only thing left that changes the bill. Failing to shave is never silent —
the euro figure is the whole point of the guard. the euro figure is the whole point of the guard.
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A - **An unreadable SoC is treated as floor-limited, not as a full battery.** A
@@ -1685,7 +1733,9 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
smaller, or your quarters are worse, raise it. smaller, or your quarters are worse, raise it.
#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25) #### ⚠️ What `peak_soc_floor` does **not** protect
*For installers: card SAFETY-25.*
**On a sustained-load evening this add-on will take your battery to the BMS **On a sustained-load evening this add-on will take your battery to the BMS
cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed
@@ -1708,7 +1758,7 @@ because the guard it bounds is not the thing doing the discharging.
**And the two can happen in the same cycle.** On the high-load block of the same **And the two can happen in the same cycle.** On the high-load block of the same
day (15:00–17:00 UTC) with the pack below the floor, the guard reports day (15:00–17:00 UTC) with the pack below the floor, the guard reports
`PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on 49 cycles, `PEAK_GUARD_FLOOR_LIMITED` — *"there is no forced discharge"* — on 49 cycles,
and on **all 49 of them** the loop is simultaneously being granted a discharge of and on **all 49 of them** the loop is simultaneously being granted a discharge of
up to 2000 W by the arbiter. The status page is telling you the truth about the up to 2000 W by the arbiter. The status page is telling you the truth about the
guard while the battery empties. guard while the battery empties.
@@ -1798,14 +1848,14 @@ Both are watched. A guard that watched only the emitted value would be silent on
every standard configuration. every standard configuration.
**The integrator bound is not a bug, and it is reported as what it is.** It **The integrator bound is not a bug, and it is reported as what it is.** It
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's reads *"the correction the add-on builds up reached its +5000 W limit,
command this cycle was +2000 W"*, titled *"the control loop is at its integrator command now +2000 W"*, titled *"Battery is not keeping up"*, logged
bound"*, logged as a `WARNING`, with an amber (not red) status row and its own as a `WARNING`, with an amber (not red) status row and its own notification
notification (`goodwe_integrator_bound`), so it never replaces a BUG SIGNAL (`goodwe_integrator_bound`), so it never replaces a software-fault notice still
notice still on screen. Brief on screen. Brief engagements after a large load or solar step are normal. If it
engagements after a large load or solar step are normal. If it stays engaged, stays engaged, check that the battery is following its commands. Only a command
check that the battery is following its commands. Only a command over the rating over the rating is logged as a software fault, at `ERROR`, and it has its own
carries the BUG SIGNAL text, and it has its own notification budget. notification budget.
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md). Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
### The frozen-meter alarm ### The frozen-meter alarm
@@ -1878,7 +1928,9 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
parked at 0 W and the two halves of the rule cannot both be true. At the schema parked at 0 W and the two halves of the rule cannot both be true. At the schema
floor of 60 s that figure is 60 W and only the margin is left. floor of 60 s that figure is 60 W and only the margin is left.
### The circulating-power alarm (SAFETY-31) ### The circulating-power alarm
*For installers: card SAFETY-31.*
> **Only at two or more inverters.** At the shipped roster of one this quantity > **Only at two or more inverters.** At the shipped roster of one this quantity
> is identically zero for every reading, no claim is ever filed, and the status > is identically zero for every reading, no claim is ever filed, and the status
@@ -2345,7 +2397,9 @@ distinct state from "prices, all of them zero": the plan is marked provisional
and arbitrage is disabled, per the degradation matrix. It never falls back to a and arbitrage is disabled, per the degradation matrix. It never falls back to a
price of zero, which would read as free electricity. price of zero, which would read as free electricity.
#### The hours the feed has not published yet (FCST-04) #### The hours the feed has not published yet
*For installers: card FCST-04.*
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**. A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
Before that, the feed is serving today and nothing else — so a `price_horizon_h` Before that, the feed is serving today and nothing else — so a `price_horizon_h`
@@ -2388,29 +2442,29 @@ firm/provisional split whenever that split is what refused a trade. And
since TARIFF-11 that row carries the provider plan itself: since TARIFF-11 that row carries the provider plan itself:
``` ```
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s Tariff: No fixed tariff set. Price plan from 412 s ago, for 48 hours ahead:
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead, day-ahead prices from the price feed, 60 prices over 33 hours,
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing, 36 published and 24 estimated, one every 15 minutes or every hour
provisional
``` ```
⚠️ **The two halves of that row are not computed at the same moment, and the ⚠️ **The two halves of that row are not computed at the same moment, and the
row says so.** Everything before the semicolon is the static-profile or Home row says so.** Everything before "Price plan" is the static-profile or Home
Assistant price-entity sentence, regenerated on the **30 s tick** from options Assistant price-entity sentence, regenerated on the **30 s tick** from options
alone — no network. Everything after it is the **background refresh's** alone — no network. Everything after it is the **background refresh's**
resolution, which runs every 1800 s and is served for up to 5400 s before it is resolution, which runs every 1800 s and is served for up to 5400 s before it is
retired; `fetched N s ago` is the age of the fetch that produced it and is the retired; `Price plan from N s ago` is the age of the fetch that produced it and is the
only figure in the row that tells you whether the feed is still answering. On a only figure in the row that tells you whether the feed is still answering. On a
site with a provider configured and no usable plan the row says that too, site with a provider configured and no usable plan the row says that too,
rather than falling silently back to the static half. rather than falling silently back to the static half.
Read the plan half as five separate facts: which *product* the feed publishes Read the plan half as five separate facts: which *product* the feed publishes
and which *plugin* read it, how many **points** it carries and how many **whole and whether it came from the feed or from your fixed tariff, how many
hours** those points cover, how many of the points are **firm** (published for **prices** it carries and how many **whole hours** they cover, how many are
their own interval) against **carried** (filled from yesterday under §8.3), the **published** (firm, for their own interval) against **estimated** (carried
**spacing** of the points in seconds — `900+3600` is the normal mixed steady from yesterday under §8.3), and how far **apart** they are — "one every 15
state, quarter-hourly firm points beside hourly carried ones — and `provisional` minutes or every hour" is the normal mixed steady state, quarter-hourly firm
whenever any point is carried. points beside hourly carried ones. The plan is provisional exactly when some
prices are estimated, so the estimated count says it.
⚠️ **Points and hours are two different numbers and this row used to print one ⚠️ **Points and hours are two different numbers and this row used to print one
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
@@ -2585,7 +2639,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
— configure two of them and the add-on refuses to start, naming the conflict, — configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one. because the add-on reads exactly one and the loser is silent. Use one.
### The three buckets — which price components can move a decision (TARIFF-17) ### The three buckets — which price components can move a decision
*For installers: card TARIFF-17.*
**Read this before adding a price component of any kind.** The optimiser reads **Read this before adding a price component of any kind.** The optimiser reads
exactly two quantities off a price series, and nothing else: exactly two quantities off a price series, and nothing else:
@@ -2641,7 +2697,9 @@ Three consequences worth having in mind:
is the only ORDER component the add-on has. Anything added next belongs in one is the only ORDER component the add-on has. Anything added next belongs in one
of the three, and saying which is part of adding it. of the three, and saying which is part of adding it.
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18) ### What `price_taxes_eur_kwh` is made of, and why yours is different
*For installers: card TARIFF-18.*
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a **It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
Flemish residential bill — everything on your invoice that is *not* the energy Flemish residential bill — everything on your invoice that is *not* the energy
@@ -3095,8 +3153,8 @@ does not protect*.
P1 meter, so every other guard, which reads that net figure, stays quiet. This P1 meter, so every other guard, which reads that net figure, stays quiet. This
has happened on this hardware once for 6 h 25 min. The alarm is latched, has happened on this hardware once for 6 h 25 min. The alarm is latched,
survives restarts, and is raised again every 5 min until control is started. survives restarts, and is raised again every 5 min until control is started.
The watts and the time window are in the log; the *Circulating power check* The watts and the time window are in the log. The *Check for inverters working
row shows the live watts. See *The circulating-power alarm*. against each other* row shows the live watts. See *The circulating-power alarm*.
**An inverter is not delivering.** SAFETY-42. The unit stood well short of its **An inverter is not delivering.** SAFETY-42. The unit stood well short of its
share of the site target for longer than `delivery_hold_s`. A unit at 0 W is share of the site target for longer than `delivery_hold_s`. A unit at 0 W is
@@ -3114,16 +3172,22 @@ and command figures are in the log. See *The frozen-meter alarm*.
## The Web UI ## The Web UI
The ingress panel shows live values, why the controller is commanding what it The add-on page shows live values, why the battery does what it does, and a
is, and a **Commissioning** checklist that names any problem in words. It also **Commissioning** checklist that names any problem in words. It also has three
carries the three buttons: start/stop control, force a maintenance cycle, and buttons: start or stop control, force a maintenance cycle, and abort one.
abort one. Under the buttons is the last thing that happened, in a few words.
Since **UX-02** it has a second page, **Configuration** — see *There are TWO It has a second page, **Configuration**. See *There are TWO places to configure
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the this add-on*, above. **It adds nothing you can do to the inverter.** It reads
inverter.** The configuration page reads and writes the add-on's own settings and writes the add-on's own settings and can restart the add-on. Only the three
and can restart the add-on; the set of commands that reach the hardware is buttons reach the inverter.
exactly the three buttons it always had.
*For installers: the Configuration page is UX-02. The line under the buttons is
`messages.hint()`, the plain form of the newest entry of the add-on's own
action log (`Controller.events`). The page no longer shows that log, but the
`status` JSON still carries its last 40 entries as `events`: open the add-on
page and add `status` to its address. The action-log tables in this document
refer to it.*
## Status page ## Status page
@@ -3141,17 +3205,19 @@ ends. That row says how to clear it.
| Row starts with | What it tells you | | Row starts with | What it tells you |
|---|---| |---|---|
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. | | Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. | | Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. |
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. | | Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. | | Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
| *month*: peak … | This month's peak, and how close it is to your peak limit. | | Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. | | 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. | | Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
| Inverter control | Whether the inverter can be controlled, and the power range it accepts. | | Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
| *name*: … cannot be controlled, commands refused, “Setpoint entity” is not set | Whether each inverter still takes commands. | | *name*: … cannot be controlled, commands refused, “Setpoint entity” is not set | Whether each inverter still takes commands. |
| Power request too high, Battery is not keeping up | See those titles under Notifications. Each shows how often it happened since the add-on started, and *fine now* once it has cleared. In shadow mode the row says what *would* have been sent. | | Power request too high, Battery is not keeping up | See those titles under Notifications. Each shows how often it happened since the add-on started, and *fine now* once it has cleared. In shadow mode the row says what *would* have been sent. |
| Direction check, Site direction check | Whether each inverter's power goes the way it is told. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. | | Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
| Circulating power check, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. | | Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. | | Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. | | Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
@@ -3176,7 +3242,17 @@ ERROR or WARNING line in the add-on log.
***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can ***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can
write, so the site refuses to run at part strength and holds 0 W. write, so the site refuses to run at part strength and holds 0 W.
- **Direction check, Site direction check** are the sign checks (SAFETY-02/30). - **Direction check, Site direction check** are the sign checks (SAFETY-02/30).
**Circulating power check** is SAFETY-31, **Delivery check** SAFETY-42. **Check for inverters working against each other** is SAFETY-31, **Delivery
check** SAFETY-42.
- **Month peak** and **15-minute periods measured** are TARIFF-01 and
TARIFF-13. The store's state at start is the INFO line `Month peak store:
…`, and the peak guard's full reason is on its log line.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line carries the full `STALE_READING` reason and when it clears.
- The table at the top: **Why** is `messages.why()`, the arbiter's answer in
words (`Resolution.explain()` stays on the log). **Tariff**, **Price
history**, **Arbitrage** and **Shadow mode** keep every figure they had
(TARIFF-11), in words.
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The - **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14). *Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up - **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
@@ -3190,7 +3266,9 @@ battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control. broker, and MQTT problems can never affect control.
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02) ## Before you commission inverter B
*For installers: card ARCH-02. The tool is `tools/rehearse_roster.py`.*
The day the `inverters:` roster is declared, two things change that are The day the `inverters:` roster is declared, two things change that are
**one-way on a live box and invisible until after the restart that applies **one-way on a live box and invisible until after the restart that applies
@@ -3371,7 +3449,9 @@ maintenance bookkeeping — and if a cycle ran during it, correct
`unit/<first>/last_completed` by hand before advancing again.** This is a stated `unit/<first>/last_completed` by hand before advancing again.** This is a stated
decision, not a discovery made at the inverter. decision, not a discovery made at the inverter.
## Before you deploy: `tools/predeploy_check.py` (PKG-09) ## Before you deploy
*For installers: card PKG-09. The tool is `tools/predeploy_check.py`.*
Two ways a deploy has gone wrong on this system, both of which the Supervisor Two ways a deploy has gone wrong on this system, both of which the Supervisor
reports as success: reports as success:
+4 -4
View File
@@ -246,11 +246,11 @@ class Allocation:
block the failsafe is worse than the hole it closes. block the failsafe is worse than the hole it closes.
""" """
if tick is None or self.tick is None or self.tick != tick: if tick is None or self.tick is None or self.tick != tick:
return (f"the allocation record on hand was made on tick " return (f"the power split on hand is from tick {self.tick!r}, "
f"{self.tick!r} and this is tick {tick!r} - a record whose " f"not this tick {tick!r}, and an old split cannot allow a "
f"envelopes have moved cannot authorise a write") f"write")
if not self.ok: 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: if unit not in self.shares:
return (f"{unit} has no share in this tick's allocation " return (f"{unit} has no share in this tick's allocation "
f"({self.explain()})") f"({self.explain()})")
+5 -4
View File
@@ -325,12 +325,13 @@ class CirculationWatchdog:
return False return False
self.tripped = True self.tripped = True
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
self.reason = ( self.reason = (
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between " 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"the inverters for {held:.0f} s, peaking at {self.peak_w:.0f} W, "
f"continuously - the meter cannot see it, because it nets to zero " f"over the limits of {self.cfg.min_w:.0f} W and "
f"there (limits: over {self.cfg.min_w:.0f} W, held over " f"{self.cfg.hold_s:.0f} s. The meter cannot see it, because it "
f"{self.cfg.hold_s:.0f} s)") f"nets to zero there")
return True return True
def held_s(self, now: datetime) -> float: def held_s(self, now: datetime) -> float:
+35 -16
View File
@@ -142,6 +142,8 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from . import messages
# The reason code, distinct from any saturation-freeze code by construction: # The reason code, distinct from any saturation-freeze code by construction:
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION". # nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
REASON_CODE = "OUTPUT_CLAMP" REASON_CODE = "OUTPUT_CLAMP"
@@ -234,31 +236,46 @@ CLEAR_CYCLES = 3
SUSTAINED_CYCLES = 3 SUSTAINED_CYCLES = 3
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something " # UX-10: the reasons below are quoted by a WARNING or ERROR line, so they
"upstream computed a setpoint the actuator cannot deliver.") # 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: def loop_clamp_note(d) -> str:
"""The loop-side alarm text for one `control.Decision`, or "". """The loop-side alarm text for one `control.Decision`, or "".
SAFETY-40: the integrator bound is anti-windup doing its job and says so; SAFETY-40: the integrator bound is anti-windup doing its job and says so;
only a command over the rating is a BUG SIGNAL. Every number is the only a command over the rating is a software fault. Every number is the
decision's own, i.e. from the tick that clamped. decision's own, i.e. from the tick that clamped.
""" """
if d.clamped: if d.clamped:
return (f"{REASON_CODE}: the loop computed {d.requested_w:+.0f} W, over " return (f"{REASON_CODE}: computed {d.requested_w:+.0f} W, cut to the "
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to " f"{d.clamped_w:+.0f} W rating, command now {d.target_w:+.0f} W")
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
if d.i_clamped: if d.i_clamped:
return (f"{REASON_CODE}: the loop's integrator reached its " return (f"{REASON_CODE}: the correction the add-on builds up reached "
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command " f"its {d.i_bound_w:+.0f} W limit, command now "
f"this cycle was {d.target_w:+.0f} W. Brief " f"{d.target_w:+.0f} W")
f"engagements after a large load or solar step are normal. If it "
f"persists, check that the battery is following its commands.")
return "" return ""
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) @dataclass(frozen=True)
class ClampResult: class ClampResult:
"""What the guard did, with the RAW value preserved. """What the guard did, with the RAW value preserved.
@@ -275,10 +292,12 @@ class ClampResult:
bound: str = "" # "" | "max_w" | "device" - which stage bit bound: str = "" # "" | "max_w" | "device" - which stage bit
def describe(self) -> str: def describe(self) -> str:
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W " # The sent value IS the limit that bound, so the rating is not
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} " # repeated: on "max_w" it equals the rating, and on "device" the
f"bound (rating {self.limit_w:.0f} W) - something upstream is " # rating was not what cut it back.
f"winding") 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: def is_commandable(value) -> bool:
+3 -4
View File
@@ -413,10 +413,9 @@ function showProblems(payload){
save.disabled = total > 0; save.disabled = total > 0;
if(total){ if(total){
note(banner, 'bad', 'This configuration would not start.', note(banner, 'bad', 'This configuration would not start.',
'The add-on refuses to run on it and would stop with exit 78. ' 'The add-on would refuse to start on it. Saving is blocked until these '
+ 'Saving is blocked until these are fixed; nothing has been written. ' + 'are fixed, and nothing has been written. Each message below comes '
+ 'Each message below is the add-on\\u2019s own words, from the same ' + 'from the same check that runs at start.');
+ 'check that runs at startup.');
banner.insertBefore(banner.lastChild, banner.firstChild); banner.insertBefore(banner.lastChild, banner.firstChild);
msg(total + (total === 1 ? ' problem' : ' problems') msg(total + (total === 1 ? ' problem' : ' problems')
+ ' - saving is blocked', 'bad'); + ' - saving is blocked', 'bad');
+4 -1
View File
@@ -289,7 +289,10 @@ def problems_by_field(problems, keys):
cross-field rule is about all of its fields, and hiding it from one of them 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. 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] + r"(?![A-Za-z0-9_])")) for key in keys]
by_key, unanchored = {}, [] by_key, unanchored = {}, []
for problem in problems: for problem in problems:
+12 -14
View File
@@ -175,6 +175,8 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from . import messages
class FormulaInvalid(Exception): class FormulaInvalid(Exception):
"""`price_formula` cannot be used. Raised by the parser, refused by """`price_formula` cannot be used. Raised by the parser, refused by
@@ -285,30 +287,28 @@ _LEGS = {
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,' '"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,' '"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}'), '"expect_incl_vat":15.55}}'),
when=("Leave it empty unless “Dynamic price source” (price_provider) " when="Leave it empty unless “Dynamic price source” is 'custom_formula'.",
"is 'custom_formula'."),
zero_factor=" and trading never finds a price difference", zero_factor=" and trading never finds a price difference",
flat="use “Fixed or day/night tariff” (static_profile) instead", flat="use “Fixed or day/night tariff” (static_profile) instead",
neg_factor="Put the minus sign on the offset, not on the factor", neg_factor="Put the minus sign on the offset, not on the factor",
card_required=True, card_required=True,
card_note="The add-on needs it to check your two numbers", card_note="The add-on needs it to check your two numbers",
card_causes=("that network charges are in “Distribution, levies and VAT” " card_causes=("the units, the VAT, and that network charges are not "
"(price_taxes_eur_kwh), not in the offset"), "in the offset"),
), ),
EXPORT_FORMULA_OPTION: _Leg( EXPORT_FORMULA_OPTION: _Leg(
option=EXPORT_FORMULA_OPTION, option=EXPORT_FORMULA_OPTION,
what="injection", what="injection",
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,' example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'), '"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
when=("Fill it in only if your injection price follows the market. It " when="Fill it in only if your injection price follows the market.",
"is used whatever the price source is."),
zero_factor="", zero_factor="",
flat="use “Injection price” (export_price_eur_kwh) instead", flat="use “Injection price” (export_price_eur_kwh) instead",
neg_factor="Put the minus sign on the offset, not on the factor", neg_factor="Put the minus sign on the offset, not on the factor",
card_required=False, card_required=False,
card_note=("It is optional here. Without it the add-on only checks " card_note=("It is optional here. Without it the add-on only checks "
"the size of your two numbers"), "the size of your two numbers"),
card_causes="that vat_pct is 0 for injection", card_causes="the units, and that the VAT is 0 for injection",
), ),
} }
@@ -527,10 +527,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
value = json.loads(text) value = json.loads(text)
except ValueError as e: except ValueError as e:
raise FormulaInvalid( raise FormulaInvalid(
f"{option} is not valid JSON ({e}). Write it on one line, for " f"{messages.not_valid(option, e)}. Copy the example from the "
f"example\n" f"documentation. {leg.when}")
f" {leg.example}\n"
f"{leg.when}")
if not isinstance(value, dict): if not isinstance(value, dict):
raise FormulaInvalid( raise FormulaInvalid(
f"{option} is {type(value).__name__}, not a JSON object with " f"{option} is {type(value).__name__}, not a JSON object with "
@@ -685,9 +683,9 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
got = in_card_units(spec, spec.card_index_eur_per_mwh) got = in_card_units(spec, spec.card_index_eur_per_mwh)
leg = _LEGS[spec.option] leg = _LEGS[spec.option]
return ( return (
f"{spec.option} does not match its own card: it gives {got:.4f} " f"{spec.option} gives {got:.4f} "
f"{spec.units} and the card says {spec.card_expect_incl_vat:g}. Check " f"{messages.UNIT_WORDS.get(spec.units, spec.units)}, the card says "
f"the units, the VAT and {leg.card_causes}.") f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+8 -6
View File
@@ -424,15 +424,17 @@ class FreezeWatchdog:
# page was telling, printed into the log and the HA notification where # page was telling, printed into the log and the HA notification where
# it would outlive the page. # it would outlive the page.
ceiling = rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max) 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)) capped = ("" if ceiling >= abs(float(max_w))
else f", capped from max_w {float(max_w):.0f} W by the " 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 = ( self.reason = (
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s " f"METER_FROZEN: the meter has not changed for "
f"without its measurement changing while the command stands at " f"{float(unchanged_s):.0f} s while the command is "
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W rail{capped} " f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W limit"
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over " f"{capped}. Limits: unchanged over {self.cfg.hold_s:.0f} s, "
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)") f"command over "
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W")
if self.active: if self.active:
return False return False
self.active = True self.active = True
+19 -24
View File
@@ -594,31 +594,26 @@ class Freshness:
# ("what did the register do" against "can I trust this entity"). One # ("what did the register do" against "can I trust this entity"). One
# helper serving both would have to drop whichever half the other does # 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. # not need, and the half it dropped would be the operator's next step.
self.reason = ( # UX-10: two plain sentences (test_messages.py part 6). SAFETY-56's
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s " # two explanations stay, in this order, and so does the admission
f"while the standing command moved {moved_w:.0f} W to " # that this check cannot tell them apart. The reading is treated as
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE " # missing either way, which the log line around this says. The
f"CANNOT TELL THEM APART: the register may have stopped reporting " # limits (`self.bound`) are on main.py's DEBUG line beside it, so the
f"reality, or the source may be unable to comply with the command " # logged line stays within 3 sentences.
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)")
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each # ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
# in its own slot: the register's unchanged time is in the sentence # in its own slot: the register's unchanged time, the WINDOW this
# above, the WINDOW this verdict actually rests on is here, and so is # verdict actually rests on after a release (`regraded`), and how many
# how many times this unit has been round the loop. Without the second # times this unit has been round the loop. Without the second one the
# one the sentence reads as though 92 s of unchanged register were what # sentence reads as though 92 s of unchanged register were what was
# was compared against the 35 s limit, and it was not. # compared against the 35 s limit, and it was not.
if self.releases: regraded = (f", checked {self.releases + 1} times over a {held_s:.0f} s "
self.reason += ( f"window since the last stop was withdrawn"
f" [re-graded: the {held_s:.0f} s window this verdict rests on " if self.releases else "")
f"opened when the previous one was withdrawn, not when the " self.reason = (
f"register last moved - this unit has now been graded " f"STALE_READING: {self.held:+.0f} W unchanged for {shown_s:.0f} s "
f"{self.releases + 1} times]") 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: def unchanged_s(self, now_mono) -> float | None:
"""Seconds since an arrival carried a different number. Status page. """Seconds since an arrival carried a different number. Status page.
+62 -2
View File
@@ -16,6 +16,38 @@ _LOG = logging.getLogger("goodwe.hass")
CORE_API = "http://supervisor/core/api" CORE_API = "http://supervisor/core/api"
SUPERVISOR_API = "http://supervisor" SUPERVISOR_API = "http://supervisor"
BAD_STATES = ("unknown", "unavailable", "none", "") BAD_STATES = ("unknown", "unavailable", "none", "")
# What the Supervisor's proxy answers while Home Assistant restarts.
UNAVAILABLE = (502, 503, 504)
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.
"""
def __init__(self):
self.down_on = set()
def down(self, channel: str, what: str, detail) -> None:
if not self.down_on:
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
"restarting. The add-on keeps trying.", what)
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.")
OUTAGE = Outage()
class HomeAssistant: class HomeAssistant:
@@ -58,10 +90,18 @@ class HomeAssistant:
async with self.session.get( async with self.session.get(
f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10 f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10
) as resp: ) 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: if resp.status == 404:
return None return None
resp.raise_for_status() resp.raise_for_status()
obj = await resp.json() 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 # SAFETY-61: an undecodable body is an absence like any other failed
# read, never an exception into the control loop. # read, never an exception into the control loop.
except (aiohttp.ClientError, TimeoutError, ValueError) as err: except (aiohttp.ClientError, TimeoutError, ValueError) as err:
@@ -113,17 +153,37 @@ class HomeAssistant:
f"{CORE_API}/services/{domain}/{service}", f"{CORE_API}/services/{domain}/{service}",
headers=self._headers, json=data, timeout=10, headers=self._headers, json=data, timeout=10,
) as resp: ) 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: if resp.status >= 400:
body = await resp.text() body = await resp.text()
# 400 here usually means out-of-range for the entity - the # 400 here usually means out-of-range for the entity - the
# value is rejected outright, not clamped. Always log it: # value is rejected outright, not clamped. Always log it:
# silently dropped commands are how a controller ends up # silently dropped commands are how a controller ends up
# believing something the hardware never did. # 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}", self._moan(f"svc:{domain}.{service}",
"service %s.%s rejected (%s): %s", "Home Assistant refused %s.%s for %s (error %s, "
domain, service, resp.status, body[:200]) "%s). The entity may still be loading, or the "
"value is out of range.", domain, service,
entity, resp.status, said)
return False return False
return True 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. # ValueError: an undecodable error body (SAFETY-61) is a failed write.
except (aiohttp.ClientError, TimeoutError, ValueError) as err: except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s", self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
+2 -1
View File
@@ -408,7 +408,8 @@ class Inverter:
stop the site (`inputs_ok`). Folding one into the other is how a stop the site (`inputs_ok`). Folding one into the other is how a
measurement fault ends up handing a sibling extra power. measurement fault ends up handing a sibling extra power.
""" """
return self.batt_fresh.note(self.name) return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
else "")
def link_note(self, now: datetime) -> str: 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.
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -351,7 +351,7 @@ class Maintenance:
the action log rather than watching nothing happen. the action log rather than watching nothing happen.
""" """
if blocked_by: if blocked_by:
msg = (f"maintenance: forced start REFUSED on {self.name} - " msg = (f"maintenance: {self.name} cannot start now, because "
f"{blocked_by}") f"{blocked_by}")
_LOG.warning("%s", msg) _LOG.warning("%s", msg)
return [msg] return [msg]
@@ -378,10 +378,9 @@ class Maintenance:
""" """
if self._deferred != why: if self._deferred != why:
self._deferred = why self._deferred = why
msg = (f"maintenance: {self.name} is due and is WAITING its turn - " msg = (f"maintenance: {self.name} is due and waits its turn, "
f"{why}. It is idle under the control loop and will " f"because {why}. It starts at the next start hour once the "
f"recalibrate at the next start hour once the slot is free; " f"other battery is done. This is not a stalled phase.")
f"this is not a stalled phase.")
_LOG.info("%s", msg) _LOG.info("%s", msg)
events.append(msg) events.append(msg)
return events return events
+269 -15
View File
@@ -10,6 +10,7 @@ per title; the body ends with a link to it.
import os import os
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so # Severity (STYLE.md section 3). An ALARM may use up to three sentences so
# that no action a person must take is lost; everything else gets one. # that no action a person must take is lost; everything else gets one.
@@ -314,6 +315,17 @@ def times(n) -> str:
return "1 time" if int(n) == 1 else f"{int(n)} times" return "1 time" if int(n) == 1 else f"{int(n)} times"
def count(n, noun, plural=None) -> str:
"""`count(1, "price")` -> `1 price`, `count(3, "price")` -> `3 prices`."""
return f"{int(n)} {noun if int(n) == 1 else plural or noun + 's'}"
def when(at) -> str:
"""`27 Sep at 09:15`, as the page writes a moment. `at` is already in the
time zone the reader wants (the caller passes `price.brussels(t)`)."""
return f"{at.day} {at:%b} at {at:%H:%M}"
def for_unit(unit) -> str: def for_unit(unit) -> str:
"""` for inverter-b` on a site with several inverters, else nothing.""" """` for inverter-b` on a site with several inverters, else nothing."""
return f" for {unit}" if unit else "" return f" for {unit}" if unit else ""
@@ -335,13 +347,13 @@ def banner_circulating() -> str:
def banner_site_direction() -> str: def banner_site_direction() -> str:
return "Total command and meter point opposite ways. All inverters at 0 W." return "Total power went the wrong way. All inverters at 0 W."
def banner_direction(several: bool) -> str: def banner_direction(several: bool) -> str:
if several: if several:
return "An inverter's own power opposes its command. It is at 0 W." return "An inverter's own power went the wrong way. Set to 0 W."
return "Command and meter point opposite ways. Inverter set to 0 W." return "The meter saw power go the wrong way. Inverter at 0 W."
def banner_not_delivering(units) -> str: def banner_not_delivering(units) -> str:
@@ -350,6 +362,10 @@ def banner_not_delivering(units) -> str:
return f"{names(units)} {verb} not delivering. Control keeps running." return f"{names(units)} {verb} not delivering. Control keeps running."
def banner_stopped() -> str:
return "Stopped — the inverters are set to 0 W"
def banner_shadow(latched: bool) -> str: def banner_shadow(latched: bool) -> str:
"""SAFETY-10: first in the cascade, never displaced by another banner. """SAFETY-10: first in the cascade, never displaced by another banner.
@@ -378,6 +394,11 @@ def row_controller_stalled(age_s, veto_s) -> str:
f"sets the inverter to 0 W after {seconds(veto_s)}.") f"sets the inverter to 0 W after {seconds(veto_s)}.")
def row_meter_waiting(limit_s) -> str:
"""UX-10: no reading yet since the start, and still inside the limit."""
return f"Meter: waiting for the first reading, limit {seconds(limit_s)}"
def row_meter_stale(age_s, limit_s) -> str: def row_meter_stale(age_s, limit_s) -> str:
return (f"Meter: no reading for {seconds(age_s)}, limit " return (f"Meter: no reading for {seconds(age_s)}, limit "
f"{seconds(limit_s)}. The add-on log shows why.") f"{seconds(limit_s)}. The add-on log shows why.")
@@ -502,9 +523,15 @@ def row_commands_refused(name, held_s, limit_s) -> str:
def row_control(lo_w, hi_w, unit=None) -> str: def row_control(lo_w, hi_w, unit=None) -> str:
def w(v): """The inverter's own range: charging below 0 W, giving power above it."""
return "?" if v is None else signed_watts(v) head = f"Inverter control{for_unit(unit)}: ready"
return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}" if lo_w is None or hi_w is None or lo_w > 0 or hi_w < 0:
def w(v):
return "?" if v is None else signed_watts(v)
return f"{head}, from {w(lo_w)} to {w(hi_w)}"
if abs(float(lo_w)) == float(hi_w):
return f"{head}, up to {watts(hi_w)} each way"
return f"{head}, {watts(lo_w)} charging, {watts(hi_w)} giving"
# Rows: the two loop alarms. The names are the notification titles, so the # Rows: the two loop alarms. The names are the notification titles, so the
@@ -541,9 +568,9 @@ def row_write_refused(unit=None) -> str:
# Rows: the direction checks (SAFETY-02/30). # Rows: the direction checks (SAFETY-02/30).
def row_direction_trip(unit=None) -> str: def row_direction_trip(unit=None) -> str:
"""Per inverter. `unit` only at several inverters, graded on its own power.""" """Per inverter. `unit` only at several inverters, graded on its own power."""
what = (f"{unit}: command and its own power point opposite ways, so it is" what = (f"{unit}'s own power went against what the add-on asked, so it is"
if unit else "Command and meter point opposite ways, so the " if unit else
"inverter is") "Grid power went against what the add-on asked, so the inverter is")
return (f"{what} set to 0 W. Find out why, then start control. A restart " return (f"{what} set to 0 W. Find out why, then start control. A restart "
f"does not clear this.") f"does not clear this.")
@@ -557,9 +584,9 @@ def row_direction_check(held_s, hold_s, unit=None) -> str:
def row_site_direction_trip() -> str: def row_site_direction_trip() -> str:
return ("Total command and meter point opposite ways, so every inverter is " return ("Grid power went against the add-on's total request, so every "
"set to 0 W. The cause is the total, not one inverter. Only starting " "inverter is set to 0 W. The cause is the total, not one inverter. "
"control clears this.") "Only starting control clears this.")
def row_site_direction_check(held_s, hold_s) -> str: def row_site_direction_check(held_s, hold_s) -> str:
@@ -575,10 +602,15 @@ def row_circulating_trip() -> str:
"0 W. The meter cannot see this. Only starting control clears this.") "0 W. The meter cannot see this. Only starting control clears this.")
CHECK_CIRCULATING = "Check for inverters working against each other"
def row_circulating_check(now_w, held_s, hold_s) -> str: def row_circulating_check(now_w, held_s, hold_s) -> str:
return ("Circulating power check: on" if held_s:
+ (f", now {watts(now_w)}" if now_w else "") return (f"{CHECK_CIRCULATING}: {watts(now_w or 0)} for {held_s:.0f} of "
+ (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s else "")) f"{seconds(hold_s)}")
return (f"{CHECK_CIRCULATING}: on"
+ (f", now {watts(now_w)}" if now_w else ""))
def row_one_inverter(check: str) -> str: def row_one_inverter(check: str) -> str:
@@ -676,6 +708,188 @@ def row_pv_stray(n) -> str:
f"entity in use") f"entity in use")
# Rows: this month's peak and the peak guard (UX-08 phase 2d). The detail
# (the projection's reason, the store's state at start) is in the add-on log.
def row_month_peak(peak_w, at, frac) -> str:
"""`at` is the local start of the 15-minute period that set the peak."""
return (f"Month peak: {watts(peak_w)} on {at.day} {at:%b} at {at:%H:%M}, "
f"{frac * 100:.0f} % of the peak limit")
def row_month_peak_none() -> str:
return "Month peak: none recorded yet this month"
def row_month_peak_unreadable() -> str:
"""The store did not answer. This is not a zero peak (TARIFF-01)."""
return "Month peak: cannot be read, so only the peak limit is used"
def row_periods_measured(n) -> str:
"""TARIFF-13's coverage figure. `n` is an int, or a text when unreadable."""
shown = n if isinstance(n, int) else "cannot be read"
return f"15-minute periods measured this month: {shown}"
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool) -> str:
"""The projection first and the limit second (SAFETY-38's ordered pair)."""
limit = (f"this month's {watts(limit_w)} peak" if month_peak
else f"the {watts(limit_w)} peak limit")
return f"Peak guard: idle, heading for {watts(projected_w)}, under {limit}"
def row_peak_guard_quiet() -> str:
return "Peak guard: idle"
BLIND_WORDS = {
"no_block": "waiting for meter readings",
"gap": "meter readings have a gap",
"frozen": "meter reading is stuck",
}
def row_peak_guard_blind(why: str) -> str:
"""`why` is a key of BLIND_WORDS. The battery may give power, not charge."""
return (f"Peak guard: {BLIND_WORDS[why]}, so the battery does not charge "
f"meanwhile")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
f"{watts(limit_w)}")
return (f"Peak guard: asks the battery for {watts(floor_w)} to stay under "
f"{watts(limit_w)}")
def row_battery_stuck(unit) -> str:
"""SAFETY-37/56. A fact, not a verdict: the reading may be stuck, or the
battery may be refusing, and the add-on cannot tell which."""
return (f"{unit}: battery power reading stopped changing, so it is not used "
f"for now")
# The top of the page: why the battery does what it does (the "Why" line).
WHY_WINNER = {
"loop": "Keeping the grid near zero",
"peak": "Keeping under the peak limit",
"arbitrage": "Trading on the price",
"maintenance": "Battery maintenance",
"failsafe": "Safety stop, power went the wrong way",
"sign_site": "Safety stop, total power went the wrong way",
"circulation": "Safety stop, inverters worked against each other",
"stopped": "Control is stopped",
"inputs missing": "A reading is missing",
"no claim was made": "Nothing asked for power",
# UX-10: the claim reasons main.why_key() can return for a safety or
# failsafe winner. test_messages.py part 7 finds every one in the code.
"sign disagreement": "Safety stop, power went the wrong way",
"unsplit": "The power could not be split, all at 0 W",
}
WHY_LIMIT = {
"peak(quarter peak)": "the peak limit",
"peak(blind)": "no charging while the meter is unclear",
"peak(SoC floor)": "the peak reserve",
"supervised": RATING,
"device": "the inverter's own limit",
"pack": "the battery level",
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
}
def why(winner, target_w, bound_by=None, *, contradiction=False) -> str:
"""`winner` is the arbiter's winning source, or its reason for the two
that share a source ("stopped", "inputs missing", "no claim was made").
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)"."""
if contradiction:
return "Two limits clash, so every inverter is set to 0 W"
out = f"{WHY_WINNER.get(winner, 'Other')}: {signed_watts(target_w)}"
if bound_by:
limit = WHY_LIMIT.get(bound_by) or WHY_LIMIT.get(
str(bound_by).split("(")[0], "another limit")
out += f", held back by {limit}"
return out
def why_waiting(missing_s) -> str:
"""A reading went missing a moment ago: control holds and waits."""
return f"A reading is missing for {seconds(missing_s)}, control waits"
# The last thing that happened, shown under the buttons. The add-on keeps its
# own technical record of each event (`Controller.events`); the page shows
# these words instead. Every `log_event()` call names one (test_messages.py).
HINTS = {
"control_started": "Control started",
"control_stopped": "Control stopped",
"settings_saved": "Settings saved, they apply after a restart",
"restart_requested": "Restart requested from the settings page",
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
"battery_stuck": "{unit}: battery power reading stopped changing",
"battery_back": "{unit}: battery power reading is used again",
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
"all_blocked": "A power request was blocked, inverters set to 0 W",
"write_cut": "{unit}: " + T_OUTPUT_CLAMP.lower(),
"write_slow": "{unit}: the inverter answered too slowly, trying again",
"direction": "{unit}: power went the wrong way, it is set to 0 W",
"site_direction": "Total power went the wrong way, inverters set to 0 W",
"circulating": T_CIRCULATING + ", all set to 0 W",
"not_delivering": T_NOT_DELIVERING,
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
"power_too_high": T_OUTPUT_CLAMP,
"not_keeping_up": T_LOOP_BOUND,
"trade_charge": "Trading: charging while the price is low",
"trade_give": "Trading: giving power while the price is high",
"trade_off": "Trading: waiting for a better price",
"split": "The power split between inverters changed",
"clash": "Two limits clash, inverters set to 0 W",
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
"unit_dropped": "{unit} left out of control, the others take its share",
"units_back": "Every inverter is back in control",
"unmet": "The inverters cannot give {w} of the power asked",
"unmet_gone": "The inverters can give all the power asked again",
"shadow_on": "Shadow mode on, inverters stay at 0 W",
"shadow_off": "Shadow mode off, the add-on controls the battery",
"shadow_decision": "Shadow mode: would ask the battery for {sw}",
"refused": "Settings refused, the add-on log shows why",
"inputs_missing": "A reading is missing, inverters set to 0 W",
"inputs_back": "All readings are back",
"stop_cleared": "{unit}: safety stop cleared by starting control",
"site_stop_cleared": "Safety stop cleared by starting control",
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
"maintenance": "Battery maintenance: {phase}",
"maint_paused": "Maintenance charging paused to stay under the peak limit",
}
def hint(key: str, **kw) -> str:
"""The plain words for one event. `w` is formatted as watts, `sw` as
watts with their sign (positive gives power, negative charges)."""
if "w" in kw:
kw["w"] = watts(kw["w"])
if "sw" in kw:
kw["sw"] = signed_watts(kw["sw"])
return HINTS[key].format(**kw)
def hint_samples() -> list:
"""`(key, text)` for every hint, rendered, for the test."""
kw = {"n": 2, "unit": "inverter-b", "w": 1200, "sw": -1200,
"phase": "drain"}
return [(k, hint(k, **{f: kw[f] for f in kw if "{" + f + "}" in v}))
for k, v in HINTS.items()]
RED, AMBER, GREEN = "bad", "warn", "ok" RED, AMBER, GREEN = "bad", "warn", "ok"
@@ -704,6 +918,7 @@ def status_samples() -> list:
("row_controller_ran", GREEN, row_controller_ran(2)), ("row_controller_ran", GREEN, row_controller_ran(2)),
("row_controller_stalled", RED, row_controller_stalled(45, 30)), ("row_controller_stalled", RED, row_controller_stalled(45, 30)),
("row_meter_stale", RED, row_meter_stale(40, 10)), ("row_meter_stale", RED, row_meter_stale(40, 10)),
("row_meter_waiting", AMBER, row_meter_waiting(30)),
("row_meter", GREEN, row_meter(-350, 2, 3, 340)), ("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
("row_meter_frozen", RED, row_meter_frozen()), ("row_meter_frozen", RED, row_meter_frozen()),
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF), *(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
@@ -769,6 +984,24 @@ def status_samples() -> list:
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)), ("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)), ("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
("row_pv_stray", AMBER, row_pv_stray(2)), ("row_pv_stray", AMBER, row_pv_stray(2)),
("banner_stopped", None, banner_stopped()),
("row_control", GREEN, row_control(-4000, 5000, "inverter-b")),
("row_month_peak", GREEN, row_month_peak(
4998, datetime(2026, 8, 12, 19, 15), 0.83)),
("row_month_peak_none", GREEN, row_month_peak_none()),
("row_month_peak_unreadable", AMBER, row_month_peak_unreadable()),
("row_periods_measured", GREEN, row_periods_measured(120)),
("row_periods_measured", GREEN, row_periods_measured("unreadable")),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
shadow=True)),
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
] ]
@@ -843,6 +1076,27 @@ LABELS = {
} }
def shown(value) -> str:
"""A setting's value inside a refusal: `'abc'`, `5`, or `empty` for a value
that is missing or blank, never Python's `None` (UX-08 2d)."""
if value is None or (isinstance(value, str) and not value.strip()):
return "empty"
return repr(value)
def not_valid(option: str, err) -> str:
"""A setting whose text does not parse, without the parser's own words:
`static_profile is not valid near character 6` (UX-08 2d)."""
pos = getattr(err, "pos", None)
if isinstance(pos, int):
return f"{option} is not valid near character {pos + 1}"
return f"{option} is not valid"
# The units a formula's "units" field may name, as a reader writes them.
UNIT_WORDS = {"c_eur_per_kwh": "c/kWh", "eur_per_kwh": "EUR/kWh"}
def setting(key: str, unit: str = "") -> str: def setting(key: str, unit: str = "") -> str:
"""`max_w` -> `“Inverter rating” (max_w)`. For one inverter in a list it is """`max_w` -> `“Inverter rating” (max_w)`. For one inverter in a list it is
`“Inverter rating” for inverter-b (inverters.max_w)`, so the config panel `“Inverter rating” for inverter-b (inverters.max_w)`, so the config panel
+26 -11
View File
@@ -48,6 +48,7 @@ from datetime import datetime, timezone
import aiohttp import aiohttp
from . import messages from . import messages
from .hass import OUTAGE
_S = messages.setting # a refusal names the setting by its label (UX-08) _S = messages.setting # a refusal names the setting by its label (UX-08)
@@ -472,7 +473,7 @@ class P1Ingest:
""" """
self.parse_errors += 1 self.parse_errors += 1
self.last_error = str(err) 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 ------------------------------------------------- # -- what consumers read -------------------------------------------------
def age_s(self) -> float | None: def age_s(self) -> float | None:
@@ -524,6 +525,16 @@ WS_URL = "ws://supervisor/core/websocket"
BAD_STATES = ("unknown", "unavailable", "none", "") 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: class HaDsmrSource:
"""Subscribes to state_changed for the configured DSMR entities. """Subscribes to state_changed for the configured DSMR entities.
@@ -574,8 +585,12 @@ class HaDsmrSource:
backoff = 1.0 backoff = 1.0
except asyncio.CancelledError: except asyncio.CancelledError:
raise 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 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: finally:
self.connected = False self.connected = False
await asyncio.sleep(backoff) await asyncio.sleep(backoff)
@@ -583,16 +598,17 @@ class HaDsmrSource:
async def _session_once(self) -> None: async def _session_once(self) -> None:
async with self.session.ws_connect(WS_URL, heartbeat=30) as ws: 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": if hello.get("type") == "auth_required":
await ws.send_json({"type": "auth", "access_token": self.token}) 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": if reply.get("type") != "auth_ok":
raise RuntimeError(f"auth rejected: {reply.get('message', reply)}") raise RuntimeError(f"auth rejected: {reply.get('message', reply)}")
await ws.send_json({"id": 1, "type": "subscribe_events", await ws.send_json({"id": 1, "type": "subscribe_events",
"event_type": "state_changed"}) "event_type": "state_changed"})
await ws.send_json({"id": 2, "type": "get_states"}) await ws.send_json({"id": 2, "type": "get_states"})
self.connected = True self.connected = True
OUTAGE.up("websocket")
_LOG.info("P1 ingest: subscribed to %s", ", ".join(sorted(self.ids))) _LOG.info("P1 ingest: subscribed to %s", ", ".join(sorted(self.ids)))
async for msg in ws: async for msg in ws:
@@ -618,7 +634,7 @@ class HaDsmrSource:
new = data.get("new_state") or {} new = data.get("new_state") or {}
self._absorb(data.get("entity_id"), new.get("state")) self._absorb(data.get("entity_id"), new.get("state"))
self._schedule() self._schedule()
raise RuntimeError("websocket closed") raise ConnectionResetError("Home Assistant closed the connection")
def _absorb(self, entity_id: str | None, state) -> None: def _absorb(self, entity_id: str | None, state) -> None:
if not entity_id or entity_id not in self.ids: if not entity_id or entity_id not in self.ids:
@@ -1167,7 +1183,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
problems.append( problems.append(
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs " f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They " f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
f"are {imp or '<blank>'!r} and {exp or '<blank>'!r}.") f"are {messages.shown(imp)} and {messages.shown(exp)}.")
elif source == SOURCE_HA_SIGNED: elif source == SOURCE_HA_SIGNED:
net = str(opts.get("p1_net_entity", "") or "").strip() net = str(opts.get("p1_net_entity", "") or "").strip()
phase_net = [str(e).strip() for e in phase_net = [str(e).strip() for e in
@@ -1194,7 +1210,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
try: try:
int(port_s) int(port_s)
except ValueError: except ValueError:
problems.append(f"{_S('p1_host')} is {opts.get('p1_host')!r}, and " problems.append(f"{_S('p1_host')} is {messages.shown(opts.get('p1_host'))}, and "
"its port is not a number.") "its port is not a number.")
try: try:
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a # ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
@@ -1204,7 +1220,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
poll_s = float(5 if raw is None or raw == "" else raw) poll_s = float(5 if raw is None or raw == "" else raw)
except (TypeError, ValueError): except (TypeError, ValueError):
problems.append(f"{_S('meter_poll_s')} is " problems.append(f"{_S('meter_poll_s')} is "
f"{opts.get('meter_poll_s')!r}, which is not a " f"{messages.shown(opts.get('meter_poll_s'))}, which is not a "
"number.") "number.")
else: else:
if poll_s <= 0: if poll_s <= 0:
@@ -1269,9 +1285,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 # 0 W cycle, which is fail-safe. The refusal above is for cadences that
# can NEVER be fresh. # can NEVER be fresh.
if poll_s > ingest.max_age_s / 2: if poll_s > ingest.max_age_s / 2:
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s " _LOG.warning("“Time between meter reads” (%.1f s) is too close to "
"%.1f: one missed poll makes the reading stale. The meter " "“Reading too old after” (%.1f s). Use 5 s and 30 s.",
"updates every ~5 s; 5 s against 30 s is the tested pair.",
poll_s, ingest.max_age_s) poll_s, ingest.max_age_s)
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s) return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
if source == SOURCE_MQTT: if source == SOURCE_MQTT:
+53 -36
View File
@@ -395,33 +395,34 @@ not need re-testing. Nothing here indexes a phase.
from dataclasses import dataclass from dataclasses import dataclass
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
from .control import peak_at_risk from .control import peak_at_risk
from .tariff import QUARTER_S from .tariff import QUARTER_S
# Why the projection may not be graded. Reasons, not booleans: the status page # 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. # 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_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 # Where the ceiling came from. Only the second is a relaxation, and only an
# OBSERVED reading may produce it. # OBSERVED reading may produce it.
CEILING_CAP = "peak_cap_w" 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, # 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 # 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. # notification all have to say WHICH, and "floor-limited" alone is unactionable.
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this " FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
"cycle, and an unreadable SoC is not a full battery") "unread is not full")
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the # ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
# possessive is load-bearing: this string is what the status page, the action log # load-bearing: this string is what the action log and the add-on log print,
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD # and `peak_soc_floor` (“Peak reserve”) bounds THIS GUARD ONLY. Widening it to
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator # "the SoC floor" would make the one sentence an operator reads at the moment
# reads at the moment the battery stops shaving into a claim about the pack that # the battery stops shaving into a claim about the pack that the
# the grid-following loop is at that instant disproving. # 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_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
SOURCE = "peak" SOURCE = "peak"
@@ -485,17 +486,29 @@ class PeakDecision:
reason: str = "" reason: str = ""
def explain(self) -> str: def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
technical record with every figure, for the log and the action log."""
if self.floor_limited: if self.floor_limited:
return self.reason return self.reason
if not self.engaged: if not self.engaged:
if self.projected_w is None: if self.projected_w is None:
return f"peak guard idle - {self.reason}" return messages.row_peak_guard_quiet()
return (f"peak guard idle - quarter projects " return messages.row_peak_guard_idle(
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W " self.projected_w, self.ceiling_w,
f"ceiling ({self.ceiling_from})") month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
return self.reason 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]: def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
"""The average this quarter may not exceed, and where the number came from. """The average this quarter may not exceed, and where the number came from.
@@ -553,8 +566,9 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return BLIND_NO_BLOCK return BLIND_NO_BLOCK
gap = uncovered_s(projection) gap = uncovered_s(projection)
if max_hold_s is not None and gap > float(max_hold_s): 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 " return (f"{BLIND_COVERAGE}, {gap:.0f} s of this 15-minute period not "
f"the {float(max_hold_s):.0f} s the averager will hold a sample") f"covered, past the {float(max_hold_s):.0f} s a reading is "
f"held")
return None return None
@@ -730,9 +744,9 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
return PeakDecision( return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where, engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
blind=blind, projected_w=None, blind=blind, projected_w=None,
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import " reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
f"while the billed quarter cannot be measured; commanding no " f"while this 15-minute period cannot be measured, so it "
f"charging (floor 0 W)")) f"is asked for no charging, floor 0 W"))
projected = float(projection.projected_w) projected = float(projection.projected_w)
if batt_w is None: if batt_w is None:
@@ -794,17 +808,18 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
ceiling_from=from_where, projected_w=projected, excess_w=ex, ceiling_from=from_where, projected_w=projected, excess_w=ex,
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor, wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
soc_floor_reason=denied, 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=( reason=(
f"PEAK GUARD FLOOR-LIMITED: the quarter projects " f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
f"{projected:.0f} W against a {ceiling:.0f} W ceiling " f"{projected:.0f} W against a {ceiling:.0f} W limit "
f"({from_where}) and {wanted:+.0f} W of discharge would hold " f"({from_where}), and {wanted:+.0f} W from the battery would "
f"it, but {denied}" f"hold it. But {denied}"
+ ("" if soc is None else f" ({float(soc):.0f} %") + ("" if soc is None else f", at {float(soc):.0f} %")
+ ("" if soc is None or soc_floor is None + ("" if soc is None or soc_floor is None
else f" vs {float(soc_floor):.0f} %") else f" against {float(soc_floor):.0f} %")
+ ("" if soc is None else ")") + ", so no forced discharge and no charging, and this period "
+ " - commanding no forced discharge, so THIS QUARTER WILL GO " "will go over the limit"))
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
floor = min(wanted, ceil_top) floor = min(wanted, ceil_top)
saturated = floor < wanted saturated = floor < wanted
@@ -813,13 +828,15 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted, blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
saturated=saturated, soc=soc, soc_floor=soc_floor, saturated=saturated, soc=soc, soc_floor=soc_floor,
reason=( reason=(
f"PEAK GUARD: quarter projects {projected:.0f} W against a " f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
f"{ceiling:.0f} W ceiling ({from_where}) with " f"against a {ceiling:.0f} W limit ({from_where}), with "
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must " 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"come off the meter. The battery measured {float(batt_w):+.0f} W "
f"floor {floor:+.0f} W" f"and is asked for {floor:+.0f} W"
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can " # Saturated means floor == ceil_top: the ask IS the most the
f"only give {ceil_top:.0f} W" if saturated else ""))) # inverters give, so one clause says both.
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
if saturated else "")))
def charge_headroom_w(decision) -> float | None: def charge_headroom_w(decision) -> float | None:
+27 -28
View File
@@ -150,6 +150,7 @@ from enum import Enum
from textwrap import dedent from textwrap import dedent
from urllib.parse import urlencode from urllib.parse import urlencode
from . import messages
from .custom_formula import ( from .custom_formula import (
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula, EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
parse_price_formula, preview_lines, price_eur_kwh, to_resolution) parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
@@ -1314,8 +1315,8 @@ def code_leaks(cls) -> list:
src = dedent(inspect.getsource(cls)) src = dedent(inspect.getsource(cls))
tree = ast.parse(src) tree = ast.parse(src)
except (OSError, TypeError, IndentationError, SyntaxError) as e: except (OSError, TypeError, IndentationError, SyntaxError) as e:
_LOG.warning("could not read %s's source for the framework-key scan " _LOG.warning("Could not read the code of price source %s (%s), so its "
"(%s); the double-counting guard is NOT enforced for it", "double-counting check is skipped.",
getattr(cls, "id", cls), e) getattr(cls, "id", cls), e)
return [] return []
for node in ast.walk(tree): for node in ast.walk(tree):
@@ -1718,9 +1719,8 @@ def _points_from_columnar_json(body, expect_unit, scale,
# Not a refusal: today's prices are still prices. But §11.4 makes a # 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 # 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. # source is going away has to be emitted while it still works.
_LOG.warning("the price feed reports deprecated=%r; it still answers, " _LOG.warning("The price feed says it will stop (deprecated=%r), "
"but this endpoint is scheduled to stop", "though it still answers for now.", doc.get("deprecated"))
doc.get("deprecated"))
out = [] out = []
dropped = [] dropped = []
for i, (t, v) in enumerate(zip(times, prices)): for i, (t, v) in enumerate(zip(times, prices)):
@@ -1739,12 +1739,11 @@ def _points_from_columnar_json(body, expect_unit, scale,
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale, out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
provisional=bool(provisional_default))) provisional=bool(provisional_default)))
if dropped: if dropped:
_LOG.warning("the feed published no price for %d of %d instants " _LOG.warning("The price feed gave no price for %d of %s, at index "
"(index %s); those instants are left OUT of the series, " "%s. Those are left out, not counted as free.",
"never zero-filled - a missing hour is not a free hour", len(dropped), messages.count(len(times), "time"),
len(dropped), len(times),
", ".join(str(i) for i in dropped[:5]) ", ".join(str(i) for i in dropped[:5])
+ (", ..." if len(dropped) > 5 else "")) + (" and more" if len(dropped) > 5 else ""))
if not out: if not out:
raise SourceUnavailable( raise SourceUnavailable(
f"the feed carried {len(times)} instants and a price for none of " f"the feed carried {len(times)} instants and a price for none of "
@@ -1790,16 +1789,16 @@ def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
override = str(opts.get("price_feed_url") or "").strip() override = str(opts.get("price_feed_url") or "").strip()
if override: if override:
_LOG.warning( _LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as " "price_feed_url is set, so %s asks for %s as given, and "
"given and is adding NO date range to it. The public feed " "tomorrow's prices may stay provisional. Clear it to ask for %s "
"answers a single Brussels day to a request that names no " "to %s.",
"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.",
plugin_id, override, *(d.isoformat() plugin_id, override, *(d.isoformat()
for d in brussels_date_range(since, until))) 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 return override
first, last = brussels_date_range(since, until) first, last = brussels_date_range(since, until)
return dated_feed_url(base, bzn=bzn, start=first.isoformat(), return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
@@ -2083,8 +2082,8 @@ def _emit_startup_preview(plugin, pid: str, points) -> None:
try: try:
lines = list(plugin.startup_preview(points)) lines = list(plugin.startup_preview(points))
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
_LOG.warning("%s's startup preview raised (%s: %s); prices are " _LOG.warning("%s could not show its start-up preview (%s: %s). Prices "
"unaffected - this is a log line, not a decision", "are not affected.",
pid, type(e).__name__, e) pid, type(e).__name__, e)
return return
for line in lines: for line in lines:
@@ -2108,12 +2107,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
pid = provider_id(opts) pid = provider_id(opts)
if not provider_configured(opts): if not provider_configured(opts):
return PriceRead.no_data( return PriceRead.no_data(
"no price provider configured (price_provider is 'off'); " "no price feed is set (price_provider is 'off'), so the fixed "
"§11.4: static profile, plan provisional, no arbitrage") "tariff is used, the plan is estimated and there is no trading")
cls = PROVIDERS.get(pid) cls = PROVIDERS.get(pid)
if cls is None: if cls is None:
return PriceRead.no_data( 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))}") f"{', '.join(sorted(PROVIDERS))}")
caps = cls.capabilities() caps = cls.capabilities()
@@ -2131,12 +2130,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
# Not silent, and not a traceback into the 30 s tick. validate.py # 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 # 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. # 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 " _LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes "
"has NO tax component to add - it is not falling back to " "are added to this price update. Fix the table or clear it.",
"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",
type(e).__name__, e) 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( return PriceRead.no_data(
f"price_taxes_profile cannot be used: {e}") f"price_taxes_profile cannot be used: {e}")
if table is not None: if table is not None:
+27 -24
View File
@@ -164,6 +164,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import Enum from enum import Enum
from . import messages
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
from .price import PricePoint, PriceRead, _Outcome, brussels from .price import PricePoint, PriceRead, _Outcome, brussels
@@ -411,13 +412,14 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
if not out: if not out:
reason = read.match( reason = read.match(
series=lambda r: (f"the {len(r.points)} published hours all fall " series=lambda r: (f"all {messages.count(len(r.points), 'published price')} "
f"outside {since.isoformat()}..{until.isoformat()}"), f"fall outside {messages.when(brussels(since))} to "
f"{messages.when(brussels(until))}"),
no_data=lambda r: r.reason) no_data=lambda r: r.reason)
return PriceRead.no_data( return PriceRead.no_data(
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's " f"no hours could be filled from yesterday's prices: {reason}. So "
f"profile has nothing to fill them with either. §11.4 applies: " f"the fixed tariff is used, the plan is estimated and there is no "
f"static profile, plan provisional, no arbitrage") f"trading")
_audit(out, published) _audit(out, published)
carried = sum(1 for p in out if _utc_hour(p.start) not in 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 # 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. # feed's is the POINT; nothing derived from one answers for the other.
total_h = len({_utc_hour(p.start) for p in out}) total_h = len({_utc_hour(p.start) for p in out})
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's " _LOG.info("%d of %s use yesterday's prices, marked as estimated. "
"profile and flagged provisional; the feed published %d " "The feed published %s over the other %s.",
"points over the other %d hours", carried, messages.count(total_h, "hour"),
carried, total_h, len(out) - carried, total_h - carried) messages.count(len(out) - carried, "price"),
messages.count(total_h - carried, "hour"))
product = read.match(series=lambda r: r.product, product = read.match(series=lambda r: r.product,
no_data=lambda _r: yesterday.product) no_data=lambda _r: yesterday.product)
@@ -525,8 +528,8 @@ class _Permitted(Gate):
"one rule this type has.") "one rule this type has.")
self.action = action self.action = action
self.hours = pts self.hours = pts
self.words = (f"{action.name} permitted on {len(pts)} firm hours " self.words = (f"allowed on {messages.count(len(pts), 'published price')} "
f"({pts[0].start.isoformat()} ..)") f"from {messages.when(brussels(pts[0].start))}")
class _Blocked(Gate): class _Blocked(Gate):
@@ -570,8 +573,8 @@ def price_permits(read: PriceRead, now: datetime,
return read.match( return read.match(
series=lambda r: _walk(r, hours, action), series=lambda r: _walk(r, hours, action),
no_data=lambda r: Gate.block( no_data=lambda r: Gate.block(
f"{action.name} is gated on a price and there is no price data: " f"{action.name} needs a price and there is none: {r.reason}. A "
f"{r.reason}. §11.4 - and a missing price is not a cheap one")) f"missing price is not a cheap one"))
def _walk(series, hours, action) -> Gate: def _walk(series, hours, action) -> Gate:
@@ -579,19 +582,18 @@ def _walk(series, hours, action) -> Gate:
for u in hours: for u in hours:
if not series.covers(u): if not series.covers(u):
return Gate.block( return Gate.block(
f"{action.name} commits {action.commits_h:g} h from " f"{action.name} needs prices for {action.commits_h:g} hours "
f"{hours[0].isoformat()} and no price covers " f"from {messages.when(brussels(hours[0]))}, and no price "
f"{u.isoformat()}. An hour outside the series is an hour with " f"covers {messages.when(brussels(u))}. An hour with no price "
f"no price (§11.4 for that hour), not a free one") f"is not a free one")
p = series.at(u) p = series.at(u)
if p.provisional: if p.provisional:
return Gate.block( return Gate.block(
f"{action.name} commits {action.commits_h:g} h from " f"{action.name} needs prices for {action.commits_h:g} hours "
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - " f"from {messages.when(brussels(hours[0]))}, and the price for "
f"carried from yesterday's profile or standing in for a dead " f"{messages.when(brussels(u))} is only estimated. It can start "
f"feed. §8.3: no irreversible action may be taken on a " f"once the day-ahead prices are published, around 14:00 to "
f"provisional price. It becomes available when the day-ahead " f"16:00")
f"print lands (~14-16 h CET, §5.4)")
pts.append(p) pts.append(p)
return Gate.permit(action, pts) return Gate.permit(action, pts)
@@ -622,5 +624,6 @@ def _go(maint, now, gate) -> Gate:
def _no(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 return gate
+57 -40
View File
@@ -186,8 +186,9 @@ import json
import logging import logging
import os import os
import tempfile 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 import PricePoint, PriceProduct, PriceRead, brussels
from .price_forecast import fill_from_yesterday 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 whole day to carry, and that is the HOUR figure; the point count says how
finely it is priced. Neither answers for the other. finely it is priced. Neither answers for the other.
""" """
return (f"{day} ({points} point{'' if points == 1 else 's'} " # UX-10: the day as the page writes dates ("26 Sep"), not ISO.
f"over {hours} h)") 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: def _bad_rows(rec) -> int:
@@ -393,9 +405,8 @@ class PriceHistory:
self.last_error = f"no durable volume at {parent}" self.last_error = f"no durable volume at {parent}"
# INFO, not ERROR: on a dev host or in a test container there is no # INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong. # /data and nothing is wrong.
_LOG.info("no price history: %s - §8.3 carry-forward has nothing " _LOG.info("No price history is kept (%s), so missing hours are "
"to carry and the horizon stays as short as the feed", "not filled from yesterday's prices.", self.last_error)
self.last_error)
return return
self.ok = True self.ok = True
self._load() self._load()
@@ -437,9 +448,9 @@ class PriceHistory:
# exactly what this handler says, and a third `ValueError` subclass # exactly what this handler says, and a third `ValueError` subclass
# nobody has thought of is how this defect arrived the first time. # nobody has thought of is how this defect arrived the first time.
self.last_error = f"unreadable ({err})" self.last_error = f"unreadable ({err})"
_LOG.warning("price history at %s is %s - starting from empty. " _LOG.warning("Price history at %s is %s, so it starts empty. "
"§8.3 carry-forward has nothing to carry until the " "Missing hours cannot be filled in until a full day "
"next refresh has written a full day", "is saved.",
self.path, self.last_error) self.path, self.last_error)
return return
found = blob.get("version") if isinstance(blob, dict) else None 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. # would not notice a writer this build has never met.
if not isinstance(blob, dict) or isinstance(found, bool) \ if not isinstance(blob, dict) or isinstance(found, bool) \
or not isinstance(found, int) or found != SCHEMA: or not isinstance(found, int) or found != SCHEMA:
self.last_error = f"schema {found!r}, not {SCHEMA}" self.last_error = f"version {found!r}, not {SCHEMA}"
_LOG.warning("price history at %s has %s - discarding it rather " _LOG.warning("Price history at %s has %s, so it is not used.",
"than guessing what its points mean",
self.path, self.last_error) self.path, self.last_error)
return return
days = blob.get("days") days = blob.get("days")
if not isinstance(days, dict): if not isinstance(days, dict):
self.last_error = "no `days` object" self.last_error = "no days in it"
_LOG.warning("price history at %s has %s", self.path, self.last_error) _LOG.warning("Price history at %s has %s, so it is not used.",
self.path, self.last_error)
return return
self._days = self._usable_days(days) self._days = self._usable_days(days)
@@ -504,20 +515,22 @@ class PriceHistory:
kept[key] = rec kept[key] = rec
notes = [] notes = []
if dropped: if dropped:
notes.append(f"{len(dropped)} unusable day record(s) dropped " notes.append(f"{messages.count(len(dropped), 'unusable day')} "
f"({', '.join(sorted(dropped))})") f"dropped, " + ", ".join(day_words(d)
for d in sorted(dropped)))
if malformed: if malformed:
notes.append( notes.append(
f"{sum(malformed.values())} malformed point row(s) in " f"{messages.count(sum(malformed.values()), 'bad price')} in "
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items()))) + ", ".join(f"{day_words(d)} ({n})"
for d, n in sorted(malformed.items())))
if notes: if notes:
# Recorded, not silent: `yesterday()` and `words` both report # Recorded, not silent: `yesterday()` and `words` both report
# `last_error`, and a cache that quietly discards half a file reads # `last_error`, and a cache that quietly discards half a file reads
# to an operator exactly like a normal first day. # to an operator exactly like a normal first day.
self.last_error = "; ".join(notes) self.last_error = " and ".join(notes)
_LOG.warning("price history at %s: %s - the controller starts " _LOG.warning("Price history at %s: %s. The add-on starts anyway, "
"anyway and the horizon is as short as the feed makes " "and fills in only what the rest holds.",
"it", self.path, self.last_error) self.path, self.last_error)
return kept return kept
def _save(self) -> bool: def _save(self) -> bool:
@@ -680,10 +693,11 @@ class PriceHistory:
# undone with the rest of the merge, so those rows are still in the # undone with the rest of the merge, so those rows are still in the
# file and the next write that completes drops them again. # file and the next write that completes drops them again.
self.last_error = ( self.last_error = (
f"{sum(junked.values())} malformed point row(s) dropped from " f"{messages.count(sum(junked.values()), 'bad price')} dropped "
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items()))) f"from " + ", ".join(f"{day_words(d)} ({n})"
_LOG.warning("price history at %s: %s - the record is rewritten " for d, n in sorted(junked.items())))
"without them and the refresh continues", _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.path, self.last_error)
self._prune(stamp) self._prune(stamp)
if not self._save(): if not self._save():
@@ -752,21 +766,23 @@ class PriceHistory:
if self.last_error and not self._days: if self.last_error and not self._days:
return PriceRead.no_data( return PriceRead.no_data(
f"the price history at {self.path} is {self.last_error}, so " 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"there are no prices from yesterday to fill in for "
f"covers only the hours the feed published") f"{day_words(want)}. The plan covers only the hours the feed "
f"published")
rec = self._days.get(want) rec = self._days.get(want)
if rec is None: 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( return PriceRead.no_data(
f"no published prices recorded for {want} (held: {have}), so " f"no published prices are kept for {day_words(want)}, only for "
f"§8.3 has no profile to carry forward. A record for another " f"{have}, so there are no prices from yesterday to fill in. A "
f"day is not a yesterday") f"record for another day is not a yesterday")
points, bad = _parse_points(rec.get("points")) points, bad = _parse_points(rec.get("points"))
if not points: if not points:
why = bad or "it is empty" why = bad or "it is empty"
return PriceRead.no_data( return PriceRead.no_data(
f"the {want} record holds no usable point ({why}), and an " f"the {day_words(want)} record holds no usable price, {why}, "
f"empty series is not a free day") f"and an empty series is not a free day")
try: try:
product = PriceProduct(rec.get("product")) product = PriceProduct(rec.get("product"))
except ValueError: except ValueError:
@@ -775,8 +791,9 @@ class PriceHistory:
f"which is not a PriceProduct. The optimiser switches on that " f"which is not a PriceProduct. The optimiser switches on that "
f"enum, so a record that cannot name one cannot be a series") f"enum, so a record that cannot name one cannot be a series")
if bad: if bad:
_LOG.warning("price history: %s in the %s record were dropped; " _LOG.warning("Price history: %s dropped from the %s record, and "
"%d point(s) carried forward", bad, want, len(points)) "%s kept.", bad, day_words(want),
messages.count(len(points), "price"))
return PriceRead.series(points, product, return PriceRead.series(points, product,
str(rec.get("provider") or "price_history"), str(rec.get("provider") or "price_history"),
stamp) stamp)
@@ -805,7 +822,7 @@ class PriceHistory:
if not self._days: if not self._days:
why = f" - {self.last_error}" if self.last_error else "" why = f" - {self.last_error}" if self.last_error else ""
return f"empty{why} - nothing to carry forward yet" 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)) for d in sorted(self._days, key=str))
# A file that PARTLY loaded says so, or the dropped days read as days # A file that PARTLY loaded says so, or the dropped days read as days
# that were simply never written. # that were simply never written.
@@ -833,7 +850,7 @@ def _parse_points(raw) -> tuple:
continue continue
out.append(pt) out.append(pt)
out.sort(key=lambda p: p.start.astimezone(timezone.utc)) 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: class NoHistory:
@@ -861,5 +878,5 @@ class NoHistory:
def yesterday(self, stamp: datetime) -> PriceRead: def yesterday(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data( return PriceRead.no_data(
"this caller keeps no price history (NoHistory), so §8.3 has no " "this caller keeps no price history, so there are no prices from "
"profile to carry forward") "yesterday to fill in")
+6 -4
View File
@@ -82,6 +82,7 @@ import os
import aiohttp import aiohttp
from . import messages
from .configui import changed_keys from .configui import changed_keys
from .inverter import build_roster from .inverter import build_roster
from .validate import validate_startup_config, validate_unit_ranges from .validate import validate_startup_config, validate_unit_ranges
@@ -381,8 +382,9 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
""" """
problems = await problems_for(proposed, hass) problems = await problems_for(proposed, hass)
if problems: if problems:
_LOG.warning("panel REFUSED to save: %d configuration problem(s); " _LOG.warning("The settings page did not save, because of %s. The "
"nothing written. First: %s", len(problems), problems[0]) "first: %s", messages.count(len(problems), "problem"),
problems[0])
raise SaveRefused("invalid", problems=problems) raise SaveRefused("invalid", problems=problems)
info = await selfcfg.info() info = await selfcfg.info()
@@ -390,8 +392,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
actual = fingerprint(stored) actual = fingerprint(stored)
if actual != expect: if actual != expect:
changed = changed_keys(stored, proposed) changed = changed_keys(stored, proposed)
_LOG.warning("panel REFUSED to save: the stored options changed " _LOG.warning("The settings page did not save, because the settings "
"underneath this page (keys: %s); nothing written", "changed while it was open (keys: %s).",
", ".join(changed) or "none this page also edited") ", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored) raise SaveRefused("stale", changed=changed, stored=stored)
+19 -22
View File
@@ -318,11 +318,8 @@ WIRE_W = 0.0
#: a cap that does not bind - and a number invented for the row would be a #: 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. #: second helping of the defect this constant exists to disclose.
SIZED_FROM_METER = ( SIZED_FROM_METER = (
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE " "This is an upper limit, not a forecast, because the meter did not see "
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same " "the battery move.")
"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")
def mode(opts: dict) -> str: def mode(opts: dict) -> str:
@@ -373,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 # discount. What is true in every live state is that what this add-on
# decides reaches the inverter; whether control is RUNNING is the # decides reaches the inverter; whether control is RUNNING is the
# `Stopped` banner's question and it is answered there. # `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) m = mode(opts)
live = "what the add-on decides reaches the inverters"
inert = "the add-on decides but sends nothing"
if m == "off": if m == "off":
return ("off (shadow_mode: off) - NOT in shadow mode: what this " return f"Off, {live}"
"add-on decides DOES reach the inverter")
if m == "on": if m == "on":
return ("on (shadow_mode: on) - decisions are recorded and nothing " return f"On, {inert}"
"is commanded")
if control_started: if control_started:
return ("off (shadow_mode: auto, control is started) - NOT in shadow " return f"Off (automatic, control is started), {live}"
"mode: what this add-on decides DOES reach the inverter") return f"On (automatic, control is stopped), {inert}"
return ("on (shadow_mode: auto, control is stopped) - decisions are "
"recorded and nothing is commanded")
def words(opts: dict, *, control_started: bool, would_w: float | None, def words(opts: dict, *, control_started: bool, would_w: float | None,
@@ -411,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 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. 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 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 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. The ⚠️ below is the STRONGER, 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 conditional statement about the cycles where the answer can be multiples
out - see this module's header for the measured table. out - see this module's header for the measured table.
@@ -431,19 +428,19 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
if not engaged(opts, control_started=control_started): if not engaged(opts, control_started=control_started):
return head return head
if would_w is None: if would_w is None:
return (f"{head}; no decision has been reached yet - the inverter is " return (f"{head}. No decision yet, the inverters stay at "
f"held at {WIRE_W:.0f} W") f"{WIRE_W:.0f} W.")
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to # ⚠️ 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 # 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 # half that says WHICH RUNG decided, which is the claim MUTATION.md's
# masking rule says must survive. # masking rule says must survive.
num = f"{float(would_w):+,.0f}".replace(",", " ") num = f"{float(would_w):+,.0f}".replace(",", " ")
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter " out = (f"{head}. This cycle it would ask for {num} W, the inverters stay "
f"held at {WIRE_W:.0f} W") f"at {WIRE_W:.0f} W.")
if sized_from_meter: if sized_from_meter:
out += f" {SIZED_FROM_METER}" out += f" {SIZED_FROM_METER}"
if reason: if reason:
out += f" - {reason}" out += f" Why: {reason}."
return out return out
@@ -504,6 +501,6 @@ def problems(opts: dict) -> list:
text = str("" if raw is None else raw).strip().lower() text = str("" if raw is None else raw).strip().lower()
if text in MODES: if text in MODES:
return [] return []
return [f"{messages.setting('shadow_mode')} is {raw!r}. Choose " return [f"{messages.setting('shadow_mode')} is {messages.shown(raw)}. Choose "
+ ", ".join(f"'{m}'" for m in MODES[:-1]) + ", ".join(f"'{m}'" for m in MODES[:-1])
+ f" or '{MODES[-1]}'."] + f" or '{MODES[-1]}'."]
+57 -35
View File
@@ -204,6 +204,7 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from . import messages
from .price import ( from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead, MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option, Uncertainty, _Outcome, brussels, load_prices, price_option,
@@ -597,11 +598,9 @@ def parse_static_profile(value, *, option: str = "static_profile",
value = json.loads(text) value = json.loads(text)
except ValueError as e: except ValueError as e:
raise ProfileInvalid( raise ProfileInvalid(
f"{option} is not valid JSON ({e}). Write it on one line with " f"{messages.not_valid(option, e)}. Copy the example from the "
f"a \"periods\" list, for example\n" f"documentation, or leave it empty if you have no fixed "
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",' f"tariff.")
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
f"Leave it empty if you have no fixed tariff.")
if isinstance(value, dict): if isinstance(value, dict):
tz = str(value.get("timezone", "") or "").strip().lower() tz = str(value.get("timezone", "") or "").strip().lower()
@@ -1120,18 +1119,39 @@ class TariffPlan:
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
surface said either. surface said either.
""" """
# UX-08: plain words, every figure in its own slot as before.
if not self.has_prices: if not self.has_prices:
return f"no tariff - {self.read.reason}" return f"no prices, {self.read.reason}"
tag = {"provider": self.read.provider, "static": "static_profile", tag = {"provider": "the price feed", "static": "your fixed tariff",
"fallback": "static_profile (feed down)"}.get(self.origin, "fallback": "your fixed tariff, because the feed is down"}.get(
self.origin) self.origin, self.origin)
what = {PriceProduct.DAY_AHEAD: "day-ahead prices",
PriceProduct.REALTIME: "real-time prices",
PriceProduct.STATIC: "day/night prices",
PriceProduct.FIXED: "a fixed price"}.get(self.product,
str(self.product))
# UX-10: the spacing in words, and no "not final yet": a plan is
# provisional exactly when it holds an estimated price, which the
# count already says.
gaps = self.spacings_s gaps = self.spacings_s
return (f"{self.product.value} from {tag}, " return (f"{what} from {tag}, "
f"{self.n_points} points over {self.hours} h, " f"{self.n_points} prices over {self.hours} hours, "
f"{self.n_firm} firm / {self.n_carried} carried" f"{self.n_firm} published and {self.n_carried} estimated"
+ (f", {'+'.join(str(g) for g in gaps)} s spacing" + (f", {spacing_words(gaps)}" if gaps else ""))
if gaps else "")
+ (", provisional" if self.provisional else ""))
def spacing_words(gaps) -> str:
"""`(900, 3600)` -> `one every 15 minutes or every hour`."""
def one(s):
s = int(s)
if s == 3600:
return "every hour"
if s % 3600 == 0:
return f"every {s // 3600} hours"
if s % 60 == 0:
return f"every {s // 60} minutes"
return f"every {s} seconds"
return "one " + " or ".join(one(g) for g in gaps)
def _whole_hours(points) -> int: def _whole_hours(points) -> int:
@@ -1201,9 +1221,9 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
if profile is None: if profile is None:
return TariffPlan( return TariffPlan(
PriceRead.no_data( PriceRead.no_data(
"no static_profile configured; §11.4: plan provisional, no " "no fixed tariff is set, so the plan is estimated and there "
"arbitrage. Self-consumption, peak shaving and reserve " "is no trading. Self-consumption, peak limiting and the "
"management are unaffected"), "reserve are not affected"),
None, "none") None, "none")
points = profile.series(since, until, provisional=provisional) points = profile.series(since, until, provisional=provisional)
return TariffPlan( return TariffPlan(
@@ -1225,21 +1245,22 @@ def static_status(opts: dict, now: datetime):
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS), plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
now=now) now=now)
except ProfileInvalid as e: except ProfileInvalid as e:
_LOG.warning("static_profile is unusable (%s); no cheap window. This " _LOG.warning("static_profile cannot be used (%s), so there is no "
"should have been refused at startup - see validate.py", e) "cheap window. The start-up check should have refused "
return False, f"static_profile unusable: {e}" "it.", e)
return False, f"Fixed tariff cannot be used: {e}"
if not plan.has_prices: if not plan.has_prices:
return False, "no static profile" return False, "No fixed tariff set"
cheap = plan.cheap_now(now) cheap = plan.cheap_now(now)
window = plan.window_at(now) window = plan.window_at(now)
price = plan.price_at(now) price = plan.price_at(now)
when = plan.profile.next_change(now) when = plan.profile.next_change(now)
until = "" if when is None else f" until {brussels(when):%H:%M}" until = "" if when is None else f" until {brussels(when):%H:%M}"
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night") kind = ("Fixed" if plan.product is PriceProduct.FIXED else "Day/night")
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, " return cheap, (f"{kind} tariff, {window}{until}: {price:.4f} EUR/kWh, "
f"mean {plan.mean_eur_kwh():.4f} over " f"{'cheap' if cheap else 'not cheap'} against the "
f"{MEAN_WINDOW_DAYS} d - " f"{MEAN_WINDOW_DAYS}-day average of "
f"{'CHEAP window' if cheap else 'not a cheap window'}") f"{plan.mean_eur_kwh():.4f}")
async def resolve_tariff(opts: dict, since: datetime, until: datetime, async def resolve_tariff(opts: dict, since: datetime, until: datetime,
@@ -1332,17 +1353,18 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
try: try:
profile = parse_static_profile(price_option(opts, "static_profile")) profile = parse_static_profile(price_option(opts, "static_profile"))
except ProfileInvalid as e: except ProfileInvalid as e:
_LOG.warning("the price feed is down AND static_profile is unusable " _LOG.warning("The price feed is down and the fixed tariff "
"(%s), so there is no tariff at all", e) "(static_profile) cannot be used either (%s), so there is "
"no tariff at all.", e)
profile = None profile = None
if profile is None: if profile is None:
_LOG.warning("§11.4: %s, and no static_profile is configured to fall " _LOG.warning("The price feed failed and no fixed tariff is set, so "
"back to. The plan is provisional and arbitrage is off; " "trading is off, while peak limiting and home use go on. "
"self-consumption, peak shaving and reserve management " "%s.", read.reason)
"continue (§6, §10.1).", read.reason)
return TariffPlan(read, None, "none") return TariffPlan(read, None, "none")
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked " _LOG.warning("The price feed failed, so the fixed tariff is used "
"PROVISIONAL, arbitrage off.", read.reason) "instead, marked as estimated, and trading is off. %s.",
read.reason)
points = profile.series(since, until, provisional=True) points = profile.series(since, until, provisional=True)
return TariffPlan( return TariffPlan(
PriceRead.series(points, profile.product, "static_profile", stamp), PriceRead.series(points, profile.product, "static_profile", stamp),
+33 -24
View File
@@ -222,6 +222,8 @@ class MigrationReport:
self.misdirected = [] self.misdirected = []
# Why nothing ran, when nothing ran. Empty means "already at schema". # Why nothing ran, when nothing ran. Empty means "already at schema".
self.skipped = "" self.skipped = ""
# The inverter list the moves were graded against, for `explain()`.
self.roster = []
@property @property
def ok(self) -> bool: def ok(self) -> bool:
@@ -237,19 +239,27 @@ class MigrationReport:
# the flat keys ARE its namespace - and an operator reading the # the flat keys ARE its namespace - and an operator reading the
# startup log needs to be told which, because only one of them # startup log needs to be told which, because only one of them
# changes when a second inverter is declared. # changes when a second inverter is declared.
return self.skipped or (f"store already at schema " return self.skipped or (f"the saved state is already at version "
f"{self.from_version}; no " f"{self.from_version}, so nothing moves")
f"per-inverter migration needed") # UX-10: plain sentences (test_messages.py part 6). Each problem is
out = (f"migrated {len(self.moved)} flat key(s) to inverter " # named by its key and what went wrong, and `problems`, `lost` and
f"namespaces: " + ", ".join(sorted(self.moved))) # `misdirected` keep their full notes for the checks that read them.
n = len(self.moved)
out = (f"moved {n} saved {'value' if n == 1 else 'values'} to one part "
f"per inverter: " + ", ".join(sorted(self.moved)) if n
else "moved nothing")
if self.kept: if self.kept:
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")" out += ". Already in place: " + ", ".join(sorted(self.kept))
if self.problems: def keys(notes):
out += " - PROBLEMS: " + "; ".join(self.problems) return ", ".join(note.split(" ", 1)[0] for note in notes)
if self.lost: other = [n for n in self.problems
out += " - STATE LOST: " + "; ".join(self.lost) if n not in self.lost and n not in self.misdirected]
if self.misdirected: parts = ([f"lost {keys(self.lost)}"] if self.lost else []) + (
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected) [f"moved under an inverter not in the list {self.roster!r}: "
f"{keys(self.misdirected)}"] if self.misdirected else []) + (
[f"read back differently {keys(other)}"] if other else [])
if parts:
out += ". Problems: " + ", and ".join(parts)
return out return out
@@ -328,8 +338,8 @@ def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the # ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
# next start rather than be recorded as finished - the one thing worse # next start rather than be recorded as finished - the one thing worse
# than a failed migration is a failed migration a later version skips. # than a failed migration is a failed migration a later version skips.
_LOG.error("per-inverter store migration LOST STATE and has not been " _LOG.error("Moving the saved state to one part per inverter lost "
"marked done: %s", rep.explain()) "data, so it is not marked done: %s", rep.explain())
return rep return rep
@@ -436,6 +446,7 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
if roster is None: if roster is None:
return return
names = [str(n) for n in roster] names = [str(n) for n in roster]
rep.roster = names
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split # `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
# through the same spelling the writer used rather than re-deriving it. # through the same spelling the writer used rather than re-deriving it.
# #
@@ -469,8 +480,8 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
if unit in names: if unit in names:
continue continue
rep.verified = False rep.verified = False
note = (f"{target} was migrated into namespace {unit!r}, which is NOT " note = (f"{target} was moved under {unit!r}, which is not in the "
f"on the roster {names!r} - nothing reads that key") f"inverter list {names!r}, so nothing reads it")
rep.problems.append(note) rep.problems.append(note)
rep.misdirected.append(note) rep.misdirected.append(note)
@@ -536,14 +547,12 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
names = [str(n) for n in roster] names = [str(n) for n in roster]
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0) rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
if len(names) <= 1: if len(names) <= 1:
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's " rep.skipped = ("one inverter, so it is read where it is and nothing "
"history and are read in place, so there is nothing to " "moves")
"move and the store layout does not change")
return rep return rep
if rep.from_version >= MAINT_SCHEMA_VERSION: if rep.from_version >= MAINT_SCHEMA_VERSION:
rep.skipped = (f"maintenance history already at schema " rep.skipped = (f"already at version {rep.from_version}, written for "
f"{rep.from_version}; written by a multi-inverter " f"several inverters, so it is left as it is")
f"version and not touched")
return rep return rep
rep.ran = True rep.ran = True
@@ -567,8 +576,8 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
# the re-run a no-op where the keys did land, so refusing the marker # the re-run a no-op where the keys did land, so refusing the marker
# costs one repeated pass and buys the difference between a migration # costs one repeated pass and buys the difference between a migration
# that can still be fixed and one the store says already happened. # that can still be fixed and one the store says already happened.
_LOG.error("per-pack maintenance-history migration did NOT land and " _LOG.error("Moving the maintenance history to one part per inverter "
"has not been marked done: %s", rep.explain()) "did not work, so it is not marked done: %s", rep.explain())
return rep return rep
+22 -13
View File
@@ -114,6 +114,8 @@ import sqlite3
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone, tzinfo from datetime import datetime, timedelta, timezone, tzinfo
from . import messages
_LOG = logging.getLogger("goodwe.tariff") _LOG = logging.getLogger("goodwe.tariff")
QUARTER_S = 900 QUARTER_S = 900
@@ -339,8 +341,8 @@ class PeakStore:
# INFO, not ERROR: on a dev host or in a test container there is no # INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong. A store that OPENED and then broke is # /data and nothing is wrong. A store that OPENED and then broke is
# the error case, and it is logged as one below. # the error case, and it is logged as one below.
_LOG.info("no peak store: %s - the monthly peak will read " _LOG.info("No peak store (%s), so the month peak cannot be read "
"NOT_OBSERVED", self.last_error) "and only the peak limit is used.", self.last_error)
return return
try: try:
self._db = sqlite3.connect(self.path, timeout=5.0, self._db = sqlite3.connect(self.path, timeout=5.0,
@@ -360,8 +362,9 @@ class PeakStore:
# fault must not stop a controller that is regulating a battery. The # fault must not stop a controller that is regulating a battery. The
# cost of continuing is a peak that reads NOT_OBSERVED, and every # cost of continuing is a peak that reads NOT_OBSERVED, and every
# reader is built to treat that as "do not use me", not as 0 W. # reader is built to treat that as "do not use me", not as 0 W.
_LOG.error("peak store unusable at %s (%s) - the monthly peak will " _LOG.error("The peak store at %s cannot be used (%s), so only the "
"read NOT_OBSERVED, which is not a zero peak", self.path, err) "peak limit is used. The month peak is unknown, not "
"zero.", self.path, err)
def close(self) -> None: def close(self) -> None:
if self._db is not None: if self._db is not None:
@@ -672,12 +675,12 @@ class PeakTracker:
now = now or datetime.now(timezone.utc) now = now or datetime.now(timezone.utc)
snap = self.store.load_partial() snap = self.store.load_partial()
if not snap: if not snap:
self.restore_state = ("fresh: no checkpoint" if self.store.ok self.restore_state = ("fresh start, nothing saved" if self.store.ok
else "fresh: store unreadable") else "fresh start, the store cannot be read")
return self.restore_state return self.restore_state
block = snap.get("block") block = snap.get("block")
if block is None: if block is None:
self.restore_state = "fresh: checkpoint had no open block" self.restore_state = "fresh start, no open 15-minute period saved"
return self.restore_state return self.restore_state
current = int(now.timestamp() // QUARTER_S) * QUARTER_S current = int(now.timestamp() // QUARTER_S) * QUARTER_S
@@ -686,11 +689,13 @@ class PeakTracker:
# A reshaped accumulator (meter_phases changed) is a different # A reshaped accumulator (meter_phases changed) is a different
# measurement, not a resumable one. Say so; do not half-resume. # measurement, not a resumable one. Say so; do not half-resume.
self.store.clear_partial() self.store.clear_partial()
self.restore_state = "fresh: checkpoint does not fit this averager" self.restore_state = ("fresh start, the saved period does not "
"fit the number of phases")
return self.restore_state return self.restore_state
self.restore_state = ( self.restore_state = (
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s " f"resumed the open 15-minute period at "
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)") f"{snap.get('elapsed', 0.0):.0f} s of {QUARTER_S} s, with "
f"{snap.get('acc', 0.0) / QUARTER_S:.0f} W counted so far")
return self.restore_state return self.restore_state
# The block ended while the add-on was down. Its measured part really # The block ended while the add-on was down. Its measured part really
@@ -704,10 +709,14 @@ class PeakTracker:
if snap.get("pp_acc") and snap.get("last_pp") else None)) if snap.get("pp_acc") and snap.get("last_pp") else None))
banked = self.store.record_block(closed, self.policy) banked = self.store.record_block(closed, self.policy)
self.store.clear_partial() self.store.clear_partial()
# UX-10: plain words and the local time, not ISO and "banked".
self.restore_state = ( self.restore_state = (
f"closed a stale block from {closed.start.isoformat()} at " f"closed the 15-minute period from "
f"{closed.offtake_avg_w:.0f} W (partial coverage), " f"{messages.when(closed.start.astimezone(BILLING_TZ))} at "
+ ("banked" if banked else "NOT banked - store unwritable")) f"{closed.offtake_avg_w:.0f} W, partly measured, "
+ ("and saved it" if banked
else "but could not save it, because the store cannot be "
"written"))
return self.restore_state return self.restore_state
# -- steady state ------------------------------------------------------- # -- steady state -------------------------------------------------------
+89 -81
View File
@@ -343,6 +343,30 @@ from .watchdog import SIGN_OPTION_DEFAULTS
_LOG = logging.getLogger("goodwe.config") _LOG = logging.getLogger("goodwe.config")
class _WarnOnce(logging.Filter):
"""UX-10: a settings warning is said once per start. The validator runs
twice at start (before the first cycle and again once the inverter's range
is known), and a warning about options that have not changed is the same
news both times. Refusals are ERROR and pass untouched."""
def __init__(self):
super().__init__()
self.seen = set()
def filter(self, record):
if record.levelno != logging.WARNING:
return True
text = record.getMessage()
if text in self.seen:
return False
self.seen.add(text)
return True
WARN_ONCE = _WarnOnce()
_LOG.addFilter(WARN_ONCE)
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware # ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
# threshold is a compile-time YAML substitution baked into the ESP32 image # threshold is a compile-time YAML substitution baked into the ESP32 image
# (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at # (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at
@@ -647,7 +671,7 @@ def _roster_problems(opts: dict) -> list[str]:
where = f"inverters[{i}]" where = f"inverters[{i}]"
if not isinstance(entry, dict): if not isinstance(entry, dict):
problems.append( problems.append(
f"{where} in the “Inverter list” is {entry!r}. Each entry needs " f"{where} in the “Inverter list” is {messages.shown(entry)}. Each entry needs "
"a name, a setpoint entity and an inverter rating.") "a name, a setpoint entity and an inverter rating.")
continue continue
name = str(entry.get("name") or "").strip() name = str(entry.get("name") or "").strip()
@@ -670,7 +694,7 @@ def _roster_problems(opts: dict) -> list[str]:
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0: if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
problems.append( problems.append(
f"{who} has no usable {S('max_w')}: it is " f"{who} has no usable {S('max_w')}: it is "
f"{entry.get('max_w')!r}. Set this inverter's own rating in " f"{messages.shown(entry.get('max_w'))}. Set this inverter's own rating in "
"watts.") "watts.")
# --- (f) and (g): OPT-09's capacity, which is optional but not partial --- # --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
@@ -690,7 +714,7 @@ def _roster_problems(opts: dict) -> list[str]:
val = None val = None
if val is None or not (val > 0.0): if val is None or not (val > 0.0):
problems.append( problems.append(
f"{who} has {S('capacity_kwh')} {raw!r}, which is not a usable " f"{who} has {S('capacity_kwh')} {messages.shown(raw)}, which is not a usable "
"number. Set it in kWh, or leave it empty on every inverter.") "number. Set it in kWh, or leave it empty on every inverter.")
else: else:
stated.append(who) stated.append(who)
@@ -831,7 +855,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# has answered; it is not called from here because at startup no device has. # has answered; it is not called from here because at startup no device has.
max_w = _number(opts, "max_w", 2000.0) max_w = _number(opts, "max_w", 2000.0)
if max_w is None: if max_w is None:
problems.append(f"{S('max_w')} is {opts.get('max_w')!r}, which is not " problems.append(f"{S('max_w')} is {messages.shown(opts.get('max_w'))}, which is not "
"a number. Set it in watts.") "a number. Set it in watts.")
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so # ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
# the check is not for the Supervisor path - it is because # the check is not for the Supervisor path - it is because
@@ -866,8 +890,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if phases is None or max_age_s is None: if phases is None or max_age_s is None:
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must " problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
f"both be numbers. They are " f"both be numbers. They are "
f"{opts.get('meter_phases')!r} and " f"{messages.shown(opts.get('meter_phases'))} and "
f"{opts.get('meter_max_age_s')!r}.") f"{messages.shown(opts.get('meter_max_age_s'))}.")
else: else:
problems.extend(validate_meter_options(opts, int(phases), max_age_s)) problems.extend(validate_meter_options(opts, int(phases), max_age_s))
@@ -918,14 +942,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
weakest = min(caps) if caps else None weakest = min(caps) if caps else None
if sign_w is None: if sign_w is None:
problems.append(f"{S('sign_disagree_w')} is " problems.append(f"{S('sign_disagree_w')} is "
f"{opts.get('sign_disagree_w')!r}, which is not a " f"{messages.shown(opts.get('sign_disagree_w'))}, which is not a "
"number. Set it back to 300 W.") "number. Set it back to 300 W.")
elif weakest is not None and weakest > 0 and sign_w >= weakest: elif weakest is not None and weakest > 0 and sign_w >= weakest:
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0]) where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
problems.append( problems.append(
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the " f"{S('sign_disagree_w')} is {sign_w:.0f} W, not below "
f"{S('max_w', where)} is {weakest:.0f} W, so this safety stop can " f"{S('max_w', where)} at {weakest:.0f} W, so this safety stop can "
f"never trip. Lower it below {weakest:.0f} W, or raise the rating.") f"never trip. Lower it or raise the rating.")
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A # ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a # SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
@@ -972,15 +996,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if caps_ix else "") if caps_ix else "")
if unit_sign_w is None: if unit_sign_w is None:
problems.append(f"{S('unit_sign_disagree_w')} is " problems.append(f"{S('unit_sign_disagree_w')} is "
f"{opts.get('unit_sign_disagree_w')!r}, which is not a " f"{messages.shown(opts.get('unit_sign_disagree_w'))}, which is not a "
"number. Set it back to " "number. Set it back to "
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.") f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest: elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
problems.append( problems.append(
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, but the " f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, not below "
f"{S('max_w', unit_where)} is {weakest:.0f} W, so this safety stop " f"{S('max_w', unit_where)} at {weakest:.0f} W, so this safety stop "
f"can never trip. Lower it below {weakest:.0f} W, or raise the " f"can never trip. Lower it or raise the rating.")
"rating.")
# --- 6. a maintenance charge the ceiling can actually accommodate -------- # --- 6. a maintenance charge the ceiling can actually accommodate --------
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is # ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
@@ -1054,7 +1077,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if charge_w is None: if charge_w is None:
problems.append( problems.append(
f"{S('maintenance_charge_w')} is " f"{S('maintenance_charge_w')} is "
f"{opts.get('maintenance_charge_w')!r}, which is not a number. Set " f"{messages.shown(opts.get('maintenance_charge_w'))}, which is not a number. Set "
"it in watts.") "it in watts.")
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap: elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
problems.append( problems.append(
@@ -1089,7 +1112,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
slew_w = _number(opts, "slew_w", 0.0) slew_w = _number(opts, "slew_w", 0.0)
rail = site_rail_w(opts) rail = site_rail_w(opts)
if slew_w is None: if slew_w is None:
problems.append(f"{S('slew_w')} is {opts.get('slew_w')!r}, which is " problems.append(f"{S('slew_w')} is {messages.shown(opts.get('slew_w'))}, which is "
"not a number. Set it to 0 to follow the inverter " "not a number. Set it to 0 to follow the inverter "
"rating.") "rating.")
elif slew_w < 0: elif slew_w < 0:
@@ -1133,8 +1156,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if soc_floor is None or bms_floor is None: if soc_floor is None or bms_floor is None:
problems.append( problems.append(
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must " f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
f"both be numbers. They are {opts.get('peak_soc_floor')!r} and " f"both be numbers. They are {messages.shown(opts.get('peak_soc_floor'))} and "
f"{_raw!r}.") f"{messages.shown(_raw)}.")
elif soc_floor <= bms_floor: elif soc_floor <= bms_floor:
problems.append( problems.append(
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the " f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
@@ -1147,10 +1170,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
integ_w = None if not integ else _number(opts, "integrator_max_w", None) integ_w = None if not integ else _number(opts, "integrator_max_w", None)
if integ_w is not None and max_w is not None and integ_w > max_w: if integ_w is not None and max_w is not None and integ_w > max_w:
_LOG.warning( _LOG.warning(
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the " "integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
"arbiter still bounds the wire to +-max_w - but the only thing the " "but it only adds overshoot, so 0 is recommended.",
"extra headroom buys is more wrong-direction power to unwind after "
"saturation. 0 (follow max_w) is the recommended value.",
integ_w, max_w) integ_w, max_w)
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The # ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
@@ -1173,15 +1194,13 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if (disch_w is not None and maint_cap is not None and maint_cap > 0 if (disch_w is not None and maint_cap is not None and maint_cap > 0
and disch_w > maint_cap): and disch_w > maint_cap):
_LOG.warning( _LOG.warning(
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not " "maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
"refused - the drain phase SETS the setpoint and the arbiter clamps " "draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), " disch_w, maint_where, maint_cap, maint_cap, maint_cap)
"so nothing is silent and the cycle still completes. But it drains " _LOG.debug(
"at %.0f W, not the %.0f W you configured, and the drain phase has " "Not refused: the drain phase sets the setpoint, the arbiter "
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the " "clamps it to %.0f W and names the clamp, and the drain phase has "
"surprise.", "a 720 min timeout.", maint_cap)
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
maint_cap)
return problems return problems
@@ -1289,10 +1308,9 @@ def _price_problems(opts: dict) -> list[str]:
if str(opts.get(k, "") or "").strip()] if str(opts.get(k, "") or "").strip()]
if pid != "off" and ha_price: if pid != "off" and ha_price:
problems.append( problems.append(
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} " f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} would "
f"{'is' if len(ha_price) == 1 else 'are'} also set and would win. " f"win over it. Clear {'it' if len(ha_price) == 1 else 'them'}, or "
f"Clear {'it' if len(ha_price) == 1 else 'them'}, or set the price " "set the source to 'off'.")
"source to 'off'.")
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE # ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap` # CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
@@ -1302,7 +1320,8 @@ def _price_problems(opts: dict) -> list[str]:
if ha_price and str(opts.get("static_profile", "") or "").strip(): if ha_price and str(opts.get("static_profile", "") or "").strip():
problems.append( problems.append(
f"{S('static_profile')} is set, but {_labels(ha_price)} would win " f"{S('static_profile')} is set, but {_labels(ha_price)} would win "
"over it. Clear the one you do not use.") f"over it. Clear the fixed tariff or "
f"{'the entity' if len(ha_price) == 1 else 'the two entities'}.")
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile # ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
# that does not tile the day has hours with no price, and the only ways to # that does not tile the day has hours with no price, and the only ways to
@@ -1319,12 +1338,11 @@ def _price_problems(opts: dict) -> list[str]:
if profile is not None and profile.product is not None \ if profile is not None and profile.product is not None \
and len(set(profile.prices)) == 1: and len(set(profile.prices)) == 1:
_LOG.info( _LOG.info(
"static_profile carries one distinct price (%.4f EUR/kWh " "static_profile has one price (%.4f EUR/kWh all-in), so this "
"all-in), so this site is priced as FULLY FIXED. Arbitrage is " "site has a fully fixed price. A flat price has no spread to "
"off by arithmetic - a flat series has no spread - and the " "trade on, so the add-on uses the battery for self-consumption "
"add-on runs self-consumption only, which is §9.2's handling " "only. Peak limiting, the reserve and battery maintenance are "
"for this product. Peak shaving, reserve management and the " "not affected.", profile.prices[0])
"maintenance cycle are unaffected.", profile.prices[0])
# ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT # ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT
# 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read # 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read
@@ -1374,9 +1392,8 @@ def _price_problems(opts: dict) -> list[str]:
and not any(abs(scalar - u) <= 1e-12 for u in untouched): and not any(abs(scalar - u) <= 1e-12 for u in untouched):
problems.append( problems.append(
f"{S(TAXES_PROFILE_OPTION)} is set, so " f"{S(TAXES_PROFILE_OPTION)} is set, so "
f"{S('price_taxes_eur_kwh')} is not used, but it reads {scalar} " f"{S('price_taxes_eur_kwh')} is not used. Set it back to "
f"instead of {shipped}. Set it back to {shipped}, or clear the " f"{shipped} from {scalar}, or clear the table.")
"table.")
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A # ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh # RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
@@ -1403,10 +1420,9 @@ def _price_problems(opts: dict) -> list[str]:
# observe. §14's warning box governs CONTRADICTIONS, and this is # observe. §14's warning box governs CONTRADICTIONS, and this is
# not one. # not one.
_LOG.warning( _LOG.warning(
"%s carries negative levies %s EUR/kWh. That is accepted, but " "%s has negative levies %s EUR/kWh, which are subtracted from "
"a tax component below zero is usually a sign error: the " "the price. This is allowed, but it is usually a sign error.",
"add-on will SUBTRACT it from the published price in those " TAXES_PROFILE_OPTION, negative)
"hours.", TAXES_PROFILE_OPTION, negative)
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT # ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00. # STARTUP RATHER THAN AT 03:00.
@@ -1459,11 +1475,9 @@ def _price_problems(opts: dict) -> list[str]:
# silent loser this module's case (a) is about, so it is said out # silent loser this module's case (a) is about, so it is said out
# loud rather than left to be discovered from a bill. # loud rather than left to be discovered from a bill.
_LOG.warning( _LOG.warning(
"price_formula is filled in but price_provider is %r, so the " "price_formula is filled in, but price_provider is %r, so the "
"coefficients decide NOTHING - only 'custom_formula' reads " "formula is not used. Set price_provider to 'custom_formula' "
"them. Set price_provider to 'custom_formula' to use them, or " "or clear price_formula.", pid)
"clear price_formula so the configuration says what the "
"add-on is doing.", pid)
if spec is not None and pid == "custom_formula": if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh", taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]) PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1514,9 +1528,9 @@ def _price_problems(opts: dict) -> list[str]:
_LOG.info( _LOG.info(
"%s is set: injection priced as (%g x index %+g) x " "%s is set: injection priced as (%g x index %+g) x "
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a " "(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS " "%+.5f EUR/kWh offset. No part of the add-on reads it yet: "
"IT: arbitrage.py prices the sell side as displaced IMPORT " "trading prices the sell side as displaced import and "
"and compares two all-in CONSUMPTION prices, so this option " "compares two all-in consumption prices, so this setting "
"changes no decision the add-on takes today. It is what you " "changes no decision the add-on takes today. It is what you "
"are shown, and the shape a future export-aware strategy " "are shown, and the shape a future export-aware strategy "
"would read.", "would read.",
@@ -1552,20 +1566,18 @@ def _price_problems(opts: dict) -> list[str]:
# bounds in `implausible_injection()` are applied, and the # bounds in `implausible_injection()` are applied, and the
# operator is told which of the two checks did NOT run. # operator is told which of the two checks did NOT run.
_LOG.warning( _LOG.warning(
"%s has no \"card\" block, so its coefficients are " "%s has no \"card\" block, so its numbers are not checked "
"UNGRADED: nothing recomputed a printed price from them. " "against a printed price, only their shape (pass-through "
"That is accepted because it is the real market - Eneco " "%.4g, offset %+.5f EUR/kWh).",
"prints its injection formula with no price beside it, so "
"there is nothing to grade against on that card - and "
"refusing would make the published line inexpressible. "
"What IS checked is the shape of the coefficients: the "
"pass-through (%.4g x the wholesale price) and the offset "
"(%+.5f EUR/kWh). If YOUR card prints an injection "
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
"..., \"expect_incl_vat\": ...} and the add-on will "
"refuse to start unless your numbers reproduce it.",
EXPORT_FORMULA_OPTION, xspec.passthrough, EXPORT_FORMULA_OPTION, xspec.passthrough,
xspec.offset_eur_kwh) xspec.offset_eur_kwh)
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
"prints an injection estimate, add {\"month\": ..., "
"\"index_eur_per_mwh\": ..., \"expect_incl_vat\": ...} "
"and the add-on refuses to start unless your numbers "
"reproduce it.")
else: else:
xaged = stale_card(xspec) xaged = stale_card(xspec)
if xaged: if xaged:
@@ -1578,11 +1590,9 @@ def _price_problems(opts: dict) -> list[str]:
# every install TARIFF-02 shipped, none of which could have had one. # every install TARIFF-02 shipped, none of which could have had one.
if pid != "off" and not str(opts.get("static_profile", "") or "").strip(): if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
_LOG.warning( _LOG.warning(
"price_provider is %r but static_profile is empty. §11.4's " "price_provider is %r but static_profile is empty, so a price "
"degradation row is 'no price data -> fall back to the static " "feed outage leaves no tariff at all. Fill in static_profile as a "
"profile'; with none configured, a feed outage leaves this site " "fallback.", pid)
"with no tariff at all rather than with a coarse one. Not refused, "
"but filling it in is what makes that row work.", pid)
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused # ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot # rather than logged, because a strategy that is armed in the UI and cannot
@@ -1598,7 +1608,7 @@ def _price_problems(opts: dict) -> list[str]:
if thresh is None or thresh < 0: if thresh is None or thresh < 0:
problems.append( problems.append(
f"{S('cycle_cost_threshold_eur')} is " f"{S('cycle_cost_threshold_eur')} is "
f"{opts.get('cycle_cost_threshold_eur')!r}. Set it to 0 or more, " f"{messages.shown(opts.get('cycle_cost_threshold_eur'))}. Set it to 0 or more, "
"in EUR/kWh.") "in EUR/kWh.")
if arbitrage_on(opts): if arbitrage_on(opts):
@@ -1655,11 +1665,9 @@ def refuse(problems: list[str]) -> None:
at all and even the exit status distinguishes the two. at all and even the exit status distinguishes the two.
""" """
_LOG.error("=" * 72) _LOG.error("=" * 72)
_LOG.error("REFUSING TO START: the add-on configuration is not usable.") _LOG.error("The add-on did not start, because its settings have problems.")
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;") _LOG.error("Nothing is broken. Fix the settings below, then start it again.")
_LOG.error("the add-on will start as soon as the settings below are fixed.") _LOG.error("They are on the add-on's Configuration tab.")
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
_LOG.error("Configuration, then start the add-on again.")
_LOG.error("-" * 72) _LOG.error("-" * 72)
for i, problem in enumerate(problems, 1): for i, problem in enumerate(problems, 1):
_LOG.error(" %d. %s", i, problem) _LOG.error(" %d. %s", i, problem)
+16 -9
View File
@@ -31,7 +31,7 @@ import logging
from aiohttp import web from aiohttp import web
from . import selfconfig from . import messages, selfconfig
from .config_page import CONFIG_PAGE from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field from .configui import changed_keys, field_model, problems_by_field
@@ -125,11 +125,8 @@ refresh(); setInterval(refresh, 3000);
# them too, and says so, because a green page that is green for a reason the # them too, and says so, because a green page that is green for a reason the
# operator cannot see is the exact failure this add-on keeps refusing to ship. # operator cannot see is the exact failure this add-on keeps refusing to ship.
DEVICE_NOTE = ( DEVICE_NOTE = (
"%s did not report a range, so the two checks that compare a configured " "%s did not report its power range, so two checks were skipped, here and "
"ceiling against what the device itself accepts were skipped - here and at " "at start. If they fail later, the add-on will not start then.")
"startup, identically. Everything else was checked. If the device answers "
"later and disagrees, the add-on refuses to start then, with the same "
"message you would have seen here.")
def build_app(controller, selfcfg=None) -> web.Application: def build_app(controller, selfcfg=None) -> web.Application:
@@ -264,7 +261,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
_LOG.error("saving options failed: %s", err) _LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502) return web.json_response({"error": str(err)}, status=502)
controller.log_event("configuration saved from the panel - " controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts") "not applied until the add-on restarts",
hint=messages.hint("settings_saved"))
return web.json_response({"ok": True, **result}) return web.json_response({"ok": True, **result})
async def config_restart(request): async def config_restart(request):
@@ -288,7 +286,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
except selfconfig.SupervisorUnavailable as err: except selfconfig.SupervisorUnavailable as err:
_LOG.error("restart request failed: %s", err) _LOG.error("restart request failed: %s", err)
return web.json_response({"error": str(err)}, status=502) return web.json_response({"error": str(err)}, status=502)
controller.log_event("restart requested from the configuration panel") controller.log_event("restart requested from the configuration panel",
hint=messages.hint("restart_requested"))
return web.json_response({"ok": True}) return web.json_response({"ok": True})
app = web.Application() app = web.Application()
@@ -305,8 +304,16 @@ def build_app(controller, selfcfg=None) -> web.Application:
return app return app
def access_log():
"""UX-10: the page polls /status every few seconds, and aiohttp logs each
request at INFO. That access log is kept for a DEBUG log level only."""
return (logging.getLogger("aiohttp.access")
if _LOG.isEnabledFor(logging.DEBUG) else None)
async def start(controller, port: int = 8099, selfcfg=None): async def start(controller, port: int = 8099, selfcfg=None):
runner = web.AppRunner(build_app(controller, selfcfg)) runner = web.AppRunner(build_app(controller, selfcfg),
access_log=access_log())
await runner.setup() await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port) site = web.TCPSite(runner, "0.0.0.0", port)
await site.start() await site.start()
+7 -6
View File
@@ -1487,13 +1487,14 @@ s47check("the shipped row carries the caveat at the worst cycle: the operator is
s47check("...and it still carries the watts, in their own slot, and the standing " s47check("...and it still carries the watts, in their own slot, and the standing "
"0 W beside them - the caveat NARROWS the claim rather than deleting " "0 W beside them - the caveat NARROWS the claim rather than deleting "
"the record a shadow run exists to produce", "the record a shadow run exists to produce",
"WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row "it would ask for" in _s47_row
and "the inverters stay at 0 W" in _s47_row
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "") and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
s47check("...and the watts are introduced as what it would have commanded ON " s47check("...and the watts are introduced as what it would ask for THIS CYCLE, "
"THIS CYCLE, in every shadow state and not only the caveated ones - no " "in every shadow state and not only the caveated ones - no number a "
"number a shadow run records is a forecast of a live run", "shadow run records is a forecast of a live run (UX-08: in plain words)",
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row "This cycle it would ask for" in _s47_row
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row) and "This cycle it would ask for" in _s47_first_row)
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS ----------- # --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY. # ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
+38 -28
View File
@@ -77,6 +77,7 @@ Read MUTATION.md before you report a survivor.
import asyncio import asyncio
import io import io
import math import math
import os
import re import re
import sys import sys
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -1036,7 +1037,8 @@ check("...and the operator is told WHICH bound bit, so a device-rating clamp "
and messages.LIMIT_WORDS["device"] in notes_w[0]["message"] and messages.LIMIT_WORDS["device"] in notes_w[0]["message"]
and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"]) and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"])
check("...and the action log carries the row, written by write_setpoint()", check("...and the action log carries the row, written by write_setpoint()",
any(REASON_CODE in e and "device bound" in e for e in ctl_w.events)) any(REASON_CODE in e and messages.LIMIT_WORDS["device"] in e
for e in ctl_w.events))
# The same stimulus, the same clamp, the two calls swapped. This is the ticket # The same stimulus, the same clamp, the two calls swapped. This is the ticket
# in one pair of runs. # in one pair of runs.
@@ -1124,8 +1126,8 @@ check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - " "the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
"past the 2000 W rating printed four words later - which is the alarm " "past the 2000 W rating printed four words later - which is the alarm "
"reading backwards on the one line an operator is woken by", "reading backwards on the one line an operator is woken by",
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to " any(f"asked {_asked_x:+.0f} W and sent {_sent_x:+.0f} W" in e
f"{_sent_x:+.0f} W" in e for e in _rows_x)) for e in _rows_x))
# UX-08: the notification is a SECOND surface off the same numbers, in its own # UX-08: the notification is a SECOND surface off the same numbers, in its own
# words (messages.output_clamp): asked first, sent second. # words (messages.output_clamp): asked first, sent second.
def _asked_then_sent(msg, asked, sent): def _asked_then_sent(msg, asked, sent):
@@ -1141,8 +1143,8 @@ check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION, which is a SECOND surfac
bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x)) bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x))
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two " check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
"checks above discriminate rather than decorate", "checks above discriminate rather than decorate",
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to " not any(f"asked {_sent_x:+.0f} W and sent {_asked_x:+.0f} W" in e
f"{_asked_x:+.0f} W" in e for e in _rows_x) for e in _rows_x)
and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x) and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x)
and present(nth(notes_x, 0))) and present(nth(notes_x, 0)))
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The # The DEVICE bound, off part 6c's controller: same sentence, other stage. The
@@ -1153,8 +1155,8 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the "
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those " "loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
"two also render differently", "two also render differently",
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}" f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to " and any(f"asked {_asked_w:+.0f} W and sent {_sent_w:+.0f} W" in e
f"{_sent_w:+.0f} W" in e for e in ctl_w.events) for e in ctl_w.events)
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w)) and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
@@ -1874,8 +1876,8 @@ check("...and it is FIRST in the cascade: the same controller has other "
"conditions to report and none of them displaces this sentence", "conditions to report and none of them displaces this sentence",
_live_pay["banner"] != _shad_pay["banner"] _live_pay["banner"] != _shad_pay["banner"]
and "Shadow mode" not in _live_pay["banner"]) and "Shadow mode" not in _live_pay["banner"])
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at " _slot = re.search(r"This cycle it would ask for ([-+][\d ]+) W, the inverters "
r"([-\d]+) W", _shad_rows[_ROW]) r"stay at ([-\d]+) W", _shad_rows[_ROW])
check("the row answers 'what would it have done' with a NUMBER in its own " check("the row answers 'what would it have done' with a NUMBER in its own "
"named slot, and says where the inverter actually is in a second one - " "named slot, and says where the inverter actually is in a second one - "
"two slots in one sentence, so a transposition moves them", "two slots in one sentence, so a transposition moves them",
@@ -1889,15 +1891,16 @@ if _slot is not None:
check("...and the second slot is the STANDING command, so the two cannot " check("...and the second slot is the STANDING command, so the two cannot "
"be read as one number", "be read as one number",
float(_slot.group(2)) == 0.0) float(_slot.group(2)) == 0.0)
check("the row answers 'and why' by carrying the arbiter's own sentence, " check("the row answers 'and why' by carrying the page's own Why line, the "
"rather than a re-derivation of it", "arbiter's answer in plain words (UX-08), rather than a re-derivation",
_shad_c.reason in _shad_rows[_ROW]) _shad_c.why_words in _shad_rows[_ROW]
and dict(_shad_pay["rows"])["Why"] == _shad_c.why_words)
check("with shadow mode off the row says so, and says what follows from it - " check("with shadow mode off the row says so, and says what follows from it - "
"which is the arrangement that makes the one above a claim about this " "which is the arrangement that makes the one above a claim about this "
"controller and not about the string", "controller and not about the string",
"NOT in shadow mode" in _live_rows[_ROW] _live_rows[_ROW].startswith("Off")
and "DOES reach the inverter" in _live_rows[_ROW] and "reaches the inverters" in _live_rows[_ROW]
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW]) and "would ask for" not in _live_rows[_ROW])
# ⚠️ A STANDING LATCH IS SHOWN RATHER THAN HIDDEN. The shadow banner sits above # ⚠️ A STANDING LATCH IS SHOWN RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to say what they would have said - a # the three `bad` branches, so it has to say what they would have said - a
@@ -2026,16 +2029,17 @@ check("PRECONDITION: the integrator bound engaged, and on a tick where the "
"carried integrator AND the emitted command both differ from the bound", "carried integrator AND the emitted command both differ from the bound",
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w') any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
for d, t, r in wind)) for d, t, r in wind))
check("defect 1: the integrator bound is NOT reported as a BUG SIGNAL", check("defect 1: the integrator bound is NOT reported as an over-rating",
wind and all("BUG SIGNAL" not in r for d, t, r in wind)) wind and all(" rating" not in r and "fault" not in r
check("defect 1: ...it names the anti-windup bound, from the same tick", for d, t, r in wind))
wind and all(f"its {_f(d, 'i_bound_w'):+.0f} W anti-windup bound" in r check("defect 1: ...it names the integrator's bound, from the same tick",
wind and all(f"reached its {_f(d, 'i_bound_w'):+.0f} W limit" in r
for d, t, r in wind)) for d, t, r in wind))
check("defect 2: the integrator accumulator is not printed as a request", check("defect 2: the integrator accumulator is not printed as a request",
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
for d, t, r in wind)) for d, t, r in wind))
check("the command quoted is that tick's Decision, not a later read", check("the command quoted is that tick's Decision, not a later read",
wind and all(f"command this cycle was {d.target_w:+.0f} W" in r wind and all(f"command now {d.target_w:+.0f} W" in r
for d, t, r in wind)) for d, t, r in wind))
check("at the bound nothing is described as 'above' or 'over' the rating", check("at the bound nothing is described as 'above' or 'over' the rating",
wind and all(" above " not in r and " over " not in r wind and all(" above " not in r and " over " not in r
@@ -2048,7 +2052,7 @@ check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
"notice still on screen", "notice still on screen",
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i)) notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
check("...logged as a WARNING, not an ERROR", check("...logged as a WARNING, not an ERROR",
any(x.levelno == logging.WARNING and "anti-windup" in x.getMessage() any(x.levelno == logging.WARNING and "reached its" in x.getMessage()
for x in log_i) for x in log_i)
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage() and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
for x in log_i)) for x in log_i))
@@ -2068,15 +2072,21 @@ for d, t, r in over[:2]:
check("PRECONDITION: the output clamp engaged on a tick where the slew limit " check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
"rewrote the command AND the integrator differs from the request", "rewrote the command AND the integrator differs from the request",
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over)) any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
check("the over-rating KEEPS the BUG SIGNAL", check("the over-rating says it was cut to the rating",
over and all("BUG SIGNAL" in r for d, t, r in over)) over and all(" W rating, command now " in r for d, t, r in over))
check("defect 2: it quotes the requested COMMAND of that tick, not i_w", check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over)) over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
check("defect 3: 'clamped to' is the clamp's own result, not self.target", check("defect 3: 'cut to' is the clamp's own result, not self.target",
over and all(f"clamped to {_f(d, 'clamped_w'):+.0f} W" in r for d, t, r in over)) over and all(f"cut to the {_f(d, 'clamped_w'):+.0f} W rating" in r
check("...and the 14 768 W rationale is still in its ERROR", for d, t, r in over))
any(x.levelno == logging.ERROR and "14 768 W" in x.getMessage() check("...and its ERROR says it is a software fault and that control keeps "
for x in log_o)) "running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
"it, which is still in main.py)",
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
and "control runs on" in x.getMessage() for x in log_o)
and "reached 14 768 W on this project" in
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
"main.py"), encoding="utf-8").read())
check("...under the over-rating title, quoting a tick's own asked and sent " check("...under the over-rating title, quoting a tick's own asked and sent "
"numbers in that order, with a red status row", "numbers in that order, with a red status row",
clamp_notes(hass_o) clamp_notes(hass_o)
+4 -4
View File
@@ -189,13 +189,13 @@ check(f"the wind-down ran: 0 W written AFTER the fault (writes after = {after})"
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran", check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
["cleanup"] in ev[(fault_at or 0):]) ["cleanup"] in ev[(fault_at or 0):])
died_line = next((i for i, ln in enumerate(err.splitlines()) died_line = next((i for i, ln in enumerate(err.splitlines())
if "CONTROL LOOP DIED (RuntimeError('" + FAULT + "'))" in ln), None) if "Control stopped unexpectedly (RuntimeError('" + FAULT + "'))" in ln), None)
wind_line = next((i for i, ln in enumerate(err.splitlines()) wind_line = next((i for i, ln in enumerate(err.splitlines())
if "shutting down - commanding 0 W" in ln), None) if "shutting down - commanding 0 W" in ln), None)
check("a CRITICAL line names the exception and the exit status", check("a CRITICAL line names the exception and the exit status",
died_line is not None died_line is not None
and "CRITICAL" in err.splitlines()[died_line] and "CRITICAL" in err.splitlines()[died_line]
and "commanding 0 W and exiting with status 70" in err.splitlines()[died_line]) and "set to 0 W and the add-on exits with code 70" in err.splitlines()[died_line])
check("the traceback is logged, down to the frame that raised", check("the traceback is logged, down to the frame that raised",
"Traceback (most recent call last)" in err "Traceback (most recent call last)" in err
and f"RuntimeError: {FAULT}" in err and "in cycle" in err) and f"RuntimeError: {FAULT}" in err and "in cycle" in err)
@@ -211,7 +211,7 @@ print("2. the same entry point stopped by SIGTERM - the callback must stay quiet
rc2, out2, err2, ev2 = run_child("clean") rc2, out2, err2, ev2 = run_child("clean")
print(f" exit status {rc2}; events {ev2}") print(f" exit status {rc2}; events {ev2}")
check("an ordinary stop still exits 0", rc2 == 0) check("an ordinary stop still exits 0", rc2 == 0)
check("an ordinary stop logs no CONTROL LOOP DIED", "CONTROL LOOP DIED" not in err2) check("an ordinary stop logs no unexpected stop", "Control stopped unexpectedly" not in err2)
check("an ordinary stop still winds down to 0 W, from a non-zero command", check("an ordinary stop still winds down to 0 W, from a non-zero command",
any(e[0] == "w" and e[1] != 0.0 for e in ev2) any(e[0] == "w" and e[1] != 0.0 for e in ev2)
and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]]) and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]])
@@ -222,7 +222,7 @@ rc3, out3, err3, ev3 = run_child("refuse")
print(f" exit status {rc3}") print(f" exit status {rc3}")
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70", check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
rc3 == 78) rc3 == 78)
check("and is not reported as a death", "CONTROL LOOP DIED" not in err3) check("and is not reported as a death", "Control stopped unexpectedly" not in err3)
if rc3 != 78: if rc3 != 78:
print(tail(err3)) print(tail(err3))
+21 -20
View File
@@ -360,7 +360,7 @@ check("...and `inputs_ok` goes False, so the site stops rather than regulating "
_cs.inputs_ok is False and _cf.inputs_ok is True) _cs.inputs_ok is False and _cf.inputs_ok is True)
check("...and the operator gets a line saying which unit and why, on the same " check("...and the operator gets a line saying which unit and why, on the same "
"status list the liveness notes use", "status list the liveness notes use",
any("STALE_READING" in n and "inverter-a" in n any("stopped changing" in n and "inverter-a" in n
for n in _cs.liveness_notes()) for n in _cs.liveness_notes())
and _cf.liveness_notes() == []) and _cf.liveness_notes() == [])
check("...and the raw number is still recorded for diagnostics, so the " check("...and the raw number is still recorded for diagnostics, so the "
@@ -1581,7 +1581,7 @@ check("...and the site stops holding inputs bad, which is what ends the "
_c57.inputs_bad_since is None and _c57.inputs_ok is True) _c57.inputs_bad_since is None and _c57.inputs_ok is True)
_ev57 = [e for e in _c57.events if "STALE" in e] _ev57 = [e for e in _c57.events if "STALE" in e]
_msgs57 = [r.getMessage() for r in _LOGREC] _msgs57 = [r.getMessage() for r in _LOGREC]
_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m] _rel57 = [m for m in _msgs57 if "is used again and rechecked" in m]
print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}") print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}")
check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR " check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR "
"CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach " "CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach "
@@ -1592,8 +1592,9 @@ check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATO
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has " check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
"happened: it says in its own words that nothing has been learned about " "happened: it says in its own words that nothing has been learned about "
"the register, and it prints the value the register is STILL holding", "the register, and it prints the value the register is STILL holding",
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in nth(_rel57, 0) "used again" in nth(_rel57, 0) and "recovered" not in nth(_rel57, 0)
and f"still holding {A_FROZEN_W:+.0f}" in nth(_rel57, 0)) and f"reading {A_FROZEN_W:+.0f} W is used again and rechecked"
in nth(_rel57, 0))
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) --------------------- # --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
@@ -1602,7 +1603,8 @@ check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has
# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new # could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new
# escape clause, and 250 W and 17.0 s again in the release note - so each is # escape clause, and 250 W and 17.0 s again in the release note - so each is
# asserted with its value embedded in its own literal position. # asserted with its value embedded in its own literal position.
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m] _stale57 = [m for m in _msgs57
if "battery power reading stopped changing" in m]
print(f" escape clause: ...{nth(_stale57, 0)[-150:]}") print(f" escape clause: ...{nth(_stale57, 0)[-150:]}")
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED " check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
"PAIR. It used to end 'It clears itself the moment the register reports " "PAIR. It used to end 'It clears itself the moment the register reports "
@@ -1610,17 +1612,16 @@ check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDER
"before it is what stops the register being asked. The new clause names " "before it is what stops the register being asked. The new clause names "
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes " "17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
"red", "red",
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing " f"or {BOUNDS['batt'].release_s:.0f} s after the command drops below "
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0)) f"{BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
check("...and the release note names 250 W in the POWER slot and 17 s in the " check("...and the release note names 250 W in the POWER slot and 17 s in the "
"TIME slot, in that order, which is the reverse arrangement of the same " "TIME slot, in that order, which is the reverse arrangement of the same "
"two numbers", "two numbers",
f"sat below {BOUNDS['batt'].move_w:.0f} W for " f"stayed below {BOUNDS['batt'].move_w:.0f} W for "
f"{BOUNDS['batt'].release_s:.0f} s" in nth(_rel57, 0)) f"{BOUNDS['batt'].release_s:.0f} s" in nth(_rel57, 0))
check("...and the 35.0 s the register is re-graded over is named in the note " check("...and the 35.0 s the register is re-graded over is named in the note "
"too, because it is what the operator is being told to wait out", "too, because it is what the operator is being told to wait out",
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window" f"rechecked over {BOUNDS['batt'].unchanged_s:.0f} s" in nth(_rel57, 0))
in nth(_rel57, 0))
check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, " check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
"WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power " "WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power "
"register is not following its own command' to still be present, so " "register is not following its own command' to still be present, so "
@@ -1632,7 +1633,7 @@ check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN,
present(nth(_stale57, 0)) present(nth(_stale57, 0))
and "battery-power register is not following its own command" and "battery-power register is not following its own command"
not in _stale57[0] not in _stale57[0]
and "did not move under a command that did" in _stale57[0]) and "stopped changing" in _stale57[0])
check("...and a re-trip's reason names the TWO durations separately, each " check("...and a re-trip's reason names the TWO durations separately, each "
"beside its own value, because after a release the verdict's window and " "beside its own value, because after a release the verdict's window and "
"the register's own unchanged time are different facts", "the register's own unchanged time are different facts",
@@ -1643,9 +1644,8 @@ check("...and a re-trip's reason names the TWO durations separately, each "
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)], [f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
f.stale and f.releases == 1 f.stale and f.releases == 1
and "unchanged for 92 s" in f.reason and "unchanged for 92 s" in f.reason
and "the 35 s window this verdict rests on opened when the previous " and "checked 2 times over a 35 s window since the last stop was "
"one was withdrawn" in f.reason "withdrawn" in f.reason)[-1])(
and "graded 2 times" in f.reason)[-1])(
Freshness(BOUNDS["batt"]))) Freshness(BOUNDS["batt"])))
@@ -1798,8 +1798,8 @@ check("...and the two runs were driven at 50 % and 11 % SoC and the sentence "
# bytes, and "the register may be unable to comply / the source may have # bytes, and "the register may be unable to comply / the source may have
# stopped reporting reality" is a different and wrong claim. # stopped reporting reality" is a different and wrong claim.
_S56_EXPLANATIONS = [ _S56_EXPLANATIONS = [
("DEAD", "the register may have stopped reporting reality"), ("DEAD", "reading may have stopped"),
("REFUSED", "the source may be unable to comply with the command"), ("REFUSED", "battery may be unable to follow the command"),
] ]
# ⚠️ WHAT THE THREE SENTENCES USED TO ASSERT. Each is a claim about the # ⚠️ WHAT THE THREE SENTENCES USED TO ASSERT. Each is a claim about the
# REGISTER or about the ENTITY, and 11a measured the register to be alive on # REGISTER or about the ENTITY, and 11a measured the register to be alive on
@@ -1811,7 +1811,8 @@ _S56_ACCUSATIONS = [
("app/main.py _read_unit()", "register is not following its own command"), ("app/main.py _read_unit()", "register is not following its own command"),
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"), ("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
] ]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:] if "SAFETY-37" in r.getMessage()] _log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
if "battery power reading stopped changing" in r.getMessage()]
def _s56_row(c): def _s56_row(c):
@@ -1866,7 +1867,7 @@ check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
check("...and the sentence says in its own words that it CANNOT choose, rather " check("...and the sentence says in its own words that it CANNOT choose, rather "
"than leaving two explanations side by side for a reader to rank - an " "than leaving two explanations side by side for a reader to rank - an "
"operator who reads a list picks the first item", "operator who reads a list picks the first item",
"CANNOT TELL THEM APART" in _r56_ref) "this check cannot tell which" in _r56_ref)
# ⚠️ THE POSITIVE CONTROL FOR THE `not any(...)` BELOW, AND IT IS NOT OPTIONAL: # ⚠️ THE POSITIVE CONTROL FOR THE `not any(...)` BELOW, AND IT IS NOT OPTIONAL:
# a quantifier over an empty list is True, and so is one over three literals # a quantifier over an empty list is True, and so is one over three literals
# with a typo in each. These are the three sentences as 0.10.1 SHIPPED them, # with a typo in each. These are the three sentences as 0.10.1 SHIPPED them,
@@ -1952,8 +1953,8 @@ check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which
"row below?' answers the question this one declines to answer. A row " "row below?' answers the question this one declines to answer. A row "
"that pointed at a surface present in both states would be worse than " "that pointed at a surface present in both states would be worse than "
"the accusation it replaced", "the accusation it replaced",
any("STALE_READING" in r["text"] for r in _c56_ref.checks()) any("stopped changing" in r["text"] for r in _c56_ref.checks())
and not any("STALE_READING" in r["text"] for r in _c56_abs.checks())) and not any("stopped changing" in r["text"] for r in _c56_abs.checks()))
check("...and the row's wording is NOT shared with the log line's, which is a " check("...and the row's wording is NOT shared with the log line's, which is a "
"design choice rather than an omission: the two sentences have no clause " "design choice rather than an omission: the two sentences have no clause "
"in common longer than the word 'entity', because one explains a " "in common longer than the word 'entity', because one explains a "
+1 -1
View File
@@ -832,7 +832,7 @@ check("...and the per-inverter rows now say WHAT the unit was compared "
"look at P1 for a fault that is visible only on one inverter's own power " "look at P1 for a fault that is visible only on one inverter's own power "
"sensor", "sensor",
len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2 len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
and all("its own power" in r["text"] for r in _c3r.checks() and all("own power" in r["text"] for r in _c3r.checks()
if r.get("id") == "direction")) if r.get("id") == "direction"))
# The operator's clear, which is one button and has to reach every latch. # The operator's clear, which is one button and has to reach every latch.
+9 -7
View File
@@ -497,7 +497,7 @@ check("a FORCED start is refused while another pack holds the slot, and the "
"phase machine does not move. The calendar is a preference an operator " "phase machine does not move. The calendar is a preference an operator "
"may overrule; one pack at a time is a property of the grid connection " "may overrule; one pack at a time is a property of the grid connection "
"and a button does not add a second one", "and a button does not add a second one",
_b8.phase == IDLE and len(_ev) == 1 and "REFUSED" in _ev[0] _b8.phase == IDLE and len(_ev) == 1 and "cannot start now" in _ev[0]
and "inverter-a" in _ev[0]) and "inverter-a" in _ev[0])
check("...and an unblocked forced start still works, so the refusal is a " check("...and an unblocked forced start still works, so the refusal is a "
"condition and not a disabled feature", "condition and not a disabled feature",
@@ -534,7 +534,7 @@ check("...and the deferred pack's PHASE does not move either, so "
_b9.phase == IDLE and _b9.store.get_time("phase_started") is None) _b9.phase == IDLE and _b9.store.get_time("phase_started") is None)
check("...and the words say WAITING, name the pack it waits for, and say it " check("...and the words say WAITING, name the pack it waits for, and say it "
"will run next - none of which any failure path in this module says", "will run next - none of which any failure path in this module says",
"WAITING" in _r9[1].events[0] "waits its turn" in _r9[1].events[0]
and "inverter-a" in _r9[1].events[0] and "inverter-a" in _r9[1].events[0]
and "not a stalled phase" in _r9[1].events[0] and "not a stalled phase" in _r9[1].events[0]
and "ABORT" not in _r9[1].events[0] and "stuck" not in _r9[1].events[0]) and "ABORT" not in _r9[1].events[0] and "stuck" not in _r9[1].events[0])
@@ -687,7 +687,7 @@ print(f" a store already written by a multi-inverter version: {_rep3.explain(
check("a store already at the per-pack schema is NOT TOUCHED - not one key " check("a store already at the per-pack schema is NOT TOUCHED - not one key "
"written - and says so", "written - and says so",
not _rep3.ran and _m3.data == _before3 and _m3.writes == [] not _rep3.ran and _m3.data == _before3 and _m3.writes == []
and "not touched" in _rep3.explain()) and "left as it is" in _rep3.explain())
# ⚠️ AND BREAK IT. The read-back postcondition is the whole difference between # ⚠️ AND BREAK IT. The read-back postcondition is the whole difference between
# this and a two-line copy, and it is inherited from `migrate_unit_keys()` # this and a two-line copy, and it is inherited from `migrate_unit_keys()`
@@ -766,7 +766,7 @@ check("...and the refusal NAMES the namespace it used AND the roster it was "
"graded against, rather than merely saying something went wrong - the " "graded against, rather than merely saying something went wrong - the "
"operator's next question is 'which name, and which one should it have " "operator's next question is 'which name, and which one should it have "
"been', and a bare `not ok` answers neither", "been', and a bare `not ok` answers neither",
"WRONG NAMESPACE" in _rep5.explain() "moved under an inverter not in the list" in _rep5.explain()
and unit_key(UNIT_A, "last_completed") in _rep5.explain() and unit_key(UNIT_A, "last_completed") in _rep5.explain()
and repr(ROSTER) in _rep5.explain()) and repr(ROSTER) in _rep5.explain())
check("⚠️ AND THE MARKER IS NOT STAMPED, WHICH IS THE WHOLE REMEDY. A stamped " check("⚠️ AND THE MARKER IS NOT STAMPED, WHICH IS THE WHOLE REMEDY. A stamped "
@@ -893,7 +893,7 @@ check("...and the counterfactual: the SAME two calls with the roster's own "
"`already at schema` early return, not a refusal. So 9c is about the " "`already at schema` early return, not a refusal. So 9c is about the "
"NAME surviving into the second start, not about second calls", "NAME surviving into the second start, not about second calls",
_rep7ok_a.ok and _m7ok.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION _rep7ok_a.ok and _m7ok.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION
and _rep7ok_b.ran is False and "already at schema" in _rep7ok_b.explain()) and _rep7ok_b.ran is False and "already at version" in _rep7ok_b.explain())
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1581,9 +1581,11 @@ try:
_logged, _ = controller(NAMED_OPTS, store=_log_store) _logged, _ = controller(NAMED_OPTS, store=_log_store)
finally: finally:
logging.getLogger("goodwe").removeHandler(_h) logging.getLogger("goodwe").removeHandler(_h)
_s44_lines = [m for m in _records if "SAFETY-44" in m] # UX-08 2d: the line is plain now, so it is found by its words, not a card id.
_s44_lines = [m for m in _records
if "which is not in the inverter list" in m]
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}") print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line " check("⭐ the repair SAYS SO, at ERROR, in one plain line - deleting this line "
"left the whole suite green, and it is how an operator learns their " "left the whole suite green, and it is how an operator learns their "
"latch was re-armed rather than never lost", "latch was re-armed rather than never lost",
len(_s44_lines) == 1) len(_s44_lines) == 1)
File diff suppressed because it is too large Load Diff
+27 -11
View File
@@ -1221,19 +1221,32 @@ rows = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows[0]["text"] if rows else "(none)")) print(" status row: " + (rows[0]["text"] if rows else "(none)"))
check("the status page carries exactly one peak-guard row, flagged", check("the status page carries exactly one peak-guard row, flagged",
len(rows) == 1 and rows[0]["warn"]) len(rows) == 1 and rows[0]["warn"])
# UX-08 2d: the engaged row names what the battery is asked for FIRST and the
# limit SECOND. Both are watts, so only the ordered pair tells them apart.
from app import messages as _msg_on # noqa: E402
_on = (f"asks the battery for {_msg_on.watts(c.peak_guard.floor_w)} to stay "
f"under {_msg_on.watts(c.peak_guard.ceiling_w)}")
_on_swapped = (f"asks the battery for {_msg_on.watts(c.peak_guard.ceiling_w)} "
f"to stay under {_msg_on.watts(c.peak_guard.floor_w)}")
check("...and it names the battery's share first and the limit second, and "
"the two render differently so the swap is visible",
len(rows) == 1 and _on in rows[0]["text"]
and _on_swapped not in rows[0]["text"]
and _msg_on.watts(c.peak_guard.floor_w)
!= _msg_on.watts(c.peak_guard.ceiling_w))
check("an engagement that is not saturated raises NO notification - §10.3's " check("an engagement that is not saturated raises NO notification - §10.3's "
"'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house " "'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house "
"load bigger than the inverter is not a bug", "load bigger than the inverter is not a bug",
"goodwe_peak_saturated" not in hass.notifications) "goodwe_peak_saturated" not in hass.notifications)
check("...but it does reach the action log, which is where a control action " check("...but it does reach the action log, which is where a control action "
"that moves energy belongs", "that moves energy belongs",
any("PEAK GUARD" in e for e in c.events)) any("PEAK_GUARD" in e for e in c.events))
n_events = sum(1 for e in c.events if "PEAK GUARD" in e) n_events = sum(1 for e in c.events if "PEAK_GUARD" in e)
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c.cycle()) asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c.cycle())
check("...and it is edge-logged, not level-logged: a second engaged cycle adds " check("...and it is edge-logged, not level-logged: a second engaged cycle adds "
"no second line, so a quarter of engagement cannot push the cause of the " "no second line, so a quarter of engagement cannot push the cause of the "
"event off a 40-line log", "event off a 40-line log",
sum(1 for e in c.events if "PEAK GUARD" in e) == n_events) sum(1 for e in c.events if "PEAK_GUARD" in e) == n_events)
# The saturated case, through the same real cycle. # The saturated case, through the same real cycle.
# A floor of 1.5x what the inverter can give: it saturates wherever in the # A floor of 1.5x what the inverter can give: it saturates wherever in the
@@ -1648,8 +1661,8 @@ check("...and it still says what it WOULD have taken, so the under-shave is a "
near(d_lim.wanted_w, WANTED, 1e-6) and d_lim.wanted_w > d_lim.floor_w) near(d_lim.wanted_w, WANTED, 1e-6) and d_lim.wanted_w > d_lim.floor_w)
check("...and its one-line explanation tells the operator the consequence in " check("...and its one-line explanation tells the operator the consequence in "
"the words that matter - the quarter is going over the ceiling", "the words that matter - the quarter is going over the ceiling",
"FLOOR-LIMITED" in d_lim.explain() "FLOOR_LIMITED" in d_lim.explain()
and "WILL GO OVER THE CEILING" in d_lim.explain() and "will go over the limit" in d_lim.explain()
and f"{CEIL:.0f} W" in d_lim.explain()) and f"{CEIL:.0f} W" in d_lim.explain())
check("an unreadable SoC lands in the same branch, with the OTHER reason - so " check("an unreadable SoC lands in the same branch, with the OTHER reason - so "
"an operator reading one line can tell a flat battery from a dead sensor", "an operator reading one line can tell a flat battery from a dead sensor",
@@ -1931,7 +1944,7 @@ check("the status page carries exactly one peak-guard row and it is NOT ok - "
"the money is going to be spent and a human is the only actuator left", "the money is going to be spent and a human is the only actuator left",
len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"] len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"]
and "peak limit will be passed" in rows24[0]["text"]) and "peak limit will be passed" in rows24[0]["text"])
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e check("...and it reaches the action log too", any("FLOOR_LIMITED" in e
for e in c24.events)) for e in c24.events))
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop # A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
# at 0 W in that state, so the guard's own answer is what is graded here. # at 0 W in that state, so the guard's own answer is what is graded here.
@@ -5932,10 +5945,12 @@ _S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi, _S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_OK, soc_floor=SOC_FLOOR) soc=SOC_OK, soc_floor=SOC_FLOOR)
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a " # UX-08 2d: the row is in plain words now, and the pair is still ordered.
f"{_S38_CEIL_W:.0f} W ceiling") from app import messages as _msg38 # noqa: E402
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a " _S38_LITERAL = (f"heading for {_msg38.watts(_S38_PROJ.projected_w)}, under "
f"{_S38_PROJ.projected_w:.0f} W ceiling") f"this month's {_msg38.watts(_S38_CEIL_W)} peak")
_S38_SWAPPED = (f"heading for {_msg38.watts(_S38_CEIL_W)}, under "
f"this month's {_msg38.watts(_S38_PROJ.projected_w)} peak")
print(f" projection {_S38_PROJ.projected_w:.0f} W against a " print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})") f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
print(f" {_S38_IDLE.explain()!r}") print(f" {_S38_IDLE.explain()!r}")
@@ -5958,7 +5973,8 @@ check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
_S38_SWAPPED not in _S38_IDLE.explain()) _S38_SWAPPED not in _S38_IDLE.explain())
check("...and the row still names WHERE the ceiling came from, which is the " check("...and the row still names WHERE the ceiling came from, which is the "
"difference between a cap an operator set and a peak the month already " "difference between a cap an operator set and a peak the month already "
"banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain()) "banked", "under this month's" in _S38_IDLE.explain()
and "peak limit" not in _S38_IDLE.explain())
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints # THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status # it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
+49 -35
View File
@@ -2189,7 +2189,7 @@ _p = validate_startup_config(
check("a provider AND the Home Assistant price entities is refused - two price " check("a provider AND the Home Assistant price entities is refused - two price "
"sources on one site", len(_p) == 1) "sources on one site", len(_p) == 1)
check("...and the message says which one actually wins", check("...and the message says which one actually wins",
why(_p, "are also set and would win")) why(_p, "would win over it"))
# TARIFF-03 added a second source with the same silent-loser shape. # TARIFF-03 added a second source with the same silent-loser shape.
_ps = validate_startup_config( _ps = validate_startup_config(
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",' vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
@@ -3427,8 +3427,8 @@ check(f"network charges folded into `offset` move the card's own price to "
isinstance(_double_price, float) and abs(_double_price - 15.55) > 10) isinstance(_double_price, float) and abs(_double_price - 15.55) > 10)
check(f"...and it is refused, by the CARD rule, naming the discrepancy and " check(f"...and it is refused, by the CARD rule, naming the discrepancy and "
f"the network-charge cause ({_double[:60]!r})", f"the network-charge cause ({_double[:60]!r})",
"does not match its own card" in _double and "the card says" in _double "the card says" in _double
and "(price_taxes_eur_kwh), not in the offset" in _double) and "network charges are not in the offset" in _double)
_CORRUPTIONS = { _CORRUPTIONS = {
"VAT left out (vat_pct 0)": spoilt(vat_pct=0), "VAT left out (vat_pct 0)": spoilt(vat_pct=0),
@@ -3447,7 +3447,7 @@ for _name, _blob in sorted(_CORRUPTIONS.items()):
_why = refusal(_blob) _why = refusal(_blob)
check(f"{_name}: refused, and by the CARD rule rather than by another one " check(f"{_name}: refused, and by the CARD rule rather than by another one "
f"({_why[:48]!r})", f"({_why[:48]!r})",
"does not match its own card" in _why and "the card says" in _why) "the card says" in _why)
# ⚠️ THE TOLERANCE IS A REAL EDGE AND IT IS GRADED FROM BOTH SIDES, or # ⚠️ THE TOLERANCE IS A REAL EDGE AND IT IS GRADED FROM BOTH SIDES, or
# "within 0.01" and "within anything" would be the same check. # "within 0.01" and "within anything" would be the same check.
@@ -3459,7 +3459,7 @@ _outside = refusal(spoilt(card_expect_incl_vat=round(
+ CARD_TOLERANCE * 2.0, 6))) + CARD_TOLERANCE * 2.0, 6)))
check(f"half a tolerance out passes and twice a tolerance out does not, so " check(f"half a tolerance out passes and twice a tolerance out does not, so "
f"{CARD_TOLERANCE} is the number being enforced rather than a shape", f"{CARD_TOLERANCE} is the number being enforced rather than a shape",
_inside == "" and "does not match its own card" in _outside) _inside == "" and "the card says" in _outside)
# A card that is absent is refused, and NOT by the generic unknown-field rule. # A card that is absent is refused, and NOT by the generic unknown-field rule.
check(f"a formula with NO card block is refused, and the message says why the " check(f"a formula with NO card block is refused, and the message says why the "
@@ -3851,12 +3851,12 @@ check("a formula that does not reproduce its card is refused AT STARTUP, "
why(validate_startup_config( why(validate_startup_config(
vbase(price_provider="custom_formula", vbase(price_provider="custom_formula",
price_formula=json.dumps(spoilt(offset=13.0))), price_formula=json.dumps(spoilt(offset=13.0))),
-5000, 5000), "does not match its own card")) -5000, 5000), "the card says"))
check("...and a formula that is not JSON at all is refused with the shape it " check("...and a formula that is not JSON at all is refused with the shape it "
"wanted, rather than with a traceback", "wanted, rather than with a traceback",
why(validate_startup_config( why(validate_startup_config(
vbase(price_provider="custom_formula", price_formula="0.102*spot+1"), vbase(price_provider="custom_formula", price_formula="0.102*spot+1"),
-5000, 5000), "not valid JSON")) -5000, 5000), "price_formula is not valid near character"))
check(f"...and a card outside the plausible band is refused too, WITH THE " check(f"...and a card outside the plausible band is refused too, WITH THE "
f"SHIPPED taxes in place - the flat " f"SHIPPED taxes in place - the flat "
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} must not be able to " f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} must not be able to "
@@ -4100,13 +4100,15 @@ def _t11_render(over=None, *, plan=None, plan_age_s=0.0, soc=55.0, cycles=1,
# row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are # row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are
# asserted. # asserted.
_T11_SLOTS = { _T11_SLOTS = {
"points": r"(\d+) points over \d+ h", # UX-08 2d: the row is in plain words; every figure keeps its own slot.
"hours": r"\d+ points over (\d+) h", "points": r"(\d+) prices over \d+ hours",
"firm": r"(\d+) firm / \d+ carried", "hours": r"\d+ prices over (\d+) hours",
"carried": r"\d+ firm / (\d+) carried", "firm": r"(\d+) published and \d+ estimated",
"horizon": r"for a (\d+) h horizon", "carried": r"\d+ published and (\d+) estimated",
"age_s": r"fetched (\d+) s ago", "horizon": r"for (\d+) hours ahead",
"spacings": r"([\d+]+) s spacing", "age_s": r"Price plan from (\d+) s ago",
# UX-10: the spacing is in words ("one every 15 minutes or every hour").
"spacings": r"one (every [a-z0-9 ]+?)(?:,|\.|$)",
# ⚠️ THE *Arbitrage* ROW'S OWN AGE SLOT, and it is deliberately the same # ⚠️ THE *Arbitrage* ROW'S OWN AGE SLOT, and it is deliberately the same
# pattern for "decided N s ago" and "the last one was taken N s ago" - # pattern for "decided N s ago" and "the last one was taken N s ago" -
# WHICH of the two sentences was rendered is graded by inequality below, # WHICH of the two sentences was rendered is graded by inequality below,
@@ -4124,6 +4126,18 @@ _T11_SLOTS = {
} }
def _spacing_s(raw):
"""`every 15 minutes or every hour` -> `[900, 3600]`; raises otherwise."""
unit = {"second": 1, "minute": 60, "hour": 3600}
out = []
for part in raw.split(" or "):
m = re.fullmatch(r"every (?:(\d+) )?(second|minute|hour)s?", part.strip())
if m is None:
raise ValueError(f"no spacing in {part!r}")
out.append(int(m.group(1) or 1) * unit[m.group(2)])
return out
def _slot(row, name): def _slot(row, name):
"""The number in ONE NAMED SLOT of a rendered row, or `_Raised`. """The number in ONE NAMED SLOT of a rendered row, or `_Raised`.
@@ -4139,18 +4153,18 @@ def _slot(row, name):
if m is None: if m is None:
raise ValueError(f"no {name!r} slot in {row[:90]!r}") raise ValueError(f"no {name!r} slot in {row[:90]!r}")
raw = m.group(1) raw = m.group(1)
return ([int(x) for x in raw.split("+")] if name == "spacings" return (_spacing_s(raw) if name == "spacings" else int(raw))
else int(raw))
return derived(_get) return derived(_get)
_T11_GOOD = ("x (recomputed this tick); provider plan fetched 7 s ago for a " _T11_GOOD = ("x. Price plan from 7 s ago, for 48 hours ahead: day-ahead "
"48 h horizon: dynamic_dayahead from generic_dayahead, " "prices from the price feed, 132 prices over 33 hours, "
"132 points over 33 h, 36 firm / 96 carried, 900+3600 s spacing") "36 published and 96 estimated, one every 15 minutes or every "
_T11_SHIPPED_DEFECT = ("x (recomputed this tick); provider plan fetched 7 s " "hour")
"ago for a 48 h horizon: dynamic_dayahead from " _T11_SHIPPED_DEFECT = ("x. Price plan from 7 s ago, for 48 hours ahead: "
"generic_dayahead, 132 points over 132 h, " "day-ahead prices from the price feed, 132 prices over "
"96 firm / 36 carried, 900+3600 s spacing") "132 hours, 96 published and 36 estimated, one every "
"15 minutes or every hour")
# The slots divide into the ones a *Tariff*/​*Arbitrage* ROW carries and the # The slots divide into the ones a *Tariff*/​*Arbitrage* ROW carries and the
# ones a REFUSAL sentence carries; each group is calibrated against its own # ones a REFUSAL sentence carries; each group is calibrated against its own
# well-formed example, because a slot asked of the wrong kind of sentence is # well-formed example, because a slot asked of the wrong kind of sentence is
@@ -4399,11 +4413,11 @@ check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
f"in the options blob", f"in the options blob",
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"]) _T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON " check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
f"({_T11_ON_HELD[1].reason!r}), so the operator is told which input is " f"({_T11_ON_HELD[1].why_words!r}), so the operator is told which input is "
f"missing rather than being sent to look at a switch that is already on. " f"missing rather than being sent to look at a switch that is already on. "
f"The reason comes from main.py, not from this file", f"The reason comes from main.py, not from this file",
_T11_ON_HELD[1].reason _T11_ON_HELD[1].why_words
and _T11_ON_HELD[1].reason in _T11_ON_HELD[0]["Arbitrage"]) and _T11_ON_HELD[1].why_words in _T11_ON_HELD[0]["Arbitrage"])
check(f"...and a cycle that DID reach the step renders the strategy's own " check(f"...and a cycle that DID reach the step renders the strategy's own "
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a " f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
f"re-derivation of it, which is the guarantee OPT-04 put on this row and " f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
@@ -4449,10 +4463,10 @@ check(f"DRIVEN, no clock touched: a decision stands "
and _T11_DROP_STALE is True and _T11_DROP_STALE is True
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"]) and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
check(f"...and it names the reason the cycle stopped " check(f"...and it names the reason the cycle stopped "
f"({_T11_ON_DROPPED[1].reason!r}) beside the age of the decision it is " f"({_T11_ON_DROPPED[1].why_words!r}) beside the age of the decision it is "
f"still showing, so 'the strategy decided this' and 'the strategy last " f"still showing, so 'the strategy decided this' and 'the strategy last "
f"decided this' are not one sentence", f"decided this' are not one sentence",
_T11_ON_DROPPED[1].reason in _T11_ON_DROPPED[0]["Arbitrage"] _T11_ON_DROPPED[1].why_words in _T11_ON_DROPPED[0]["Arbitrage"]
and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int)) and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int))
check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than " check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than "
@@ -4585,8 +4599,8 @@ check(f"...and the row an operator reads names the SPACING that makes those "
check("...and this is the same wording FCST-04's *Price history* row already " check("...and this is the same wording FCST-04's *Price history* row already "
"used, so the two rows describing the same feed can be compared rather " "used, so the two rows describing the same feed can be compared rather "
"than being two conventions on one page", "than being two conventions on one page",
"points over" in _T11_E2E_ROW "prices over" in _T11_E2E_ROW
and "points over" in ("" if isinstance(_T11_E2E, _Raised) and "prices over" in ("" if isinstance(_T11_E2E, _Raised)
else _T11_E2E["Price history"])) else _T11_E2E["Price history"]))
@@ -5360,14 +5374,14 @@ _T16_DIFFER = [
# UX-08 2c: a negative factor gets one sentence on both legs now ("put the # UX-08 2c: a negative factor gets one sentence on both legs now ("put the
# minus sign on the offset"), so it left this table. # minus sign on the offset"), so it left this table.
("a card miss -> what else to check on THIS leg", ("a card miss -> what else to check on THIS leg",
spoilt(factor=0.120), "(price_taxes_eur_kwh), not in the offset", spoilt(factor=0.120), "network charges are not in the offset",
"vat_pct is 0 for injection"), "the VAT is 0 for injection"),
("invalid JSON -> WHEN this option is read at all, which is not the " ("invalid JSON -> WHEN this option is read at all, which is not the "
"same answer on the two legs: `price_formula` decides nothing unless " "same answer on the two legs: `price_formula` decides nothing unless "
"price_provider is 'custom_formula', and the injection leg is read " "price_provider is 'custom_formula', and the injection leg is read "
"whatever the consumption provider is", "whatever the consumption provider is",
"{not json", "(price_provider) is 'custom_formula'", "{not json", "unless “Dynamic price source” is 'custom_formula'",
"used whatever the price source is"), "only if your injection price follows the market"),
] ]
for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER: for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER:
_a, _b = ((_reason(lambda b=_blob: parse_price_formula(b)), _a, _b = ((_reason(lambda b=_blob: parse_price_formula(b)),
@@ -5549,7 +5563,7 @@ check(f"a card block ON THIS LEG is graded by the same rung at the same "
f"15.55) is refused, naming the option the operator typed " f"15.55) is refused, naming the option the operator typed "
f"({_T16_SPOILT_CARD[:64]!r})", f"({_T16_SPOILT_CARD[:64]!r})",
_T16_WITH_CARD == "" _T16_WITH_CARD == ""
and "does not match its own card" in _T16_SPOILT_CARD and "the card says" in _T16_SPOILT_CARD
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION)) and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION))
check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is " check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is "
f"enforced on this leg too rather than 'something'", f"enforced on this leg too rather than 'something'",
+11 -6
View File
@@ -66,6 +66,7 @@ import sys
from named_absence import first, nth from named_absence import first, nth
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from app import messages as _msg_fc
from app import price as _price from app import price as _price
from app import price_forecast as _fc from app import price_forecast as _fc
from app import static_tariff as _st from app import static_tariff as _st
@@ -668,8 +669,10 @@ check("with neither a published hour nor a yesterday, the result is no_data - "
check("...so it has no .points to iterate and no .get() to default out of", check("...so it has no .points to iterate and no .get() to default out of",
raises(lambda: _nothing.points, Absence) raises(lambda: _nothing.points, Absence)
and raises(lambda: _nothing.get("points", []), Absence)) and raises(lambda: _nothing.get("points", []), Absence))
check("...and its reason names §11.4 for an operator", check("...and its reason says the fixed tariff is used and there is no "
"§11.4" in _nothing.reason) "trading (§11.4), in words an operator reads",
"fixed tariff is used" in _nothing.reason
and "no trading" in _nothing.reason)
check("a naive `since` is refused rather than silently interpreted as UTC", check("a naive `since` is refused rather than silently interpreted as UTC",
raises(lambda: fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY, raises(lambda: fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
since=datetime(2026, 1, 15), since=datetime(2026, 1, 15),
@@ -745,9 +748,10 @@ check("...and the phase machine did not move: it is still idle, so the "
_m.phase == IDLE) _m.phase == IDLE)
check("...and the reason names §8.3, the offending hour, and when the real " check("...and the reason names §8.3, the offending hour, and when the real "
"price is expected", "price is expected",
"§8.3" in _gate_bad.reason "is only estimated" in _gate_bad.reason
and bool(_first_prov) and _first_prov.isoformat() in _gate_bad.reason and bool(_first_prov)
and "14-16" in _gate_bad.reason) and _msg_fc.when(brussels(_first_prov)) in _gate_bad.reason
and "14:00 to 16:00" in _gate_bad.reason)
# The positive arm, so the block above is discriminating rather than universal. # The positive arm, so the block above is discriminating rather than universal.
_firm_long = series_of(hours_between(START, _firm_long = series_of(hours_between(START,
@@ -772,7 +776,8 @@ _gate_none = begin_phase(_m3, START, PriceRead.no_data("the feed timed out"),
MAINTENANCE_DRAIN) MAINTENANCE_DRAIN)
check("no price data at all BLOCKS an irreversible action too - a missing " check("no price data at all BLOCKS an irreversible action too - a missing "
"price is not a cheap one (§11.4)", "price is not a cheap one (§11.4)",
_gate_none.kind == "blocked" and "§11.4" in _gate_none.reason _gate_none.kind == "blocked"
and "missing price is not a cheap one" in _gate_none.reason
and _m3.phase == IDLE) and _m3.phase == IDLE)
_short = series_of(DAY[:4], lambda u: t_price(brussels(u).hour), _short = series_of(DAY[:4], lambda u: t_price(brussels(u).hour),
PriceProduct.DAY_AHEAD, "generic_dayahead", PriceProduct.DAY_AHEAD, "generic_dayahead",
+18 -18
View File
@@ -1020,7 +1020,7 @@ check("a store holding only the day before LAST answers no_data, not those "
_y3.kind == "no_data") _y3.kind == "no_data")
check(f"...and the reason names BOTH the date wanted and the dates held, so an " check(f"...and the reason names BOTH the date wanted and the dates held, so an "
f"operator can see which it is: {why(_y3)!r}", f"operator can see which it is: {why(_y3)!r}",
"2026-01-14" in why(_y3) and "2026-01-12" in why(_y3)) "14 Jan" in why(_y3) and "12 Jan" in why(_y3))
_h4 = store() _h4 = store()
_ph.PriceHistory._remember( _ph.PriceHistory._remember(
@@ -1217,7 +1217,7 @@ json.dump({"version": SCHEMA + 99, "days": {}}, open(_p7, "w", encoding="utf-8")
_h7 = PriceHistory(_p7) _h7 = PriceHistory(_p7)
check("a file from a schema this build does not know is discarded rather than " check("a file from a schema this build does not know is discarded rather than "
"guessed at - its points might mean something else", "guessed at - its points might mean something else",
"schema" in (_h7.last_error or "") "version" in (_h7.last_error or "")
and _h7.yesterday(NOW).kind == "no_data") and _h7.yesterday(NOW).kind == "no_data")
# A record whose points are malformed. THE DEFECT IS A DEFAULTED PRICE. # A record whose points are malformed. THE DEFECT IS A DEFAULTED PRICE.
@@ -1270,7 +1270,7 @@ json.dump({"version": SCHEMA, "days": {
_h14 = PriceHistory(_p14) _h14 = PriceHistory(_p14)
check("a day record that is not a dict is DROPPED and REPORTED, and the rest " check("a day record that is not a dict is DROPPED and REPORTED, and the rest "
"of the file still loads - one bad record is not a bad file", "of the file still loads - one bad record is not a bad file",
_h14.ok and "unusable day record" in (_h14.last_error or "") _h14.ok and "unusable day" in (_h14.last_error or "")
and sorted(_h14._days) == ["2026-01-13"]) and sorted(_h14._days) == ["2026-01-13"])
_w14 = derived(lambda: _h14.words) _w14 = derived(lambda: _h14.words)
_y14 = derived(lambda: _h14.yesterday(NOW).kind) _y14 = derived(lambda: _h14.yesterday(NOW).kind)
@@ -1291,7 +1291,7 @@ for _shape in ("oops", [1, 2, 3], 7, None, {"points": "not a list"}):
check(f"...and the same holds for a day record of {type(_shape).__name__} " check(f"...and the same holds for a day record of {type(_shape).__name__} "
f"{_shape!r}: dropped, reported, and every accessor answers " f"{_shape!r}: dropped, reported, and every accessor answers "
f"({_wX!r}, {_yX!r})", f"({_wX!r}, {_yX!r})",
_hX._days == {} and "unusable day record" in (_hX.last_error or "") _hX._days == {} and "unusable day" in (_hX.last_error or "")
and isinstance(_wX, str) and _yX == "no_data") and isinstance(_wX, str) and _yX == "no_data")
_pF = os.path.join(tmpdir(), "price_history.json") _pF = os.path.join(tmpdir(), "price_history.json")
@@ -1300,7 +1300,7 @@ json.dump({"version": float(SCHEMA), "days": {}},
check(f"a `version` of {float(SCHEMA)!r} is refused although it EQUALS " check(f"a `version` of {float(SCHEMA)!r} is refused although it EQUALS "
f"{SCHEMA} - a schema check that cannot tell 1 from 1.0 is one that " f"{SCHEMA} - a schema check that cannot tell 1 from 1.0 is one that "
f"would not notice a writer this build has never met", f"would not notice a writer this build has never met",
"schema" in (PriceHistory(_pF).last_error or "")) "version" in (PriceHistory(_pF).last_error or ""))
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1328,7 +1328,7 @@ check(f"...and a store holding a day names the DATE, the POINT COUNT and the "
f"HOUR COUNT {words_of(_h_words)!r} - 'a cache exists' and 'the cache " f"HOUR COUNT {words_of(_h_words)!r} - 'a cache exists' and 'the cache "
f"holds yesterday, all 24 hours of it' are different facts, and only " f"holds yesterday, all 24 hours of it' are different facts, and only "
f"the second says the horizon will be full tomorrow morning", f"the second says the horizon will be full tomorrow morning",
words_of(_h_words) == f"{YDAY_D} (24 points over 24 h)") words_of(_h_words) == "24 prices over 24 hours for 14 Jan")
# ⚠️ AND THE TWO NUMBERS ARE NOT THE SAME NUMBER - FCST-05's LOW. The row # ⚠️ AND THE TWO NUMBERS ARE NOT THE SAME NUMBER - FCST-05's LOW. The row
# shipped as `f"{d} ({_n_points(rec)} h)"`, which counts POINTS and prints them # shipped as `f"{d} ({_n_points(rec)} h)"`, which counts POINTS and prints them
@@ -1362,15 +1362,15 @@ check(f"...and the row reports BOTH, each under its own unit: {_qw!r}. "
f"A day of 96 quarter-hourly prints is 96 points over 24 h, and the " f"A day of 96 quarter-hourly prints is 96 points over 24 h, and the "
f"figure that says whether tomorrow's fill has a whole day to carry is " f"figure that says whether tomorrow's fill has a whole day to carry is "
f"the HOUR one", f"the HOUR one",
_qw == f"{YDAY_D} (96 points over 24 h)") _qw == "96 prices over 24 hours for 14 Jan")
check(f"...and specifically the row does NOT say '96 h' {_qw!r} - a point " check(f"...and specifically the row does NOT say '96 h' {_qw!r} - a point "
f"count wearing the hour's name is H1's defect, and this row is where " f"count wearing the hour's name is H1's defect, and this row is where "
f"an operator would have read it", f"an operator would have read it",
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 points " in _qw) isinstance(_qw, str) and "(96 h)" not in _qw and _qw.startswith("96 prices "))
check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the " check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the "
f"days it dropped read as days that were simply never written", f"days it dropped read as days that were simply never written",
isinstance(_w14, str) and (_h14.last_error or "") in _w14 isinstance(_w14, str) and (_h14.last_error or "") in _w14
and "2026-01-13" in _w14) and "13 Jan" in _w14)
check("NoHistory() has a sentence of its own rather than an empty cell", check("NoHistory() has a sentence of its own rather than an empty cell",
NoHistory().words == "not kept") NoHistory().words == "not kept")
check("...and `words` never raises, whatever the file holds. It is read once " check("...and `words` never raises, whatever the file holds. It is read once "
@@ -1546,8 +1546,8 @@ for _shape, _bad in _BAD_STARTS:
f"{_wload!r}: ONE point held, and the row that is not a point named " f"{_wload!r}: ONE point held, and the row that is not a point named "
f"rather than counted", f"rather than counted",
isinstance(_wload, str) isinstance(_wload, str)
and _wload.startswith(f"{TODAY_D} (1 point over 1 h) [") and _wload.startswith("1 price over 1 hour for 15 Jan [")
and "1 malformed point row(s) in" in _wload) and "1 bad price in" in _wload)
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER # ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
# OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND # OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND
# at open and `_remember()` records what it DROPPED on the refresh; both # at open and `_remember()` records what it DROPPED on the refresh; both
@@ -1562,22 +1562,22 @@ for _shape, _bad in _BAD_STARTS:
f"'{TODAY_D} (1 h)' - the status row reporting a full cache while " f"'{TODAY_D} (1 h)' - the status row reporting a full cache while "
f"every tariff refresh was dying on it", f"every tariff refresh was dying on it",
isinstance(_wafter, str) isinstance(_wafter, str)
and "malformed point row(s) dropped from" in _wafter and "bad price dropped from" in _wafter
and TODAY_D in _wafter) and "15 Jan" in _wafter)
check(f"...and the row MOVED from what was found to what was dropped " check(f"...and the row MOVED from what was found to what was dropped "
f"{_wload.split('[')[-1]!r} -> {_wafter.split('[')[-1]!r}, so the " f"{_wload.split('[')[-1]!r} -> {_wafter.split('[')[-1]!r}, so the "
f"two notes are not one note read twice", f"two notes are not one note read twice",
isinstance(_wload, str) and isinstance(_wafter, str) isinstance(_wload, str) and isinstance(_wafter, str)
and "malformed point row(s) in" in _wload and "bad price in" in _wload
and "malformed point row(s) in" not in _wafter and "bad price in" not in _wafter
and "malformed point row(s) dropped from" not in _wload) and "bad price dropped from" not in _wload)
# ...and a SECOND refresh over the same store is clean, which is the state # ...and a SECOND refresh over the same store is clean, which is the state
# a restarted add-on comes up in. # a restarted add-on comes up in.
_hM2 = PriceHistory(_pM) _hM2 = PriceHistory(_pM)
check(f"...and the store reopened from that file is CLEAN: the whole day " check(f"...and the store reopened from that file is CLEAN: the whole day "
f"held and nothing to report {words_of(_hM2)!r}, so the note clears " f"held and nothing to report {words_of(_hM2)!r}, so the note clears "
f"itself rather than becoming permanent furniture", f"itself rather than becoming permanent furniture",
words_of(_hM2) == f"{TODAY_D} ({_want} points over {_want} h)" words_of(_hM2) == f"{_want} prices over {_want} hours for 15 Jan"
and _hM2.last_error is None) and _hM2.last_error is None)
# ...and the shipped path, end to end, through the caller that catches # ...and the shipped path, end to end, through the caller that catches
@@ -1701,7 +1701,7 @@ check(f"...and `words` and `yesterday()` AGREE about that store "
f"{_h16.words[:44]!r}: neither names a day the file does not hold, and " f"{_h16.words[:44]!r}: neither names a day the file does not hold, and "
f"both name the write failure. They disagreed - `words` printed two " f"both name the write failure. They disagreed - `words` printed two "
f"full days BESIDE the error while `yesterday()` answered 24 points", f"full days BESIDE the error while `yesterday()` answered 24 points",
YDAY_D not in _h16.words and TODAY_D not in _h16.words "14 Jan" not in _h16.words and "15 Jan" not in _h16.words
and "could not write" in _h16.words and "could not write" in _h16.words
and _y16.kind == "no_data" and "could not write" in why(_y16)) and _y16.kind == "no_data" and "could not write" in why(_y16))
check(f"...and it DEGRADES rather than raising: the carry still returned a " check(f"...and it DEGRADES rather than raising: the carry still returned a "
+9 -9
View File
@@ -516,7 +516,7 @@ check("⚠️ AN UNSET OPTION ADDS NO STATUS ROW AND NO PAGE ROW - not a caution
"input; this is instrumentation and a permanent caution on every install " "input; this is instrumentation and a permanent caution on every install "
"that never asked for it is the warning spam the card forbids", "that never asked for it is the warning spam the card forbids",
not pv_rows(c_off) not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "PV"]) and not [r for r in page_rows(c_off) if r[0] == "Solar production"])
check("...and the whole row list is otherwise the same length, so nothing else " check("...and the whole row list is otherwise the same length, so nothing else "
"moved either", len(_on_rows) == len(_off_rows) + 1) "moved either", len(_on_rows) == len(_off_rows) + 1)
_pv_row = [r["text"] for r in pv_rows(c_on)] _pv_row = [r["text"] for r in pv_rows(c_on)]
@@ -527,7 +527,7 @@ check("a good read says the watts, as a green row, and names no entity id",
_pv_row == [f"Solar production: {messages.signed_watts(3210)}"] _pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
and "sensor." not in _pv_row[0] and "sensor." not in _pv_row[0]
and pv_rows(c_on)[0]["ok"] is True and pv_rows(c_on)[0]["ok"] is True
and ("PV", "3 210 W") in page_rows(c_on)) and ("Solar production", "3 210 W") in page_rows(c_on))
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON) c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
_bad_row = pv_rows(c_bad) _bad_row = pv_rows(c_bad)
@@ -552,7 +552,7 @@ check("⭐ ...and it is a CAUTION, never a `bad` row: `status_payload()` builds
and c_bad.status_payload()["banner"] and c_bad.status_payload()["banner"]
== c_off.status_payload()["banner"]) == c_off.status_payload()["banner"])
check("...and the page row renders an absence as an em dash, not as 0 W", check("...and the page row renders an absence as an em dash, not as 0 W",
("PV", "—") in page_rows(c_bad)) ("Solar production", "—") in page_rows(c_bad))
# ============================================================================== # ==============================================================================
@@ -936,7 +936,7 @@ print(f" {_many_row}")
check("a healthy roster says the SITE total and how many sources it came from", check("a healthy roster says the SITE total and how many sources it came from",
_many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 " _many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
f"sources"] f"sources"]
and ("PV", "3 750 W") in page_rows(_c_many)) and ("Solar production", "3 750 W") in page_rows(_c_many))
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY) _c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
_gap_row = pv_rows(_c_gap) _gap_row = pv_rows(_c_gap)
@@ -960,8 +960,8 @@ check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at
== c_off.status_payload()["banner"]) == c_off.status_payload()["banner"])
check("...and the page row is an em dash, not a smaller number: the one row an " check("...and the page row is an em dash, not a smaller number: the one row an "
"operator glances at cannot show a partial total either", "operator glances at cannot show a partial total either",
("PV", "—") in page_rows(_c_gap) ("Solar production", "—") in page_rows(_c_gap)
and ("PV", "3 300 W") not in page_rows(_c_gap)) and ("Solar production", "3 300 W") not in page_rows(_c_gap))
# ⚠️ THE ACCEPTANCE CRITERION THE REST OF THIS FILE CANNOT STATE: at a roster of # ⚠️ THE ACCEPTANCE CRITERION THE REST OF THIS FILE CANNOT STATE: at a roster of
# ONE the surfaces are 0.11.0's, character for character. `c_on` above is the # ONE the surfaces are 0.11.0's, character for character. `c_on` above is the
@@ -978,7 +978,7 @@ check("⭐ ONE ENTITY IS ONE PATH, however it was declared: the status "
== [r["text"] for r in pv_rows(c_on)] == [r["text"] for r in pv_rows(c_on)]
== [f"Solar production: {messages.signed_watts(3210)}"] == [f"Solar production: {messages.signed_watts(3210)}"]
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)] and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
.index("PV")] == ("PV", "3 210 W")) .index("Solar production")] == ("Solar production", "3 210 W"))
check("...and a refused read at a roster of one is the one-sensor refusal " check("...and a refused read at a roster of one is the one-sensor refusal "
"too, not the roster one - `no reading, <reason>`", "too, not the roster one - `no reading, <reason>`",
[r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1], [r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
@@ -1007,7 +1007,7 @@ check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
check("...and an install with NO PV roster still adds no row of any kind - the " check("...and an install with NO PV roster still adds no row of any kind - the "
"whole of TEL-12 is invisible at the shipped default", "whole of TEL-12 is invisible at the shipped default",
not pv_rows(c_off) not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "PV"] and not [r for r in page_rows(c_off) if r[0] == "Solar production"]
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1 and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
and run(cycles=1)[1].state_calls == [] and run(cycles=1)[1].state_calls == []
and run(cycles=1, pv_entities=[])[1].state_calls == [] and run(cycles=1, pv_entities=[])[1].state_calls == []
@@ -1106,7 +1106,7 @@ check("the status row prints the total AFTER the sign",
_mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} " _mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
f"from 3 sources"]) f"from 3 sources"])
check("...and the page's PV row is the corrected total", check("...and the page's PV row is the corrected total",
("PV", "3 270 W") in page_rows(_c_mix)) ("Solar production", "3 270 W") in page_rows(_c_mix))
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP) _c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
check("a holed roster with a flipped member names the missing source, not " check("a holed roster with a flipped member names the missing source, not "
"the flipped one", "the flipped one",
+2 -2
View File
@@ -172,7 +172,7 @@ class FakeController:
self.inverters = [] self.inverters = []
self.events = [] self.events = []
def log_event(self, text): def log_event(self, text, hint=None):
self.events.append(text) self.events.append(text)
def status_payload(self): def status_payload(self):
@@ -552,7 +552,7 @@ class H:
async def limits(self, e): return (None, None, None) async def limits(self, e): return (None, None, None)
class C: class C:
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = [] o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
def log_event(self, t): pass def log_event(self, t, hint=None): pass
def status_payload(self): return {} def status_payload(self): return {}
async def handle_action(self, w): raise AssertionError("no") async def handle_action(self, w): raise AssertionError("no")
+1 -1
View File
@@ -527,7 +527,7 @@ check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused" _none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and arbitrage_allowed(_none.read) is False) and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price", check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "no static profile")) static_status(opts(None), THU) == (False, "No fixed tariff set"))
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to # ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
# serve it are to invent one or to fail when the clock reaches it. # serve it are to invent one or to fail when the clock reaches it.
+42 -29
View File
@@ -116,6 +116,7 @@ from pathlib import Path
from named_absence import given, nth, present from named_absence import given, nth, present
from app import messages
from app.main import Controller from app.main import Controller
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
from app.tariff import ( from app.tariff import (
@@ -447,7 +448,7 @@ print(f" restore() across the boundary said: {said}")
aug7, sep7 = st7.peak(month="2026-08"), st7.peak(month="2026-09") aug7, sep7 = st7.peak(month="2026-08"), st7.peak(month="2026-09")
check("a restart that straddles the month boundary BANKS the dead August " check("a restart that straddles the month boundary BANKS the dead August "
"quarter rather than dropping measured energy", "quarter rather than dropping measured energy",
said.startswith("closed a stale block") and aug7.is_observed) said.startswith("closed the 15-minute period") and aug7.is_observed)
check(f"...at its partial-coverage figure ({aug7.peak_w:.2f} W = " check(f"...at its partial-coverage figure ({aug7.peak_w:.2f} W = "
f"{RUN_W * 399 / QUARTER_S:.2f} W, the seconds it actually covered)", f"{RUN_W * 399 / QUARTER_S:.2f} W, the seconds it actually covered)",
near(aug7.peak_w, RUN_W * 399 / QUARTER_S, 1e-6)) near(aug7.peak_w, RUN_W * 399 / QUARTER_S, 1e-6))
@@ -977,16 +978,22 @@ check("...and the peak is unchanged by the repeat",
# quarter in August, so every check below passed in August and failed from # quarter in August, so every check below passed in August and failed from
# 2026-09-01 - on a commit nobody had touched. The suite must grade the month # 2026-09-01 - on a commit nobody had touched. The suite must grade the month
# it created, not the month the machine happens to be running in. # it created, not the month the machine happens to be running in.
rows = [r for r in c.checks(when=B) if "capacity-tariff" in r["text"] or "peak" in r["text"]] # UX-08 2d: the rows are in plain words and are found by their `id`. The peak
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]] # and the coverage figure (TARIFF-13) are two rows now, one sentence each.
rows = c.checks(when=B)
peak_rows = [r for r in rows if r.get("id") == "month_peak"]
cov_rows = [r for r in rows if r.get("id") == "periods_measured"]
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)")) print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row", print(" status row: " + (cov_rows[0]["text"] if cov_rows else "(none)"))
len(peak_rows) == 1) check("the status page carries exactly one capacity-tariff peak row, and one "
check("...naming the peak, the soft limit and a coverage figure, all derived " "coverage row beside it",
"from the controller that was actually built", len(peak_rows) == 1 and len(cov_rows) == 1)
bool(peak_rows) and "4998 W" in peak_rows[0]["text"] check("...naming the peak and its share of the peak limit, and the coverage "
and f"{given(SHIPPED_CAP_W):.0f} W soft limit" in peak_rows[0]["text"] "figure, all derived from the controller that was actually built",
and "quarter(s) banked" in peak_rows[0]["text"]) bool(peak_rows) and "Month peak: 4 998 W on " in peak_rows[0]["text"]
and f"{4998 / given(SHIPPED_CAP_W) * 100:.0f} % of the peak limit"
in peak_rows[0]["text"]
and bool(cov_rows) and "periods measured" in cov_rows[0]["text"])
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH, # ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
# NOT TO `when` - TARIFF-13, and it is a KNOWN GAP stated rather than rounded # NOT TO `when` - TARIFF-13, and it is a KNOWN GAP stated rather than rounded
@@ -1007,22 +1014,23 @@ print(f" the row read at {month_key(B)} carries the tally for "
check("the row's coverage figure is the LIVE month's tally - graded against a " check("the row's coverage figure is the LIVE month's tally - graded against a "
"row count taken straight from quarter_blocks, so this pins the gap " "row count taken straight from quarter_blocks, so this pins the gap "
"rather than restating the property that produces it", "rather than restating the property that produces it",
bool(peak_rows) bool(cov_rows)
and f", {_live_rows} quarter(s) banked" in peak_rows[0]["text"]) and cov_rows[0]["text"].endswith(f"this month: {_live_rows}"))
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point " check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
f"on a {given(SHIPPED_CAP_W):.0f} W limit", f"on a {given(SHIPPED_CAP_W):.0f} W limit",
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"]) bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
# The half that matters: the same page with a store that cannot answer. # The half that matters: the same page with a store that cannot answer.
c2 = built(TMP / "no-such-dir" / "nope.db") c2 = built(TMP / "no-such-dir" / "nope.db")
u_rows = [r for r in c2.checks(when=B) if "capacity-tariff peak" in r["text"]] u_rows = [r for r in c2.checks(when=B) if r.get("id") == "month_peak"]
print(" status row (unreadable store): " print(" status row (unreadable store): "
+ (u_rows[0]["text"] if u_rows else "(none)")) + (u_rows[0]["text"] if u_rows else "(none)"))
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1) check("an unreadable store gets its OWN row, saying it cannot be read",
len(u_rows) == 1)
check("...and it does NOT read as a quiet month at 0 W - without this, a broken " check("...and it does NOT read as a quiet month at 0 W - without this, a broken "
"instrument looks exactly like a month with no peak", "instrument looks exactly like a month with no peak",
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"] bool(u_rows) and "cannot be read" in u_rows[0]["text"]
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"]) and "%" not in u_rows[0]["text"] and u_rows[0]["warn"])
check("...and the healthy row and the unreadable row are different text", check("...and the healthy row and the unreadable row are different text",
bool(u_rows) and bool(peak_rows) bool(u_rows) and bool(peak_rows)
and u_rows[0]["text"] != peak_rows[0]["text"]) and u_rows[0]["text"] != peak_rows[0]["text"])
@@ -1040,7 +1048,7 @@ check("...and the healthy row and the unreadable row are different text",
# (That asymmetry in the row's own text is worth a look on its own; it is what # (That asymmetry in the row's own text is worth a look on its own; it is what
# made the original filter brittle. Not changed here - it is product text with # made the original filter brittle. Not changed here - it is product text with
# other suites reading it.) # other suites reading it.)
_peak_rows = lambda rows: [r for r in rows if "quarter(s) banked" in r["text"]] _peak_rows = lambda rows: [r for r in rows if r.get("id") == "month_peak"]
_later = B + timedelta(days=45) # same fixture, a later billing month _later = B + timedelta(days=45) # same fixture, a later billing month
_other_peak = _peak_rows(c.checks(when=_later)) _other_peak = _peak_rows(c.checks(when=_later))
@@ -1048,12 +1056,12 @@ print(f" the SAME controller read at {_later.date()}: "
+ (_other_peak[0]["text"] if _other_peak else "(no peak row)")) + (_other_peak[0]["text"] if _other_peak else "(no peak row)"))
check("asked for a LATER month, the same controller reports that month's " check("asked for a LATER month, the same controller reports that month's "
"absence of a quarter, not August's 4998 W - the row follows `when`", "absence of a quarter, not August's 4998 W - the row follows `when`",
bool(_other_peak) and "4998 W" not in _other_peak[0]["text"] bool(_other_peak) and "4 998 W" not in _other_peak[0]["text"]
and "no completed quarter recorded yet" in _other_peak[0]["text"]) and "none recorded yet" in _other_peak[0]["text"])
check("...and that is NO_RECORD, distinguishable from BOTH an observed peak and " check("...and that is NO_RECORD, distinguishable from BOTH an observed peak and "
"an unreadable store - a month with no quarter is not a broken instrument", "an unreadable store - a month with no quarter is not a broken instrument",
bool(_other_peak) and bool(peak_rows) bool(_other_peak) and bool(peak_rows)
and "UNREADABLE" not in _other_peak[0]["text"] and "cannot be read" not in _other_peak[0]["text"]
and _other_peak[0]["text"] != peak_rows[0]["text"]) and _other_peak[0]["text"] != peak_rows[0]["text"])
check("...and it is not FLAGGED, because an empty month is not an exceedance", check("...and it is not FLAGGED, because an empty month is not an exceedance",
bool(_other_peak) and _other_peak[0]["ok"] and not _other_peak[0]["warn"]) bool(_other_peak) and _other_peak[0]["ok"] and not _other_peak[0]["warn"])
@@ -1076,7 +1084,7 @@ check("calling checks() with NO argument still reads the month being billed "
bool(_now_peak) and (bool(peak_rows) bool(_now_peak) and (bool(peak_rows)
and _now_peak[0]["text"] == peak_rows[0]["text"] and _now_peak[0]["text"] == peak_rows[0]["text"]
if _today_is_fixture_month if _today_is_fixture_month
else "no completed quarter recorded yet" else "none recorded yet"
in _now_peak[0]["text"])) in _now_peak[0]["text"]))
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed # THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
@@ -1253,21 +1261,25 @@ for _q in range(_N13):
# Ground truth from the OTHER tier, which the tracker did not supply - the count # Ground truth from the OTHER tier, which the tracker did not supply - the count
# under test must be graded against something that is not itself (habit 2). # under test must be graded against something that is not itself (habit 2).
_truth_before = len(c13.peak.store.blocks_in(_mon13)) _truth_before = len(c13.peak.store.blocks_in(_mon13))
_cov_row = lambda c: [r for r in c.checks()
if r.get("id") == "periods_measured"][0]["text"]
_row_before = _peak_rows(c13.checks())[0]["text"] _row_before = _peak_rows(c13.checks())[0]["text"]
print(" before restart: " + _row_before) _cov_before = _cov_row(c13)
print(" before restart: " + _row_before + " / " + _cov_before)
check(f"precondition: the fixture banked {_N13} quarters in {_mon13}, so a " check(f"precondition: the fixture banked {_N13} quarters in {_mon13}, so a "
f"count of 0 and a count of the truth are different numbers", f"count of 0 and a count of the truth are different numbers",
_truth_before == _N13 and _N13 > 1) _truth_before == _N13 and _N13 > 1)
check(f"...and the row says so before the restart", check(f"...and the row says so before the restart",
f"{_N13} quarter(s) banked" in _row_before) _cov_before.endswith(f"this month: {_N13}"))
c13.peak.store.close() c13.peak.store.close()
# THE RESTART. # THE RESTART.
c13b = built(_T13) c13b = built(_T13)
_row_after = _peak_rows(c13b.checks())[0]["text"] _row_after = _peak_rows(c13b.checks())[0]["text"]
_cov_after = _cov_row(c13b)
_truth_after = len(c13b.peak.store.blocks_in(_mon13)) _truth_after = len(c13b.peak.store.blocks_in(_mon13))
print(" after restart: " + _row_after) print(" after restart: " + _row_after + " / " + _cov_after)
print(f" rows in quarter_blocks: {_truth_before} before, " print(f" rows in quarter_blocks: {_truth_before} before, "
f"{_truth_after} after; the restarted tracker has banked " f"{_truth_after} after; the restarted tracker has banked "
f"{len(c13b.peak._seen)} block(s) of its own through sync()") f"{len(c13b.peak._seen)} block(s) of its own through sync()")
@@ -1279,16 +1291,17 @@ check("precondition: the second Controller is a REAL restart - it shares no "
check("the peak and its attributed quarter survive the restart - the durable " check("the peak and its attributed quarter survive the restart - the durable "
"half, asserted so that the coverage claim below is not graded against a " "half, asserted so that the coverage claim below is not graded against a "
"reading that had already collapsed", "reading that had already collapsed",
_row_before.split(" - ")[0] == _row_after.split(" - ")[0] _row_before == _row_after
and f"{_W13:.0f} W" in _row_after) and f"Month peak: {messages.watts(_W13)} on" in _row_after)
check("...and the COUNT BESIDE IT DOES NOT COLLAPSE TO ZERO, which is the " check("...and the COUNT BESIDE IT DOES NOT COLLAPSE TO ZERO, which is the "
"defect: a coverage figure that resets while the value beside it " "defect: a coverage figure that resets while the value beside it "
"persists actively misinforms", "persists actively misinforms",
"; 0 quarter(s) banked" not in _row_after) not _cov_after.endswith("this month: 0"))
check(f"...and it is the store's own tally, {_truth_after}, not a " check(f"...and it is the store's own tally, {_truth_after}, not a "
f"process-lifetime counter - graded against a COUNT OF ROWS in the " f"process-lifetime counter - graded against a COUNT OF ROWS in the "
f"other tier rather than against the property under test", f"other tier rather than against the property under test",
f"{_truth_after} quarter(s) banked" in _row_after and _truth_after >= _N13) _cov_after.endswith(f"this month: {_truth_after}")
and _truth_after >= _N13)
# ⚠️ The restarted run banks one MORE than it drove, and that is correct: the # ⚠️ The restarted run banks one MORE than it drove, and that is correct: the
# checkpointed open block was stale, so `restore()` closed and banked it. The # checkpointed open block was stale, so `restore()` closed and banked it. The
@@ -1312,7 +1325,7 @@ print(f" quarters banked in {month_key(_sb)}: {_before_stale} -> "
check("a block closed by restore() rather than by sync() is counted too - the " check("a block closed by restore() rather than by sync() is counted too - the "
"old counter incremented only in sync() and missed it entirely", "old counter incremented only in sync() and missed it entirely",
_before_stale == 0 and _tr13.recorded_in(_sb) == 1 _before_stale == 0 and _tr13.recorded_in(_sb) == 1
and "banked" in _state) and "and saved it" in _state)
# --- the count is scoped to the MONTH it is printed beside -------------------- # --- the count is scoped to the MONTH it is printed beside --------------------
# ⚠️ TWO MONTHS AT DIFFERENT COUNTS. Equal counts would be satisfied by a # ⚠️ TWO MONTHS AT DIFFERENT COUNTS. Equal counts would be satisfied by a
+10 -10
View File
@@ -1223,7 +1223,8 @@ def _verdict(opts, token):
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p] ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
if not ps: if not ps:
return (False, None, None) return (False, None, None)
m = re.search(r"for (\S+) \(inverters\.max_w\) is (\d+) W", ps[0]) m = re.search(r"for (\S+) \(inverters\.max_w\),? (?:is|at) (\d+) W",
ps[0])
return (True, m.group(1) if m else None, float(m.group(2)) if m else None) return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
@@ -1431,8 +1432,8 @@ check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
"refusal says WHICH ONE WINS and what to do - not merely that the two " "refusal says WHICH ONE WINS and what to do - not merely that the two "
"are incompatible", "are incompatible",
why(_T17_P_BOTH, "(price_taxes_profile) is set, so") why(_T17_P_BOTH, "(price_taxes_profile) is set, so")
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads " and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used. Set it "
"0.1198")) "back to"))
check("...and the SHIPPED scalar beside the table is NOT refused, or every " check("...and the SHIPPED scalar beside the table is NOT refused, or every "
"Walloon site would have to clear a key it never touched. The refusal is " "Walloon site would have to clear a key it never touched. The refusal is "
"about a value the operator chose, and the shipped default is the one " "about a value the operator chose, and the shipped default is the one "
@@ -1482,8 +1483,7 @@ check("...while a value that is NEITHER the shipped default NOR a superseded "
"one is still REFUSED beside the table, naming it - the set of " "one is still REFUSED beside the table, naming it - the set of "
"'untouched' figures widened by exactly the old defaults and by nothing " "'untouched' figures widened by exactly the old defaults and by nothing "
"else", "else",
why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads " why(_T17_P_BOTH, "from 0.1198, or clear")
"0.1198")
and 0.1198 not in _T18_SUPERSEDED and 0.1198 not in _T18_SUPERSEDED
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9) and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told # ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
@@ -1660,7 +1660,7 @@ check("...and a malformed blob is refused as a malformed blob, naming the "
# coefficients against arithmetic done with the same coefficients. So the leg # coefficients against arithmetic done with the same coefficients. So the leg
# starts, and the operator is told WHICH check did not run. # starts, and the operator is told WHICH check did not run.
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG _T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
if lvl == "WARNING" and "UNGRADED" in m] if lvl == "WARNING" and "not checked against a printed price" in m]
print(f" {(_T16_UNGRADED or [''])[0][:118]}") print(f" {(_T16_UNGRADED or [''])[0][:118]}")
check("a leg with no card block starts, and says at startup that its " check("a leg with no card block starts, and says at startup that its "
"coefficients are UNGRADED - naming what was checked instead, so the " "coefficients are UNGRADED - naming what was checked instead, so the "
@@ -1683,21 +1683,21 @@ check("...and a leg that DOES carry a card does NOT print it, or the sentence "
"would be noise an operator learns to ignore rather than a report of " "would be noise an operator learns to ignore rather than a report of "
"which check ran", "which check ran",
_T16_WITH_CARD == [] _T16_WITH_CARD == []
and not [m for lvl, m in _T16_CARD_LOG if "UNGRADED" in m]) and not [m for lvl, m in _T16_CARD_LOG if "not checked against a printed price" in m])
# ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung. # ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung.
check("...and a card ON THIS LEG that the coefficients do not reproduce is " check("...and a card ON THIS LEG that the coefficients do not reproduce is "
"REFUSED at startup, so the optional block is optional and not inert", "REFUSED at startup, so the optional block is optional and not inert",
why(_t16_start(price_export_formula=_T16_CONS.replace( why(_t16_start(price_export_formula=_T16_CONS.replace(
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0], '"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
"price_export_formula does not match its own card")) "price_export_formula gives"))
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT. # ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO" _T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
and "NO STRATEGY IN THIS ADD-ON READS IT" in m] and "No part of the add-on reads it yet" in m]
check("the startup line says, in so many words, that NO STRATEGY IN THIS " check("the startup line says, in so many words, that NO STRATEGY IN THIS "
"ADD-ON READS IT - the operator is told what the option does not do in " "ADD-ON READS IT - the operator is told what the option does not do in "
"the same breath as it being accepted", "the same breath as it being accepted",
len(_T16_INFO) == 1 and "displaced IMPORT" in _T16_INFO[0]) len(_T16_INFO) == 1 and "displaced import" in _T16_INFO[0])
# ⚠️ THE ASYMMETRY IS ASSERTED AS AN ORDERED PAIR, NOT AS TWO NUMBERS IN ONE # ⚠️ THE ASYMMETRY IS ASSERTED AS AN ORDERED PAIR, NOT AS TWO NUMBERS IN ONE
# SENTENCE (MUTATION.md habit 14). "6 and 0 appear somewhere" is satisfied by # SENTENCE (MUTATION.md habit 14). "6 and 0 appear somewhere" is satisfied by
# the two legs TRANSPOSED - which is the sentence telling an operator that the # the two legs TRANSPOSED - which is the sentence telling an operator that the
+8 -1
View File
@@ -375,6 +375,13 @@ check("the status page says so in words: a red row, at 0 W, cleared by start",
for c in ctrl.checks())) for c in ctrl.checks()))
check("the banner leads with it", check("the banner leads with it",
ctrl.status_payload()["banner"] == messages.banner_direction(False)) ctrl.status_payload()["banner"] == messages.banner_direction(False))
check("...the line under the buttons says it in plain words, and the action "
"log with the raw reason is still reachable in the status JSON",
ctrl.status_payload()["hint"].endswith(
messages.hint("direction", unit=ctrl.inverters[0].name))
and ctrl.status_payload()["events"] == ctrl.events
and any(e.split(" ", 1)[1].startswith("FAILSAFE ")
for e in ctrl.status_payload()["events"]))
check("it stays at 0 W on later cycles too", check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1) hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
@@ -1930,7 +1937,7 @@ check("the status page carries a SITE row of its own, and the banner says the "
any(ck.get("id") == "site_direction" and not ck["ok"] any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"] and "every inverter" in ck["text"] and not ck["warn"] and "every inverter" in ck["text"]
for ck in _c31.checks()) for ck in _c31.checks())
and "Total command" in _c31.status_payload()["banner"] and "Total power" in _c31.status_payload()["banner"]
and "inverter-" not in _c31.status_payload()["banner"]) and "inverter-" not in _c31.status_payload()["banner"])
_c31_mode = _c31.o.get("shadow_mode") _c31_mode = _c31.o.get("shadow_mode")
_c31.o["shadow_mode"] = "on" _c31.o["shadow_mode"] = "on"
+9 -9
View File
@@ -161,16 +161,16 @@ configuration:
name: "Tariff · Injection price (EUR/kWh)" name: "Tariff · Injection price (EUR/kWh)"
description: "What you are paid per kWh fed into the grid, all in. It may be negative." description: "What you are paid per kWh fed into the grid, all in. It may be negative."
static_profile: static_profile:
name: "Tariff · Fixed or day/night tariff (JSON)" name: "Tariff · Fixed or day/night tariff"
description: "Your fixed or day/night contract as one line of JSON, prices all in. Fill it in even on a dynamic contract, as the fallback when the price feed fails." description: "Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback."
price_taxes_profile: price_taxes_profile:
name: "Tariff · Day/night levies and VAT (JSON)" name: "Tariff · Day/night levies and VAT"
description: "Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both." description: "Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both."
price_formula: price_formula:
name: "Tariff · Your own tariff formula (JSON)" name: "Tariff · Your own tariff formula"
description: "For custom_formula only. Type the numbers from your supplier's tariff card as one line of JSON, and the add-on checks them against the card." description: "For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card."
price_export_formula: price_export_formula:
name: "Tariff · Your own injection formula (JSON)" name: "Tariff · Your own injection formula"
description: "Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price." description: "Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price."
price_csv_path: price_csv_path:
name: "Tariff · Price CSV file" name: "Tariff · Price CSV file"
@@ -278,14 +278,14 @@ configuration:
name: "Advanced · Command rounding (W)" name: "Advanced · Command rounding (W)"
description: "The command is rounded to a multiple of this, so noise does not change it every cycle." description: "The command is rounded to a multiple of this, so noise does not change it every cycle."
saturation_w: saturation_w:
name: "Advanced · Inverter at limit gap (W)" name: "Advanced · At-limit shortfall (W)"
description: "When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this." description: "When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this."
saturation_cycles: saturation_cycles:
name: "Advanced · Cycles at limit before freezing" name: "Advanced · Cycles at limit before freezing"
description: "How many cycles in a row at the limit before control stops building up error. Do not set it to 1." description: "How many cycles in a row at the limit before control stops building up error. Do not set it to 1."
integrator_max_w: integrator_max_w:
name: "Advanced · Error build-up limit (W)" name: "Advanced · Catch-up limit (W)"
description: "Caps the error that control saves up from past cycles. 0 means “Inverter rating”. Do not raise it above the rating." description: "Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating."
heartbeat_s: heartbeat_s:
name: "Advanced · Command refresh interval (s)" name: "Advanced · Command refresh interval (s)"
description: "How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit." description: "How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit."
+10 -10
View File
@@ -59,7 +59,7 @@ configuration:
description: "Zet dit aan als de sensor van deze omvormer leveren als een negatief getal toont." description: "Zet dit aan als de sensor van deze omvormer leveren als een negatief getal toont."
capacity_kwh: capacity_kwh:
name: "Bruikbare capaciteit (kWh) (optioneel)" name: "Bruikbare capaciteit (kWh) (optioneel)"
description: "De bruikbare capaciteit van deze batterij, van het datablad. Vul ze bij elke omvormer in of bij geen enkele. De add-on verdeelt het werk dan volgens de energie die elke batterij nog heeft." description: "De bruikbare capaciteit van deze batterij, van het datablad. Vul ze bij elke omvormer in of bij geen. Dan verdeelt de add-on het werk volgens de energie in elke batterij."
estop_fitted: estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst" name: "Hardware · RS485-noodstop is geplaatst"
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina." description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
@@ -142,16 +142,16 @@ configuration:
name: "Tarief · Injectievergoeding (EUR/kWh)" name: "Tarief · Injectievergoeding (EUR/kWh)"
description: "Wat u krijgt per kWh die u op het net zet, alles inbegrepen. Dit mag negatief zijn." description: "Wat u krijgt per kWh die u op het net zet, alles inbegrepen. Dit mag negatief zijn."
static_profile: static_profile:
name: "Tarief · Vast of dag/nacht-tarief (JSON)" name: "Tarief · Vast of dag/nacht-tarief"
description: "Uw vaste of dag/nacht-contract als één regel JSON, prijzen alles inbegrepen. Vul dit ook in bij een dynamisch contract. Het is de reserve als de prijsfeed uitvalt." description: "Uw vaste of dag/nacht-contract op één regel, prijzen alles inbegrepen, zoals de documentatie toont. Vul dit ook in bij een dynamisch contract. Het is de reserve als de prijsfeed uitvalt."
price_taxes_profile: price_taxes_profile:
name: "Tarief · Heffingen en btw dag/nacht (JSON)" name: "Tarief · Heffingen en btw dag/nacht"
description: "Laat dit leeg, tenzij uw netbeheerder een ander dag- en nachttarief rekent, zoals in Wallonië en Brussel. Vul dit of het vaste bedrag hierboven in, niet allebei." description: "Laat dit leeg, tenzij uw netbeheerder een ander dag- en nachttarief rekent, zoals in Wallonië en Brussel. Vul dit of het vaste bedrag hierboven in, niet allebei."
price_formula: price_formula:
name: "Tarief · Uw eigen tariefformule (JSON)" name: "Tarief · Uw eigen tariefformule"
description: "Alleen voor custom_formula. Typ de getallen van de tariefkaart van uw leverancier als één regel JSON. De add-on controleert ze met de kaart." description: "Alleen voor custom_formula. Typ de getallen van de tariefkaart van uw leverancier op één regel, zoals de documentatie toont. De add-on controleert ze met de kaart."
price_export_formula: price_export_formula:
name: "Tarief · Uw eigen injectieformule (JSON)" name: "Tarief · Uw eigen injectieformule"
description: "Optioneel. De injectieformule van uw tariefkaart, in dezelfde vorm als uw eigen tariefformule. Laat leeg om de vaste injectievergoeding te gebruiken." description: "Optioneel. De injectieformule van uw tariefkaart, in dezelfde vorm als uw eigen tariefformule. Laat leeg om de vaste injectievergoeding te gebruiken."
price_csv_path: price_csv_path:
name: "Tarief · CSV-bestand met prijzen" name: "Tarief · CSV-bestand met prijzen"
@@ -259,14 +259,14 @@ configuration:
name: "Geavanceerd · Afronding van het commando (W)" name: "Geavanceerd · Afronding van het commando (W)"
description: "Het commando wordt afgerond op een veelvoud hiervan, zodat ruis het niet elke cyclus verandert." description: "Het commando wordt afgerond op een veelvoud hiervan, zodat ruis het niet elke cyclus verandert."
saturation_w: saturation_w:
name: "Geavanceerd · Verschil omvormer aan grens (W)" name: "Geavanceerd · Tekort aan de grens (W)"
description: "Levert een omvormer zoveel minder dan gevraagd, dan zit ze aan haar grens. De drempel verkeerde richting per omvormer is de helft hiervan." description: "Levert een omvormer zoveel minder dan gevraagd, dan zit ze aan haar grens. De drempel verkeerde richting per omvormer is de helft hiervan."
saturation_cycles: saturation_cycles:
name: "Geavanceerd · Cycli aan grens voor bevriezen" name: "Geavanceerd · Cycli aan grens voor bevriezen"
description: "Hoeveel cycli na elkaar aan de grens voor de regeling stopt met afwijking opbouwen. Zet dit niet op 1." description: "Hoeveel cycli na elkaar aan de grens voor de regeling stopt met afwijking opbouwen. Zet dit niet op 1."
integrator_max_w: integrator_max_w:
name: "Geavanceerd · Grens opgebouwde afwijking (W)" name: "Geavanceerd · Inhaalgrens (W)"
description: "Begrenst de afwijking die de regeling uit vorige cycli opspaart. 0 betekent het vermogen van de omvormer. Zet dit niet hoger dan dat vermogen." description: "Begrenst hoe hard de regeling blijft duwen als de batterij achterloopt. 0 betekent het vermogen van de omvormer. Zet dit niet hoger dan dat vermogen."
heartbeat_s: heartbeat_s:
name: "Geavanceerd · Commando opnieuw sturen na (s)" name: "Geavanceerd · Commando opnieuw sturen na (s)"
description: "Hoe vaak het commando opnieuw gestuurd wordt, ook als het gelijk bleef. Houd dit ruim onder de eigen grens van 30 s van de stuurmodule." description: "Hoe vaak het commando opnieuw gestuurd wordt, ook als het gelijk bleef. Houd dit ruim onder de eigen grens van 30 s van de stuurmodule."
+5 -50
View File
@@ -1,50 +1,5 @@
# UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that # UX-08 ratchet (test_messages.py part 3). Each line would be a user-facing
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start". # string that does not follow STYLE.md yet: "<kind> <sha1-12 of its text> #
# These are waiting for phase 2d (log); phase 2b emptied the row and banner # where :: start". Phase 2d emptied it: every row, banner, refusal, option text
# kinds and phase 2c the refusal and option kinds. Delete the line when you # and WARNING+ log line now follows STYLE.md, and ALLOWLIST_MAX is 0. Never add
# rewrite the string, because a stale line fails the test. Never add a line for a new string: write the new # a line for a new string: write the new string to STYLE.md instead.
# string to STYLE.md instead.
log a5e99679fd80 # main.py banned: SAFETY-02, latch :: 'SAFETY-02: the per-inverter store migration did not verify (%s). The l'
log 128b18b32d86 # main.py over 25 words (41); banned: LATCHED, SAFETY-44, latched :: 'SAFETY-44: this store carries a LATCHED sign-disagreement failsafe und'
log f60446541ae5 # main.py over 25 words (83); banned: LATCH, LATCHED, SAFETY-02, latch; capitals: LATCH GUARD :: 'SAFETY-02 LATCH GUARD: this store carries a LATCHED sign-disagreement '
log 04d06bf223c8 # main.py over 25 words (73); banned: REGISTER, SAFETY-37, SAFETY-57, register; capitals: WHICH IS NOT BY ITSELF :: 'SAFETY-37: %s battery-power reading did not move under a command that '
log aec01642bd07 # main.py banned: SAFETY-57 :: 'SAFETY-57: %s'
log aebbca17acb3 # main.py over 25 words (52); banned: allocator :: 'write_setpoint() was asked to put %+.0f W on every one of %d inverters'
log b74c3018461e # main.py over 25 words (30); banned: SAFETY-28, allocator :: 'SAFETY-28 REFUSED a write of %+.0f W to %s: %s. Commanding 0 W instead'
log cefffafa8b27 # main.py over 25 words (31) :: 'the write of %+.0f W to %s (%s) did not complete in %.0f s and was aba'
log 1001c6250b85 # main.py over 25 words (29); banned: SAFETY-02 :: 'SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on that inverter and hold'
log 8252ef5eefad # main.py over 25 words (48); banned: SAFETY-30 :: 'SAFETY-30 TRIPPED at the SITE - %s. That is the SUM of every standing '
log 8a65cf945e00 # main.py over 25 words (32); banned: SAFETY-31 :: 'SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and holding i'
log 5d2beed2f1a2 # main.py over 25 words (65); banned: HW-01, SAFETY-42; capitals: NOTHING IS COMMANDED DIFFERENTLY :: 'SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED DIFFERENTLY: this g'
log d50b775b9cd4 # main.py banned: ARBITER; capitals: ARBITER SCOPE :: 'ARBITER SCOPE: %s'
log 749c2c6fe81d # main.py over 25 words (26); banned: SAFETY-10, heartbeat; capitals: SHADOW MODE IS ON :: 'SAFETY-10 SHADOW MODE IS ON. The full decision path runs and NOTHING i'
log 4d271dfc6f1c # main.py banned: SAFETY-10 :: 'SAFETY-10 shadow mode is OFF. This controller is commanding the batter'
log 730387e0da2b # main.py banned: SAFETY-03 :: 'SAFETY-03 - %s Engagements so far: %d.'
log 3bf899e99297 # main.py over 25 words (56); banned: SAFETY-03, clamp, clamped, integrator; capitals: STILL RUNNING :: 'SAFETY-03 - %s Silently clipping this is how a winding integrator reac'
log 78cb6bd09633 # main.py over 25 words (48); banned: SAFETY-07; capitals: STILL RUNNING :: 'SAFETY-07 - %s. The guard is asking for everything the inverter has an'
log cc735997adc7 # main.py over 25 words (44); banned: SAFETY-24; capitals: STILL RUNNING :: 'SAFETY-24 - %s. The controller is STILL RUNNING and charging is still '
log ebcb428a70d0 # main.py over 25 words (70); banned: SAFETY-18, arbiter, watchdog; capitals: CHECK THE METER :: 'SAFETY-18 - %s. The grid-following loop is regulating against a number'
log 38018b887495 # main.py over 25 words (43) :: 'tariff refresh degraded (%s); KEEPING the plan resolved at %s, %.0f s '
log 0eab1ec96fe8 # main.py capitals: CONTROL LOOP DIED :: 'CONTROL LOOP DIED (%s) - commanding 0 W and exiting with status %d'
log db529f2c419d # main.py over 25 words (40); banned: SAFETY-02 :: 'SAFETY-02 was TRIPPED on %s before this restart and stays tripped: %s.'
log c5aadd5c8bf9 # main.py over 25 words (45); banned: SAFETY-42; capitals: NOTHING IS BEING COMMANDED DIFFERENTLY :: 'SAFETY-42 was TRIPPED on %s before this restart and stays tripped: %s.'
log d72bbdb7e595 # main.py over 25 words (39); banned: SAFETY-30 :: 'SAFETY-30 (site) was TRIPPED before this restart and stays tripped: %s'
log 8b880e65a7c3 # main.py over 25 words (38); banned: SAFETY-31 :: 'SAFETY-31 was TRIPPED before this restart and stays tripped: %s. Every'
log 23f58b7f305c # p1.py:1264 over 25 words (30) :: 'meter_poll_s %.1f leaves no room under meter_max_age_s %.1f: one misse'
log 88a7251e842b # p1.py:470 banned: telegram :: 'P1 telegram rejected: %s'
log 0dd83d6dfaf4 # price.py over 25 words (30) :: 'the feed published no price for %d of %d instants (index %s); those in'
log bcfd7062e28e # price.py over 25 words (79); banned: §8 :: 'price_feed_url is set, so %s is requesting %s exactly as given and is '
log b57bc6de4a6b # price.py over 25 words (47); banned: validate.py :: 'price_taxes_profile cannot be used (%s: %s), so this fetch has NO tax '
log d5595aaaacc4 # price_history.py banned: §8 :: 'price history at %s is %s - starting from empty. §8.3 carry-forward ha'
log dbed0b8e35ea # static_tariff.py banned: validate.py :: 'static_profile is unusable (%s); no cheap window. This should have bee'
log 8fbe9f5407ea # static_tariff.py over 25 words (28); banned: §1, §6 :: '§11.4: %s, and no static_profile is configured to fall back to. The pl'
log 4ce29e043d89 # static_tariff.py banned: §1 :: '§11.4: %s - falling back to the static profile, plan marked PROVISIONA'
log a3c7c9719ed2 # store.py capitals: LOST STATE :: 'per-inverter store migration LOST STATE and has not been marked done: '
log 5b537a77052b # validate.py over 25 words (41); banned: arbiter, saturation :: 'integrator_max_w %.0f W is above max_w %.0f W. Not refused - the arbit'
log 12ae40f9d289 # validate.py over 25 words (73); banned: arbiter, clamp, clamps :: 'maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not refused - '
log c6dc799502cf # validate.py over 25 words (32) :: '%s carries negative levies %s EUR/kWh. That is accepted, but a tax com'
log 1688a7478ceb # validate.py over 25 words (36) :: 'price_formula is filled in but price_provider is %r, so the coefficien'
log f37f9fce4433 # validate.py over 25 words (96) :: '%s has no "card" block, so its coefficients are UNGRADED: nothing reco'
log 396fbdd009c2 # validate.py over 25 words (52); banned: §1 :: "price_provider is %r but static_profile is empty. §11.4's degradation "
log 37bba6ed37e3 # validate.py capitals: REFUSING TO START :: 'REFUSING TO START: the add-on configuration is not usable.'