Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 5.5 badea3cb2d UX-13 P0 review: keep unread keys, back off a failed entity read
checks / unit (pull_request) Successful in 9m30s
infra-pin / fresh (pull_request) Successful in 1s
- uimodel: editing a listed inverter keeps the stored flat keys, and a
  region of "own" keeps a stored price_taxes_profile (both were dropped).
- haentities: a failed read also waits CACHE_S, so polls while Home
  Assistant is down do not queue one 10 s read each.
- ui_text: the Flanders preset names its DSO, and the help says
  Wallonia and Brussels have no figures yet. REGION_PRESETS cites the card.
- tests: decode of a reversed solar row, both kept keys, the backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:39:43 +02:00
glenn schrooyenandClaude Opus 5.5 15627c38a6 UX-13 P0: layout table, page<->options derivation, warning tier, entity list
checks / unit (pull_request) Successful in 9m32s
infra-pin / fresh (pull_request) Successful in 1s
The foundation for the new settings page. No visible UI change.

- app/uilayout.py: screen -> group -> part -> field for every option in
  config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display
  scale, step, entity kind, show conditions, Simple/wizard placement and
  "applies after a restart". Ranges stay in the schema, words in
  translations and the new app/ui_text/en.json.
- app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one
  inverter vs the list, solar rows, static_profile from simple prices, a
  year-stamped region preset for the levies figure, and an OPT-11 hook for
  arbitrage_enabled that changes nothing yet). Unchanged derivations write
  the stored values back, so the round trip is exact.
- validate.py: validate.Note, a warn/info tier. Each logged warning is also
  a note when a list is passed; config_notes() adds checks that need live
  data (missing entity, a kW sensor read as W, a limit below this month's
  peak). /config/validate returns them as "notes". No refusal changed: 261
  recorded validator calls, 0 differ (tools/ux13_validator_calls.py).
- GET ha/entities: Home Assistant's entity list for the picker, read through
  hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200
  with the old list marked old, or an empty list that says why.
- DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI
  code": keys may appear in uilayout.py and uimodel.py only, both tested.
- Tests: test_uilayout, test_uimodel (round trip over the recorded calls,
  the TEST-23 option files and config.yaml defaults), test_haentities, a
  warning-tier part in test_validate; test_messages scans notes and
  ui_text; test_pv and test_selfconfig part 7 widened on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 23:11:31 +02:00
admin 652097abde Merge pull request 'SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min' (#141) from SAFETY-52-refused-hold into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 18:21:21 +02:00
adminandClaude Opus 5.5 3c0f2b9669 SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
checks / unit (pull_request) Successful in 9m22s
infra-pin / fresh (pull_request) Successful in 1s
- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's
  own 100 W answer, not below saturation_w: a battery derated to 100-499 W
  was held at 0 W ~93 % of the time.
- A reading of 100 W or more in the held direction ends the refusal, probe
  or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy
  reading's lag, and that held a healthy step at 0 W until the next probe.
- test_refusal part 5b and a seed test; DOCS states the N>1 limit; the
  DECISIONS record carries the traced cause of the replay's extra trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:11:42 +02:00
glenn schrooyenandClaude Opus 5.5 1d33265aac test_price: grade the Why row of the same render, not a second read of why_words
checks / unit (pull_request) Successful in 9m20s
infra-pin / fresh (pull_request) Successful in 1s
Since UX-10 the waiting sentence counts its seconds when it is read, so the
second read could cross a second boundary on a loaded runner ("1 second" in
the row, "2 seconds" in the check). CI run 953 on this PR failed that way;
test_price.py passes locally on the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:04:55 +02:00
glenn schrooyenandClaude Opus 5.5 1f97c7652f SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Failing after 9m32s
At the BMS cut-out the saturation freeze held the last request for hours
(+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid
error changed sign. compute() now carries a refused direction: after
SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction
at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go
on charge, an answered probe, stop/start or another writer. A stale-reading
trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's
~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A).

test_refusal.py drives the real Controller and gate; tools/replay_refusal.py
replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined
where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what
SAFETY-58/59 must take into account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:27 +02:00
admin d1f7e3336e Merge pull request 'UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG' (#140) from UX-11-short-safety-lines into release/1.0
infra-pin / fresh (push) Successful in 2s
Merge UX-11: short safety log lines, figures to DEBUG and events (#140)
2026-09-27 14:31:05 +02:00
adminandClaude Opus 5.5 3c298af3d8 UX-11 review: restored stops log their figures to events, the quiet-meter WARNING is rate-limited
checks / unit (pull_request) Successful in 9m7s
infra-pin / fresh (pull_request) Successful in 1s
- After a restart, each restored stop (direction per inverter and site,
  delivery, circulation) also writes its saved reason to the action log.
  log_level defaults to info and events starts empty, so the DEBUG
  evidence alone left the figures only in state.json.
- A flapping meter (ha_signed under a flat load, 42-97 s between changes)
  logs at most one WARNING/INFO pair per 10 minutes, with the episodes in
  between at DEBUG.
- LONG_LINE_OK refuses any key whose text is a safety reason.
- test_watchdog keeps the byte pin beside the meaning pin, since the text
  did not change.
- DOCS says the DEBUG line needs log_level debug.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:21:05 +02:00
glenn schrooyenandClaude Opus 5.5 c628099e69 UX-11: the meter WARNING names the limit only, as 'over 30 seconds'
checks / unit (pull_request) Successful in 9m4s
infra-pin / fresh (pull_request) Successful in 2s
TEST-23 showed 'No meter reading for 30 seconds, over the limit of 30
seconds': the age is 30.4 s, which rounds to the limit. The limit alone is
always true at the moment the line is logged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 13:49:17 +02:00
glenn schrooyenandClaude Opus 5.5 29837dfaa7 UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
- Every WARNING/ERROR line that reported a safety reason (direction, delivery,
  stuck meter, stale reading, peak guard, circulation, over-rating) now ends in
  the reason's code (messages.reason_code()). The reason itself, figures and
  limits included, is on a DEBUG line right after it and in the action log.
- The reasons and state.json are unchanged, so an old state file loads and
  shows the same way: every saved reason opens with its code.
- test_messages part 6 checks every WARNING+ line at 25 words as logged; nine
  non-safety families are listed with a reason and capped. The byte pin in
  test_watchdog now pins the meaning, in order.
- The meter logs one WARNING per quiet episode, and INFO when back, so the red
  row's "the add-on log shows why" holds. The Why line past the hold names
  the missing reading. Action log: "25 engagements", "Shadow mode off: ...".

No trigger, latch, threshold, log level, notification id, cadence, row colour
or banner order changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 13:34:30 +02:00
admin c4032ebc21 Merge pull request 'CI: bump infra pin to d637d0b (admin/goodwe#61, UX-10)' (#139) from pin-bump-d637d0b into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 12:30:15 +02:00
glenn schrooyenandClaude Opus 5.5 2f1627e287 CI: bump infra pin to d637d0b (admin/goodwe#61, UX-10)
checks / unit (pull_request) Successful in 9m5s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:19:51 +02:00
admin 2b14b88cb4 Merge pull request 'UX-10: the last plain-language leftovers (log lines, two table rows, 'Why: Other', red at 0 s)' (#138) from UX-10-plain-leftovers into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 12:18:22 +02:00
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
goodwe-review c84d5fd14f UX-08 2c review: unit settings anchor to inverters, plainer texts
checks / unit (pull_request) Successful in 8m50s
infra-pin / fresh (pull_request) Successful in 2s
- messages.setting() for one inverter in the list reads
  “Inverter rating” for inverter-b (inverters.max_w), so the config panel
  files per-unit reasons under `inverters`, where the value is changed.
- The tariff card and plausibility reasons name
  “Distribution, levies and VAT” (price_taxes_eur_kwh) again.
- The wrong-direction reasons say "safety stop", the sign_disagree_w
  option says what it protects, and the roster, price-entity,
  integrator_max_w and capacity_kwh texts read more plainly.
- NL: stuurprint -> stuurmodule, "De piekbeperking".

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:07:34 +02:00
admin 6a8a5ffa27 Merge pull request 'CI-12: CI grades test_freeze.py against the real hwsim at a pinned admin/goodwe' (#125) from CI-12 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-25 18:19:43 +02:00
81 changed files with 10039 additions and 2782 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
2d026dfa7c425f462c6969b2f7cdfddc17b17a81
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
+5 -3
View File
@@ -286,8 +286,9 @@ jobs:
run: |
set -eu
rm -rf "$WS" && mkdir -p "$WS/goodwe-addon" && cd "$WS/goodwe-addon"
git init -q .
git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:${TOKEN}@#")"
git init -q . && git remote add origin "$URL"
# Token as an env-set header: not in argv or .git/config (TOOL-9).
export GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN"
if [ -n "$PRNUM" ]; then
# Two fetches, not one, so that a failure to reach either ref fails
# HERE with the ref in the message, rather than inside the merge
@@ -498,7 +499,8 @@ jobs:
TOKEN: ${{ secrets.INFRA_READ_TOKEN }}
run: |
set -eu
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]')
# `|| true`: under pipefail a missing or comment-only pin would skip the FAIL line (TOOL-9).
PIN=$(grep -v '^#' "$WS/goodwe-addon/.gitea/infra-pin" | tr -d '[:space:]') || true
echo "$PIN" | grep -Eq '^[0-9a-f]{40}$' || { echo "FAIL: .gitea/infra-pin holds '$PIN', not a full 40-hex SHA."; exit 1; }
test -n "$TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
rm -rf "$WS/goodwe" && mkdir -p "$WS/goodwe" && cd "$WS/goodwe"
+10 -6
View File
@@ -23,16 +23,20 @@ jobs:
INFRA_TOKEN: ${{ secrets.INFRA_READ_TOKEN }} # admin/goodwe is private
run: |
set -eu
D=$(mktemp -d) && cd "$D"
git init -q . && git remote add origin "$(echo "$SELF" | sed "s#://#://x-access-token:${TOKEN}@#")"
D=$(mktemp -d) && trap 'cd / && rm -rf "$D"' EXIT && cd "$D"
git init -q .
# The pin of the commit this event is about (the PR head, or the push).
git fetch -q --depth 1 origin "$GITHUB_SHA"
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]')
# Token as an env-set header: not in argv or .git/config (TOOL-9).
GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $TOKEN" \
git fetch -q --depth 1 "$SELF" "$GITHUB_SHA" || { echo "FAIL: could not fetch $GITHUB_SHA here."; exit 1; }
# `|| true`: under the runner's pipefail a missing or comment-only pin
# would stop here on git's error instead of the FAIL line below (TOOL-9).
PIN=$(git show FETCH_HEAD:.gitea/infra-pin | grep -v '^#' | tr -d '[:space:]') || true
test -n "$PIN" || { echo "FAIL: .gitea/infra-pin is missing at $GITHUB_SHA, or holds only comments."; exit 1; }
test -n "$INFRA_TOKEN" || { echo "FAIL: secret INFRA_READ_TOKEN is not available to this run (a fork PR?)."; exit 1; }
# Header via env, as in checks.yml: the token stays out of argv.
HEAD=$(GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=http.extraHeader GIT_CONFIG_VALUE_0="Authorization: token $INFRA_TOKEN" \
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1)
cd / && rm -rf "$D"
git ls-remote "$INFRA" refs/heads/release/1.0 | cut -f1) || true
test -n "$HEAD" || { echo "FAIL: could not read admin/goodwe release/1.0."; exit 1; }
if [ "$PIN" != "$HEAD" ]; then
echo "STALE: .gitea/infra-pin is $PIN but admin/goodwe release/1.0 is $HEAD."
+63
View File
@@ -0,0 +1,63 @@
# STYLE.md - how user-facing text is written (UX-08)
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Its allowlist is empty since phase 2d and its cap is 0: a new string that breaks the rule fails the test.
## 1. Shape
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
## 2. Numbers
Use a number only if it helps the reader act, for example the peak limit, or what was asked and what was sent in a fault report. Write it the way `app/messages.py` does: `5 000 W`, `20 %`, `10 minutes`, `14:05`.
## 3. Severity
Severity is shown by the title and the colour, never by CAPITALS or by words like "BUG SIGNAL".
| Level | Colour | When |
|---|---|---|
| alarm | red | Control stopped, money or energy lost now, a fault, or a person must act now. Up to 3 sentences. |
| warning | orange | Control is still running, but something needs a look. 1 sentence. |
| info | grey | No action needed. 1 sentence. |
| resolved | green | An earlier alarm or warning has cleared. 1 sentence. |
A rewrite never changes an alarm's severity, `notification_id` or cadence.
## 4. Words - the same word for the same thing everywhere
A setting is named by its label in `translations/en.yaml`, in quotes when it sits mid-sentence (“Inverter rating”). The words below match those labels. If a label changes, change it here and in `app/messages.py` in the same change.
| Say | Not |
|---|---|
| the add-on (who decides), control (what it does: "start control", "Control: last ran …") | controller, loop, planner |
| battery | pack, BMS pack |
| inverter, or its name | unit, device, actuator |
| meter | P1 feed, telegram |
| peak limit / this month's peak | ceiling, capaciteitstarief, billed quarter |
| 15-minute period | quarter |
| battery level (setting: “Battery level entity”) | SoC, state of charge, charge level |
| battery cut-out level, or cut-out level next to "battery" (setting: “Battery cut-out level”) | BMS floor, `bms_soc_floor` |
| peak reserve (setting: “Peak reserve”) | `peak_soc_floor`, EMS floor, "peak guard will not discharge below" |
| “Inverter rating” (the setting) | `max_w`, rail, inverter rating as a fact about the hardware |
| solar production, solar entity (settings: “Solar production entity”, “Solar production entities, per inverter”, “Reversed-sign solar entities”) | PV, reversed-sign list, single sensor setting |
| “Where the meter reading comes from” | meter source |
| “Grid power entity”; entity for any Home Assistant sensor a setting points at | grid power sensor, sensor setting |
| draw the battery down, give power | discharge, discharging |
| control is stopped / every inverter is set to 0 W | failsafe, trip, latch, parked |
| safety stop (a stop that only starting control clears) | failsafe latch |
| software fault | BUG SIGNAL |
| control board | master, ESP32 |
| power request, what the add-on asks | command, setpoint (on the page) |
| the power went the wrong way | sign disagreement, command and meter point opposite ways |
| check for inverters working against each other | circulating power check |
| month peak, 15-minute periods measured | billed quarter, banked, soft limit, open block |
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. In shadow mode a text says what the add-on **would** send, never that it sent it. |
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
**Glossary.** DOCS.md "Words used on this page" explains, once, the words a home owner meets without context: control board, isolator, Developer Tools, shadow mode, safety stop, peak limit, peak guard, peak reserve and cut-out level. A home-owner text may use them without explaining them again. Card ids stay out of DOCS.md headings: they go on a *For installers* line under the heading.
@@ -0,0 +1,28 @@
# SAFETY-40: the integrator bound is not a bug
**Decision.** `i_clamped` (the integrator reaching `integrator_max_w`, i.e.
anti-windup) keeps alarming through the same `ClampWatchdog`: it is counted,
notified on the same cadence, and escalates on a sustained run
(SAFETY-41). It gets its own text, a `WARNING` and an amber status row. Only
`clamped` (a command over `max_w`) and write-path clamps keep the BUG SIGNAL,
the `ERROR` and the red row.
**Why it still notifies.** On a default install `clamped` is unreachable
(`control.py`, at the integrator bound), so the integrator bound is the only
loop-side signal of a runaway such as the 14 768 W one. Silencing it would
remove that. The card says the same: "the fix is to say the true thing, not to
say less."
**Why a bug has its own budget.** A genuine over-rating must not wait up to
`REASSERT_S` behind anti-windup notices. `ClampWatchdog` tracks a separate
`last_bug_alarm`, and a bug's text is never overwritten by later anti-windup
text within an episode. Across episodes the WARNING uses its own
notification id (`goodwe_integrator_bound`), so it cannot replace a BUG SIGNAL
notice still on screen (review of #130). With only bug engagements, the cadence is the same as
before (`test_clamp.py` part 9).
**Numbers.** They come from the `Decision` of the clamping tick (`i_bound_w`,
`requested_w`, `clamped_w`, `target_w`), not from `i_w` or `self.target`.
**Open for the owner.** Whether scattered one-cycle anti-windup episodes should
notify at all, or only log and count. They notify now, as before.
@@ -0,0 +1,110 @@
# SAFETY-52 - a battery that refuses is asked for 0 W
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
questions answered yes.**
## The defect
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
inside the interval a limit can produce. The request stood at +5000 W for
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
moved again only when the grid error changed sign. SAFETY-48's lead cap only
changes where `i_w` parks (one slew step ahead instead of at the rail). When
the request moved after the reading went flat, the stale-reading gate won
instead and ran SAFETY-59's ~70 s trip cycle.
## What was decided
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
`saturation_w`. The drop to 0 skips
the slew limit (it only removes an ask nobody meets). The direction is
carried like `sat_count` (`refused=`, `Decision.refused`).
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
cycle. The log keeps SAFETY-56's two-explanation wording.
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
Other exits: the loop wants the other direction (charge is accepted at the
floor), control stopped or started, another writer on the wire (the arbiter
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
what seeds it.
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
battery's discharge end and moves its share. There, a one-unit gate trip must
not refuse the site. `compute()` is passed `refused=None` there, and that path
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
`test_control.py` still holds).
## Evidence
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
shapes, recovery with no sign change, the probe cadence, every exit).
`tools/replay_refusal.py` replays the 16-day capture, same model as
`replay_recorded_loop.py`, and should be read for deltas:
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|---|---|---|---|---|---|---|
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
just above its floor, the probe is answered, control ramps, the pack empties
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
follow an other-direction exit the same way. On a site this is one
`STALE_READING` ERROR line per answered probe at most, and only on a battery
that answers a probe and then cuts out again. Catching the re-cut before the
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
battery, so it is left to SAFETY-58/59. After the review changes below the
fitted-plant row reads 133 trips (the delivery exit now also catches answers
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
the phase A plan: bounding the integrator by demand made the worst quarter
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
configured floor to be right and needs SoC.
## Review changes (#141)
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
old law. The capture has 0.11 h in that band, so the replay does not move.
- **Any delivery ends the hold, not only an answered probe.** With
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
held it for 15 min. Now the late reading ends it within seconds (2 s held in
`test_refusal.py` part 5b).
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
battery only when its cut-out level is configured right.
## Not covered
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
genuine limit (SAFETY-13 case a) and still holds its request.
## What SAFETY-58 and SAFETY-59 must take into account
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
the first verdict, the seed arrives later. The 09-19 shape then needs the
compute-side engagement (12 cycles) to win, which it does only while the
request stands still. Keep `test_refusal.py` part 4 green.
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
change a refusing battery no longer produces the pattern at one inverter. The
DOCS text should say so, and that the `battery refuses` reason is the
discriminator the add-on now has. At N>1 the old pattern can still occur.
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
`test_refusal.py` part 2 checks the inequality.
@@ -357,3 +357,5 @@ the bump is PKG's call. The material an entry needs is this file.
The option surface moves **76 keys → 77**, one new optional key, `pv_entities`,
empty on every install that does not set it.
**Superseded in part (UX-08, 2026-09-27):** the PV status rows no longer read 0.11.0's sentences at one entity. The owner ruled out entity ids on status rows, so 2/5 now holds for the control document only. See `UX-08-short-notifications.md`, phase 2b item 3.
@@ -0,0 +1,92 @@
# UX-08 phase 2a: short notifications, and what moved out of them
The owner decided on 2026-09-27: a title of at most about 5 everyday words and a one-sentence body. A red safety alarm may use up to 3 sentences, so no action-relevant information is lost. Titles are neutral, with no "GoodWe:" prefix. HA entity ids appear only in refusals. The rule is `STYLE.md` at the repository root, the texts are in `app/messages.py`, and `test_messages.py` enforces both. The detail each notification used to carry is in DOCS.md under "Notifications", and the body links to it.
**Unchanged:** every `notification_id`, every trigger and cadence (edges, SAFETY-34 re-announce, per-unit ids), every log line and every status row. The status banner and rows are phase 2b, refusals and option texts 2c, logs 2d; `ux08_allowlist.txt` lists them until then.
## Where a choice was made
1. **Severity of "not delivering" (SAFETY-42).** The notification is an *alarm*, so it may use 3 sentences. Its log line is ERROR and the alarm is latched. The page banner stays amber, as SAFETY-42 decided, because control keeps running; the two answer different questions (is control running / must a person act) and HA shows no colour on a notification. The three checks stay in their original order. Clearing it takes stop then start, and the body says so.
2. **Figures left the bodies** of the frozen-meter card (the rail and the command), the circulating-power card (the watts and the time window) and the peak cards (the projection). None of them is needed to act, and all of them are still in the reason text that the log line and the status row carry. `test_freeze.py` and `test_watchdog.py` now pin that. The frozen-meter card can therefore no longer quote a wrong rail (SAFETY-20), because it quotes none.
3. **The over-rating alarm keeps its numbers** (asked, sent, and which limit cut it), because they are what a fault report needs. `ClampWatchdog.facts` carries them under the same keep-the-bug rule as `reason`, so the words and the numbers always describe the same tick, with asked before sent (SAFETY-40, `test_clamp.py` part 6).
4. **SAFETY-25 stays in the peak-reserve alarm** as its last sentence ("the reserve only limits peak shaving … down to its cut-out level"). The alarm now gives each branch its own action: at the reserve, lower it; battery level unread, check the sensor; floor unknown, set the cut-out level. That last branch is `soc_floor is None`, which happens when an inverter's cut-out level is missing, not the peak reserve (that option always has a value). Before, every branch said "lower peak_soc_floor". The title, "Battery stopped limiting the peak", is true on all three branches.
5. **SAFETY-32 at a roster of one.** The body no longer ends in a separate "WHICH PACK" paragraph. The pack's name is part of the first sentence ("The battery of inverter-b …"), and at a roster of one that is the only difference.
6. **Dropped as background, now in DOCS:** "charging stays blocked" (peak reserve), "why no other guard fired" (circulating power, not delivering), and the saturation-freeze distinction (over-rating). "The meter cannot see this" stays in the circulating-power card, as the owner asked.
7. **The Details link** opens the add-on's documentation page (`/hassio/addon/<slug>/documentation`, with the slug taken from the container hostname) and names the section. A `#heading` anchor was not used because nobody has checked that Home Assistant's documentation tab renders heading ids; if it does, adding one is a one-line change. Outside an add-on container it names the section without a link.
# Phase 2b: status rows and page banners
Every row of the Commissioning list and every banner is now in `app/messages.py` (`row_*`, `banner_*`), and `test_messages.py` checks a rendering of each. The row and banner kinds are gone from `ux08_allowlist.txt` (200 → 168 entries). DOCS.md "Status page" explains the rows. **Unchanged:** every row's colour (ok / warn / bad), which rows show when, and the banner cascade order.
## Where a choice was made
1. **A red row may use 3 sentences (30 words).** STYLE.md gave rows 15 words; the owner allows red safety states 3 sentences. A red row needs room for what happened, the state (0 W) and how it clears. STYLE.md now says so, and the test applies it by colour.
2. **Figures left the rows, not the product.** The reason texts (freeze, sign, circulating, delivery, peak guard) carry reason codes and limits; they stay on the ERROR/WARNING log line and the event list, as before. Rows keep the numbers a reader acts on: the stall age and the 0 W deadline, the meter age and limit, asked and sent for an over-rating, the peak limit, and "opposite for 12 of 60 seconds" while a check counts. So 2a item 2 ("also on the status row") no longer holds, and DOCS.md says "in the add-on log".
3. **No entity id on any row**, even the green ones that used to print `sensor.x = 54`. The sensor rows name the setting (*Battery power for inverter-b*). Solar sources are numbered in the order of the settings list (*source 2 of 3*, *entry 1 of the reversed-sign list*). This retires TEL-12's rule that a one-entity site reads 0.11.0's sentence byte for byte. A `tools/tel11_pv_trace.py --compare` against a pre-UX-08 tree therefore shows the PV rows as changed telemetry, and the control documents stay equal. The per-source watts and units left the multi-source row. The total, the count and which source is missing remain.
4. **UX-07 item 1.** `ClampWatchdog` now also counts `over_rating` and `bound` separately. After an episode, each count shows under its own alarm's title ("Power request too high: 2 times since start, not now", "Battery is not keeping up: …"). A site with both gets two amber rows where it had one.
5. **The "Not ready" banner** is the first red row's own `banner`, or else its first sentence. The peak-reserve and over-rating rows set `banner` to their notification title. The three trips keep their own banner branches, reworded (e.g. "Inverters worked against each other. Every inverter is set to 0 W.").
6. **How a latch clears stays on the row**, shortened: "Only starting control clears this", "A restart does not clear this", "only stopping and starting control clears this" (not delivering). SAFETY-25's sentence stays on the peak-reserve row.
7. **Rows got an `id`** (`meter_frozen`, `direction`, `pv`, …), so tests find a row by what it is, not by its words. The page ignores it.
8. **Left for later:** texts that rows embed from other modules and that were not flagged as rows (the month-peak line, `peakguard.explain()`, the liveness and stale notes, `shadow.banner()`). The month-peak row only lost its semicolons.
# UX-09: fixes after the phase 2a test
1. **The Details link follows the Home Assistant version.** HA 2026.2 added the built-in `app` panel (core #157554, frontend #28245), whose add-on pages are `/config/app/<slug>/documentation`. 2026.5 stopped registering the `hassio` panel, so `/hassio/addon/<slug>/documentation` is a 404 there (TEST-23, HA 2026.9.1). 2026.1 and older have only the old path. The one path valid on both, `/_my_redirect/supervisor_addon?addon=<slug>`, opens the Info tab, not Documentation. So the add-on reads its slug (`GET /addons/self/info`) and the HA version (`GET /info`) at start, and picks the path. An unknown version gets the current path. The hostname stays as a fallback for the slug. Phase 2a item 7 is replaced by this.
2. **Shadow mode says "would".** The over-rating alarm and row said "sent X W" while 0 W was on the wire, and the peak alarm said "the battery is giving all it can". Both now take `shadow=`, and in shadow mode say what would have been sent and that the inverters stay at 0 W. Severity, id and cadence do not change with the mode.
3. **"Controller" is gone from texts.** "The add-on" names who decides, and "control" names what it does, as in the existing "start control". The add-on is what the reader sees in Home Assistant, and "controller" was easy to confuse with the control board.
4. **One term per setting, matching its label.** Four labels changed instead of the texts, because the texts' words were plainer: “Peak reserve (%)” (was "Peak guard will not discharge below"), “Battery level entity (%)” (was "Battery charge level"), and “Solar production entity / entities, per inverter / Reversed-sign solar entities” (was "PV …"). The Dutch labels already said "Zonneproductie". Option descriptions are phase 2c and were not changed, so some still say "PV".
5. **The shadow banner and the inverter notes** are now `messages.banner_shadow()` and `row_no_setpoint` / `row_not_controllable` / `row_commands_refused`, so `test_messages.py` checks them. The red shadow banner no longer lists the latches, because 12 words cannot carry them. It says a safety stop stays on, and each latch keeps its own red row, which names it and says how it clears. The colour of a liveness row now comes from `is_live()`, not from matching "NOT COMMANDABLE" in the text.
6. **DOCS "Notifications" and "Status page"** are written for a home owner, and the card ids, keys and log detail moved to a "For installers" part of each.
# UX-08 phase 2c: refusals, error texts and option texts
Validator refusals, the parse errors they carry, and every option name and description are now short. No rule, key, schema, default or exit code changed. Over 36 spoiled configurations the old and new validators refuse the same number of times. The allowlist went from 167 entries to 43, and only log lines (phase 2d) remain on it.
1. **A refusal names the setting by its label and then its key**, as `messages.setting()` writes it: “Inverter rating” (max_w). The key has to stay, because `configui.problems_by_field()` shows a reason beside every key its text names. A test checks each label against en.yaml. A few reasons no longer mention keys they only named in passing, such as `price_markup_eur_kwh` in the withdrawn-provider reason, so the panel shows those reasons beside fewer settings. Each reason still names the setting that fixes it. A setting of one inverter in the list is written “Inverter rating” for inverter-b (inverters.max_w), so the panel shows it beside `inverters`, where that value is changed, and not only beside the flat `max_w` that a list does not read.
2. **Citations moved to DOCS.** Option descriptions say "Measured value, leave it unchanged" or similar, and the record behind the value is under "For installers: where the numbers come from". The UX-01 citation check in `test_translations.py` now checks the tab for that instruction and DOCS for the record.
3. **What left the refusals:** the shipped default in the peak-reserve reason, the superseded-default note in the levy-table reason (DOCS already covers it), the endpoints of the withdrawn providers (DOCS "Energy price providers"), the plant measurement behind `slew_w` (DOCS, beside the setting), and the leg-specific paragraphs of the tariff-formula errors. Each reason keeps the rejected value, the limit and what to change.
4. **Not rewritten:** errors that only a code defect can raise, such as plugin-contract and argument-type checks in `price.py`, `price_forecast.py`, `static_tariff.py`, `arbitrage.py` and `main.py`, which a home owner never reads. INFO lines such as the tariff-formula preview were also left.
# UX-08 phase 2d: log lines, the rest of the status page, and DOCS
The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons (the semicolon rule now covers log lines too). **Unchanged:** every log level, every trigger and cadence, every notification id, every row's colour and when it shows, the banner cascade, and every refusal count.
1. **No code prefixes on log lines.** Nothing outside the repositories greps them, except `/home/homelab/clampwatch.sh`, a one-off site watch from 2026-09-05 that greps `OUTPUT_CLAMP` and `Engagements so far`. `OUTPUT_CLAMP` is still in the reason the line quotes; "Engagements so far" is now "Cut back N times so far". The tests that found a line by its card id (`SAFETY-37`, `SAFETY-44`, `CONTROL LOOP DIED`, `UNGRADED`) now find it by its words.
2. **Reason texts keep their codes.** A line quotes a watchdog's reason (`%s`), such as `STALE_READING: …` or `OUTPUT_CLAMP: …`. Those are the evidence an installer searches for and many tests pin them, so they were not rewritten. The line around them is plain.
3. **Reasoning moved to DEBUG, not deleted,** for the 10 lines marked DROP-to-DEBUG (for example the 14 768 W history behind the over-rating alarm, why SAFETY-42 reports and does not act, and the Eneco card advice). The 25-word line keeps the action and the numbers. SAFETY-57's promise stays on the ERROR line, as an ordered pair: "until it changes, or 17 s after the command drops below 250 W".
4. **The line under the buttons** showed the newest raw action-log entry (`OUTPUT_CLAMP …`, `FAILSAFE …: SIGN_DISAGREEMENT`, `ALLOCATOR …`). Each `log_event()` call now also names a plain hint from `messages.HINTS`, and the page shows that. The action log itself is unchanged, because tests and later analysis read it, and the page no longer shows it. It stays reachable as `events` in the `status` JSON (review of #136), because some entries are written nowhere else: a dropped inverter's reason, the unplaced watts, the peak guard's projection and the shadow decision.
5. **The top table.** *Why* is `messages.why()`: the arbiter's winner and limit in words ("Keeping the grid near zero: 440 W, held back by the peak limit"). `self.reason` keeps `Resolution.explain()` for the log and the checks. *Tariff*, *Price history*, *Arbitrage* and *Shadow mode* keep every figure TARIFF-11 and SAFETY-47 put there, each in its own slot, in words. Labels: *Battery level*, *Solar production*, *Power request*.
6. **The month-peak row became two rows**, because one sentence of 15 words cannot hold the peak, its time, its share of the limit and TARIFF-13's coverage figure. The store's state at start (`restore_state`) left the row: it is the INFO line at start.
7. **The peak guard rows** name the projection first and the limit second, as before (SAFETY-38). The blind row says what it means for the owner: the battery does not charge until the period can be measured again.
8. **Wording that changed meaning-carrying words**: "command and meter point opposite ways" is now "the power went the wrong way" (banner) and "…went against what the add-on asked" (row). The N=1 banner still names the meter, the N≥2 texts still say "own power". The inverter range reads "up to 5 000 W each way" instead of "-5 000 W to 5 000 W".
9. **Refusals are counted as shown.** Setting placeholders are expanded to label and key, and the long cases the 2c test found are driven through the validator. Seven refusals were shortened. The card refusals no longer show `c_eur_per_kwh`, `vat_pct` or "its own card", and a text that does not parse says "not valid near character N" instead of the parser's words. A missing value shows as *empty*, not `None`.
10. **The panel's key match** no longer files `inverters.max_w` under the flat `max_w`.
11. **The Supervisor question at start** is one INFO line with a real reason (a timeout's own text is empty, so the error type is named).
12. **DOCS**: a glossary ("Words used on this page") explains control board, isolator and Developer Tools once. Card ids left the headings and sit on a *For installers* line under each. The links to the renamed headings were updated.
# UX-10: the last plain-language leftovers (after the 2d test)
**Unchanged:** every safety behaviour, every alarm's log level (WARNING and ERROR stay), every notification id, trigger and cadence, the banner cascade, and every row colour except the meter row right after a start (item 6).
1. **Why the 2d scan missed the clamp lines.** Part 3 reads a log call's own literal and counts `%s` as one word. The clamp lines are `"…software fault: %s …"`, and the text in `%s` was built elsewhere, by `loop_clamp_note()` and `ClampResult.describe()`. So the scan passed a line whose logged text had a semicolon, "BUG SIGNAL", "clamped" and "1 times". The same was true of every quoted reason. Part 6 closes the gap. It lists every dynamic part of every WARNING+ line as data (a number, a name, a path, a library's own error text) or as a built text, and a new part in neither list fails. Each built text is produced by the real code (the watchdogs, `decide()`, `loop_clamp_note()`, `MigrationReport.explain()`) or, for a family of builders, by every template of it (`PriceRead.no_data`, `Gate.block`). The text and the line as logged are then checked. A mutation shows the 2d clamp text is caught on both clamp lines.
2. **A quoted reason gets the red-alarm shape**, 3 sentences of at most 30 words, and not the 25-word line limit. It is the evidence behind an alarm, and it keeps its code and its limits, which the owner asked to keep. The alarm shape is STYLE.md's existing rule for the texts behind a safety stop, so no new limit was made up. Rewritten: `OUTPUT_CLAMP` (both kinds), `METER_FROZEN`, `CIRCULATING_POWER`, `STALE_READING` (SAFETY-56's two explanations and "cannot tell which" kept, in order), the peak guard's reasons, the state-migration report, the price feed and price history reasons, and the maintenance and trading gate reasons. "Software fault" left the clamp reason, because the ERROR line around it already says it.
3. **Codes are one token.** `PEAK GUARD`, `PEAK GUARD FLOOR-LIMITED` and `PEAK GUARD BLIND` read as capitals, so they are now `PEAK_GUARD`, `PEAK_GUARD_FLOOR_LIMITED` and `PEAK_GUARD_BLIND`, like `OUTPUT_CLAMP`. Nothing in admin/goodwe or in the scripts under `/home/homelab` greps them (checked). `/home/homelab/clampwatch.sh` greps `OUTPUT_CLAMP`, which stays.
4. **Top table.** *Price history* writes "96 prices over 24 hours for 26 Sep", joined by commas. *Tariff* writes the spacing as "one every 15 minutes or every hour". "Not final yet" was dropped, because a plan is provisional exactly when it holds an estimated price (`n_carried` counts the provisional points), so the estimated count already says it.
5. **Levels and noise.** The shadow line of every cycle is DEBUG, and "Shadow mode is on/off" is INFO: it follows a setting or a button, and the banner, hint and event are unchanged. aiohttp's access log is on only at log level debug. A settings warning is logged once per start (a filter on `goodwe.config`, WARNING only, so refusals are untouched), because the validator runs twice at start. When Home Assistant restarts, `hass.Outage` says so in one WARNING line for the REST API and the meter websocket together, puts the library's text on DEBUG, and says at INFO when both answer again. INFO lines lost "§8.3", ISO timestamps, "banked" and "keys ARE", and error texts lost "NOT_OBSERVED", "did NOT land" and "AND".
6. **Display bugs.**
- "Why: Other" during a latched per-inverter stop: `main.py` turns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit", main.py's own refusal to split) had no entry in `WHY_WINNER`. The mapping is now `main.why_key()`, and part 7 finds every `Claim.set()` in the code and checks that every key it can produce has words.
- Red "Meter: no reading for 0 seconds" after a restart: until the first reading arrives, `P1Ingest.stale` is true, and the age is the time since start. The row now reads amber "waiting for the first reading" while that age is inside the limit. **Colour change, justified:** red at 0 s contradicted the limit the same row prints, and once readings exist the row is red only past that limit. Amber, not green, because control waits for the reading. Past the limit it is red as before. `stale` itself, which gates control, is unchanged.
- "A reading is missing for 0 seconds" stuck at 0: with the meter reading gone, the grid value stops changing, so the loop runs a cycle only every `cycle_floor_s`, and the line was computed once per cycle. The age is now counted when the page asks. The hold and its 15 s escalation are unchanged.
7. **Review: the line as logged gets the shape too.** Part 6 first checked the quoted reason alone, so a title, a 2-sentence reason and two tails still logged as 4 sentences ("Power request too high …: OUTPUT_CLAMP: … +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far."). Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. The wrappers became one sentence each, the circulation and peak guard reasons two, and `STALE_READING`'s fixed limits moved from the reason to a DEBUG line beside the ERROR (its release clause stays, pinned by test_freshness). The clamp lines for one inverter are pinned at 30 words at most: `OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W`. Several clamped inverters read as one sentence of asked/sent pairs; which limit bound each is on its own action-log row and in the notification. Totals above 30 words remain on the direction, delivery, frozen-meter and stale lines (up to 74 words). Shortening them is a follow-up: the direction and delivery reasons are also persisted in state.json, and the direction one is pinned byte-for-byte in test_watchdog.
8. **Home Assistant 400 while it loads** now reads "Home Assistant refused number.set_value for number.x (error 400, no details). The entity may still be loading, or the value is out of range." A body with more than "400: Bad Request" is still quoted. Still WARNING, still once a minute, still a failed write.
9. **Witness** (admin/goodwe): the resolved push says "after no answers from … to …", as the resolved card does. The YAML with every title and message stripped is identical.
# UX-11: safety log lines give the code, the figures go to DEBUG
**Unchanged:** every trigger, latch, threshold and alarm log level (the WARNING and ERROR lines stay), every notification id and cadence, every row colour and the banner cascade. No notification is added.
1. **One plain sentence and the code.** A WARNING/ERROR line that reports a safety reason (direction, delivery, stuck meter, stale reading, peak guard, circulation, over-rating) now ends in the reason's code, taken by `messages.reason_code()`. The reason itself, figures and limits included, is on a DEBUG line logged right after it ("Evidence for inverter-a: …"), and the action log entry carries it as before. Part 6 checks that pairing in the code. Circulation and the clamp lines were not among the five named, but they break the same 25-word rule, so they follow the same pattern. The clamp numbers stay in the notification (phase 2a item 3).
2. **The reasons and `state.json` are unchanged.** `reason` stays the evidence text, so what is saved has the same format as before. An old state file therefore loads and displays like a new one: every saved reason has opened with its code since it was first written (checked in the git history), and that code is all the ERROR line reads. The saved text goes on the DEBUG line. A stop saved without a reason shows `REASON_NOT_SAVED`. `test_messages.py` part 7 restarts from a state file holding the old long texts. `test_watchdog.py` pins the meaning (the code first, then command, reading, time and both limits, in that order) and, since the text did not change, still the bytes.
3. **The 25-word rule is checked as logged.** Part 6 fails any WARNING+ line over 25 words with its built text filled in. Nine non-safety families still run long (tariff plan words, price feed reasons, the state migration report, write refusals and claim scope errors that only a software fault raises, maintenance, trading gate and price history reasons). They are listed with a reason in `LONG_LINE_OK`, capped at 9, and a listed family that gets shorter fails until it is removed. Shortening them is a follow-up.
4. **The meter row's "the add-on log shows why" is now true.** When the meter goes quiet past its limit, the add-on logs one WARNING per episode, not one per cycle, and an INFO line when readings return. A meter that flaps (on `ha_signed` a healthy meter under a flat load can go 42–97 s between changes) logs at most one such pair per 10 minutes, and the episodes in between go to DEBUG. This is a new log line, not a new alarm: it has no notification and changes no decision.
5. **The Why line past the 15 s hold** names the missing reading ("No usable meter reading for over 15 seconds: 0 W", or the battery power or level of an inverter), instead of "A reading is missing: 0 W".
6. **Action log:** "25 engagement(s)" is "25 engagements", and "SHADOW MODE OFF - …" is "Shadow mode off: …". "SHADOW MODE ON" was not named in the card and is left as it is.
7. **Review: after a restart the figures go on the action log too.** `log_level` defaults to `info`, so the DEBUG evidence line is not seen by default, and `events` starts empty. Each restored stop (direction per inverter and site, delivery, circulation) now also writes its saved reason to the action log ("FAILSAFE inverter-a, saved before the restart: …"), and the page's event line gets the same plain words the live stop gives. `LONG_LINE_OK` also refuses a key whose text is a safety reason, not only a `reason_code` key.
@@ -0,0 +1,69 @@
# UX-13 — one layout table, and why UI code may now name an option key
**Status: DECIDED 2026-09-27 (owner approved the UX-13 design and mockup).**
Card 6ab979852f3eca02fb40f69e. Amends [[UX-02]] §3.1. Design note and
mockup: `goodwe-session/UX-13-design.md`, `UX-13-mockup.html`.
## 1. What UX-02 said, and why it no longer fits
UX-02 built the configuration panel from the Supervisor's schema, so
`app/configui.py` contains "no option key, no label and no rule", and
test_selfconfig.py part 5 checks that the page script names no key. That kept
a second copy of the option surface from drifting, and it is why the panel is
a flat list of fields in schema order.
UX-13 needs things a schema cannot say: groups and sub-groups, a slider in kW
for a value stored in W, a picker for an entity, a field shown only for one
meter source, locked measured values, a Simple view and a setup wizard. It
also needs page fields that are not options at all: one inverter card that is
stored as six flat keys, solar rows that become `pv_entity` or `pv_entities`,
a contract and two prices that become `static_profile` JSON, and a region
that becomes the levies figure. None of that can be read off the schema, so
some code has to name option keys.
## 2. The decision
Option keys may appear in UI code in exactly two files:
- **`app/uilayout.py`**, the layout table: screen → group → part → field, with
control, unit, display scale, step, entity kind, when it is shown, whether it
is on the Simple view or a wizard step, and whether it applies now or after a
restart. Every option in `config.yaml` is a field, `DERIVED` or `HIDDEN`,
exactly once.
- **`app/uimodel.py`**, the derivations the table names: `options_to_ui()` and
`ui_to_options()`. It may name only the keys it writes.
What UX-02 made authoritative stays authoritative:
- **Ranges** come from the schema (`uilayout.range_of()`); the table writes
none. A unit holds no digit.
- **Labels and help** come from `translations/*.yaml` by key. Page-only fields,
group titles and choices are in `app/ui_text/*.json`, held to STYLE by
test_messages.py.
- **Rules** stay in `validate.py`. It gains a warning tier (`validate.Note`,
"warn" or "info") for the page. A note never blocks a save, and passing the
notes list does not change the refusals returned.
- **The stored blob** is still the one truth. The page model carries the stored
values of every derived key (`keep`). A derivation whose page fields are
unchanged writes those values back untouched, so a blob the page cannot
express survives a save of another field.
## 3. How it is held
- test_uilayout.py fails when `config.yaml` gains an option, or an inverter
field, that the table does not place. It also checks that uimodel.py names
only the keys it writes and that web.py and haentities.py name none.
- test_uimodel.py round-trips options → page model → JSON → options for every
validator call the suite made at 652097a (`scenarios/ux13/`), the TEST-23
option files and config.yaml's defaults. All must be identical.
- `tools/ux13_validator_calls.py compare` runs those calls through the old and
new validator. At P0: 261 calls, 158 refused, no refusal changed.
- test_selfconfig.py part 5 still checks the old page script; P1 moves the page
into `app/static/` and the same check applies there.
## 4. What this costs
The table is a second place that knows the option surface, which UX-02 set
out to avoid. The cost is bounded by the coverage test: an option added to
config.yaml and not placed here fails CI, rather than silently missing from
the page. Changing a key's type or range needs no change here.
+584 -91
View File
@@ -7,45 +7,73 @@ balance cells and recalibrate its coulomb counter.
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
v0.1.0 and outdated; where it disagrees with this page, this page wins.
## Words used on this page
- **Control board**: the small computer on the inverter's meter cable. It
passes the add-on's power request to the inverter. If the add-on goes quiet
for about 30 seconds, it sets the inverter to 0 W by itself. (For
installers: a T-CAN485 board with an ESP32 chip on the RS485 bus.)
- **Isolator**: the switch that cuts an inverter off from the mains. Switching
it off stops the inverter, whatever the add-on or the control board asks.
- **Developer Tools**: the Home Assistant page that lists every entity and its
current value, on its *States* tab. Use it to check that a sensor the add-on
reads is really there and updating.
- **Shadow mode**: the add-on makes every decision but sends nothing, so every
inverter stays at 0 W. Pages and notifications say what it *would* have done.
- **Safety stop**: the add-on has set one inverter, or all of them, to 0 W and
keeps it there until you start control on the add-on page.
- **Peak limit**: the 15-minute average grid use you want to stay under this
month, because your capacity tariff bills the highest one.
- **Peak guard**: the part of the add-on that uses the battery to keep each
15-minute period under the peak limit.
- **Peak reserve** and **battery cut-out level**: the charge the peak guard
keeps back, and the level where the battery switches itself off.
## Before you start
You need:
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
- The vendor's meter-emulating controller **disconnected** from the bus
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml`
- A control board: a T-CAN485 (ESP32) flashed with
`firmware/goodwe-master.yaml`
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
After installing, turn on **Watchdog** on the add-on's Info tab. A crashed
control loop stops the add-on at 0 W, and the Supervisor restarts it only when
Watchdog is on (it is off by default). See
`DECISIONS/SAFETY-61-control-task-death.md`.
After installing, turn on **Watchdog** on the add-on's Info tab. If control
stops by accident, the add-on sets the inverters to 0 W and exits, and Home
Assistant starts it again only when Watchdog is on. It is off by default.
## The safety model, in short
The inverter **holds its last command forever** — it has no meter-timeout. So:
The inverter **keeps its last power request forever**. It has no timeout of its
own. So:
- The ESP32 commands 0 W if this add-on stops refreshing for ~30 s, and keeps
commanding it.
- This add-on commands 0 W when its inputs go missing, when you stop control,
and when it shuts down.
- This add-on commands 0 W **and latches there** if what it is commanding and
what the meter reads point opposite ways for three minutes — see "The
- The control board sets the inverter to 0 W if this add-on stops sending for
about 30 seconds, and keeps it there.
- This add-on sets the inverters to 0 W when a reading goes missing, when you
stop control, and when it shuts down.
- This add-on sets the inverters to 0 W **and keeps them there** if the power
goes the opposite way to what it asks for three minutes. See "The
sign-disagreement failsafe" below.
- The **optional RS485 e-stop** is the only thing that covers this machine
dying. Without it, a failed host leaves the battery latched at its last
command until someone intervenes.
- This add-on **raises an alarm — and nothing more —** when the meter keeps
answering with a reading that never changes while the command sits at the
rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff
exposure of the grid-following loop".
- The **optional emergency stop** is the only thing that covers this computer
failing. Without it, the battery keeps its last power request until someone
steps in.
- This add-on **raises an alarm, and does nothing more,** when the meter keeps
answering with a value that never changes while the battery is asked for
full power. See "The frozen-meter alarm" and, before you rely on it,
"Capacity-tariff exposure of the grid-following loop".
**If anything looks wrong: stop the add-on.** That commands 0 W and the
hardware holds it there.
**If anything looks wrong: stop the add-on.** That sets the inverters to 0 W,
and the control board keeps them there.
*For installers: a crashed control task is SAFETY-61, see
`DECISIONS/SAFETY-61-control-task-death.md`.*
## Configuration
### ⚠️ There are TWO places to configure this add-on (UX-02)
### ⚠️ There are TWO places to configure this add-on
*For installers: card UX-02.*
Since **UX-02** the ingress panel has a **Configuration** page of its own,
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
@@ -108,7 +136,9 @@ One column, big targets, sections collapsed until you open them, and the
refusals pinned where you cannot scroll past them. It is the screen for
standing in front of the inverter.
### What the Configuration tab actually shows you (UX-01)
### What the Configuration tab actually shows you
*For installers: card UX-01.*
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
so Home Assistant's *Configuration* tab renders human labels and a description
@@ -125,7 +155,7 @@ whole rendered list, in both languages.
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
| **The description says whether to touch it** | A number that was **derived** names the campaign it came from and says not to change it without re-deriving; a number that is genuinely yours (`max_w`, `peak_cap_w`) says **"THIS IS YOUR NUMBER"**; and `cycle_cost_threshold_eur`, which is neither, says it is an open product question |
| **The description says whether to touch it** | One or two plain sentences. A measured or derived value says "leave it unchanged", a number that is genuinely yours (`max_w`, `peak_cap_w`) says "Your own number", and `cycle_cost_threshold_eur` says it is a product choice. Where each number comes from is under [For installers](#for-installers-where-the-numbers-come-from) |
| **Dutch falls back per key** | `translations[your language]?.name \|\| translations["en"]?.name \|\| the raw key`. A key missing from `nl.yaml` degrades to English; a key present but wrong does not degrade at all |
| **⚠️ The per-entry keys inside `inverters:` still render raw** | Measured, and negative. A list-of-objects becomes an `object` selector with `multiple: true`, and `ha-selector-object` labels its fields `fields[name].label \|\| name` — the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do **not** reach the screen. The `inverters` label and description themselves do |
@@ -133,6 +163,148 @@ whole rendered list, in both languages.
option that does not exist, or an option with no translation, fails CI — in
either direction, and the check is mutation-tested both ways.
### Settings at a glance
The label and description each setting shows on the Configuration tab, in tab order. `translations/en.yaml` is the source, and `test_translations.py` keeps this table in step with it.
| Group | Setting | Key | What it does |
|---|---|---|---|
| Hardware | Inverter setpoint entity | `setpoint_entity` | The ESPHome number entity the add-on writes the inverter's power to. Leave it empty when you fill in the inverter list. |
| Hardware | Battery level entity (%) | `soc_entity` | The sensor with the battery level in percent. Use the control board's own reading. |
| Hardware | Battery power entity (W) | `batt_entity` | The sensor with this inverter's battery power. Positive must mean the battery is giving power. |
| Hardware | Battery power sign is reversed | `batt_invert` | Switch this on only if your battery power sensor shows giving power as a negative number. |
| Hardware | Inverter list | `inverters` | Leave it empty if you have one inverter. With two or more, add one entry per inverter, which replaces the inverter settings above. |
| Hardware · Inverter list | Name | `inverters.name` | A short, unique name for this inverter, for example inverter-a. |
| Hardware · Inverter list | Setpoint entity | `inverters.setpoint_entity` | This inverter's own ESPHome setpoint entity. Two entries may not use the same one. |
| Hardware · Inverter list | Inverter rating (W) | `inverters.max_w` | The most power the add-on may ask of this inverter. The site total is the sum of the list. |
| Hardware · Inverter list | Battery cut-out level (%) | `inverters.bms_soc_floor` | The level at which this battery's own management system stops giving power. Read it off the battery that is installed. |
| Hardware · Inverter list | Battery level entity (%) (optional) | `inverters.soc_entity` | This battery's own level sensor. Leave it empty if this inverter has none. |
| Hardware · Inverter list | Battery power entity (W) (optional) | `inverters.batt_entity` | This inverter's own battery power sensor. Leave it empty if it has none. |
| Hardware · Inverter list | Battery power sign is reversed (optional) | `inverters.batt_invert` | Switch this on if this inverter's sensor shows giving power as a negative number. |
| Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left. |
| Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. |
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
| Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. |
| Meter | Where the meter reading comes from | `meter_source` | How the add-on reads your meter. 'off' uses “Grid power entity” below, and only homewizard_local can spot a stuck meter. |
| Meter | Grid power entity (W) | `meter_entity` | Used when the meter source is 'off'. Positive must mean drawing from the grid. |
| Meter | Grid power sign is reversed | `meter_invert` | Switch this on if your grid sensor shows drawing from the grid as a negative number. |
| Meter | Number of phases (1 or 3) | `meter_phases` | Must match your grid connection. Meter readings with a different number of phases are rejected. |
| Meter | HomeWizard P1 address | `p1_host` | For homewizard_local only. The meter's address on your network, as host or host:port. |
| Meter | Time between meter reads (s) | `meter_poll_s` | For homewizard_local only. The meter updates every 5 s, so lower gains nothing. Keep it well below “Reading too old after”. |
| Meter | Reading too old after (s) | `meter_max_age_s` | After this a meter reading counts as missing. The “Missing inputs time limit” starts only after this one runs out. |
| Meter | MQTT topic | `meter_mqtt_topic` | For mqtt_p1 only. The topic your meter readings are published on. |
| Meter | Grid import entity (W) | `p1_import_entity` | The sensor for power drawn from the grid, never negative. The add-on works out the net value itself. |
| Meter | Grid export entity (W) | `p1_export_entity` | The sensor for power fed into the grid, never negative. It pairs with “Grid import entity”. |
| Meter | Signed net power entity (W) | `p1_net_entity` | For ha_signed only. One sensor that is positive when drawing from the grid and negative when feeding in. |
| Meter | Import entities per phase (L1-L3) | `p1_phase_import_entities` | Optional, in L1, L2, L3 order. Needed for the peak limit on a three-phase connection, one entry per phase. |
| Meter | Export entities per phase (L1-L3) | `p1_phase_export_entities` | Optional, in L1, L2, L3 order, next to the import entities per phase. One entry per phase. |
| Meter | Net entities per phase (L1-L3) | `p1_phase_net_entities` | For ha_signed only. Optional, in L1, L2, L3 order, signed like “Signed net power entity”, one entry per phase. |
| Limits | Inverter rating (W) | `max_w` | Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning. |
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
| Tariff | Distribution, levies and VAT (EUR/kWh) | `price_taxes_eur_kwh` | Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates. |
| Tariff | Injection price (EUR/kWh) | `export_price_eur_kwh` | What you are paid per kWh fed into the grid, all in. It may be negative. |
| Tariff | Fixed or day/night tariff | `static_profile` | Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback. |
| Tariff | Day/night levies and VAT | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. |
| Tariff | Your own tariff formula | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card. |
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
| Tariff | Average price entity (older) | `price_avg_entity` | The average the current price is compared with. Use it only with the current price entity, not with a dynamic price source. |
| Tariff | Trade on the price spread | `arbitrage_enabled` | Charge in the cheapest hour and draw the battery down in the dearest, when the spread covers the cost. Turning it off does not turn the price feed off. |
| Tariff | Battery cycle cost (EUR/kWh) | `cycle_cost_threshold_eur` | What one full battery cycle costs per kWh. A smaller price spread is not traded, and the value is a product choice, not a measured one. |
| Tariff | Trading keeps at least (%) | `arbitrage_soc_floor` | Price trading will not draw the battery below this level. It is separate from the maintenance settings. |
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
| Maintenance | Recharge up to (%) | `maintenance_soc_target` | The level the recharge aims for before the hold starts. |
| Maintenance | Hold at full for (minutes) | `maintenance_hold_min` | How long the battery stays full so its cells can balance. |
| Safety | Wrong-direction threshold (W) | `sign_disagree_w` | Part of the safety stop for an inverter pushing the wrong way. It counts only when command and meter reading both exceed this. Measured value, leave it unchanged. |
| Safety | Wrong-direction time (s) | `sign_disagree_s` | How long a wrong direction may last before a safety stop sets every inverter to 0 W. Measured value, leave it unchanged. |
| Safety | Per-inverter wrong-direction threshold (W) | `unit_sign_disagree_w` | For two or more inverters: the wrong-direction threshold for each inverter. Derived value, leave it unchanged. |
| Safety | Per-inverter wrong-direction time (s) | `unit_sign_disagree_s` | For two or more inverters: how long one inverter may push the wrong way before it is set to 0 W. Derived value, leave it unchanged. |
| Safety | Site wrong-direction threshold (W) | `site_sign_disagree_w` | For two or more inverters: the wrong-direction threshold for the whole site. Measured value, leave it unchanged. |
| Safety | Site wrong-direction time (s) | `site_sign_disagree_s` | For two or more inverters: how long the whole site may push the wrong way before a safety stop. Derived value, leave it unchanged. |
| Safety | Stuck meter alarm after (s) | `meter_freeze_s` | An alarm when the meter repeats one reading this long while the command is at full power, with homewizard_local only. Measured value, leave it unchanged. |
| Safety | Opposing inverters threshold (W) | `circulation_w` | For two or more inverters: how much power one may push against another before it counts. Measured value, leave it unchanged. |
| Safety | Opposing inverters time (s) | `circulation_hold_s` | For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged. |
| Safety | Not delivering alarm after (s) | `delivery_hold_s` | For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged. |
| Safety | Shadow mode | `shadow_mode` | The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never. |
| Advanced | Start control on boot | `auto_start` | Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart. |
| Advanced | Control gain | `gain` | How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it. |
| Advanced | Largest change per cycle (W) | `slew_w` | How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended. |
| Advanced | Ignore errors below (W) | `deadband_w` | Errors smaller than this are ignored. Keep it small, since any resting point inside it holds. |
| Advanced | Meter resting point (W) | `target_grid_w` | What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day. |
| Advanced | Command rounding (W) | `step_w` | The command is rounded to a multiple of this, so noise does not change it every cycle. |
| Advanced | At-limit shortfall (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. |
| Advanced | Cycles at limit before freezing | `saturation_cycles` | How many cycles in a row at the limit before control stops building up error. Do not set it to 1. |
| Advanced | Catch-up limit (W) | `integrator_max_w` | Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating. |
| Advanced | Command refresh interval (s) | `heartbeat_s` | How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit. |
| Advanced | Missing inputs time limit (s) | `stale_input_s` | How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”. |
| Advanced | Longest time between cycles (s) | `cycle_floor_s` | A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s. |
| Advanced | Log level | `log_level` | How much detail the add-on writes to its log. 'debug' and 'trace' are for finding a problem. |
### For installers: where the numbers come from
The Configuration tab keeps each description to one or two sentences. The detail that used to sit there is here, one entry per setting. A value marked *measured* or *derived* in its description came from the record named below; change it only after re-deriving it from a new measurement.
When the add-on refuses to start, each reason names the setting by its label and then its key, for example “Inverter rating” (max_w), and adds the inverter's name when there is a list. The configuration panel shows a reason beside every setting it names.
- **Battery power entity** (`batt_entity`): on the reference installation this is holding register 0x0518, read by the control board. The wrong-direction checks and the opposing-inverters alarm both grade each inverter against it.
- **Inverter list** (`inverters`): empty means the single-inverter settings describe the one inverter you have. Filled in, the list replaces them and nothing is inherited: each entry names its own setpoint entity, rating and cut-out level, so no inverter is judged by another's numbers. “Inverter setpoint entity” must then be empty, and the add-on refuses to start when both are set. Each entry's name keys its own safety stop and records in the add-on's state, so two entries may not share one.
- **Usable battery capacity** (`inverters.capacity_kwh`): read only to share the site target by the energy each battery has left rather than by its rating. The reference batteries are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so the two ways of sharing give the work to different batteries. Stated on some entries and not others, the add-on refuses to start.
- **Solar production entities** (`pv_entity`, `pv_entities`, `pv_invert_entities`): measurement only, no control behaviour changes. The unit is read off each entity, so a sensor without one is refused rather than guessed. With several entities, one that stops reporting gives no site total at all, because a partial sum looks like a real one. Setting `pv_entities` ignores `pv_entity`.
- **Where the meter reading comes from** (`meter_source`): 'off' keeps “Grid power entity”. ha_dsmr follows the DSMR integration, mqtt_p1 reads an MQTT topic, ha_signed reads one signed entity (what a HomeWizard P1 publishes), and homewizard_local polls the meter's own local API. Only homewizard_local can tell "the meter did not answer" from "the value did not change", so the stuck-meter alarm only means something there.
- **Grid import and export entities** (`p1_import_entity`, `p1_export_entity`, `p1_net_entity`): the add-on computes net power itself, so the sign convention lives in tested code rather than in a template sensor.
- **Number of phases** (`meter_phases`): a reading whose phase count disagrees is rejected and logged as it arrives, so a wrong value shows up at once. The range accepts 2, which is not a real Belgian connection.
- **Time between meter reads, Reading too old after** (`meter_poll_s`, `meter_max_age_s`): the add-on refuses to start if the poll interval is at or above the age limit. 30 s is six polls of headroom at the default 5 s. The reading-age limit on its own does not command 0 W. “Missing inputs time limit” starts after it, so the two add up.
- **Inverter rating** (`max_w`): your own number, not a derived one. It is not read when the inverter list is filled in, since each entry carries its own and the site limit is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not.
- **Battery cut-out level** (`bms_soc_floor`, `inverters.bms_soc_floor`): measured, not chosen. Register 0x0560 reads 10 % on the reference GW5000S-BP. It exists so the add-on can refuse a “Peak reserve” at or below it, which could never take effect. Each inverter entry states its own, because one default for two different batteries protects the shallower one with the deeper one's number.
- **Peak limit** (`peak_cap_w`): your own number, from your grid contract. In Belgium it is the capacity-tariff peak your distribution charge is billed on.
- **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
- **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
- **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
- **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different).
- **Day/night levies and VAT** (`price_taxes_profile`): for Wallonia and Brussels, where distribution has a day and a night rate (ORES 13,27 against 7,39 c/kWh, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10). The figures are levies alone, added to the market price. The add-on refuses to start if it and the flat figure disagree. See [Levies and VAT by time of day](#levies-and-vat-by-time-of-day-price_taxes_profile).
- **Injection price** (`export_price_eur_kwh`): one number because no supplier here publishes a dynamic injection curve. In Belgium it is often near zero, and at a sunny midday it can be negative.
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
- **Trading keeps at least, Trading charges at most** (`arbitrage_soc_floor`, `arbitrage_soc_ceiling`): kept apart from the maintenance levels, so one setting does not couple two unrelated policies.
- **Draw-down and recharge rate** (`maintenance_discharge_w`, `maintenance_charge_w`): the draw-down rate above the rating is cut back, not refused. A recharge rate above the rating would ask for more than the limit allows and resolve to 0 W, so maintenance would never charge. That is why the add-on refuses it.
- **Draw down to** (`maintenance_soc_floor`): the monthly cycle goes this deep on purpose, while “Peak reserve” is a depth peak limiting never reaches. 11 % against 20 % is why they are two settings.
- **Wrong-direction threshold and time** (`sign_disagree_w`, `sign_disagree_s`): measured, not chosen (SAFETY-02). On the captured healthy day (2026-08-23) signs agree on 93.4 % of gated samples, on the fault day (2026-08-20) 0.0-10.5 %, with opposed runs up to 1752 s. The longest legitimate opposed run is 30 s, and the fault day still trips 13 times at 300 s, so 180 s sits in a wide gap. The threshold must stay below “Inverter rating”.
- **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit shortfall. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
- **Site wrong-direction threshold** (`site_sign_disagree_w`): the single-inverter 300 W carried across deliberately (SAFETY-30), because near zero the sign is noise whatever is behind the one meter.
- **Site wrong-direction time** (`site_sign_disagree_s`): derived from the same 1890 runs (SAFETY-30). The worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, so 180 s would false-trip. 325 s is 1.51 times that and inside the 900 s billing period.
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode).
- **Control gain** (`gain`): measured on real hardware and already at the limit.
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
- **Ignore errors below, Meter resting point** (`deadband_w`, `target_grid_w`): the meter's import counter bills every positive watt, so a resting point at 0 W leaks about half the deadband as billed import, roughly 0.2-0.35 kWh a day at 15 W. -10 W moves that leak to export.
- **At-limit shortfall** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
- **Catch-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
- **Command refresh interval** (`heartbeat_s`): the control board refuses a link that has been quiet for 30 s.
- **Longest time between cycles** (`cycle_floor_s`): keep it under “Reading too old after”, since past that there is no reading left to act on.
### Sources
| option | required | meaning |
@@ -144,9 +316,9 @@ either direction, and the check is mutation-tested both ways.
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities-tel-14) |
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity) |
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities) |
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities) |
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
those serve cached values, and a stale reading here ends the maintenance charge
@@ -202,7 +374,7 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
| `soc_entity` | | This pack's state of charge |
| `batt_entity` | | This unit's battery power, `+` = discharging |
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs-opt-09) |
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs) |
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
sensor** — it does *not* fall back to the flat one. That is deliberate: every
@@ -245,7 +417,9 @@ in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.**
#### How the site target is divided between the packs (OPT-09)
#### How the site target is divided between the packs
*For installers: card OPT-09.*
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
version before it the split is a fixed 50/50 regardless of what either pack can
@@ -352,7 +526,7 @@ accumulator is doing:
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
| `Loop reason` | — | the law's own word for this cycle |
`Loop reason` takes one of eight values:
`Loop reason` takes one of ten values:
| value | what happened |
|---|---|
@@ -364,10 +538,32 @@ accumulator is doing:
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
**When the battery refuses.** At its cut-out level the battery gives nothing,
whatever it is asked. The add-on then asks 0 W instead of repeating its last
request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
15 minutes it asks 200 W for about half a minute. Control resumes as soon as
the battery gives 100 W or more, when the grid needs charging instead, and when
control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request.
⚠️ **One inverter only.** With several inverters this hold is off. There, the
battery level limit moves an empty battery's share to the others, but only if
its “Battery cut-out level” matches where the battery really stops. A battery
that refuses at another level, or for another reason (cold, a battery fault),
can still hold its last request until the grid starts to export.
*For installers:* SAFETY-52. The event log reads `BATTERY REFUSES …` and
`BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`.
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures.
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
often disagree.** `Why` is the *arbiter's* sentence — `loop -> -1200 W` — which
names the strategy that won and the watts it won with. It has never carried the
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
near zero: -1 200 W* — which names the strategy that won and the watts it won
with. The log keeps the arbiter's own `loop -> -1200 W`. It has never carried the
strategy's own reason, so a loop that is slew-limited or frozen says so **only**
here.
@@ -425,10 +621,12 @@ and `Inverter B` — are two names to the validator and one id once slugged. The
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
get then depend on roster order. Give your inverters plainly different names.
### PV production (`pv_entity`, TEL-11)
### PV production (`pv_entity`)
*For installers: card TEL-11.*
> **More than one solar inverter?** Use `pv_entities` instead — see
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The
> [Several PV inverters](#several-pv-inverters-pv_entities) below. The
> rest of this section applies to both.
@@ -462,7 +660,9 @@ Only the third is a software defect, and **none of them can be told apart
without a record of production.** This entity is that record. The attribution
itself is OPT-10's, with a week of history behind it.
#### The sign, and `pv_invert_entities` (TEL-14)
#### The sign, and `pv_invert_entities`
*For installers: card TEL-14.*
**Positive must mean producing.** Some sensors — typically a kWh meter or an
ESPHome power clamp in the PV circuit — read negative while producing and a few
@@ -477,7 +677,7 @@ History recorded before you set it keeps the old sign. See
The add-on reads your entity's own `unit_of_measurement` and converts: `mW`,
`W`, `kW`, `MW`, `GW`, `TW` — Home Assistant's own power units. A sensor with
**no** unit, or with a unit that is not one of those, is **refused**, and the
status page says which entity and which unit. That is deliberate: there is no
status page says so (*the sensor has no unit*). That is deliberate: there is no
magnitude test that can separate 30 W of dusk from 30 kW of noon, so a guess
here would be a silent factor of 1000 in the one channel that exists to be
believed a week later.
@@ -500,7 +700,9 @@ pictures, which is exactly the distinction the whole card exists to preserve.
A failed read also **clears** the previous good one: the value never outlives
its own sensor.
### Several PV inverters (`pv_entities`, TEL-12)
### Several PV inverters (`pv_entities`)
*For installers: card TEL-12.*
A multi-string or multi-orientation roof routinely carries **two or three PV
inverters**, each publishing its own power sensor into Home Assistant. List them
@@ -534,7 +736,7 @@ about:
|---|---|---|
| all three reporting 1200 / 2100 / 450 W | `3750.0` | `PV 3 750 W` |
| all three reporting, one of them **0 W** | `1650.0` | `PV 1 650 W` |
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which entity, why, and what the others reported |
| one of the three `unavailable` | `unknown` | `PV —`, plus a caution naming which source is missing |
| all three gone | `unknown` | as above |
**It does not publish the sum of the ones that answered.** Two inverters out of
@@ -546,8 +748,8 @@ it say something false. So the total goes to `unknown` and the recorder stores a
gap, exactly as it does for a single dead sensor.
The diagnostic is not thrown away — it is put where a person will see it. The
status page names the entity that is missing, the reason, **and** what the
others reported. That page is read live by a human who wants to fix something;
status page names the missing source by its place in the `pv_entities` list
(*source 2 of 3*). That page is read live by a human who wants to fix something;
the recorded number is read cold by an analysis that would believe it.
Note the **third row against the second**: a member honestly reporting `0 W` is
@@ -569,7 +771,7 @@ It is **instrumentation**. It is not an input to the control law, the
allocator, the arbiter, the peak guard, the maintenance cycle or any watchdog;
it is not part of `inputs_ok`, so a PV integration that breaks cannot stop the
battery being regulated; and its status row is a **caution**, never a
`NOT READY`. Wiring production into the control law would be a behaviour change
*Not ready*. Wiring production into the control law would be a behaviour change
on a system driving a real battery — and it would destroy the measurement,
because the loop would then be reacting to the thing being measured.
@@ -791,8 +993,8 @@ repeats. Thresholding that at 30 s would rebuild the false-trip limit cycle at
the exact operating point the controller aims for. Freeze detection is a
separate problem and needs the low-power case solved first.
You read it yourself instead: the add-on's status page shows it on the P1 line,
as `… 4 rejected, measurement unchanged for 312 s`. On a house drawing real
You read it yourself instead: the add-on's status page shows it on the Meter
row, as `… 4 readings refused, unchanged for 5 minutes`. On a house drawing real
power that figure stays in the seconds; minutes of it while the load is clearly
not near zero is the meter to go and look at.
@@ -883,7 +1085,9 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
#### Shadow mode (`shadow_mode`, SAFETY-10)
#### Shadow mode (`shadow_mode`)
*For installers: card SAFETY-10.*
Shadow mode runs the **whole** decision path — the price strategy, the capacity
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
@@ -1050,11 +1254,13 @@ If the setpoint this add-on is emitting and the grid figure it is regulating on
point **opposite ways** — charging the battery while the house imports, or
discharging it while the house exports — with both over `sign_disagree_w`, for
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
there. The banner reads `FAILSAFE`, the commissioning list says which two
numbers disagreed and for how long, and the `Controller status` entity reports
`failsafe`.
there. The banner reads *The meter saw power go the wrong way*, the add-on
log says which two numbers disagreed and for how long, and the `Controller
status` entity reports `failsafe`.
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
#### ⚠️ At two or more inverters this is TWO checks, not one
*For installers: card SAFETY-30.*
The paragraph above describes what a **one-inverter** install does, and it is
unchanged: there is one meter, one actuator, and comparing the command against
@@ -1143,7 +1349,9 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
says so.
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
#### ⚠️ A unit that stops DELIVERING is a third fault again
*For installers: card SAFETY-42.*
The two checks above both key on **direction**. A unit sitting at **0 W against
a non-zero share** has no direction: it is not pushing the wrong way, it is
@@ -1194,7 +1402,8 @@ start, because a `persistent_notification` is in memory and that is precisely ho
the 2026-09-02 outage went unseen. It is cleared by pressing **Start**, like the
other latches.
The banner reads **`NOT DELIVERING — <unit> …`** as a *warning*, not a failure,
The banner reads **`<unit> is not delivering. Control keeps running.`** as a
*warning*, not a failure,
and it **displaces** `Running — holding grid at zero`. That last sentence is a
claim the site cannot make while a unit is absent, and a page that goes on
reading healthy while the fault runs is the whole shape this check exists to
@@ -1250,7 +1459,9 @@ correct toward that wrong value instead of holding a stale command. It is bounde
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
safety property.
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
#### When the freeze lets go, and when it deliberately never does
*For installers: card SAFETY-13.*
While the loop is frozen the command's **magnitude may fall but never rise**. That is
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
@@ -1428,7 +1639,7 @@ What exists today, and what does not:
| SoC guard on the loop | ❌ — the BMS is the only energy backstop |
On the legacy `meter_source: off` path there are no P1 telegrams, so there is no
quarter-hour average to guard, and the status page says **NOT COVERED**. There
quarter-hour average to guard, and the status page says *Peak guard: off*. There
the mitigation is still operational: set `max_w` no higher than the loop actually
needs (the tuning was measured at 2000 W and a 1.78 kW load step is tracked fine
there), fit the e-stop, and watch for the alarm.
@@ -1449,7 +1660,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
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
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
@@ -1525,8 +1736,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
capacity-peak guard will not spend the pack for money below 20 %"*, and
**nothing more**. See below for what that costs on a real evening.
- **When it binds you are told**, once per episode, with notification
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
go over the ceiling, so shedding load is
the only thing left that changes the bill. Failing to shave is never silent —
the euro figure is the whole point of the guard.
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A
@@ -1542,7 +1754,9 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
smaller, or your quarters are worse, raise it.
#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25)
#### ⚠️ What `peak_soc_floor` does **not** protect
*For installers: card SAFETY-25.*
**On a sustained-load evening this add-on will take your battery to the BMS
cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed
@@ -1565,7 +1779,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
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
up to 2000 W by the arbiter. The status page is telling you the truth about the
guard while the battery empties.
@@ -1630,7 +1844,9 @@ share no reason code, no notification id, no status row and no counter.
running - this is not the sign-disagreement failsafe, which commands 0 W. You
get a persistent notification (`goodwe_output_clamp`, replaced rather than
stacked), an `ERROR` in the log with the reason code `OUTPUT_CLAMP`, a row in
the event list, and a red status banner while it is engaged. Once it clears, a
the event list, and a red status banner while it is engaged. The message quotes
the command the loop computed and the value it was clamped to, both from the
cycle that clamped. Once it clears, a
warning row keeps the engagement count so the evidence does not vanish with the
condition. Consecutive engagements share one event row carrying the exact count,
because the event list keeps only 40 rows and a row per cycle would flush every
@@ -1639,9 +1855,11 @@ other guard's entries out of it.
**There is nothing to configure**, deliberately. The bound is `max_w`, which you
already set; giving the guard a limit of its own would be a second number that
can disagree with the first, which is exactly the failure the startup validation
exists to prevent. The alarm's cadence - first engagement, then re-asserted every
30 s while it persists, cleared after 3 clean cycles - is fixed for the same
reason `RAIL_FRAC` is: it describes the notification bus, not the plant.
exists to prevent. The alarm's cadence - normally at most one notification per
150 s (an episode that has not yet notified does so once it has engaged 3
cycles in a row, and an over-rating has its own 150 s budget), cleared after 3
clean cycles - is fixed for the same reason `RAIL_FRAC` is: it describes the
notification bus, not the plant.
WARNING: **on a default install the engagement you will see is the integrator's,
not the output's.** The control law bounds the integrator to `max_w` and *then*
@@ -1650,6 +1868,17 @@ rating unless `integrator_max_w` has been deliberately raised above `max_w`.
Both are watched. A guard that watched only the emitted value would be silent on
every standard configuration.
**The integrator bound is not a bug, and it is reported as what it is.** It
reads *"the correction the add-on builds up reached its +5000 W limit,
command now +2000 W"*, titled *"Battery is not keeping up"*, logged
as a `WARNING`, with an amber (not red) status row and its own notification
(`goodwe_integrator_bound`), so it never replaces a software-fault notice still
on screen. Brief engagements after a large load or solar step are normal. If it
stays engaged, check that the battery is following its commands. Only a command
over the rating is logged as a software fault, at `ERROR`, and it has its own
notification budget.
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
### The frozen-meter alarm
A meter that has died is easy — it stops answering, the sample age climbs past
@@ -1697,13 +1926,13 @@ the add-on UI.
⚠️ **Only `meter_source: homewizard_local` is covered.** It is the only transport
that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
`off` there is no signal to threshold and the status page says **NOT COVERED**
rather than pretending the guard is armed.
`off` there is no signal to threshold and the status page says *Stuck-meter
check: off* rather than pretending the guard is armed.
The same row appears for a second reason: a setpoint device that reports a range
from which no command magnitude is available in both directions leaves no rail
to measure at all, and a 0 W rail would be satisfied by *every* command. That is
reported as **NOT COVERED** too, and the detector stays silent rather than
reported as *off* too, and the detector stays silent rather than
degenerating into the bare "unchanged for N seconds" rule this design exists to
avoid.
@@ -1720,7 +1949,9 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
parked at 0 W and the two halves of the rule cannot both be true. At the schema
floor of 60 s that figure is 60 W and only the margin is left.
### The circulating-power alarm (SAFETY-31)
### The circulating-power alarm
*For installers: card SAFETY-31.*
> **Only at two or more inverters.** At the shipped roster of one this quantity
> is identically zero for every reading, no claim is ever filed, and the status
@@ -2187,7 +2418,9 @@ distinct state from "prices, all of them zero": the plan is marked provisional
and arbitrage is disabled, per the degradation matrix. It never falls back to a
price of zero, which would read as free electricity.
#### The hours the feed has not published yet (FCST-04)
#### The hours the feed has not published yet
*For installers: card FCST-04.*
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
@@ -2230,29 +2463,29 @@ firm/provisional split whenever that split is what refused a trade. And
since TARIFF-11 that row carries the provider plan itself:
```
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead,
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing,
provisional
Tariff: No fixed tariff set. Price plan from 412 s ago, for 48 hours ahead:
day-ahead prices from the price feed, 60 prices over 33 hours,
36 published and 24 estimated, one every 15 minutes or every hour
```
⚠️ **The two halves of that row are not computed at the same moment, and the
row says so.** Everything before the semicolon is the static-profile or Home
row says so.** Everything before "Price plan" is the static-profile or Home
Assistant price-entity sentence, regenerated on the **30 s tick** from options
alone — no network. Everything after it is the **background refresh's**
resolution, which runs every 1800 s and is served for up to 5400 s before it is
retired; `fetched N s ago` is the age of the fetch that produced it and is the
retired; `Price plan from N s ago` is the age of the fetch that produced it and is the
only figure in the row that tells you whether the feed is still answering. On a
site with a provider configured and no usable plan the row says that too,
rather than falling silently back to the static half.
Read the plan half as five separate facts: which *product* the feed publishes
and which *plugin* read it, how many **points** it carries and how many **whole
hours** those points cover, how many of the points are **firm** (published for
their own interval) against **carried** (filled from yesterday under §8.3), the
**spacing** of the points in seconds — `900+3600` is the normal mixed steady
state, quarter-hourly firm points beside hourly carried ones — and `provisional`
whenever any point is carried.
and whether it came from the feed or from your fixed tariff, how many
**prices** it carries and how many **whole hours** they cover, how many are
**published** (firm, for their own interval) against **estimated** (carried
from yesterday under §8.3), and how far **apart** they are — "one every 15
minutes or every hour" is the normal mixed steady state, quarter-hourly firm
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
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
@@ -2427,7 +2660,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
— configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one.
### The three buckets — which price components can move a decision (TARIFF-17)
### The three buckets — which price components can move a decision
*For installers: card TARIFF-17.*
**Read this before adding a price component of any kind.** The optimiser reads
exactly two quantities off a price series, and nothing else:
@@ -2483,7 +2718,9 @@ Three consequences worth having in mind:
is the only ORDER component the add-on has. Anything added next belongs in one
of the three, and saying which is part of adding it.
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18)
### What `price_taxes_eur_kwh` is made of, and why yours is different
*For installers: card TARIFF-18.*
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
Flemish residential bill — everything on your invoice that is *not* the energy
@@ -2787,18 +3024,270 @@ it.
| `estop_fitted` | Whether the RS485 e-stop is installed. Drives the warning banner |
| `log_level` | `trace`/`debug`/`info`/`warning`/`error` |
## Notifications
The add-on tells you about a problem with a Home Assistant notification. Each
one has a short title, one to three sentences, and a **Details** link to this
page. The link opens the add-on's Documentation tab. Look for the section with
the same name as the notification title below. A repeat of the same problem
replaces the notification rather than adding a new one.
In **shadow mode** the add-on makes every decision but sends nothing: every
inverter stays at 0 W. Notifications then say what the add-on *would* have
sent.
### Battery is not keeping up
The battery gives less power than the add-on asks for. This is normal for a
short time, for example just after a large load switches on or the sun comes
out. It is not a fault, and control keeps running. If it keeps coming back,
check that the battery is switched on, and that it is not empty (when it should
give power) or full (when it should charge).
### Power request too high
The add-on worked out a power request above what is allowed, and cut it back
before sending it. The notification shows what was asked and what was sent,
and which limit cut it: the “Inverter rating” setting, or the inverter's own
limit. In shadow mode it shows what *would* have been sent. Control keeps
running. This should never happen, so it is a software fault: please send the
notification and the add-on log to your installer.
### Load too high for battery
Your home uses more power than the battery can supply, so this 15-minute
period will go over your peak limit and raise this month's peak. The battery
is already giving all it can. This is not a fault. Switch off large loads,
such as an oven, a car charger or a heat pump, for the next few minutes. In
shadow mode the battery gives nothing, so this can happen with a much smaller
load.
### Battery stopped limiting the peak
The add-on stopped using the battery to keep your peak down, so this
15-minute period will go over your peak limit and raise this month's peak.
Switch off large loads for the next few minutes. The notification says why,
and what to do:
- **The battery is at its peak reserve.** The “Peak reserve” setting keeps
that much charge back from peak limiting. Lower it if you accept a lower
battery level.
- **The battery level cannot be read.** Check the “Battery level entity”
setting and that sensor in Home Assistant.
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
for each inverter in the settings.
The peak reserve only applies to keeping the peak down. Normal home use can
still draw the battery below it, down to the battery cut-out level, where the
battery switches itself off.
### Inverters working against each other
With several inverters, one was charging its battery from another. Your meter
cannot see this, because the two flows cancel out, but it wastes energy. The
add-on has stopped control and set every inverter to 0 W. If the grid power
does not change after that, switch the inverters off at the isolator. A
restart does not clear this. Find out why, then start control on the add-on
page.
### An inverter is not delivering
With several inverters, one gives much less power than it is asked for, while
the others do. Nothing is changed: control keeps running, and the other
inverters carry on. Check, in this order:
1. Is the inverter on and connected?
2. Is its battery empty or cold? If it is emptier than its “Battery cut-out
level” says, that setting does not match this battery.
3. Is anything else controlling it?
A restart does not clear this. Once you know why, stop and then start control
on the add-on page.
### Meter reading may be stuck
The meter keeps answering, but its value has not changed for a long time while
the add-on asks the battery for full power. That usually means the meter
reading is stuck. Control keeps running and keeps following the reading, so a
stuck reading can raise this month's peak. Check the meter. If it is stuck,
stop control on the add-on page.
### For installers: notifications
This part is for installers. The texts live in `app/messages.py` and follow
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
replaces the card. The ERROR or WARNING line of the add-on log names the
reason code. The full figures are in `events` in the `status` JSON, also for a
stop restored after a restart, and on the DEBUG line logged right after it.
That DEBUG line only shows with “Log level” (log_level) set to `debug`; the
default `info` hides it.
| Title | Severity | id | Card |
|---|---|---|---|
| Battery is not keeping up | warning | `goodwe_integrator_bound` | SAFETY-40 |
| Power request too high | alarm | `goodwe_output_clamp` | SAFETY-03 |
| Load too high for battery | alarm | `goodwe_peak_saturated` | SAFETY-07 |
| Battery stopped limiting the peak | alarm | `goodwe_peak_soc_floor` | SAFETY-24/25/32 |
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
`/hassio/addon/<slug>/documentation`, which 2026.1 and older still use. At
start the add-on reads its slug from `GET /addons/self/info` and the Home
Assistant version from `GET /info`, and builds the link for that version. If
the version cannot be read it uses the new path. If the slug cannot be read it
falls back to the container hostname, and outside an add-on the line names the
section without a link. The documentation tab adds no heading anchors, so the
link cannot jump to the section.
**Battery is not keeping up.** The grid-following loop's integrator reached its
anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the
rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
winding-loop alarm*.
**Power request too high.** A computed command above a limit was cut before it
was written (SAFETY-03). The limits are `max_w` (label “Inverter rating”), the
setpoint device's own range, or the rating on the loop side. Asked always comes
first. The old wording was "BUG SIGNAL". It is not the inverter failing to
follow a legal command, which is the saturation freeze and does not alarm. In
shadow mode the clamp still grades the decision (SAFETY-10), so the alarm can
fire while 0 W is on the wire, and the text says "would have sent". ERROR line,
reason code `OUTPUT_CLAMP`.
**Load too high for battery.** The capacity-peak guard (SAFETY-07) asks for
everything the inverter can discharge and the running 15-minute average is
still projected over the peak limit, so the month's billed peak
(capaciteitstarief) goes up. In shadow mode the guard's projection is made on a
plant that did not respond (SAFETY-47), and the text says so. See *The
capacity-peak guard*.
**Battery stopped limiting the peak.** The peak guard may not discharge below
`peak_soc_floor` (label “Peak reserve”, SAFETY-24). It also stops when the
state of charge is unread, or when an inverter's `bms_soc_floor` (label
“Battery cut-out level”) is missing, because the reserve is graded against it.
Charging from the grid stays blocked meanwhile. With several inverters the
notification names the battery at its limit (SAFETY-32), each graded against
its own cut-out level. ⚠️ **The reserve binds the peak guard only
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
does not protect*.
**Inverters working against each other.** SAFETY-31. The flows cancel at the
P1 meter, so every other guard, which reads that net figure, stays quiet. This
has happened on this hardware once for 6 h 25 min. The alarm is latched,
survives restarts, and is raised again every 5 min until control is started.
The watts and the time window are in the log. The *Check for inverters working
against each other* row shows the live watts. See *The circulating-power alarm*.
**An inverter is not delivering.** SAFETY-42. The unit stood well short of its
share of the site target for longer than `delivery_hold_s`. A unit at 0 W is
absent rather than pushing the wrong way, so the per-unit sign check and the
circulation check stay quiet. The guard only reports: commanding an inverter
that has stopped listening achieves nothing. Check first whether it answers on
RS485. The alarm is latched and survives restarts; stop then start clears it.
See *A unit that stops DELIVERING is a third fault again*.
**Meter reading may be stuck.** SAFETY-18. The value has not changed for
longer than `meter_freeze_s` while the command stands at the power limit, and
the age is still fresh, so no other guard stops it. Only `meter_source:
homewizard_local` can tell a silent meter from an unchanged value. The limit
and command figures are in the log. See *The frozen-meter alarm*.
## The Web UI
The ingress panel shows live values, why the controller is commanding what it
is, and a **Commissioning** checklist that names any problem in words. It also
carries the three buttons: start/stop control, force a maintenance cycle, and
abort one.
The add-on page shows live values, why the battery does what it does, and a
**Commissioning** checklist that names any problem in words. It also has three
buttons: start or stop control, force a maintenance cycle, and abort one.
Under the buttons is the last thing that happened, in a few words.
Since **UX-02** it has a second page, **Configuration** — see *There are TWO
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
inverter.** The configuration page reads and writes the add-on's own settings
and can restart the add-on; the set of commands that reach the hardware is
exactly the three buttons it always had.
It has a second page, **Configuration**. See *There are TWO places to configure
this add-on*, above. **It adds nothing you can do to the inverter.** It reads
and writes the add-on's own settings and can restart the add-on. Only the three
buttons reach the inverter.
*For installers: the Configuration page is UX-02. The line under the buttons is
`messages.hint()`, the plain form of the newest entry of the add-on's own
action log (`Controller.events`). The page no longer shows that log, but the
`status` JSON still carries its last 40 entries as `events`: open the add-on
page and add `status` to its address. The action-log tables in this document
refer to it.*
## Status page
The add-on page shows a banner at the top and a **Commissioning** list below
it. The banner says in one line what matters most now. Each row of the list is
one check: ✓ green is fine, ! amber needs a look, ✗ red needs action. Rows name
settings by their label, never by a Home Assistant entity id.
In **shadow mode** the banner says so first, whatever else is going on: the
add-on makes every decision but the battery is not controlled, and every
inverter stays at 0 W. If the banner is red and says a safety stop stays on,
one of the red rows below is a stop that will still be there when shadow mode
ends. That row says how to clear it.
| Row starts with | What it tells you |
|---|---|
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. 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. |
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
| *name*: … cannot be controlled, commands refused, “Setpoint entity” is not set | Whether each inverter still takes commands. |
| Power request too high, Battery is not keeping up | See those titles under Notifications. Each shows how often it happened since the add-on started, and *fine now* once it has cleared. In shadow mode the row says what *would* have been sent. |
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
### For installers: status page
The texts are in `app/messages.py`. An alarm's ERROR or WARNING line names its
reason code, and its full figures are on the DEBUG line right after it and in
`events` in the `status` JSON.
- **Stuck-meter check: off** needs `meter_source: homewizard_local` (label
“Where the meter reading comes from”), a `max_w` (“Inverter rating”) and a
power range the inverter can reach. *The inverter's own limit* means the
setpoint device's range sets the threshold, below `max_w`.
- **Peak guard: off** means `meter_source` is `off`, so there is no 15-minute
average to guard.
- **Battery level / Battery power / Grid power** are `soc_entity`,
`batt_entity` and `meter_entity`. *No usable reading* is SAFETY-56: missing
**or** held back as stale, and a separate stale-reading row then says which.
- **Inverter control** is the `setpoint_entity` and the range it accepts.
- ***name* cannot be controlled** (red): every write was rejected for
`LINK_STALE_S`, so the unit is dropped and the others carry its share
(DEV-02). ***name*: commands refused** (amber) is the same before that limit.
***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can
write, so the site refuses to run at part strength and holds 0 W.
- **Direction check, Site direction check** are the sign checks (SAFETY-02/30).
**Check for inverters working against each other** is SAFETY-31, **Delivery
check** SAFETY-42.
- **Month peak** and **15-minute periods measured** are TARIFF-01 and
TARIFF-13. The store's state at start is the INFO line `Month peak store:
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line says when it clears and names `STALE_READING`. The full reason,
with both possible causes, is on the DEBUG line after it.
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
meter goes quiet, "No meter reading for …", and an INFO line when readings
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
line names the missing reading.
- The table at the top: **Why** is `messages.why()`, the arbiter's answer in
words (`Resolution.explain()` stays on the log). **Tariff**, **Price
history**, **Arbitrage** and **Shadow mode** keep every figure they had
(TARIFF-11), in words.
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
(circulating power, or a site or per-inverter sign trip), and that latch keeps
its own red row.
## Status entities
@@ -2807,7 +3296,9 @@ battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control.
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
## Before you commission inverter B
*For installers: card ARCH-02. The tool is `tools/rehearse_roster.py`.*
The day the `inverters:` roster is declared, two things change that are
**one-way on a live box and invisible until after the restart that applies
@@ -2988,7 +3479,9 @@ maintenance bookkeeping — and if a cycle ran during it, correct
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
decision, not a discovery made at the inverter.
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
## Before you deploy
*For installers: card PKG-09. The tool is `tools/predeploy_check.py`.*
Two ways a deploy has gone wrong on this system, both of which the Supervisor
reports as success:
+4 -4
View File
@@ -246,11 +246,11 @@ class Allocation:
block the failsafe is worse than the hole it closes.
"""
if tick is None or self.tick is None or self.tick != tick:
return (f"the allocation record on hand was made on tick "
f"{self.tick!r} and this is tick {tick!r} - a record whose "
f"envelopes have moved cannot authorise a write")
return (f"the power split on hand is from tick {self.tick!r}, "
f"not this tick {tick!r}, and an old split cannot allow a "
f"write")
if not self.ok:
return f"the allocator refused this tick ({self.explain()})"
return f"no power split was possible this tick ({self.explain()})"
if unit not in self.shares:
return (f"{unit} has no share in this tick's allocation "
f"({self.explain()})")
+2 -4
View File
@@ -262,10 +262,8 @@ def threshold_eur_kwh(opts: dict) -> float:
v = float(price_option(opts, "cycle_cost_threshold_eur"))
if not math.isfinite(v) or v < 0:
raise ValueError(
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
f"in EUR/kWh, so it is non-negative and finite - a negative "
f"threshold is one every spread clears, which is arbitrage with the "
f"brake removed.")
f"“Battery cycle cost” (cycle_cost_threshold_eur) is {v!r}. Set "
f"it to 0 or more, in EUR/kWh.")
return v
+5 -4
View File
@@ -325,12 +325,13 @@ class CirculationWatchdog:
return False
self.tripped = True
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
self.reason = (
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
f"continuously - the meter cannot see it, because it nets to zero "
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
f"{self.cfg.hold_s:.0f} s)")
f"the inverters for {held:.0f} s, peaking at {self.peak_w:.0f} W, "
f"over the limits of {self.cfg.min_w:.0f} W and "
f"{self.cfg.hold_s:.0f} s. The meter cannot see it, because it "
f"nets to zero there")
return True
def held_s(self, now: datetime) -> float:
+102 -24
View File
@@ -73,6 +73,7 @@ An alarm wired only to this module's `ClampResult.engaged` would therefore be
silent on the exact configuration the whole ticket is about. The winding is
visible one layer up, at the integrator bound, and that is why `Decision` now
carries `i_clamped` / `clamped` BOOLEANS and the caller ORs them in here.
They alarm in different words: see `loop_clamp_note()` (SAFETY-40).
AND WHY BOOLEANS RATHER THAN `Decision.reason`. `reason` is a single string that
later stages overwrite: a cycle that clamps the integrator and is then slew
@@ -141,6 +142,8 @@ import math
from dataclasses import dataclass
from datetime import datetime
from . import messages
# The reason code, distinct from any saturation-freeze code by construction:
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
REASON_CODE = "OUTPUT_CLAMP"
@@ -200,9 +203,8 @@ REASON_CODE = "OUTPUT_CLAMP"
# ceiling rests on one number a test author chose; if that number is ever
# revisited, this constant moves with it.
#
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
# ⚠️ THIS WAS A STOP-GAP; the wording and the numbers were SAFETY-40's, fixed
# in `loop_clamp_note()` below. Nothing here is
# suppressed: every engagement is still counted, the action-log row is written on
# every engaged cycle regardless, and the rising edge of every episode alarms
# unconditionally, so no alarm is DROPPED - only delayed.
@@ -234,6 +236,46 @@ CLEAR_CYCLES = 3
SUSTAINED_CYCLES = 3
# UX-10: the reasons below are quoted by a WARNING or ERROR line, so they
# follow STYLE.md's rule for a quoted reason (test_messages.py part 6): no
# final full stop, the line adds it. The line around them says "software
# fault" for an over-rating, so the reason does not. Each is one sentence, so
# the logged line stays within 3 sentences and about 30 words.
def loop_clamp_note(d) -> str:
"""The loop-side alarm text for one `control.Decision`, or "".
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
only a command over the rating is a software fault. Every number is the
decision's own, i.e. from the tick that clamped.
"""
if d.clamped:
return (f"{REASON_CODE}: computed {d.requested_w:+.0f} W, cut to the "
f"{d.clamped_w:+.0f} W rating, command now {d.target_w:+.0f} W")
if d.i_clamped:
return (f"{REASON_CODE}: the correction the add-on builds up reached "
f"its {d.i_bound_w:+.0f} W limit, command now "
f"{d.target_w:+.0f} W")
return ""
def write_clamp_note(named) -> str:
"""The write-side alarm text: `named` is `[(inverter name or None,
ClampResult)]` for every inverter whose write was cut back this cycle.
One inverter reads as `describe()`. Several read as one sentence of
asked/sent pairs, so the line stays one sentence however many inverters
there are. Which limit cut each one back is on its own action-log row
(`describe()`) and in the notification."""
if len(named) == 1:
name, r = named[0]
return f"{name}: {r.describe()}" if name else r.describe()
return f"{REASON_CODE}: " + ", ".join(
f"{name} asked {r.requested_w:+.0f} W and sent {r.applied_w:+.0f} W"
for name, r in named)
@dataclass(frozen=True)
class ClampResult:
"""What the guard did, with the RAW value preserved.
@@ -250,10 +292,12 @@ class ClampResult:
bound: str = "" # "" | "max_w" | "device" - which stage bit
def describe(self) -> str:
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} "
f"bound (rating {self.limit_w:.0f} W) - something upstream is "
f"winding")
# The sent value IS the limit that bound, so the rating is not
# repeated: on "max_w" it equals the rating, and on "device" the
# rating was not what cut it back.
return (f"{REASON_CODE}: asked {self.requested_w:+.0f} W and sent "
f"{self.applied_w:+.0f} W, "
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}")
def is_commandable(value) -> bool:
@@ -333,32 +377,72 @@ class ClampWatchdog:
def __init__(self):
self.active = False
self.engagements = 0 # every engagement, never deduplicated
# UX-08: the same count split by alarm type, so the status page names
# each with its own alarm's title after the episode clears.
self.over_rating = 0 # bug=True: "Power request too high"
self.bound = 0 # bug=False: "Battery is not keeping up"
self.episodes = 0 # rising edges
self.since: datetime | None = None
self.last_alarm: datetime | None = None
self.reason = ""
# UX-08: the numbers behind `reason`, for the notification - set under
# the same rule as `reason`, so both always describe the same tick.
self.facts = ()
self.clean_cycles = 0
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
# spoken yet - the two the escalation clause needs.
self.run_len = 0
self.spoken = False
# SAFETY-40. Did this episode include a genuine over-rating (`bug`), and
# has THAT spoken? A bug has its own budget, so anti-windup notices can
# never delay it; with bug=True throughout, cadence is as before.
self.bug = False
self.bug_spoken = False
self.last_bug_alarm: datetime | None = None
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
def _due(self, last, now) -> bool:
return last is None or (now - last).total_seconds() >= REASSERT_S
def _speak(self, now, bug) -> bool:
self.last_alarm = now
self.spoken = True
if bug:
self.last_bug_alarm = now
self.bug_spoken = True
return True
def note(self, engaged: bool, reason: str, now: datetime,
bug: bool = True, facts=()) -> bool:
"""Advance one cycle. True when the caller should raise the alarm.
`now` is the caller's clock, the same discipline as
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`.
`SignWatchdog.evaluate()` and `FreezeWatchdog.evaluate()`. `bug` False
is the integrator bound (SAFETY-40): counted and notified like the
rest, but it never replaces a bug's text within an episode.
"""
if engaged:
self.engagements += 1
if bug:
self.over_rating += 1
else:
self.bound += 1
self.clean_cycles = 0
self.reason = reason
self.run_len += 1
if not self.active:
rising = not self.active
if rising:
self.active = True
self.since = now
self.episodes += 1
self.spoken = False
self.bug = False
self.bug_spoken = False
if bug or not self.bug:
self.reason = reason
self.facts = tuple(facts)
self.bug = self.bug or bug
if bug and not self.bug_spoken and self._due(self.last_bug_alarm, now):
return self._speak(now, True)
if rising:
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
# not used to be. It fired unconditionally, so a condition that
# engages and clears repeatedly raised one notification PER
@@ -384,11 +468,8 @@ class ClampWatchdog:
# still written on every engaged cycle, and a genuine runaway -
# which by definition keeps engaging - is picked up by the
# re-assert path within REASSERT_S. Delay, not loss.
if (self.last_alarm is None
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
self.last_alarm = now
self.spoken = True
return True
if self._due(self.last_alarm, now):
return self._speak(now, self.bug)
return False
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
# budget was spent by an EARLIER, unrelated episode speaks anyway
@@ -396,15 +477,10 @@ class ClampWatchdog:
# engages every cycle and reaches it; the scattered one-cycle
# railing that produced the live flood never does.
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
self.last_alarm = now
self.spoken = True
return True
return self._speak(now, self.bug)
# Still engaged: re-assert on the slow cadence, not every cycle.
if (self.last_alarm is None
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
self.last_alarm = now
self.spoken = True
return True
if self._due(self.last_alarm, now):
return self._speak(now, self.bug)
return False
self.run_len = 0
@@ -420,5 +496,7 @@ class ClampWatchdog:
# above. The episode ends; the notification budget does not reset
# with it.
self.reason = ""
self.facts = ()
self.bug = False
self.clean_cycles = 0
return False
+3 -4
View File
@@ -413,10 +413,9 @@ function showProblems(payload){
save.disabled = total > 0;
if(total){
note(banner, 'bad', 'This configuration would not start.',
'The add-on refuses to run on it and would stop with exit 78. '
+ 'Saving is blocked until these are fixed; nothing has been written. '
+ 'Each message below is the add-on\\u2019s own words, from the same '
+ 'check that runs at startup.');
'The add-on would refuse to start on it. Saving is blocked until these '
+ 'are fixed, and nothing has been written. Each message below comes '
+ 'from the same check that runs at start.');
banner.insertBefore(banner.lastChild, banner.firstChild);
msg(total + (total === 1 ? ' problem' : ' problems')
+ ' - saving is blocked', 'bad');
+4 -1
View File
@@ -289,7 +289,10 @@ def problems_by_field(problems, keys):
cross-field rule is about all of its fields, and hiding it from one of them
would be this card's own defect in miniature.
"""
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
# ⚠️ `.` BEFORE THE KEY DOES NOT COUNT (UX-08 2d): `inverters.max_w`
# names a setting of one inverter in the list, which is changed under
# `inverters`, and it must not also land beside the flat `max_w`.
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_.])" + re.escape(key)
+ r"(?![A-Za-z0-9_])")) for key in keys]
by_key, unanchored = {}, []
for problem in problems:
+67 -1
View File
@@ -265,6 +265,34 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
# `slew_limit_w()` and therefore with the rail.
SLEW_LEAD_STEPS = 1
# ---------------------------------------------------------------------------
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
#
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
# saturation freeze below holds the command where it stood (the recorded
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
# carried between cycles like `sat_count`, and left again when the loop wants
# the other direction, when a small probe is answered, or when the caller
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
#
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
# test_refusal.py pins the inequality. The cadence and length are the owner's
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
# (17 s is how long a healthy reading needs to show a command).
REFUSED = "battery refuses"
PROBING = "battery probe"
PROBE_W = 200.0
PROBE_ANSWER_W = PROBE_W / 2
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
# (review of #141). The capture's refusals read -4 W.
REFUSED_BELOW_W = PROBE_ANSWER_W
PROBE_EVERY_S = 900.0
PROBE_S = 34.0
@dataclass(frozen=True)
class Tuning:
@@ -340,6 +368,15 @@ class Decision:
# the comment at the bound below, and clamp.py.
i_clamped: bool = False
clamped: bool = False
# SAFETY-40. The alarm's numbers, taken at the clamp so they are one tick's:
# the bound the integrator reached, and the command before/after the
# rating clamp. `target_w` is this tick's emitted command.
i_bound_w: float = 0.0
requested_w: float = 0.0
clamped_w: float = 0.0
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
# charge, 0 none), or None when the caller does not carry it.
refused: int | None = None
def slew_limit_w(tuning: Tuning) -> float:
@@ -436,6 +473,8 @@ def compute(
tuning: Tuning,
sat_count: int = 0,
i_w: float | None = None,
refused: int | None = None,
probe: bool = False,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -443,6 +482,9 @@ def compute(
`grid_w` net grid power, + = importing
`actual_w` what the inverter reports it is doing, + = discharging
`i_w` the carried integrator, or None to seed it from `prev_w`
`refused` SAFETY-52's carried refused direction, or None to leave it out
(the `i_w=None` equivalence path does)
`probe` the caller's clock says a refused direction may try PROBE_W now
"""
reason = "tracking"
@@ -563,6 +605,7 @@ def compute(
# winding, "clamped" is a command that came out over the rating anyway.
reason = "i-clamped"
i_w = bounded
i_bound_w = bounded if i_clamped else 0.0
want = i_w
# ⚠️ Maintenance shaping (charge-only, cheap-window floor) used to live
@@ -574,6 +617,7 @@ def compute(
# An early draft applied a floor after the clamp and let demand escape it.
target = max(-tuning.max_w, min(tuning.max_w, want))
clamped = target != want
clamped_w = target
if clamped:
reason = "clamped"
@@ -731,6 +775,27 @@ def compute(
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
# limit because it only removes an ask the battery is not meeting.
if refused is not None:
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w) < REFUSED_BELOW_W):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * actual_w >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
wants = refused * i_w > 0
i_w = 0.0
target = refused * PROBE_W if probe and wants else 0.0
reason = PROBING if probe and wants else REFUSED
# --- quantise ----------------------------------------------------------
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
@@ -740,7 +805,8 @@ def compute(
target = round(target / step) * step
return Decision(float(target), sat_count, frozen, reason, float(i_w),
i_clamped, clamped)
i_clamped, clamped, float(i_bound_w), float(want),
float(clamped_w), refused)
def maintenance_charge_floor(
+64 -212
View File
@@ -175,6 +175,8 @@ import math
from dataclasses import dataclass
from datetime import date, datetime, timezone
from . import messages
class FormulaInvalid(Exception):
"""`price_formula` cannot be used. Raised by the parser, refused by
@@ -199,7 +201,7 @@ RESOLUTIONS: tuple = ("hourly", "quarter_hourly")
# index id -> the human name of the series it refers to. One entry today; a
# second index is a new row here plus a fetch path, not a new grammar.
INDEXES: dict = {
"epex_be": "EPEX BE day-ahead, EUR/MWh",
"epex_be": "Belgian day-ahead price, EUR/MWh",
}
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
@@ -285,87 +287,28 @@ _LEGS = {
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}'),
when="Leave it empty unless price_provider is 'custom_formula'.",
zero_factor=("every spread is exactly 0, and arbitrage can never "
"clear any threshold - a strategy that silently never "
"fires rather than one that is off"),
flat=("that is `static_profile` with a single period covering "
"00:00-24:00 on all days and price_provider 'off'; the add-on "
"then says so at startup instead of leaving you to find out "
"from a year of unchanged bills"),
neg_factor=("No consumption tariff is indexed negatively; if your "
"card shows a minus sign it is on the OFFSET (`A`), "
"which is allowed here and common on injection"),
when="Leave it empty unless “Dynamic price source” is 'custom_formula'.",
zero_factor=" and trading never finds a price difference",
flat="use “Fixed or day/night tariff” (static_profile) instead",
neg_factor="Put the minus sign on the offset, not on the factor",
card_required=True,
card_note=("It is the only thing that can tell a correct pair of "
"coefficients from a plausible-looking wrong one"),
card_causes=(
"or - the expensive one - network charges, levies or excise "
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
"card's Tariefformule line is the ENERGY component alone (Eneco "
"tabulates network tariffs and levies separately; ENGIE says in "
"so many words that the formula excludes network, distribution, "
"transport, levies and excise), the add-on adds "
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
"network charge into `offset` bills you for it twice. That is "
"the error this check exists to catch, and it is why it cannot "
"be switched off."),
card_note="The add-on needs it to check your two numbers",
card_causes=("the units, the VAT, and that network charges are not "
"in the offset"),
),
EXPORT_FORMULA_OPTION: _Leg(
option=EXPORT_FORMULA_OPTION,
what="injection",
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
# none to copy - the formula is printed with no price beside it. An
# example carrying one would invite an operator to compute the figure
# themselves, which is the one thing this leg must not encourage.
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
when=("Leave it empty unless your supplier pays you an injection "
"price that moves with the market; a flat one is "
"export_price_eur_kwh. ⚠️ This option is read whatever "
"price_provider is set to - it is the leg you are PAID on, not "
"the one you buy on."),
zero_factor=("every hour pays the same, which is a FLAT injection "
"price written as a formula. ⚠️ Note this is NOT the "
"arbitrage argument the consumption leg gives: no "
"strategy in this add-on reads an injection price at "
"all, so a flat one here fires nothing and declines "
"nothing - it is simply the wrong option"),
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
"which is what every site used before this option existed and "
"which still answers when this one is empty"),
neg_factor=("No Belgian injection tariff is indexed negatively - you "
"are paid MORE when the market is dearer, not less. The "
"minus sign on an injection card is on the OFFSET (`A`): "
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
"`0,07 x Belpex - 2`, and a negative offset is accepted "
"here"),
when="Fill it in only if your injection price follows the market.",
zero_factor="",
flat="use “Injection price” (export_price_eur_kwh) instead",
neg_factor="Put the minus sign on the offset, not on the factor",
card_required=False,
card_note=(
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
"formula with the reference index and the resulting price beside "
"it, which is what makes `price_formula`'s self-check possible. "
"Eneco's injection line has NO printed price beside it, so on "
"that card there is nothing to grade against and requiring a "
"`card` block would make the market's own published formula "
"inexpressible - or, worse, invite an operator to compute the "
"estimate themselves, at which point the check grades the "
"coefficients against arithmetic done with the same coefficients. "
"Supply it if YOUR card prints one; when it is absent the add-on "
"says at startup that this leg is UNGRADED and the two "
"coefficient bounds below are all there is"),
card_causes=(
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
"Belgian card the research found, and a 6 typed here because it "
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
"is added to this leg by the framework: price_taxes_eur_kwh is a "
"CONSUMPTION component and is not applied to what you are paid "
"for export, so an offset carrying network charges is a "
"different error here than it is on price_formula and this "
"check cannot tell you which one you made."),
card_note=("It is optional here. Without it the add-on only checks "
"the size of your two numbers"),
card_causes="the units, and that the VAT is 0 for injection",
),
}
@@ -488,16 +431,12 @@ def _finite(value, field: str, option: str = "price_formula") -> float:
"""
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise FormulaInvalid(
f"{option}.{field} is {value!r}, which is not a number. "
f"Write the coefficient as a bare JSON number - 0.102, not "
f"\"0.102\" and not an expression: this provider takes FIXED "
f"coefficients and evaluates nothing.")
f"{option}.{field} is {value!r}, which is not a number. Write a "
f"plain number, for example 0.102.")
if not math.isfinite(float(value)):
raise FormulaInvalid(
f"{option}.{field} is {value!r}, which is not finite. A NaN "
f"or an infinity here makes every price NaN, every price "
f"comparison False, and an arbitrage strategy that never fires "
f"while reporting that it is armed.")
f"{option}.{field} is {value!r}, which is not finite. Write a "
f"plain number, for example 0.102.")
return float(value)
@@ -517,11 +456,8 @@ def _card(raw, leg: _Leg = None) -> tuple:
if not leg.card_required:
return ("", None, None)
raise FormulaInvalid(
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
f"{leg.card_note}: the add-on recomputes your "
"supplier's own printed example from the numbers you typed and "
"refuses to start if it does not come out. Copy the three figures "
"off the same tariff card as the coefficients: "
f"{leg.option} has no \"card\" block. {leg.card_note}. Copy "
"the three figures from your tariff card, for example "
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
"\"expect_incl_vat\": 15.55}.")
if not isinstance(raw, dict):
@@ -531,16 +467,13 @@ def _card(raw, leg: _Leg = None) -> tuple:
unknown = sorted(set(raw) - set(_CARD_KEYS))
if unknown:
raise FormulaInvalid(
f"{leg.option}.card has no field(s) {unknown}. The fields it "
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
f"number the operator believes took effect.")
f"{leg.option}.card has unknown field(s) {unknown}. It takes "
f"only {list(_CARD_KEYS)}.")
month = str(raw.get("month", "") or "").strip()
if not _month_date(month):
raise FormulaInvalid(
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
f"not used in any price - it is what lets the add-on tell you "
f"your card has gone stale.")
f"{leg.option}.card.month is {raw.get('month')!r}. Write the "
f"month of the card as \"YYYY-MM\", for example \"2026-09\".")
return (month,
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
leg.option),
@@ -594,10 +527,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
value = json.loads(text)
except ValueError as e:
raise FormulaInvalid(
f"{option} is not valid JSON ({e}). It is a JSON object "
f"on one line, e.g.\n"
f" {leg.example}\n"
f"{leg.when}")
f"{messages.not_valid(option, e)}. Copy the example from the "
f"documentation. {leg.when}")
if not isinstance(value, dict):
raise FormulaInvalid(
f"{option} is {type(value).__name__}, not a JSON object with "
@@ -612,34 +543,21 @@ def parse_price_formula(value, *, option: str = "price_formula"):
unknown = sorted(set(value) - set(_TOP_KEYS))
if unknown:
raise FormulaInvalid(
f"{option} has no field(s) {unknown}. The fields it takes "
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
f"takes an expression: this provider multiplies and adds two "
f"FIXED coefficients and evaluates nothing, because two "
f"coefficients can be graded against your card's printed price "
f"and an arbitrary expression cannot be graded against anything. "
f"If your contract needs a shape this cannot express, it needs a "
f"new named field here - say which shape.")
f"{option} has unknown field(s) {unknown}. It takes only "
f"{list(_TOP_KEYS)}.")
index = str(value.get("index", "") or "").strip()
if index not in INDEXES:
raise FormulaInvalid(
f"{option}.index is {value.get('index')!r}. The reference "
f"series this add-on can fetch are "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
f"If your card names a different index, the add-on cannot get it "
f"and guessing which series your supplier meant would price your "
f"battery against the wrong curve.")
f"{option}.index is {value.get('index')!r}. The add-on can only "
f"fetch "
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}.")
resolution = str(value.get("resolution", "") or "").strip()
if resolution not in RESOLUTIONS:
raise FormulaInvalid(
f"{option}.resolution is {value.get('resolution')!r}; it "
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
f"a day; a contract written against the HOURLY series is priced "
f"on the mean of each hour's quarters. Which one your contract "
f"uses is on your card and cannot be inferred from the feed.")
f"{option}.resolution is {value.get('resolution')!r}. Choose one "
f"of {list(RESOLUTIONS)}, as your tariff card says.")
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
# factor-1000 class with a friendly face: the add-on would silently assume
@@ -648,12 +566,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
units = str(value.get("units", "") or "").strip()
if units not in UNITS:
raise FormulaInvalid(
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
f"there is no default, on purpose. It must be one of "
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
f"and two suppliers use two different conventions for the same "
f"shape, so assuming one would be a factor of 100 or 1000 on "
f"every hour with nothing able to notice.")
f"{option}.units is {value.get('units')!r}. Choose one of "
f"{sorted(UNITS)}, as printed beside the formula on your card.")
factor = _finite(value.get("factor"), "factor", option)
offset = _finite(value.get("offset"), "offset", option)
@@ -667,10 +581,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
# the operator at it rather than let them build it by accident.
if factor == 0.0:
raise FormulaInvalid(
f"{option}.factor is 0, so the index would not enter the "
"price at all: every hour of every day comes out at "
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
f"{option}.factor is 0, so every hour gets the same {leg.what} "
f"price{leg.zero_factor}. For one flat price, {leg.flat}.")
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
# the dearest hour becomes the cheapest one, so the battery is told to
@@ -678,22 +590,16 @@ def parse_price_formula(value, *, option: str = "price_formula"):
# mispriced site, it is a site actively trading against itself.
if factor < 0.0:
raise FormulaInvalid(
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
f"inverts the market: the dearest hour of the day comes out as "
f"the cheapest one, so the battery would charge at the evening "
f"peak and discharge at 03:00 - trading against itself on every "
f"cycle. {leg.neg_factor}.")
f"{option}.factor is {factor:g}, which turns the market upside "
f"down. {leg.neg_factor}.")
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
# and some cards are already quoted VAT-inclusive. The bound exists for the
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
if not 0.0 <= vat_pct <= 30.0:
raise FormulaInvalid(
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
f"injection is VAT-exempt, and a card that already quotes "
f"VAT-inclusive coefficients needs no second multiplication.")
f"{option}.vat_pct is {vat_pct:g}. Write a percentage between 0 "
f"and 30, for example 6.")
month, card_index, card_expect = _card(value.get("card"), leg)
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
@@ -771,33 +677,15 @@ def parse_export_formula(value):
def card_refusal(spec: FormulaSpec, delta: float) -> str:
"""The sentence an operator reads when their formula misses its own card.
⚠️ IT NAMES THE DISCREPANCY AND THE SIX WAYS IT HAPPENS. "Invalid
configuration" would be true and useless: every one of these errors looks
exactly like a working tariff from the status page, which is why the check
exists at all.
It names both numbers and what to check. The usual causes are in DOCS.md,
because each of these errors looks like a working tariff on the status page.
"""
got = in_card_units(spec, spec.card_index_eur_per_mwh)
ratio = (got / spec.card_expect_incl_vat
if spec.card_expect_incl_vat else float("inf"))
leg = _LEGS[spec.option]
return (
f"{spec.option} does not reproduce its own card, so the add-on will "
f"NOT start on it. Your contract's arithmetic is "
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
f"on the card's own index that is "
f"({spec.factor:g} x {spec.card_index_eur_per_mwh:g} + "
f"{spec.offset:g}) x (1 + {spec.vat_pct:g}/100) = {got:.4f} "
f"{spec.units}, but card.expect_incl_vat says "
f"{spec.card_expect_incl_vat:g} - OUT BY {delta:+.4f} {spec.units} "
f"(ratio {ratio:.4g}; the tolerance is {CARD_TOLERANCE}, which is the "
f"last digit a card prints). One of the two sides is wrong and the "
f"add-on cannot tell which, so it refuses rather than price a battery "
f"on it. The usual causes, in the order they happen: the coefficients "
f"and the expected price are in DIFFERENT units (ratio 10, 100 or "
f"1000 - `units` must be the one the formula line is printed in); VAT "
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
f"digit; the two sides copied off DIFFERENT cards or different "
f"months; {leg.card_causes}")
f"{spec.option} gives {got:.4f} "
f"{messages.UNIT_WORDS.get(spec.units, spec.units)}, the card says "
f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
# --------------------------------------------------------------------------- #
@@ -852,20 +740,10 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
return None
return (
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
f"price_taxes_eur_kwh is added, which is outside the plausible band "
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh for a Belgian "
f"retail tariff. The card self-check passed, so the coefficients and "
f"the printed price AGREE - they simply agree on a number no Belgian "
f"bill carries, which is what a whole card read in the wrong units "
f"looks like (a factor of 10, 100 or 1000 applied to BOTH sides at "
f"once cancels out of that check and cannot cancel out of this one). "
f"Check `units` against the unit printed beside your formula. "
f"⚠️ This bounds the CARD'S OWN POINT, not every hour: a live hour may "
f"legitimately fall outside the band, and a negative day-ahead hour - "
f"the best charging opportunity of the year - is never refused.")
f"{spec.option} gives {energy:.5f} EUR/kWh for energy and "
f"{all_in:.5f} EUR/kWh with “Distribution, levies and VAT” "
f"(price_taxes_eur_kwh), outside the plausible band of "
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh. Check \"units\" against the unit beside your formula.")
def implausible_injection(spec: FormulaSpec):
@@ -914,36 +792,16 @@ def implausible_injection(spec: FormulaSpec):
"""
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
return (
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
f"wholesale price - outside the plausible "
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
f"Belgian injection formula pays a share of the market price a "
f"little under or about equal to it: Eneco's "
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
f"factor typed in the wrong units looks like - check `units` "
f"({spec.units!r}) against the unit printed beside your formula, "
f"because the card self-check cannot see a factor of 10, 100 or "
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
f"COEFFICIENT and not the price: an injection price below zero is "
f"accepted and is the real market - Eneco's goes negative under "
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
f"{spec.option}.factor passes through {spec.passthrough:.4g}x the "
f"market price, outside {PASSTHROUGH_LO}-{PASSTHROUGH_HI}x. Check "
f"\"units\" ({spec.units!r}) against the unit beside your "
f"formula.")
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
return (
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
f"injection tariff in either direction. The published ones are "
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
f"cause is the offset typed in a different unit from `units` "
f"({spec.units!r}), or a consumption offset copied onto the "
f"injection leg: they are different numbers on the same card. "
f"⚠️ Network charges and levies do not belong here either - "
f"nothing is added to what you are PAID for export, so an offset "
f"carrying them is simply wrong rather than double-counted.")
f"{spec.option}.offset is {spec.offset:g} {spec.units}, which is "
f"{spec.offset_eur_kwh:+.5f} EUR/kWh and larger than "
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh either way. Check its "
f"unit.")
return None
@@ -957,15 +815,9 @@ def stale_card(spec: FormulaSpec, today=None):
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
return None
return (
f"{spec.option}'s card is dated {spec.card_month}, which is "
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
f"has been renewed or rolled over since then, the coefficients here "
f"are last year's and every price this add-on computes is wrong by "
f"however far they moved - and the card self-check cannot see it, "
f"because it grades your coefficients against the card you typed, not "
f"the card against your current contract. Not refused: ENGIE freezes "
f"coefficients for the contract's duration, so an old card is often "
f"exactly right. Worth re-reading your klantenzone once.")
f"{spec.option}'s card is dated {spec.card_month}, "
f"{(now - dated).days} days ago. The add-on still starts, but if your "
f"contract changed since, copy the new figures from your supplier.")
# --------------------------------------------------------------------------- #
+8 -6
View File
@@ -424,15 +424,17 @@ class FreezeWatchdog:
# page was telling, printed into the log and the HA notification where
# it would outlive the page.
ceiling = rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max)
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
capped = ("" if ceiling >= abs(float(max_w))
else f", capped from max_w {float(max_w):.0f} W by the "
f"setpoint device's own rating")
f"inverter's own rating")
self.reason = (
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
f"without its measurement changing while the command stands at "
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W rail{capped} "
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)")
f"METER_FROZEN: the meter has not changed for "
f"{float(unchanged_s):.0f} s while the command is "
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W limit"
f"{capped}. Limits: unchanged over {self.cfg.hold_s:.0f} s, "
f"command over "
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W")
if self.active:
return False
self.active = True
+19 -24
View File
@@ -594,31 +594,26 @@ class Freshness:
# ("what did the register do" against "can I trust this entity"). One
# helper serving both would have to drop whichever half the other does
# not need, and the half it dropped would be the operator's next step.
self.reason = (
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
f"while the standing command moved {moved_w:.0f} W to "
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
f"CANNOT TELL THEM APART: the register may have stopped reporting "
f"reality, or the source may be unable to comply with the command "
f"- a pack the BMS is refusing holds still under any setpoint, and "
f"an honest register then reads exactly like a frozen one. Either "
f"way the number cannot be read as this command's effect, so it is "
f"reported as MISSING rather than as a measurement (limits: "
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
f"{self.bound.move_w:.0f} W and moved by over "
f"{self.bound.move_w:.0f} W)")
# UX-10: two plain sentences (test_messages.py part 6). SAFETY-56's
# two explanations stay, in this order, and so does the admission
# that this check cannot tell them apart. The reading is treated as
# missing either way, which the log line around this says. The
# limits (`self.bound`) are on main.py's DEBUG line beside it, so the
# logged line stays within 3 sentences.
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
# in its own slot: the register's unchanged time is in the sentence
# above, the WINDOW this verdict actually rests on is here, and so is
# how many times this unit has been round the loop. Without the second
# one the sentence reads as though 92 s of unchanged register were what
# was compared against the 35 s limit, and it was not.
if self.releases:
self.reason += (
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
f"opened when the previous one was withdrawn, not when the "
f"register last moved - this unit has now been graded "
f"{self.releases + 1} times]")
# in its own slot: the register's unchanged time, the WINDOW this
# verdict actually rests on after a release (`regraded`), and how many
# times this unit has been round the loop. Without the second one the
# sentence reads as though 92 s of unchanged register were what was
# compared against the 35 s limit, and it was not.
regraded = (f", checked {self.releases + 1} times over a {held_s:.0f} s "
f"window since the last stop was withdrawn"
if self.releases else "")
self.reason = (
f"STALE_READING: {self.held:+.0f} W unchanged for {shown_s:.0f} s "
f"while the command moved {moved_w:.0f} W to {standing_w:+.0f} W"
f"{regraded}. The reading may have stopped, or the battery may be "
f"unable to follow the command, and this check cannot tell which")
def unchanged_s(self, now_mono) -> float | None:
"""Seconds since an arrival carried a different number. Status page.
+91
View File
@@ -0,0 +1,91 @@
"""UX-13: Home Assistant's entities, for the settings page's entity picker.
A read of `GET /api/states` through the add-on's own session (hass.states()),
cached for 30 s and trimmed to what a picker shows. Nothing here writes, so
UX-02 §6 holds. When Home Assistant does not answer, the last good list is
served with its age, or an empty one that says why: the route never fails.
"""
import asyncio
import time
CACHE_S = 30.0
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
def row(state: dict) -> dict | None:
"""One state object -> {entity_id, name, state, unit, device_class}."""
if not isinstance(state, dict) or not isinstance(state.get("entity_id"), str):
return None
attrs = state.get("attributes") if isinstance(state.get("attributes"), dict) else {}
return {"entity_id": state["entity_id"],
"name": str(attrs.get("friendly_name") or state["entity_id"]),
"state": state.get("state"),
"unit": attrs.get("unit_of_measurement"),
"device_class": attrs.get("device_class")}
def matches(r: dict, kind: str) -> bool:
"""Is `r` what a picker of this kind offers first? `kind` is one of
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
unit, cls = r.get("unit"), r.get("device_class")
if kind in ("power", "power_w"):
return cls == "power" or unit in _POWER_UNITS
if kind == "energy":
return cls == "energy" or unit in _ENERGY_UNITS
if kind == "pct":
return unit == "%"
if kind == "number":
return r["entity_id"].startswith(("number.", "input_number."))
return True
class EntityCache:
"""The last entity list read from Home Assistant, at most CACHE_S old."""
def __init__(self, hass, clock=time.monotonic):
self.hass = hass
self.clock = clock
self.rows: dict | None = None
self.read_at: float | None = None
self.tried_at: float | None = None
self._lock = asyncio.Lock()
async def get(self) -> tuple[dict | None, float | None]:
"""(rows by entity id, age in s), or (None, None) if never read."""
async with self._lock: # one HA read however many pickers ask
now = self.clock()
# A failed read also waits CACHE_S, so polls while HA is down do
# not queue one 10 s read each behind this lock.
if self.tried_at is None or now - self.tried_at >= CACHE_S:
self.tried_at = now
states = await self.hass.states()
if states is not None:
rows = (row(s) for s in states)
self.rows = {r["entity_id"]: r for r in rows if r}
self.read_at = now
if self.read_at is None:
return None, None
return self.rows, now - self.read_at
def peek(self) -> dict | None:
"""The cached rows if they are fresh, without asking Home Assistant."""
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
return None
return self.rows
async def payload(self, kind: str = "") -> dict:
"""The route's JSON: matching entities first, then the rest."""
rows, age = await self.get()
if rows is None:
return {"available": False, "text": DOWN_TEXT, "entities": []}
ordered = sorted(rows.values(), key=lambda r: r["entity_id"])
first = [dict(r, match=True) for r in ordered if matches(r, kind)]
rest = [dict(r, match=False) for r in ordered if not matches(r, kind)]
out = {"available": True, "age_s": round(age, 1), "entities": first + rest}
if age >= CACHE_S:
out["text"] = STALE_TEXT
return out
+89 -2
View File
@@ -16,6 +16,39 @@ _LOG = logging.getLogger("goodwe.hass")
CORE_API = "http://supervisor/core/api"
SUPERVISOR_API = "http://supervisor"
BAD_STATES = ("unknown", "unavailable", "none", "")
# What the Supervisor's proxy answers while Home Assistant restarts.
UNAVAILABLE = (502, 503, 504)
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
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:
@@ -58,10 +91,18 @@ class HomeAssistant:
async with self.session.get(
f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"read {entity_id}: {resp.status}")
return None
OUTAGE.up("api")
if resp.status == 404:
return None
resp.raise_for_status()
obj = await resp.json()
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read {entity_id}: {err}")
return None
# SAFETY-61: an undecodable body is an absence like any other failed
# read, never an exception into the control loop.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
@@ -73,6 +114,32 @@ class HomeAssistant:
return None
return obj
async def states(self) -> list | None:
"""Every entity's state object (`GET /api/states`), or None when Home
Assistant does not answer. A read only (UX-13 entity picker)."""
try:
async with self.session.get(
f"{CORE_API}/states", headers=self._headers,
timeout=STATES_TIMEOUT_S
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"read all states: {resp.status}")
return None
OUTAGE.up("api")
resp.raise_for_status()
obj = await resp.json()
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read all states: {err}")
return None
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan("read:*", "Reading the entity list failed: %s", err)
return None
if not isinstance(obj, list):
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
return None
return obj
async def number(self, entity_id: str, invert: bool = False) -> float | None:
"""Numeric state, or None. Never substitutes a default."""
obj = await self.state(entity_id)
@@ -113,17 +180,37 @@ class HomeAssistant:
f"{CORE_API}/services/{domain}/{service}",
headers=self._headers, json=data, timeout=10,
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"service {domain}.{service}: {resp.status}")
return False
OUTAGE.up("api")
if resp.status >= 400:
body = await resp.text()
# 400 here usually means out-of-range for the entity - the
# value is rejected outright, not clamped. Always log it:
# silently dropped commands are how a controller ends up
# believing something the hardware never did.
# UX-10: plain words. While Home Assistant loads its
# entities it answers 400 with only "400: Bad Request",
# which says nothing, so only a body with more is quoted.
said = body.strip()[:200]
if said.lower() in (f"{resp.status}: {resp.reason}".lower(),
""):
said = "no details"
entity = (data.get("entity_id", "no entity")
if isinstance(data, dict) else "no entity")
self._moan(f"svc:{domain}.{service}",
"service %s.%s rejected (%s): %s",
domain, service, resp.status, body[:200])
"Home Assistant refused %s.%s for %s (error %s, "
"%s). The entity may still be loading, or the "
"value is out of range.", domain, service,
entity, resp.status, said)
return False
return True
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection",
f"service {domain}.{service}: {err}")
return False
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
+14 -10
View File
@@ -123,6 +123,7 @@ from datetime import datetime
from .clamp import ClampResult
from .delivery import DeliveryConfig, DeliveryWatchdog
from . import messages
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
@@ -407,21 +408,24 @@ class Inverter:
stop the site (`inputs_ok`). Folding one into the other is how a
measurement fault ends up handing a sibling extra power.
"""
return self.batt_fresh.note(self.name)
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
else "")
def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say."""
"""One line for the status page. Empty when there is nothing to say.
The words are `app/messages.py`'s (UX-09). main.py colours the row red
exactly when `is_live()` is False.
"""
if not self.has_writer():
return (f"{self.name}: NO SETPOINT ENTITY - this controller cannot "
f"command it, and the site refuses rather than running at "
f"part strength")
live, why = self.is_live(now)
return messages.row_no_setpoint(self.name)
live, _why = self.is_live(now)
held = (0.0 if self.rejected_since is None
else (now - self.rejected_since).total_seconds())
if not live:
return f"{self.name}: NOT COMMANDABLE - {why}"
return messages.row_not_controllable(self.name, held, LINK_STALE_S)
if self.rejected_since is not None:
held = (now - self.rejected_since).total_seconds()
return (f"{self.name}: writes rejected for {held:.0f} s - still "
f"commandable until {LINK_STALE_S:.0f} s")
return messages.row_commands_refused(self.name, held, LINK_STALE_S)
return ""
def __repr__(self) -> str:
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -351,7 +351,7 @@ class Maintenance:
the action log rather than watching nothing happen.
"""
if blocked_by:
msg = (f"maintenance: forced start REFUSED on {self.name} - "
msg = (f"maintenance: {self.name} cannot start now, because "
f"{blocked_by}")
_LOG.warning("%s", msg)
return [msg]
@@ -378,10 +378,9 @@ class Maintenance:
"""
if self._deferred != why:
self._deferred = why
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
f"{why}. It is idle under the control loop and will "
f"recalibrate at the next start hour once the slot is free; "
f"this is not a stalled phase.")
msg = (f"maintenance: {self.name} is due and waits its turn, "
f"because {why}. It starts at the next start hour once the "
f"other battery is done. This is not a stalled phase.")
_LOG.info("%s", msg)
events.append(msg)
return events
File diff suppressed because it is too large Load Diff
+54 -31
View File
@@ -47,6 +47,11 @@ from datetime import datetime, timezone
import aiohttp
from . import messages
from .hass import OUTAGE
_S = messages.setting # a refusal names the setting by its label (UX-08)
try:
import paho.mqtt.client as mqtt
except ImportError: # pragma: no cover - container always has it
@@ -180,7 +185,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
# already applied a sign we are about to apply again - reject it rather
# than silently double-signing the control loop.
if imp < 0 or exp < 0:
raise P1Error(f"unsigned registers cannot be negative (import={imp:g} export={exp:g})")
raise P1Error(f"import and export readings cannot be negative "
f"(import={imp:g} export={exp:g})")
per_phase = per_phase_import = None
if phase_import_w is not None or phase_export_w is not None:
@@ -188,8 +194,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
pe = list(phase_export_w or [0.0] * len(pi))
if len(pi) != phases or len(pe) != phases:
raise P1Error(
f"phase count mismatch: telegram has {len(pi)} import / {len(pe)} export "
f"phases, meter_phases is {phases}")
f"the meter sent {len(pi)} import and {len(pe)} export phases, "
f"but {_S('meter_phases')} is {phases}")
vals = [_watts(a, f"L{i + 1} import") - _watts(b, f"L{i + 1} export")
for i, (a, b) in enumerate(zip(pi, pe))]
per_phase = tuple(vals)
@@ -467,7 +473,7 @@ class P1Ingest:
"""
self.parse_errors += 1
self.last_error = str(err)
_LOG.warning("P1 telegram rejected: %s", err)
_LOG.warning("A meter message could not be read: %s", err)
# -- what consumers read -------------------------------------------------
def age_s(self) -> float | None:
@@ -519,6 +525,16 @@ WS_URL = "ws://supervisor/core/websocket"
BAD_STATES = ("unknown", "unavailable", "none", "")
async def _receive_json(ws):
"""The next JSON message. A socket Home Assistant closed (a restart) is a
ConnectionResetError, not aiohttp's "Received message 8:1000 is not str"."""
msg = await ws.receive()
if msg.type is not aiohttp.WSMsgType.TEXT:
raise ConnectionResetError(
f"Home Assistant closed the connection ({msg.type.name})")
return json.loads(msg.data)
class HaDsmrSource:
"""Subscribes to state_changed for the configured DSMR entities.
@@ -569,8 +585,12 @@ class HaDsmrSource:
backoff = 1.0
except asyncio.CancelledError:
raise
except (aiohttp.ClientError, ConnectionError) as err:
# UX-10: Home Assistant restarting, said once (hass.Outage).
OUTAGE.down("websocket", "the meter connection closed", err)
except Exception as err: # noqa: BLE001 - any transport fault retries
_LOG.warning("P1 HA websocket: %s - reconnecting in %.0fs", err, backoff)
_LOG.warning("The meter connection to Home Assistant failed "
"(%s), trying again in %.0f s.", err, backoff)
finally:
self.connected = False
await asyncio.sleep(backoff)
@@ -578,16 +598,17 @@ class HaDsmrSource:
async def _session_once(self) -> None:
async with self.session.ws_connect(WS_URL, heartbeat=30) as ws:
hello = await ws.receive_json()
hello = await _receive_json(ws)
if hello.get("type") == "auth_required":
await ws.send_json({"type": "auth", "access_token": self.token})
reply = await ws.receive_json()
reply = await _receive_json(ws)
if reply.get("type") != "auth_ok":
raise RuntimeError(f"auth rejected: {reply.get('message', reply)}")
await ws.send_json({"id": 1, "type": "subscribe_events",
"event_type": "state_changed"})
await ws.send_json({"id": 2, "type": "get_states"})
self.connected = True
OUTAGE.up("websocket")
_LOG.info("P1 ingest: subscribed to %s", ", ".join(sorted(self.ids)))
async for msg in ws:
@@ -613,7 +634,7 @@ class HaDsmrSource:
new = data.get("new_state") or {}
self._absorb(data.get("entity_id"), new.get("state"))
self._schedule()
raise RuntimeError("websocket closed")
raise ConnectionResetError("Home Assistant closed the connection")
def _absorb(self, entity_id: str | None, state) -> None:
if not entity_id or entity_id not in self.ids:
@@ -1160,37 +1181,37 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
exp = str(opts.get("p1_export_entity", "") or "").strip()
if not imp or not exp:
problems.append(
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
f"are {messages.shown(imp)} and {messages.shown(exp)}.")
elif source == SOURCE_HA_SIGNED:
net = str(opts.get("p1_net_entity", "") or "").strip()
phase_net = [str(e).strip() for e in
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
if not net:
problems.append(
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
"(sensor.p1_sample_age_s would otherwise be announced with "
"nothing feeding it)")
f"{_S('meter_source')} is {SOURCE_HA_SIGNED!r}, so it needs "
f"{_S('p1_net_entity')}.")
if phase_net and len(phase_net) != phases:
problems.append(
f"p1_phase_net_entities has {len(phase_net)} entities but "
f"meter_phases is {phases} - every telegram would be rejected "
"on the phase-count check")
f"{_S('p1_phase_net_entities')} has {len(phase_net)} entities, "
f"but {_S('meter_phases')} is {phases}. Make them match.")
elif source == SOURCE_MQTT:
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
problems.append(f"{_S('meter_source')} is {SOURCE_MQTT!r}, so it "
f"needs {_S('meter_mqtt_topic')}.")
elif source == SOURCE_HOMEWIZARD:
host, port_s = homewizard_host_port(opts)
if not host:
problems.append(
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
"own address, e.g. 192.168.2.250)")
f"{_S('meter_source')} is {SOURCE_HOMEWIZARD!r}, so it needs "
f"{_S('p1_host')}, the meter's address on your network.")
if port_s:
try:
int(port_s)
except ValueError:
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
"unparseable port")
problems.append(f"{_S('p1_host')} is {messages.shown(opts.get('p1_host'))}, and "
"its port is not a number.")
try:
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
# cadence of 0 is a misconfiguration that must be reported, not
@@ -1198,23 +1219,26 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
raw = opts.get("meter_poll_s", 5)
poll_s = float(5 if raw is None or raw == "" else raw)
except (TypeError, ValueError):
problems.append(f"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
problems.append(f"{_S('meter_poll_s')} is "
f"{messages.shown(opts.get('meter_poll_s'))}, which is not a "
"number.")
else:
if poll_s <= 0:
problems.append("meter_poll_s must be positive")
problems.append(f"{_S('meter_poll_s')} must be above 0.")
# ⚠️ The age can never be fresher than the poll interval. At or past
# max_age_s every reading is stale before its successor arrives, so
# the controller would sit permanently on missing inputs while the
# meter is perfectly healthy - refuse it rather than ship that.
elif poll_s >= max_age_s:
problems.append(
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
f"{max_age_s:.1f} - every reading would go stale before the "
"next poll")
f"{_S('meter_poll_s')} is {poll_s:.1f}, not below "
f"{_S('meter_max_age_s')} at {max_age_s:.1f}. Poll more "
"often, or every reading goes stale.")
else:
problems.append(
f"meter_source {source!r} is not one of "
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
f"{_S('meter_source')} is {source!r}. Choose one of: off, "
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD))
+ ".")
return problems
@@ -1261,9 +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
# can NEVER be fresh.
if poll_s > ingest.max_age_s / 2:
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
"%.1f: one missed poll makes the reading stale. The meter "
"updates every ~5 s; 5 s against 30 s is the tested pair.",
_LOG.warning("“Time between meter reads” (%.1f s) is too close to "
"“Reading too old after” (%.1f s). Use 5 s and 30 s.",
poll_s, ingest.max_age_s)
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
if source == SOURCE_MQTT:
+53 -36
View File
@@ -395,33 +395,34 @@ not need re-testing. Nothing here indexes a phase.
from dataclasses import dataclass
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
# Why the projection may not be graded. Reasons, not booleans: the status page
# and the action log both have to say WHICH, and "blind" alone is unactionable.
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
BLIND_NO_BLOCK = "no open 15-minute period, the meter has given no reading yet"
BLIND_COVERAGE = "meter gap"
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
BLIND_FROZEN = "the meter is answering without changing"
# Where the ceiling came from. Only the second is a relaxation, and only an
# OBSERVED reading may produce it.
CEILING_CAP = "peak_cap_w"
CEILING_MONTH_PEAK = "the month's banked peak"
CEILING_MONTH_PEAK = "this month's peak"
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
# for BLIND_*'s reason: the status page, the action log and the operator
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
"cycle, and an unreadable SoC is not a full battery")
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
# possessive is load-bearing: this string is what the status page, the action log
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
# reads at the moment the battery stops shaving into a claim about the pack that
# the grid-following loop is at that instant disproving.
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
"unread is not full")
# ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
# load-bearing: this string is what the action log and the add-on log print,
# and `peak_soc_floor` (“Peak reserve”) bounds THIS GUARD ONLY. Widening it to
# "the SoC floor" would make the one sentence an operator reads at the moment
# the battery stops shaving into a claim about the pack that the
# grid-following loop is at that instant disproving.
FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
SOURCE = "peak"
@@ -485,17 +486,29 @@ class PeakDecision:
reason: str = ""
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
technical record with every figure, for the log and the action log."""
if self.floor_limited:
return self.reason
if not self.engaged:
if self.projected_w is None:
return f"peak guard idle - {self.reason}"
return (f"peak guard idle - quarter projects "
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
f"ceiling ({self.ceiling_from})")
return messages.row_peak_guard_quiet()
return messages.row_peak_guard_idle(
self.projected_w, self.ceiling_w,
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
return self.reason
def blind_kind(blind: str) -> str:
"""Which of the three kinds of blindness, as a key of
`messages.BLIND_WORDS`, for the status row."""
if blind == BLIND_FROZEN:
return "frozen"
if blind == BLIND_NO_BLOCK:
return "no_block"
return "gap"
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
"""The average this quarter may not exceed, and where the number came from.
@@ -553,8 +566,9 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return BLIND_NO_BLOCK
gap = uncovered_s(projection)
if max_hold_s is not None and gap > float(max_hold_s):
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
return (f"{BLIND_COVERAGE}, {gap:.0f} s of this 15-minute period not "
f"covered, past the {float(max_hold_s):.0f} s a reading is "
f"held")
return None
@@ -730,9 +744,9 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
return PeakDecision(
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
blind=blind, projected_w=None,
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
f"while the billed quarter cannot be measured; commanding no "
f"charging (floor 0 W)"))
reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
f"while this 15-minute period cannot be measured, so it "
f"is asked for no charging, floor 0 W"))
projected = float(projection.projected_w)
if batt_w is None:
@@ -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,
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
soc_floor_reason=denied,
# UX-10: plain sentences, every figure kept (test_messages.py
# part 6). The code is one token, so it is not read as shouting.
reason=(
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
f"it, but {denied}"
+ ("" if soc is None else f" ({float(soc):.0f} %")
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
f"{projected:.0f} W against a {ceiling:.0f} W limit "
f"({from_where}), and {wanted:+.0f} W from the battery would "
f"hold it. But {denied}"
+ ("" if soc is None else f", at {float(soc):.0f} %")
+ ("" if soc is None or soc_floor is None
else f" vs {float(soc_floor):.0f} %")
+ ("" if soc is None else ")")
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
else f" against {float(soc_floor):.0f} %")
+ ", so no forced discharge and no charging, and this period "
"will go over the limit"))
floor = min(wanted, ceil_top)
saturated = floor < wanted
@@ -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,
saturated=saturated, soc=soc, soc_floor=soc_floor,
reason=(
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
f"{ceiling:.0f} W ceiling ({from_where}) with "
f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
f"against a {ceiling:.0f} W limit ({from_where}), with "
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
f"floor {floor:+.0f} W"
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
f"only give {ceil_top:.0f} W" if saturated else "")))
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
f"and is asked for {floor:+.0f} W"
# Saturated means floor == ceil_top: the ask IS the most the
# inverters give, so one clause says both.
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
if saturated else "")))
def charge_headroom_w(decision) -> float | None:
+35 -44
View File
@@ -150,6 +150,7 @@ from enum import Enum
from textwrap import dedent
from urllib.parse import urlencode
from . import messages
from .custom_formula import (
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
@@ -1314,8 +1315,8 @@ def code_leaks(cls) -> list:
src = dedent(inspect.getsource(cls))
tree = ast.parse(src)
except (OSError, TypeError, IndentationError, SyntaxError) as e:
_LOG.warning("could not read %s's source for the framework-key scan "
"(%s); the double-counting guard is NOT enforced for it",
_LOG.warning("Could not read the code of price source %s (%s), so its "
"double-counting check is skipped.",
getattr(cls, "id", cls), e)
return []
for node in ast.walk(tree):
@@ -1453,9 +1454,8 @@ def _validate_raw(points, caps):
if abs(p.eur_per_kwh) > MAX_PLAUSIBLE_EUR_KWH:
raise SourceUnavailable(
f"point at {p.start.isoformat()} is {p.eur_per_kwh} EUR/kWh, "
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH} - the usual cause is a feed "
f"in EUR/MWh being read as EUR/kWh, which is a factor of 1000 "
f"and would make every hour look like an emergency")
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH}, so the feed is probably in "
f"EUR/MWh")
u = p.start.astimezone(timezone.utc)
if seen is not None and u <= seen:
raise SourceUnavailable(
@@ -1708,25 +1708,19 @@ def _points_from_columnar_json(body, expect_unit, scale,
if len(times) != len(prices):
raise SourceUnavailable(
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
f"The short array would truncate the series silently, and a price "
f"series missing its last hours is one missing the evening peak - "
f"traded on as if complete, where no data at all would have "
f"switched arbitrage off (§11.4)")
f"{COLUMNAR_PRICE_KEY} has {len(prices)}, so the feed is cut short "
f"and none of it is used")
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
if unit != _columnar_unit(expect_unit):
raise SourceUnavailable(
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
f"{expect_unit!r}. This provider converts with a fixed factor of "
f"{scale}, so a changed unit is a factor of 1000 on every price "
f"with nothing downstream able to notice it")
f"{expect_unit!r}, so its prices cannot be converted")
if doc.get("deprecated"):
# Not a refusal: today's prices are still prices. But §11.4 makes a
# dead feed look exactly like a quiet one, so the warning that the
# source is going away has to be emitted while it still works.
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
"but this endpoint is scheduled to stop",
doc.get("deprecated"))
_LOG.warning("The price feed says it will stop (deprecated=%r), "
"though it still answers for now.", doc.get("deprecated"))
out = []
dropped = []
for i, (t, v) in enumerate(zip(times, prices)):
@@ -1745,12 +1739,11 @@ def _points_from_columnar_json(body, expect_unit, scale,
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
provisional=bool(provisional_default)))
if dropped:
_LOG.warning("the feed published no price for %d of %d instants "
"(index %s); those instants are left OUT of the series, "
"never zero-filled - a missing hour is not a free hour",
len(dropped), len(times),
_LOG.warning("The price feed gave no price for %d of %s, at index "
"%s. Those are left out, not counted as free.",
len(dropped), messages.count(len(times), "time"),
", ".join(str(i) for i in dropped[:5])
+ (", ..." if len(dropped) > 5 else ""))
+ (" and more" if len(dropped) > 5 else ""))
if not out:
raise SourceUnavailable(
f"the feed carried {len(times)} instants and a price for none of "
@@ -1796,16 +1789,16 @@ def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
override = str(opts.get("price_feed_url") or "").strip()
if override:
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
"price_feed_url is set, so %s asks for %s as given, and "
"tomorrow's prices may stay provisional. Clear it to ask for %s "
"to %s.",
plugin_id, override, *(d.isoformat()
for d in brussels_date_range(since, until)))
_LOG.debug(
"The public feed answers one Brussels day to a request that names "
"no range. Every later hour is then copied from yesterday and "
"marked provisional, and §8.3 leaves provisional hours out of "
"trading.")
return override
first, last = brussels_date_range(since, until)
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
@@ -2011,11 +2004,9 @@ class CustomFormula(PriceProvider):
raise SourceUnavailable(f"price_formula: {e}")
if spec is None:
raise SourceUnavailable(
"price_provider is 'custom_formula' but price_formula is "
"empty, so there are no coefficients to price anything with. "
"validate.py refuses this configuration at startup; reaching "
"it here means the options blob changed under a running "
"add-on")
"“Dynamic price source” (price_provider) is 'custom_formula', "
"but “Your own tariff formula” (price_formula) is empty. "
"Restart the add-on to check the settings again")
url = dayahead_feed_url(self.id, opts, since, until,
self.FEED_BASE, self.BZN)
check_egress(url, self.capabilities())
@@ -2091,8 +2082,8 @@ def _emit_startup_preview(plugin, pid: str, points) -> None:
try:
lines = list(plugin.startup_preview(points))
except Exception as e: # noqa: BLE001
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
"unaffected - this is a log line, not a decision",
_LOG.warning("%s could not show its start-up preview (%s: %s). Prices "
"are not affected.",
pid, type(e).__name__, e)
return
for line in lines:
@@ -2116,12 +2107,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
pid = provider_id(opts)
if not provider_configured(opts):
return PriceRead.no_data(
"no price provider configured (price_provider is 'off'); "
"§11.4: static profile, plan provisional, no arbitrage")
"no price feed is set (price_provider is 'off'), so the fixed "
"tariff is used, the plan is estimated and there is no trading")
cls = PROVIDERS.get(pid)
if cls is None:
return PriceRead.no_data(
f"unknown price provider {pid!r}; shipped: "
f"unknown price provider {pid!r}. The known ones are "
f"{', '.join(sorted(PROVIDERS))}")
caps = cls.capabilities()
@@ -2139,12 +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
# should have refused this at startup; if the add-on is running with it
# anyway, the operator is told which key and what is wrong with it.
_LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch "
"has NO tax component to add - it is not falling back to "
"price_taxes_eur_kwh, because a silently different tax "
"basis is the defect this option exists to remove. Fix the "
"table or clear it; validate.py refuses it at startup",
_LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes "
"are added to this price update. Fix the table or clear it.",
type(e).__name__, e)
_LOG.debug("price_taxes_eur_kwh is not used instead, because a "
"silently different tax basis is the fault this setting "
"exists to remove.")
return PriceRead.no_data(
f"price_taxes_profile cannot be used: {e}")
if table is not None:
+27 -24
View File
@@ -164,6 +164,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from . import messages
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
from .price import PricePoint, PriceRead, _Outcome, brussels
@@ -411,13 +412,14 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
if not out:
reason = read.match(
series=lambda r: (f"the {len(r.points)} published hours all fall "
f"outside {since.isoformat()}..{until.isoformat()}"),
series=lambda r: (f"all {messages.count(len(r.points), 'published price')} "
f"fall outside {messages.when(brussels(since))} to "
f"{messages.when(brussels(until))}"),
no_data=lambda r: r.reason)
return PriceRead.no_data(
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
f"profile has nothing to fill them with either. §11.4 applies: "
f"static profile, plan provisional, no arbitrage")
f"no hours could be filled from yesterday's prices: {reason}. So "
f"the fixed tariff is used, the plan is estimated and there is no "
f"trading")
_audit(out, published)
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
@@ -431,10 +433,11 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
# feed's is the POINT; nothing derived from one answers for the other.
total_h = len({_utc_hour(p.start) for p in out})
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
"profile and flagged provisional; the feed published %d "
"points over the other %d hours",
carried, total_h, len(out) - carried, total_h - carried)
_LOG.info("%d of %s use yesterday's prices, marked as estimated. "
"The feed published %s over the other %s.",
carried, messages.count(total_h, "hour"),
messages.count(len(out) - carried, "price"),
messages.count(total_h - carried, "hour"))
product = read.match(series=lambda r: r.product,
no_data=lambda _r: yesterday.product)
@@ -525,8 +528,8 @@ class _Permitted(Gate):
"one rule this type has.")
self.action = action
self.hours = pts
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
f"({pts[0].start.isoformat()} ..)")
self.words = (f"allowed on {messages.count(len(pts), 'published price')} "
f"from {messages.when(brussels(pts[0].start))}")
class _Blocked(Gate):
@@ -570,8 +573,8 @@ def price_permits(read: PriceRead, now: datetime,
return read.match(
series=lambda r: _walk(r, hours, action),
no_data=lambda r: Gate.block(
f"{action.name} is gated on a price and there is no price data: "
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
f"{action.name} needs a price and there is none: {r.reason}. A "
f"missing price is not a cheap one"))
def _walk(series, hours, action) -> Gate:
@@ -579,19 +582,18 @@ def _walk(series, hours, action) -> Gate:
for u in hours:
if not series.covers(u):
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and no price covers "
f"{u.isoformat()}. An hour outside the series is an hour with "
f"no price (§11.4 for that hour), not a free one")
f"{action.name} needs prices for {action.commits_h:g} hours "
f"from {messages.when(brussels(hours[0]))}, and no price "
f"covers {messages.when(brussels(u))}. An hour with no price "
f"is not a free one")
p = series.at(u)
if p.provisional:
return Gate.block(
f"{action.name} commits {action.commits_h:g} h from "
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
f"carried from yesterday's profile or standing in for a dead "
f"feed. §8.3: no irreversible action may be taken on a "
f"provisional price. It becomes available when the day-ahead "
f"print lands (~14-16 h CET, §5.4)")
f"{action.name} needs prices for {action.commits_h:g} hours "
f"from {messages.when(brussels(hours[0]))}, and the price for "
f"{messages.when(brussels(u))} is only estimated. It can start "
f"once the day-ahead prices are published, around 14:00 to "
f"16:00")
pts.append(p)
return Gate.permit(action, pts)
@@ -622,5 +624,6 @@ def _go(maint, now, gate) -> Gate:
def _no(gate) -> Gate:
_LOG.warning("irreversible action refused - %s", gate.reason)
_LOG.warning("An action that cannot be undone does not start: %s.",
gate.reason)
return gate
+57 -40
View File
@@ -186,8 +186,9 @@ import json
import logging
import os
import tempfile
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from . import messages
from .price import PricePoint, PriceProduct, PriceRead, brussels
from .price_forecast import fill_from_yesterday
@@ -345,8 +346,19 @@ def _row_of(day: str, points: int, hours: int) -> str:
whole day to carry, and that is the HOUR figure; the point count says how
finely it is priced. Neither answers for the other.
"""
return (f"{day} ({points} point{'' if points == 1 else 's'} "
f"over {hours} h)")
# UX-10: the day as the page writes dates ("26 Sep"), not ISO.
return (f"{points} price{'' if points == 1 else 's'} over {hours} "
f"hour{'' if hours == 1 else 's'} for {day_words(day)}")
def day_words(day) -> str:
"""`'2026-09-26'` -> `'26 Sep'`, as the page writes a date. A key that is
not a date is shown as it is, because this feeds a status row."""
try:
d = date.fromisoformat(str(day))
except ValueError:
return str(day)
return f"{d.day} {d:%b}"
def _bad_rows(rec) -> int:
@@ -393,9 +405,8 @@ class PriceHistory:
self.last_error = f"no durable volume at {parent}"
# INFO, not ERROR: on a dev host or in a test container there is no
# /data and nothing is wrong.
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
"to carry and the horizon stays as short as the feed",
self.last_error)
_LOG.info("No price history is kept (%s), so missing hours are "
"not filled from yesterday's prices.", self.last_error)
return
self.ok = True
self._load()
@@ -437,9 +448,9 @@ class PriceHistory:
# exactly what this handler says, and a third `ValueError` subclass
# nobody has thought of is how this defect arrived the first time.
self.last_error = f"unreadable ({err})"
_LOG.warning("price history at %s is %s - starting from empty. "
"§8.3 carry-forward has nothing to carry until the "
"next refresh has written a full day",
_LOG.warning("Price history at %s is %s, so it starts empty. "
"Missing hours cannot be filled in until a full day "
"is saved.",
self.path, self.last_error)
return
found = blob.get("version") if isinstance(blob, dict) else None
@@ -449,15 +460,15 @@ class PriceHistory:
# would not notice a writer this build has never met.
if not isinstance(blob, dict) or isinstance(found, bool) \
or not isinstance(found, int) or found != SCHEMA:
self.last_error = f"schema {found!r}, not {SCHEMA}"
_LOG.warning("price history at %s has %s - discarding it rather "
"than guessing what its points mean",
self.last_error = f"version {found!r}, not {SCHEMA}"
_LOG.warning("Price history at %s has %s, so it is not used.",
self.path, self.last_error)
return
days = blob.get("days")
if not isinstance(days, dict):
self.last_error = "no `days` object"
_LOG.warning("price history at %s has %s", self.path, self.last_error)
self.last_error = "no days in it"
_LOG.warning("Price history at %s has %s, so it is not used.",
self.path, self.last_error)
return
self._days = self._usable_days(days)
@@ -504,20 +515,22 @@ class PriceHistory:
kept[key] = rec
notes = []
if dropped:
notes.append(f"{len(dropped)} unusable day record(s) dropped "
f"({', '.join(sorted(dropped))})")
notes.append(f"{messages.count(len(dropped), 'unusable day')} "
f"dropped, " + ", ".join(day_words(d)
for d in sorted(dropped)))
if malformed:
notes.append(
f"{sum(malformed.values())} malformed point row(s) in "
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
f"{messages.count(sum(malformed.values()), 'bad price')} in "
+ ", ".join(f"{day_words(d)} ({n})"
for d, n in sorted(malformed.items())))
if notes:
# Recorded, not silent: `yesterday()` and `words` both report
# `last_error`, and a cache that quietly discards half a file reads
# to an operator exactly like a normal first day.
self.last_error = "; ".join(notes)
_LOG.warning("price history at %s: %s - the controller starts "
"anyway and the horizon is as short as the feed makes "
"it", self.path, self.last_error)
self.last_error = " and ".join(notes)
_LOG.warning("Price history at %s: %s. The add-on starts anyway, "
"and fills in only what the rest holds.",
self.path, self.last_error)
return kept
def _save(self) -> bool:
@@ -680,10 +693,11 @@ class PriceHistory:
# undone with the rest of the merge, so those rows are still in the
# file and the next write that completes drops them again.
self.last_error = (
f"{sum(junked.values())} malformed point row(s) dropped from "
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
_LOG.warning("price history at %s: %s - the record is rewritten "
"without them and the refresh continues",
f"{messages.count(sum(junked.values()), 'bad price')} dropped "
f"from " + ", ".join(f"{day_words(d)} ({n})"
for d, n in sorted(junked.items())))
_LOG.warning("Price history at %s: %s. The file is saved without "
"them and the price update goes on.",
self.path, self.last_error)
self._prune(stamp)
if not self._save():
@@ -752,21 +766,23 @@ class PriceHistory:
if self.last_error and not self._days:
return PriceRead.no_data(
f"the price history at {self.path} is {self.last_error}, so "
f"§8.3 has no profile to carry forward for {want}; the plan "
f"covers only the hours the feed published")
f"there are no prices from yesterday to fill in for "
f"{day_words(want)}. The plan covers only the hours the feed "
f"published")
rec = self._days.get(want)
if rec is None:
have = ", ".join(sorted(self._days)) or "no days at all"
have = (", ".join(day_words(d) for d in sorted(self._days))
or "no days at all")
return PriceRead.no_data(
f"no published prices recorded for {want} (held: {have}), so "
f"§8.3 has no profile to carry forward. A record for another "
f"day is not a yesterday")
f"no published prices are kept for {day_words(want)}, only for "
f"{have}, so there are no prices from yesterday to fill in. A "
f"record for another day is not a yesterday")
points, bad = _parse_points(rec.get("points"))
if not points:
why = bad or "it is empty"
return PriceRead.no_data(
f"the {want} record holds no usable point ({why}), and an "
f"empty series is not a free day")
f"the {day_words(want)} record holds no usable price, {why}, "
f"and an empty series is not a free day")
try:
product = PriceProduct(rec.get("product"))
except ValueError:
@@ -775,8 +791,9 @@ class PriceHistory:
f"which is not a PriceProduct. The optimiser switches on that "
f"enum, so a record that cannot name one cannot be a series")
if bad:
_LOG.warning("price history: %s in the %s record were dropped; "
"%d point(s) carried forward", bad, want, len(points))
_LOG.warning("Price history: %s dropped from the %s record, and "
"%s kept.", bad, day_words(want),
messages.count(len(points), "price"))
return PriceRead.series(points, product,
str(rec.get("provider") or "price_history"),
stamp)
@@ -805,7 +822,7 @@ class PriceHistory:
if not self._days:
why = f" - {self.last_error}" if self.last_error else ""
return f"empty{why} - nothing to carry forward yet"
held = "; ".join(_row_of(d, *_held(self._days[d]))
held = ", ".join(_row_of(d, *_held(self._days[d]))
for d in sorted(self._days, key=str))
# A file that PARTLY loaded says so, or the dropped days read as days
# that were simply never written.
@@ -833,7 +850,7 @@ def _parse_points(raw) -> tuple:
continue
out.append(pt)
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
return tuple(out), (messages.count(dropped, "bad price") if dropped else "")
class NoHistory:
@@ -861,5 +878,5 @@ class NoHistory:
def yesterday(self, stamp: datetime) -> PriceRead:
return PriceRead.no_data(
"this caller keeps no price history (NoHistory), so §8.3 has no "
"profile to carry forward")
"this caller keeps no price history, so there are no prices from "
"yesterday to fill in")
+12 -7
View File
@@ -82,6 +82,7 @@ import os
import aiohttp
from . import messages
from .configui import changed_keys
from .inverter import build_roster
from .validate import validate_startup_config, validate_unit_ranges
@@ -287,7 +288,7 @@ async def problems_for(opts: dict, hass=None) -> list:
return problems
async def problems_and_ranges(opts: dict, hass=None):
async def problems_and_ranges(opts: dict, hass=None, notes=None):
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
@@ -302,6 +303,8 @@ async def problems_and_ranges(opts: dict, hass=None):
has just typed a new `setpoint_entity` must be told about THAT entity, and
a route that asked the running controller instead would report on the old
one. That was the first shape of this and it disagreed with itself.
UX-13: a list passed as `notes` collects the validator's warnings too.
"""
flat = str(opts.get("setpoint_entity", "") or "").strip()
@@ -328,7 +331,7 @@ async def problems_and_ranges(opts: dict, hass=None):
# disagreement is itself the finding - so it is reported rather than
# swallowed. Returning `[]` here would turn a crash into a silent
# acceptance, which is worse than the 500 this replaced.
found = list(validate_startup_config(opts))
found = list(validate_startup_config(opts, notes=notes))
if not found:
found = [f"the configuration could not be composed ({type(exc).__name__}: "
f"{exc}), and the validator did not name a reason. This is a "
@@ -346,7 +349,8 @@ async def problems_and_ranges(opts: dict, hass=None):
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
dev_min, dev_max = unit.dev_min, unit.dev_max
break
problems = list(validate_startup_config(opts, dev_min, dev_max))
problems = list(validate_startup_config(opts, dev_min, dev_max,
notes=notes))
problems += validate_unit_ranges(units, skip_entity=flat)
unread = [unit.setpoint_entity for unit in units
if unit.setpoint_entity and unit.dev_min is None
@@ -381,8 +385,9 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
"""
problems = await problems_for(proposed, hass)
if problems:
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
"nothing written. First: %s", len(problems), problems[0])
_LOG.warning("The settings page did not save, because of %s. The "
"first: %s", messages.count(len(problems), "problem"),
problems[0])
raise SaveRefused("invalid", problems=problems)
info = await selfcfg.info()
@@ -390,8 +395,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
_LOG.warning("panel REFUSED to save: the stored options changed "
"underneath this page (keys: %s); nothing written",
_LOG.warning("The settings page did not save, because the settings "
"changed while it was open (keys: %s).",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
+31 -43
View File
@@ -279,6 +279,8 @@ that write is issued rather than skipped as unchanged.
"""
from __future__ import annotations
from . import messages
# ⚠️ CFG-03's mirror. `test_defaults_pin.py` part 2 discovers every
# module-level name ending in OPTION_DEFAULTS and asserts each value
# reproduces config.yaml EXACTLY, so this table cannot drift from the shipped
@@ -316,11 +318,8 @@ WIRE_W = 0.0
#: a cap that does not bind - and a number invented for the row would be a
#: second helping of the defect this constant exists to disclose.
SIZED_FROM_METER = (
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
"trade against a meter the battery is moving, so this is an UPPER BOUND "
"and not a prediction; how far under it a live run lands is a closed-loop "
"question and a shadow run cannot answer it")
"This is an upper limit, not a forecast, because the meter did not see "
"the battery move.")
def mode(opts: dict) -> str:
@@ -371,18 +370,18 @@ def why(opts: dict, *, control_started: bool) -> str:
# discount. What is true in every live state is that what this add-on
# decides reaches the inverter; whether control is RUNNING is the
# `Stopped` banner's question and it is answered there.
# UX-08: the same four rungs, in plain words. The setting is named by its
# value, "automatic" for `auto`, so each rung still reads differently.
m = mode(opts)
live = "what the add-on decides reaches the inverters"
inert = "the add-on decides but sends nothing"
if m == "off":
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
"add-on decides DOES reach the inverter")
return f"Off, {live}"
if m == "on":
return ("on (shadow_mode: on) - decisions are recorded and nothing "
"is commanded")
return f"On, {inert}"
if control_started:
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
"mode: what this add-on decides DOES reach the inverter")
return ("on (shadow_mode: auto, control is stopped) - decisions are "
"recorded and nothing is commanded")
return f"Off (automatic, control is started), {live}"
return f"On (automatic, control is stopped), {inert}"
def words(opts: dict, *, control_started: bool, would_w: float | None,
@@ -409,10 +408,10 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
that decided it did not apply, which is the shape of defect this card is
about. TEST-14's rule with a new name on it.
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
⚠️ AND THE PHRASE IS "THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
CAVEAT FIRES. Every number a shadow run records was reached from a plant
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
of a live run and no number here is one. The ⚠️ below is the STRONGER,
that did not respond; "would ask for -2 000 W" alone reads as a forecast
of a live run and no number here is one. UX-08 2d put it in plain words. The ⚠️ below is the STRONGER,
conditional statement about the cycles where the answer can be multiples
out - see this module's header for the measured table.
@@ -429,19 +428,19 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
if not engaged(opts, control_started=control_started):
return head
if would_w is None:
return (f"{head}; no decision has been reached yet - the inverter is "
f"held at {WIRE_W:.0f} W")
return (f"{head}. No decision yet, the inverters stay at "
f"{WIRE_W:.0f} W.")
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
# the whole sentence it ate the comma out of `head` - and `head` is the
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
# masking rule says must survive.
num = f"{float(would_w):+,.0f}".replace(",", " ")
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
f"held at {WIRE_W:.0f} W")
out = (f"{head}. This cycle it would ask for {num} W, the inverters stay "
f"at {WIRE_W:.0f} W.")
if sized_from_meter:
out += f" {SIZED_FROM_METER}"
if reason:
out += f" - {reason}"
out += f" Why: {reason}."
return out
@@ -471,25 +470,17 @@ def log_line(*, would_w: float, reason: str, sized_from_meter: bool) -> str:
def banner(opts: dict, *, control_started: bool, latched: tuple = ()) -> tuple:
"""`(level, text)` for the top of the status page, or `None` when live.
⚠️ IT NAMES A STANDING LATCH AND GOES RED FOR ONE. The shadow banner sits
ABOVE every other branch of the cascade, because "the battery is not being
controlled" is the one sentence that may never be displaced - but a
failsafe latch that is up while the controller is inert is up when it
stops being inert, and an operator who turns shadow mode off without
clearing it gets a battery that will not move and a green page that
never mentioned why. So the latch is carried INTO the shadow banner
rather than losing its own.
⚠️ IT GOES RED FOR A STANDING LATCH. The shadow banner sits ABOVE every
other branch of the cascade, because "the battery is not controlled" is
the one sentence that may never be displaced - but a failsafe latch that
is up while the controller is inert is up when it stops being inert. So the
banner says a safety stop outlasts shadow mode, and each latch keeps its
own red row below, which names it and says how it clears. The words are
`messages.banner_shadow()`'s (UX-09); `latched` names them for callers.
"""
if not engaged(opts, control_started=control_started):
return None
text = ("SHADOW MODE - the battery is NOT being controlled. Every "
f"inverter is held at {WIRE_W:.0f} W and nothing this add-on "
"decides reaches the inverter.")
if latched:
return ("bad", text + " ⚠️ A FAILSAFE LATCH IS STILL UP ("
+ ", ".join(latched) + ") and it will still be up when shadow "
"mode is turned off.")
return ("warn", text)
return ("bad" if latched else "warn", messages.banner_shadow(bool(latched)))
def problems(opts: dict) -> list:
@@ -510,9 +501,6 @@ def problems(opts: dict) -> list:
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return []
return [f"shadow_mode is {raw!r}, which is not one of "
+ ", ".join(f"'{m}'" for m in MODES)
+ ". 'auto' leaves the battery alone while control is stopped and "
"commands it once control is started; 'on' records every "
"decision and commands nothing at all; 'off' is ordinary "
"control."]
return [f"{messages.setting('shadow_mode')} is {messages.shown(raw)}. Choose "
+ ", ".join(f"'{m}'" for m in MODES[:-1])
+ f" or '{MODES[-1]}'."]
+73 -68
View File
@@ -204,6 +204,7 @@ import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from . import messages
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option,
@@ -262,9 +263,8 @@ def _minutes(value, field: str) -> int:
parts = text.split(":")
if len(parts) != 2:
raise ProfileInvalid(
f"{field} is {value!r}; it must be \"HH:MM\" local Belgian time, "
f"e.g. \"22:00\". A bare hour or a decimal is refused rather than "
f"guessed at - 7.5 could be 07:30 or 07:50.")
f"{field} is {value!r}. Write it as \"HH:MM\" in Belgian time, "
f"for example \"22:00\".")
try:
h, m = int(parts[0]), int(parts[1])
except ValueError:
@@ -272,9 +272,8 @@ def _minutes(value, field: str) -> int:
f"integers, e.g. \"07:00\"")
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
raise ProfileInvalid(
f"{field} is {value!r}, which is not a time of day. Hours run 0-23, "
f"minutes 0-59; \"24:00\" is accepted only as an END meaning "
f"midnight at the end of the day.")
f"{field} is {value!r}, which is not a time of day. Use 00:00 to "
f"23:59, or \"24:00\" as an end time.")
return h * 60 + m
@@ -286,11 +285,8 @@ def _days(value, field: str) -> frozenset:
"""
if value is None:
raise ProfileInvalid(
f"{field} is missing. Name the days this period applies to - "
f"\"mon-fri\", \"sat,sun\" or \"all\". It is required rather than "
f"defaulted to every day, because a Belgian night tariff that "
f"silently covered the weekend too would misprice 48 h a week with "
f"nothing reporting it.")
f"{field} is missing. Name the days, for example \"mon-fri\", "
f"\"sat,sun\" or \"all\".")
tokens = []
if isinstance(value, (list, tuple)):
for item in value:
@@ -367,10 +363,8 @@ def _price(value, field: str, means: str = None) -> float:
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
raise ProfileInvalid(
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. The "
f"usual cause is a figure in cents or in EUR/MWh - 34 instead of "
f"0.34 - which would make every hour look like an emergency. Same "
f"bound the fetched feeds are held to (price.py `_validate_raw`).")
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. "
f"Write the price in EUR/kWh, for example 0.34 and not 34.")
return out
@@ -422,9 +416,8 @@ class StaticProfile:
self.option = option
if not self.periods:
raise ProfileInvalid(
f"{option} has no periods. A profile with no periods is "
f"not a fixed tariff, it is an absent one - leave the option "
f"empty to say that on purpose (§11.4 then applies).")
f"{option} has no periods. Add at least one, or leave the "
f"setting empty.")
_check_tiling(self.periods, option=option)
# -- what kind of tariff this is, DERIVED --------------------------------
@@ -558,10 +551,7 @@ def _raise_gap(weekday, start, end, option="static_profile"):
raise ProfileInvalid(
f"{option} leaves {DAY_NAMES[weekday]} "
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
f"price. Refused rather than defaulted: an invented price is one an "
f"optimiser would trade on, and §11.4 already says what a missing price "
f"means. Add a period covering it - remember a window written "
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
f"price. Add a period that covers them.")
def _raise_overlap(weekday, over, end, periods, option="static_profile"):
@@ -569,9 +559,8 @@ def _raise_overlap(weekday, over, end, periods, option="static_profile"):
names = [p.name for p in periods if p.covers(weekday, start)]
raise ProfileInvalid(
f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
f"one hour means one of them silently wins on list order. Narrow one of "
f"the windows or split the day selector.")
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Make the "
f"periods stop overlapping.")
def parse_static_profile(value, *, option: str = "static_profile",
@@ -609,28 +598,21 @@ def parse_static_profile(value, *, option: str = "static_profile",
value = json.loads(text)
except ValueError as e:
raise ProfileInvalid(
f"{option} is not valid JSON ({e}). It is a JSON object "
f"with a \"periods\" list, on one line, e.g.\n"
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
f"Leave it empty for a dynamic-only or price-free site.")
f"{messages.not_valid(option, e)}. Copy the example from the "
f"documentation, or leave it empty if you have no fixed "
f"tariff.")
if isinstance(value, dict):
tz = str(value.get("timezone", "") or "").strip().lower()
if tz not in SUPPORTED_TZ:
raise ProfileInvalid(
f"{option}.timezone is {value.get('timezone')!r}. Only "
f"Europe/Brussels is supported: the shipped image carries no tz "
f"database (see app/price.py), so any other zone could not be "
f"honoured and would be silently treated as Brussels - which is "
f"wrong by an hour for part of the year.")
f"Europe/Brussels is supported.")
rows = value.get("periods")
if rows is None:
raise ProfileInvalid(
f"{option} has no \"periods\" list. Even a fully-fixed "
f"tariff is written as one period covering 00:00-24:00 all days "
f"- one shape for both products, so nothing downstream branches "
f"on which kind of site this is.")
f"{option} has no \"periods\" list. A fixed price is one "
f"period from 00:00 to 24:00 on all days.")
else:
rows = value
@@ -1137,18 +1119,39 @@ class TariffPlan:
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
surface said either.
"""
# UX-08: plain words, every figure in its own slot as before.
if not self.has_prices:
return f"no tariff - {self.read.reason}"
tag = {"provider": self.read.provider, "static": "static_profile",
"fallback": "static_profile (feed down)"}.get(self.origin,
self.origin)
return f"no prices, {self.read.reason}"
tag = {"provider": "the price feed", "static": "your fixed tariff",
"fallback": "your fixed tariff, because the feed is down"}.get(
self.origin, self.origin)
what = {PriceProduct.DAY_AHEAD: "day-ahead prices",
PriceProduct.REALTIME: "real-time prices",
PriceProduct.STATIC: "day/night prices",
PriceProduct.FIXED: "a fixed price"}.get(self.product,
str(self.product))
# 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
return (f"{self.product.value} from {tag}, "
f"{self.n_points} points over {self.hours} h, "
f"{self.n_firm} firm / {self.n_carried} carried"
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
if gaps else "")
+ (", provisional" if self.provisional else ""))
return (f"{what} from {tag}, "
f"{self.n_points} prices over {self.hours} hours, "
f"{self.n_firm} published and {self.n_carried} estimated"
+ (f", {spacing_words(gaps)}" if gaps 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:
@@ -1218,9 +1221,9 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
if profile is None:
return TariffPlan(
PriceRead.no_data(
"no static_profile configured; §11.4: plan provisional, no "
"arbitrage. Self-consumption, peak shaving and reserve "
"management are unaffected"),
"no fixed tariff is set, so the plan is estimated and there "
"is no trading. Self-consumption, peak limiting and the "
"reserve are not affected"),
None, "none")
points = profile.series(since, until, provisional=provisional)
return TariffPlan(
@@ -1242,21 +1245,22 @@ def static_status(opts: dict, now: datetime):
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
now=now)
except ProfileInvalid as e:
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
"should have been refused at startup - see validate.py", e)
return False, f"static_profile unusable: {e}"
_LOG.warning("static_profile cannot be used (%s), so there is no "
"cheap window. The start-up check should have refused "
"it.", e)
return False, f"Fixed tariff cannot be used: {e}"
if not plan.has_prices:
return False, "no static profile"
return False, "No fixed tariff set"
cheap = plan.cheap_now(now)
window = plan.window_at(now)
price = plan.price_at(now)
when = plan.profile.next_change(now)
until = "" if when is None else f" until {brussels(when):%H:%M}"
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
f"mean {plan.mean_eur_kwh():.4f} over "
f"{MEAN_WINDOW_DAYS} d - "
f"{'CHEAP window' if cheap else 'not a cheap window'}")
kind = ("Fixed" if plan.product is PriceProduct.FIXED else "Day/night")
return cheap, (f"{kind} tariff, {window}{until}: {price:.4f} EUR/kWh, "
f"{'cheap' if cheap else 'not cheap'} against the "
f"{MEAN_WINDOW_DAYS}-day average of "
f"{plan.mean_eur_kwh():.4f}")
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
@@ -1349,17 +1353,18 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
try:
profile = parse_static_profile(price_option(opts, "static_profile"))
except ProfileInvalid as e:
_LOG.warning("the price feed is down AND static_profile is unusable "
"(%s), so there is no tariff at all", e)
_LOG.warning("The price feed is down and the fixed tariff "
"(static_profile) cannot be used either (%s), so there is "
"no tariff at all.", e)
profile = None
if profile is None:
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
"back to. The plan is provisional and arbitrage is off; "
"self-consumption, peak shaving and reserve management "
"continue (§6, §10.1).", read.reason)
_LOG.warning("The price feed failed and no fixed tariff is set, so "
"trading is off, while peak limiting and home use go on. "
"%s.", read.reason)
return TariffPlan(read, None, "none")
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
"PROVISIONAL, arbitrage off.", read.reason)
_LOG.warning("The price feed failed, so the fixed tariff is used "
"instead, marked as estimated, and trading is off. %s.",
read.reason)
points = profile.series(since, until, provisional=True)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile", stamp),
+33 -24
View File
@@ -222,6 +222,8 @@ class MigrationReport:
self.misdirected = []
# Why nothing ran, when nothing ran. Empty means "already at schema".
self.skipped = ""
# The inverter list the moves were graded against, for `explain()`.
self.roster = []
@property
def ok(self) -> bool:
@@ -237,19 +239,27 @@ class MigrationReport:
# the flat keys ARE its namespace - and an operator reading the
# startup log needs to be told which, because only one of them
# changes when a second inverter is declared.
return self.skipped or (f"store already at schema "
f"{self.from_version}; no "
f"per-inverter migration needed")
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
f"namespaces: " + ", ".join(sorted(self.moved)))
return self.skipped or (f"the saved state is already at version "
f"{self.from_version}, so nothing moves")
# UX-10: plain sentences (test_messages.py part 6). Each problem is
# named by its key and what went wrong, and `problems`, `lost` and
# `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:
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
if self.problems:
out += " - PROBLEMS: " + "; ".join(self.problems)
if self.lost:
out += " - STATE LOST: " + "; ".join(self.lost)
if self.misdirected:
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
out += ". Already in place: " + ", ".join(sorted(self.kept))
def keys(notes):
return ", ".join(note.split(" ", 1)[0] for note in notes)
other = [n for n in self.problems
if n not in self.lost and n not in self.misdirected]
parts = ([f"lost {keys(self.lost)}"] if self.lost else []) + (
[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
@@ -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
# next start rather than be recorded as finished - the one thing worse
# than a failed migration is a failed migration a later version skips.
_LOG.error("per-inverter store migration LOST STATE and has not been "
"marked done: %s", rep.explain())
_LOG.error("Moving the saved state to one part per inverter lost "
"data, so it is not marked done: %s", rep.explain())
return rep
@@ -436,6 +446,7 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
if roster is None:
return
names = [str(n) for n in roster]
rep.roster = names
# `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.
#
@@ -469,8 +480,8 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
if unit in names:
continue
rep.verified = False
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
f"on the roster {names!r} - nothing reads that key")
note = (f"{target} was moved under {unit!r}, which is not in the "
f"inverter list {names!r}, so nothing reads it")
rep.problems.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]
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
if len(names) <= 1:
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
"history and are read in place, so there is nothing to "
"move and the store layout does not change")
rep.skipped = ("one inverter, so it is read where it is and nothing "
"moves")
return rep
if rep.from_version >= MAINT_SCHEMA_VERSION:
rep.skipped = (f"maintenance history already at schema "
f"{rep.from_version}; written by a multi-inverter "
f"version and not touched")
rep.skipped = (f"already at version {rep.from_version}, written for "
f"several inverters, so it is left as it is")
return rep
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
# costs one repeated pass and buys the difference between a migration
# that can still be fixed and one the store says already happened.
_LOG.error("per-pack maintenance-history migration did NOT land and "
"has not been marked done: %s", rep.explain())
_LOG.error("Moving the maintenance history to one part per inverter "
"did not work, so it is not marked done: %s", rep.explain())
return rep
+22 -13
View File
@@ -114,6 +114,8 @@ import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone, tzinfo
from . import messages
_LOG = logging.getLogger("goodwe.tariff")
QUARTER_S = 900
@@ -339,8 +341,8 @@ class PeakStore:
# 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
# the error case, and it is logged as one below.
_LOG.info("no peak store: %s - the monthly peak will read "
"NOT_OBSERVED", self.last_error)
_LOG.info("No peak store (%s), so the month peak cannot be read "
"and only the peak limit is used.", self.last_error)
return
try:
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
# 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.
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
_LOG.error("The peak store at %s cannot be used (%s), so only the "
"peak limit is used. The month peak is unknown, not "
"zero.", self.path, err)
def close(self) -> None:
if self._db is not None:
@@ -672,12 +675,12 @@ class PeakTracker:
now = now or datetime.now(timezone.utc)
snap = self.store.load_partial()
if not snap:
self.restore_state = ("fresh: no checkpoint" if self.store.ok
else "fresh: store unreadable")
self.restore_state = ("fresh start, nothing saved" if self.store.ok
else "fresh start, the store cannot be read")
return self.restore_state
block = snap.get("block")
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
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
@@ -686,11 +689,13 @@ class PeakTracker:
# A reshaped accumulator (meter_phases changed) is a different
# measurement, not a resumable one. Say so; do not half-resume.
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
self.restore_state = (
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
f"resumed the open 15-minute period at "
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
# 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))
banked = self.store.record_block(closed, self.policy)
self.store.clear_partial()
# UX-10: plain words and the local time, not ISO and "banked".
self.restore_state = (
f"closed a stale block from {closed.start.isoformat()} at "
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
+ ("banked" if banked else "NOT banked - store unwritable"))
f"closed the 15-minute period from "
f"{messages.when(closed.start.astimezone(BILLING_TZ))} at "
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
# -- steady state -------------------------------------------------------
+38
View File
@@ -0,0 +1,38 @@
{
"groups": {
"meter": {"name": "Meter", "description": "Where the grid reading comes from."},
"inverters": {"name": "Inverters and batteries", "description": "The inverters the add-on controls, and your solar."},
"tariff": {"name": "Tariff and prices", "description": "What you pay and are paid per kWh."},
"safety": {"name": "Safety limits", "description": "The peak limit, starting and stopping, and the safety stops."},
"maintenance": {"name": "Maintenance", "description": "The monthly battery cycle and the log."},
"tuning": {"name": "Control tuning", "description": "For support only, because these were measured on real hardware."}
},
"parts": {
"meter.timing": {"name": "Timing"},
"inverters.solar": {"name": "Solar"},
"tariff.selling": {"name": "Charging and selling on price"},
"tariff.feed": {"name": "Price feed details"},
"safety.peak": {"name": "Peak limit"},
"safety.start": {"name": "Starting and stopping"},
"safety.stops": {"name": "Safety stops", "description": "Measured on real hardware, so leave them unchanged."},
"maintenance.support": {"name": "Support"}
},
"fields": {
"solar": {"name": "Solar inverters", "description": "Each solar inverter's power sensor, which the add-on records but does not use for control."},
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."}
},
"choices": {
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"}
},
"applies": {
"restart": {"name": "Applies after a restart"},
"now": {"name": "Applies right away"}
}
}
+295
View File
@@ -0,0 +1,295 @@
"""UX-13: the new page's layout, written once - screen, group, field.
UI code names option keys only here and in uimodel.py, which holds the
derivations this table lists (DECISIONS/UX-13-one-layout-table.md, amending
UX-02 §3). Everything else stays where UX-02 put it: ranges in config.yaml's
schema, rules in validate.py. test_uilayout.py fails CI when an option in
config.yaml has no place here.
A field's `key` is an option key, `inverters.<field>` for one inverter in the
list, or `ui.<name>` for a page-only control that uimodel.py turns into options.
The key is also its text key: label and help are translations/*.yaml's entry
for an option, and app/ui_text's `fields.<name>` for a `ui.` field.
"""
from dataclasses import dataclass
RESTART = "restart" # stored options apply after a restart (UX-02 §5)
NOW = "now" # runtime state (OPT-11's battery switches), none yet
# Controls are names, not string literals, so a key is never followed by a
# constant (test_defaults_pin.py reads `f("key", <constant>)` as a fallback).
CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = (
"choice", "entity", "entity_list", "toggle", "segmented", "slider")
NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = (
"number", "text", "select", "locked", "inverter_list", "solar_list")
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST)
# What an entity picker offers first. `power_w` is a power sensor the add-on
# reads as watts with no unit conversion, so validate.py warns on a kW sensor.
ENTITY_KINDS = ("power_w", "power", "energy", "pct", "number", "any")
SHOW_OPS = ("==", "!=", "in", ">", "<")
@dataclass(frozen=True)
class Field:
key: str
control: str
unit: str = ""
scale: int = 1 # shown = stored / scale (the peak limit in kW)
step: float = 0 # slider or number step, stored units; 0 = 1
kind: str = "" # entity picker filter, one of ENTITY_KINDS
show: tuple = () # ((name, op, value), ...), all must hold
applies: str = RESTART
simple: bool = False # also on Settings, Simple
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
@property
def text(self) -> str:
"""Where the label and help live: `translations` or `ui_text`."""
return "ui_text" if self.key.startswith("ui.") else "translations"
@dataclass(frozen=True)
class Part:
id: str # "" is the group's main part
fields: tuple
collapsed: bool = False
@dataclass(frozen=True)
class Group:
id: str
parts: tuple
support_only: bool = False # collapsed, with a "for support" warning
def _src(*values):
return (("meter_source", "in", values),)
_DYNAMIC = (("ui.contract", "==", "dynamic"),)
_MAINT = (("maintenance_enabled", "==", True),)
_ONE = (("inverter_count", "==", 1),)
_SEVERAL = (("inverter_count", ">", 1),)
_PHASES = (("meter_phases", ">", 1),)
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
ROSTER_FIELDS = (
Field("inverters.name", TEXT, wizard=2),
Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2),
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
Field("inverters.batt_invert", TOGGLE, wizard=2),
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, wizard=2),
)
GROUPS = (
Group("meter", (
Part("", (
Field("meter_source", CHOICE, wizard=1),
Field("meter_entity", ENTITY, kind="power_w", show=_src("off"),
wizard=1),
Field("meter_invert", TOGGLE, show=_src("off"), wizard=1),
Field("p1_net_entity", ENTITY, kind="power_w",
show=_src("ha_signed"), wizard=1),
Field("p1_import_entity", ENTITY, kind="power_w",
show=_src("ha_dsmr"), wizard=1),
Field("p1_export_entity", ENTITY, kind="power_w",
show=_src("ha_dsmr"), wizard=1),
Field("p1_host", TEXT, show=_src("homewizard_local"), wizard=1),
Field("meter_mqtt_topic", TEXT, show=_src("mqtt_p1"), wizard=1),
Field("meter_phases", SEGMENTED, wizard=1),
Field("p1_phase_net_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_signed") + _PHASES, wizard=1),
Field("p1_phase_import_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_dsmr") + _PHASES, wizard=1),
Field("p1_phase_export_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_dsmr") + _PHASES, wizard=1),
)),
Part("timing", (
Field("meter_max_age_s", SLIDER, "s", step=5),
Field("meter_poll_s", SLIDER, "s",
show=_src("homewizard_local")),
), collapsed=True),
)),
Group("inverters", (
Part("", (Field("inverters", INVERTER_LIST, wizard=2),)),
# pv.py reads each entity's own unit, so a kW sensor is fine here.
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
wizard=3),)),
)),
Group("tariff", (
Part("", (
Field("ui.contract", SEGMENTED, wizard=4),
Field("price_provider", SELECT, show=_DYNAMIC, wizard=4),
Field("price_formula", TEXT,
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
wizard=4),
Field("price_csv_path", TEXT,
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
wizard=4),
Field("ui.region", SELECT, wizard=4),
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
wizard=4),
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
show=_DYNAMIC, wizard=4),
Field("export_price_eur_kwh", NUMBER, "€/kWh", step=0.001,
wizard=4),
Field("price_export_formula", TEXT),
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "fixed"),), wizard=4),
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
)),
Part("selling", (
# OPT-11: derived from the batteries' selling switches once battery
# modes exist (uimodel.arbitrage_from_batteries). A toggle until then.
Field("arbitrage_enabled", TOGGLE),
Field("cycle_cost_threshold_eur", NUMBER, "€/kWh", step=0.01),
Field("arbitrage_soc_floor", SLIDER, "%"),
Field("arbitrage_soc_ceiling", SLIDER, "%"),
)),
Part("feed", (
Field("price_feed_url", TEXT, show=_DYNAMIC),
Field("price_horizon_h", SLIDER, "h", show=_DYNAMIC),
Field("price_timeout_s", SLIDER, "s", show=_DYNAMIC),
Field("price_now_entity", ENTITY, kind="any"),
Field("price_avg_entity", ENTITY, kind="any"),
), collapsed=True),
)),
Group("safety", (
Part("peak", (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5),
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
Part("start", (
Field("shadow_mode", SEGMENTED, simple=True, wizard=6),
Field("auto_start", TOGGLE),
Field("estop_fitted", TOGGLE),
Field("stale_input_s", SLIDER, "s"),
Field("meter_freeze_s", SLIDER, "s", step=30,
show=_src("homewizard_local")),
)),
Part("stops", (
Field("sign_disagree_w", LOCKED, "W", step=10, show=_ONE),
Field("sign_disagree_s", LOCKED, "s", step=5, show=_ONE),
Field("unit_sign_disagree_w", LOCKED, "W", step=10,
show=_SEVERAL),
Field("unit_sign_disagree_s", LOCKED, "s", step=5,
show=_SEVERAL),
Field("site_sign_disagree_w", LOCKED, "W", step=10,
show=_SEVERAL),
Field("site_sign_disagree_s", LOCKED, "s", step=5,
show=_SEVERAL),
Field("circulation_w", LOCKED, "W", step=10, show=_SEVERAL),
Field("circulation_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
), collapsed=True),
)),
Group("maintenance", (
Part("", (
Field("maintenance_enabled", TOGGLE),
Field("maintenance_interval_days", SLIDER, "days", show=_MAINT),
Field("maintenance_start_hour", SELECT, show=_MAINT),
Field("maintenance_discharge_w", SLIDER, "W", step=100,
show=_MAINT),
Field("maintenance_charge_w", SLIDER, "W", step=100,
show=_MAINT),
Field("maintenance_soc_floor", SLIDER, "%", show=_MAINT),
Field("maintenance_soc_target", SLIDER, "%", show=_MAINT),
Field("maintenance_hold_min", SLIDER, "min", step=5,
show=_MAINT),
)),
Part("support", (Field("log_level", SELECT),)),
)),
Group("tuning", (
Part("", (
Field("gain", LOCKED, step=0.05),
Field("slew_w", LOCKED, "W", step=50),
Field("deadband_w", LOCKED, "W"),
Field("target_grid_w", LOCKED, "W"),
Field("step_w", LOCKED, "W"),
Field("saturation_w", LOCKED, "W", step=10),
Field("saturation_cycles", LOCKED),
Field("integrator_max_w", LOCKED, "W", step=100),
Field("heartbeat_s", LOCKED, "s"),
Field("cycle_floor_s", LOCKED, "s"),
)),
), support_only=True),
)
# Option keys the page never shows. uimodel.py writes each from page fields.
DERIVED = {
"setpoint_entity": "inverters", # one inverter, stored flat
"soc_entity": "inverters",
"batt_entity": "inverters",
"batt_invert": "inverters",
"max_w": "inverters",
"bms_soc_floor": "inverters",
"pv_entity": "ui.solar", # one solar row, stored flat
"pv_entities": "ui.solar",
"pv_invert_entities": "ui.solar", # the rows' "reads negative" toggles
"static_profile": "ui.fallback", # built from the price fields
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
}
# Option keys the page never shows and never writes: passed through as stored.
HIDDEN: dict = {}
def fields():
"""Every field once, in page order, roster fields after `inverters`."""
for group in GROUPS:
for part in group.parts:
for field in part.fields:
yield group, part, field
if field.control == INVERTER_LIST:
for sub in ROSTER_FIELDS:
yield group, part, sub
def field(key: str) -> Field | None:
return next((f for _g, _p, f in fields() if f.key == key), None)
def visible(fld: Field, model: dict) -> bool:
"""Whether `fld` is shown for this page model (uimodel.options_to_ui)."""
for name, op, value in fld.show:
have = model.get(name)
if op == "==" and have != value:
return False
if op == "!=" and have == value:
return False
if op == "in" and have not in value:
return False
try:
if op == ">" and not float(have) > value:
return False
if op == "<" and not float(have) < value:
return False
except (TypeError, ValueError):
return False
return True
def range_of(key: str, schema_ui: list):
"""(min, max) from the Supervisor's schema (`info["schema"]`), or None.
Never typed here: config.yaml is the one place a range is written. A range
arrives as lengthMin/lengthMax (tools/supervisor_selfapi_probe.py)."""
path = key.split(".")
nodes = schema_ui or []
for step in path[:-1]:
parent = next((n for n in nodes if n.get("name") == step), None)
nodes = (parent or {}).get("schema") or []
node = next((n for n in nodes if n.get("name") == path[-1]), None)
if not node or "lengthMin" not in node or "lengthMax" not in node:
return None
return node["lengthMin"], node["lengthMax"]
+331
View File
@@ -0,0 +1,331 @@
"""UX-13: stored options <-> the page model, for the keys the page derives.
`options_to_ui()` turns the stored blob into what the page edits: always a list
of inverter cards, solar rows with a "reads negative" toggle, a contract and
simple prices instead of `static_profile` JSON, and a region preset instead of
the levies figure. `ui_to_options()` turns it back, so the validator and the
Supervisor always see the real blob.
The rule that makes the round trip exact: the model carries the stored values
of the derived keys (`keep`). A derivation whose page fields still read what
`keep` decodes to writes `keep` back untouched, and only an edited one is
re-encoded. So a blob the page cannot express (a `static_profile` with odd
windows, a malformed roster) survives a save of some other field unchanged.
See DECISIONS/UX-13-one-layout-table.md.
"""
import copy
import json
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
from .static_tariff import ProfileInvalid, parse_static_profile
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
"arbitrage_from_batteries", "DeriveError"]
class DeriveError(ValueError):
"""The page model asks for something no options blob can say."""
# --------------------------------------------------------------------------- #
# Region presets for "Grid costs, levies and VAT". Year-stamped, updated each
# January (owner, 2026-09-27). Only presets with a sourced figure are listed:
# Flanders is app/price.py's Fluvius Antwerpen sum (TARIFF-18: Eneco
# residential card, Flanders, September 2026, VAT included). Wallonia and
# Brussels have a day and a night band (config.yaml) and join with sourced
# figures; until then those sites choose "own".
# --------------------------------------------------------------------------- #
REGION_PRESETS = {
"flanders": {
"year": 2026,
"taxes_eur_kwh": round(sum(v for _n, v in
FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5),
"taxes_profile": "",
},
}
OWN = "own"
_ROSTER_FLAT = ("setpoint_entity", "soc_entity", "batt_entity", "batt_invert",
"max_w", "bms_soc_floor")
_TOL = 1e-9
def _blank(v) -> bool:
return v is None or (isinstance(v, str) and not v.strip())
def _num(v):
try:
return None if _blank(v) or isinstance(v, bool) else float(v)
except (TypeError, ValueError):
return None
# --- 1. one inverter (flat keys) or the `inverters` list -------------------- #
def _roster_decode(o):
entries = o.get("inverters")
if entries is None or entries == "" or entries == []:
card = {k: o.get(k) for k in _ROSTER_FLAT}
card.update(name="", capacity_kwh=None)
return {"inverter_shape": "flat", "inverters": [card]}
if isinstance(entries, list) and all(isinstance(e, dict) for e in entries):
return {"inverter_shape": "list",
"inverters": [dict(e) for e in entries]}
# Not something a card can show: edited in the Configuration tab only.
return {"inverter_shape": "raw", "inverters": None}
def _roster_encode(m, keep):
out = {}
shape, cards = m.get("inverter_shape"), m.get("inverters")
if shape == "raw":
raise DeriveError("the inverter list is edited in the Configuration tab")
if not cards:
raise DeriveError("at least one inverter is needed")
if shape == "flat" and len(cards) == 1:
card = cards[0]
if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")):
raise DeriveError("one inverter stored flat has no name or capacity")
for k in _ROSTER_FLAT:
if card.get(k) is not None or k in keep:
out[k] = card.get(k)
out["inverters"] = []
return out
out["inverters"] = [{k: v for k, v in c.items() if v is not None}
for c in cards]
# The flat keys beside a list are not read: they stay as stored.
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
if shape == "flat":
# CFG-04 rule 9e: a flat power request entity beside a list is refused.
out["setpoint_entity"] = ""
return out
# --- 2. solar rows: pv_entity / pv_entities / pv_invert_entities ------------ #
def _strs(v):
return [str(x).strip() for x in (v if isinstance(v, list) else [])
if not _blank(x)]
def _solar_rows(o):
listed = _strs(o.get("pv_entities"))
if listed:
return listed, True
one = "" if _blank(o.get("pv_entity")) else str(o.get("pv_entity")).strip()
return ([one] if one else []), False
def _solar_decode(o):
rows, _listed = _solar_rows(o)
flipped = set(_strs(o.get("pv_invert_entities")))
return {"ui.solar": [{"entity": e, "reversed": e in flipped}
for e in rows]}
def _solar_encode(m, keep):
out = {}
rows = [r for r in (m.get("ui.solar") or [])
if not _blank(r.get("entity"))]
entities = [str(r["entity"]).strip() for r in rows]
old_rows, was_list = _solar_rows(keep)
if len(entities) <= 1 and not was_list:
out["pv_entity"] = entities[0] if entities else ""
if "pv_entities" in keep:
out["pv_entities"] = []
else:
out["pv_entities"] = entities
if "pv_entity" in keep:
out["pv_entity"] = "" # TEL-12: the list replaces it
# Flips of entities that were never a row stay as stored (pv.stray_inverts
# reports them). Flips of removed rows go with the row.
extras = [x for x in _strs(keep.get("pv_invert_entities"))
if x not in old_rows and x not in entities]
flips = [e for e, r in zip(entities, rows) if r.get("reversed")] + extras
if flips or "pv_invert_entities" in keep:
out["pv_invert_entities"] = flips
return out
# --- 3. contract and static_profile ----------------------------------------- #
def _fixed_json(price) -> str:
return json.dumps({"periods": [
{"name": "all", "from": "00:00", "to": "24:00", "days": "mon-sun",
"eur_per_kwh": price}]})
def _daynight_json(day, night) -> str:
return json.dumps({"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": day},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": night},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": night}]})
def _shape(text):
"""The periods of a profile without their names, for comparing, or None."""
try:
prof = parse_static_profile(text)
except ProfileInvalid:
return None
if prof is None:
return ()
return tuple(sorted((p.start_min, p.end_min, tuple(sorted(p.days)),
p.eur_per_kwh) for p in prof.periods))
def _profile_decode(raw):
"""("none"|"fixed"|"daynight"|"custom", fixed, day, night)."""
if isinstance(raw, str) and _blank(raw):
return "none", None, None, None
if not isinstance(raw, str):
return "custom", None, None, None
got = _shape(raw)
if not got:
return ("none" if got == () else "custom"), None, None, None
if len(got) == 1 and got == _shape(_fixed_json(got[0][3])):
return "fixed", got[0][3], None, None
day = next((p[3] for p in got if p[:2] == (420, 1320)), None)
night = next((p[3] for p in got if p[:2] == (1320, 420)), None)
if (day is not None and night is not None
and got == _shape(_daynight_json(day, night))):
return "daynight", None, day, night
return "custom", None, None, None
def _tariff_decode(o):
kind, fixed, day, night = _profile_decode(o.get("static_profile", ""))
provider = o.get("price_provider")
dynamic = not _blank(provider) and provider != "off"
return {"ui.contract": "dynamic" if dynamic else kind,
"price_provider": provider, "ui.fallback": kind,
"ui.fixed_price": fixed, "ui.day_price": day,
"ui.night_price": night}
def _tariff_encode(m, keep):
out = {}
contract = m.get("ui.contract")
if contract == "dynamic":
if _blank(m.get("price_provider")) or m.get("price_provider") == "off":
raise DeriveError("a dynamic contract needs a price source")
out["price_provider"] = m["price_provider"]
kind = m.get("ui.fallback")
else:
out["price_provider"] = "off"
kind = contract
if kind == "none":
out["static_profile"] = ""
elif kind == "fixed":
out["static_profile"] = _fixed_json(_price_of(m, "ui.fixed_price"))
elif kind == "daynight":
out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"),
_price_of(m, "ui.night_price"))
elif kind == "custom":
if "static_profile" not in keep:
raise DeriveError("odd tariff windows are edited in the "
"Configuration tab")
out["static_profile"] = keep["static_profile"]
else:
raise DeriveError(f"unknown contract {kind!r}")
return out
def _price_of(m, name):
value = _num(m.get(name))
if value is None:
raise DeriveError(f"{name} needs a price")
return value
# --- 4. region preset -> price_taxes_eur_kwh / price_taxes_profile ---------- #
def _region_decode(o):
taxes, profile = o.get("price_taxes_eur_kwh"), o.get("price_taxes_profile", "")
region = OWN
for rid, p in REGION_PRESETS.items():
same = _num(taxes) is not None and abs(_num(taxes) - p["taxes_eur_kwh"]) < _TOL
if same and str(profile or "").strip() == p["taxes_profile"]:
region = rid
return {"ui.region": region, "price_taxes_eur_kwh": taxes}
def _region_encode(m, keep):
region = m.get("ui.region")
if region == OWN:
out = {"price_taxes_eur_kwh": m.get("price_taxes_eur_kwh")}
if "price_taxes_profile" in keep: # "own" keeps the stored table
out["price_taxes_profile"] = keep["price_taxes_profile"]
return out
if region not in REGION_PRESETS:
raise DeriveError(f"unknown region {region!r}")
p = REGION_PRESETS[region]
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
if p["taxes_profile"] or "price_taxes_profile" in keep:
out["price_taxes_profile"] = p["taxes_profile"]
return out
# --- 5. arbitrage_enabled: the OPT-11 hook ---------------------------------- #
def arbitrage_from_batteries(m):
"""OPT-11 hook. With battery modes on, `arbitrage_enabled` is true when any
battery may sell. Battery modes are not built yet, so this returns None and
the toggle is stored as the page has it: no behaviour change."""
return None
def _arbitrage_decode(o):
return {"arbitrage_enabled": o.get("arbitrage_enabled")}
def _arbitrage_encode(m, keep):
derived = arbitrage_from_batteries(m)
value = m.get("arbitrage_enabled") if derived is None else derived
return {} if value is None and "arbitrage_enabled" not in keep \
else {"arbitrage_enabled": value}
# (option keys it writes, decode, encode). Each is independent of the others.
DERIVATIONS = (
(("inverters",) + _ROSTER_FLAT, _roster_decode, _roster_encode),
(("pv_entity", "pv_entities", "pv_invert_entities"), _solar_decode,
_solar_encode),
(("price_provider", "static_profile"), _tariff_decode, _tariff_encode),
(("price_taxes_eur_kwh", "price_taxes_profile"), _region_decode,
_region_encode),
(("arbitrage_enabled",), _arbitrage_decode, _arbitrage_encode),
)
_OWNED = {k for keys, _d, _e in DERIVATIONS for k in keys}
_MODEL_ONLY = ("keep", "inverter_shape", "inverter_count", "region_year")
def options_to_ui(options: dict) -> dict:
"""The page model for a stored blob. Pure: reads nothing else."""
model = {k: copy.deepcopy(v) for k, v in options.items()
if k not in _OWNED}
for _keys, decode, _encode in DERIVATIONS:
model.update(decode(options))
model["keep"] = {k: copy.deepcopy(options[k]) for k in _OWNED
if k in options}
cards = model.get("inverters")
model["inverter_count"] = len(cards) if isinstance(cards, list) else None
preset = REGION_PRESETS.get(model["ui.region"])
model["region_year"] = preset["year"] if preset else None
return model
def ui_to_options(model: dict) -> dict:
"""The options blob for a page model. Raises DeriveError on a model no blob
can express, so the page says so instead of saving something else."""
keep = model.get("keep") or {}
out = {k: copy.deepcopy(v) for k, v in model.items()
if k not in _MODEL_ONLY and k not in _OWNED
and not k.startswith("ui.")}
for keys, decode, encode in DERIVATIONS:
was = decode(keep)
if all(model.get(name) == value for name, value in was.items()):
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
else:
out.update(encode(model, keep))
return out
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -670,11 +670,10 @@ class SignWatchdog:
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
# and it defaults to the wording that shipped - "a meter reading" - so a
# one-inverter install's `reason` is byte-identical to what an operator
# (and test_watchdog.py) has always read. A per-unit instance names what
# it actually compared instead, because "commanded +1500 W against a
# meter reading -1500 W" would be a false statement about which
# instrument produced the evidence, on the one line somebody woken by
# this alarm reads first.
# has always read (test_watchdog.py pins its meaning). A per-unit
# instance names what it actually compared instead, because "commanded
# +1500 W against a meter reading -1500 W" would be a false statement
# about which instrument produced the evidence.
self.reason = (
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "
+35 -12
View File
@@ -24,14 +24,17 @@ routes below read `GET /addons/self/info`, run the SHIPPED validator, write
mode or reaches `controller.handle_action()`. The set of things an operator can
do to the inverter through this panel is exactly what `/action` offered before
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
actions and comparing them with the shipped set.
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
of Home Assistant's entity list (app/haentities.py) and writes nothing.
"""
import logging
from aiohttp import web
from . import selfconfig
from . import messages, selfconfig
from .haentities import EntityCache
from .validate import config_notes
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
@@ -125,11 +128,8 @@ refresh(); setInterval(refresh, 3000);
# them too, and says so, because a green page that is green for a reason the
# operator cannot see is the exact failure this add-on keeps refusing to ship.
DEVICE_NOTE = (
"%s did not report a range, so the two checks that compare a configured "
"ceiling against what the device itself accepts were skipped - here and at "
"startup, identically. Everything else was checked. If the device answers "
"later and disagrees, the add-on refuses to start then, with the same "
"message you would have seen here.")
"%s did not report its power range, so two checks were skipped, here and "
"at start. If they fail later, the add-on will not start then.")
def build_app(controller, selfcfg=None) -> web.Application:
@@ -143,6 +143,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
if selfcfg is None:
selfcfg = selfconfig.SelfConfig(controller.hass.session,
controller.hass.token)
entities = EntityCache(controller.hass)
async def index(_request):
return web.Response(text=PAGE, content_type="text/html")
@@ -219,11 +220,16 @@ def build_app(controller, selfcfg=None) -> web.Application:
be a second opinion about a rule it does not own.
"""
_body, options = await _proposed(request)
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass)
options, controller.hass, notes=notes)
by_key, unanchored = problems_by_field(problems, _keys_of(options))
# UX-13: warnings and info beside the refusals. Never block a save.
# The entity checks use the picker's cached list and never wait on HA.
notes += config_notes(options, entities=entities.peek())
payload = {"problems": problems, "by_key": by_key,
"unanchored": unanchored}
"unanchored": unanchored,
"notes": [n._asdict() for n in notes]}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
return web.json_response(payload)
@@ -264,7 +270,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts")
"not applied until the add-on restarts",
hint=messages.hint("settings_saved"))
return web.json_response({"ok": True, **result})
async def config_restart(request):
@@ -288,9 +295,16 @@ def build_app(controller, selfcfg=None) -> web.Application:
except selfconfig.SupervisorUnavailable as err:
_LOG.error("restart request failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("restart requested from the configuration panel")
controller.log_event("restart requested from the configuration panel",
hint=messages.hint("restart_requested"))
return web.json_response({"ok": True})
async def ha_entities(request):
"""UX-13: Home Assistant's entities for the picker. A read only, and
never an error: when Home Assistant is down the list says so."""
kind = str(request.query.get("kind", "") or "")[:16]
return web.json_response(await entities.payload(kind))
app = web.Application()
app.add_routes([
web.get("/", index),
@@ -301,12 +315,21 @@ def build_app(controller, selfcfg=None) -> web.Application:
web.post("/config/validate", config_validate),
web.post("/config/save", config_save),
web.post("/config/restart", config_restart),
web.get("/ha/entities", ha_entities),
])
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):
runner = web.AppRunner(build_app(controller, selfcfg))
runner = web.AppRunner(build_app(controller, selfcfg),
access_log=access_log())
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
@@ -0,0 +1,73 @@
{
"arbitrage_enabled": true,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": false,
"batt_entity": "sensor.test23_battery_power",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "ha_signed",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "sensor.test23_p1_meter_active_power",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 3500,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "generic_dayahead",
"price_taxes_eur_kwh": 0.06,
"price_timeout_s": 10,
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "number.test23_setpoint_w",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "sensor.test23_soc",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
@@ -0,0 +1,73 @@
{
"arbitrage_enabled": false,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": false,
"batt_entity": "",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "off",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 3500,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "off",
"price_taxes_eur_kwh": 0.06,
"price_timeout_s": 10,
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
@@ -0,0 +1,98 @@
{
"arbitrage_enabled": false,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": false,
"batt_entity": "sensor.test23_battery_power",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [
{
"batt_entity": "sensor.goodwe_a_battery_power",
"bms_soc_floor": 10,
"capacity_kwh": 15.36,
"max_w": 5000,
"name": "inverter-a",
"setpoint_entity": "number.goodwe_a_setpoint_w",
"soc_entity": "sensor.goodwe_a_soc"
},
{
"batt_entity": "sensor.goodwe_b_battery_power",
"bms_soc_floor": 10,
"capacity_kwh": 20.48,
"max_w": 4600,
"name": "inverter-b",
"setpoint_entity": "number.goodwe_b_setpoint_w",
"soc_entity": "sensor.goodwe_b_soc"
}
],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "ha_signed",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "sensor.test23_p1_meter_active_power",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 3500,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_export_formula": "",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_formula": "",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "generic_dayahead",
"price_taxes_eur_kwh": 0.06,
"price_taxes_profile": "",
"price_timeout_s": 10,
"pv_entities": [],
"pv_entity": "",
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "",
"shadow_mode": "auto",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "sensor.test23_soc",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
@@ -0,0 +1,73 @@
{
"arbitrage_enabled": false,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": false,
"batt_entity": "sensor.test23_battery_power",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "ha_signed",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "sensor.test23_p1_meter_active_power",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 3500,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "generic_dayahead",
"price_taxes_eur_kwh": 0.06,
"price_timeout_s": 10,
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "number.test23_setpoint_w",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "sensor.test23_soc",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
@@ -0,0 +1,73 @@
{
"arbitrage_enabled": true,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": true,
"batt_entity": "sensor.test23_battery_power",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "ha_signed",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "sensor.test23_p1_meter_active_power",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 15000,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "manual_csv",
"price_taxes_eur_kwh": 0.06,
"price_timeout_s": 10,
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "number.test23_setpoint_w",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "sensor.test23_soc",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
+10 -9
View File
@@ -1487,13 +1487,14 @@ s47check("the shipped row carries the caveat at the worst cycle: the operator is
s47check("...and it still carries the watts, in their own slot, and the standing "
"0 W beside them - the caveat NARROWS the claim rather than deleting "
"the record a shadow run exists to produce",
"WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row
"it would ask for" in _s47_row
and "the inverters stay at 0 W" in _s47_row
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
s47check("...and the watts are introduced as what it would have commanded ON "
"THIS CYCLE, in every shadow state and not only the caveated ones - no "
"number a shadow run records is a forecast of a live run",
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row)
s47check("...and the watts are introduced as what it would ask for THIS CYCLE, "
"in every shadow state and not only the caveated ones - no number a "
"shadow run records is a forecast of a live run (UX-08: in plain words)",
"This cycle it would ask for" in _s47_row
and "This cycle it would ask for" in _s47_first_row)
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
@@ -2609,7 +2610,7 @@ check("arbitrage on with NO price source at all is refused - a strategy that "
"must refuse on every cycle forever looks exactly like one that is "
"running and finding nothing",
why(validate_startup_config(vbase(arbitrage_enabled=True, **P1_ON),
-5000, 5000), "NO PRICES AT ALL"))
-5000, 5000), "the site has no prices"))
check("arbitrage on with meter_source off is refused - §9.1 puts peak shaving "
"above arbitrage and an unmeasured peak cannot be shaved",
why(validate_startup_config(
@@ -2620,7 +2621,7 @@ check("a floor at or above the ceiling is refused - there is no band left "
why(validate_startup_config(
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
arbitrage_soc_floor=90, arbitrage_soc_ceiling=90, **P1_ON),
-5000, 5000), "no band left"))
-5000, 5000), "Make the lower limit the smaller number"))
check("...and the same numbers the right way round start",
validate_startup_config(
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
@@ -2629,7 +2630,7 @@ check("...and the same numbers the right way round start",
check("a NEGATIVE cycle_cost_threshold_eur is refused even with arbitrage OFF "
"- it is a cost, and a negative one is a threshold every spread clears",
why(validate_startup_config(vbase(cycle_cost_threshold_eur=-0.01),
-5000, 5000), "non-negative")
-5000, 5000), "Set it to 0 or more")
and raises(lambda: threshold_eur_kwh({"cycle_cost_threshold_eur": -1}),
ValueError))
# Rule 7's wording, which OPT-04 had to update because it named what main.py
+3 -4
View File
@@ -62,10 +62,9 @@ the step it wants: if checks.yml is restructured so that the step cannot be
found, this file goes RED rather than quietly grading nothing.
⚠️ NEEDS `git`, which is why .gitea/ci/Dockerfile installs it. No network: the
fixtures are local repositories built in a temp directory, and the workflow's
`git remote add origin "$(echo "$URL" | sed "s#://#://x-access-token:...@#")"`
is a no-op substitution on a path with no `://` in it, which is what lets the
shipped line be used verbatim.
fixtures are local repositories built in a temp directory; the workflow's
token header (TOOL-9) is ignored for a local path, which is what lets the
shipped lines be used verbatim.
⚠️ This file writes only under a temporary directory. It must stay that way:
CI mounts the checkout READ-ONLY.
+348 -59
View File
@@ -38,6 +38,9 @@ acceptance criteria; part 6 is TEST-11's and part 7 is SAFETY-28's:
the check that replaced a config-shape proxy: `ACTUATOR_WRITE_KEYS` graded
how many option KEYS exist, which stays 1 while a per-inverter config adds
a second real writer.
9. SAFETY-40. The integrator bound (normal anti-windup) is reported in its
own words and never as a BUG SIGNAL; the over-rating keeps it; and every
number in either message is the clamp tick's own Decision.
------------------------------------------------------------------------------
WHY PART 4 ALSO ASSERTS ON `Decision`'s BOOLEANS AND NOT ON `reason`
@@ -74,14 +77,17 @@ Read MUTATION.md before you report a survivor.
import asyncio
import io
import math
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from named_absence import given, nth, present
from app import messages
from app.clamp import (
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampResult,
ClampWatchdog,
clamp_to_rating,
is_commandable,
)
@@ -99,6 +105,9 @@ T0 = datetime(2026, 8, 30, 9, 0, 0, tzinfo=timezone.utc)
# which is also the device rating, so this is the bound the alarm is written at.
LIVE_MAX_W = 5000.0
CLAMP_ID = "goodwe_output_clamp"
# SAFETY-40: the integrator-bound WARNING has its own id, so it can never
# replace a BUG SIGNAL notice still on screen.
BOUND_ID = "goodwe_integrator_bound"
def check(name, cond):
@@ -458,7 +467,7 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over):
def clamp_notes(hass):
return [d for dom, svc, d in hass.calls
if (dom, svc) == ("persistent_notification", "create")
and d.get("notification_id") == CLAMP_ID]
and d.get("notification_id") in (CLAMP_ID, BOUND_ID)]
# THE 10.3 RUNAWAY, replayed. "With the inverter switched off entirely, an
@@ -488,8 +497,13 @@ check(f"AC 1: NOT ONE of the {len(emitted)} values that reached the device "
# largest emitted value is what it is and not 5000 W. The clamp is what makes it
# bounded; the freeze is what makes it small. A check that read
# `max(emitted) == max_w` would have been asserting the freeze, not the clamp.
# SAFETY-52 re-baseline: an inverter reporting 0 W for SETTLE_CYCLES is a
# battery that refuses, so the command then drops to 0 W. The freeze still
# engaged first, and the event log says so.
check(f"...and the saturation freeze is what pins it at {max(emitted):,.0f} W "
f"rather than the rail", ctl_r.sat_count >= ctl_r.tuning.saturation_cycles)
f"rather than the rail",
any("saturation freeze" in e for e in ctl_r.events)
and max(emitted) < LIVE_MAX_W)
notes_r = clamp_notes(hass_r)
print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
@@ -497,18 +511,23 @@ print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
f"notification(s)")
check("AC 3: and it ALARMED - silently clipping this is the defect",
ctl_r.clamp_wd.engagements > 0 and len(notes_r) >= 1)
check("...with its own notification_id, not shared with any other guard",
all(d["notification_id"] == CLAMP_ID for d in notes_r))
check("...with its own notification_id, not shared with any other guard - "
"on a default install the integrator bound's (SAFETY-40)",
all(d["notification_id"] == BOUND_ID for d in notes_r))
check("...one episode, re-asserted rather than one per cycle",
ctl_r.clamp_wd.episodes == 1 and len(notes_r) < ctl_r.clamp_wd.engagements)
check("...and it names itself a bug signal, not a limit in use",
bool(notes_r) and "out of range" in notes_r[0]["message"])
# SAFETY-40: on a default install this runaway is the INTEGRATOR bound, so it
# says so, and does not call anti-windup a bug (part 8 grades the wording).
check("...and it is the integrator-bound notice, which calls nothing a fault",
bool(notes_r) and notes_r[0]["title"] == messages.T_LOOP_BOUND
and "fault" not in notes_r[0]["message"])
rows_r = [e for e in ctl_r.events if REASON_CODE in e]
check(f"the action log records every engagement, coalesced into one row per "
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
f"engagement(s))",
len(rows_r) == ctl_r.clamp_wd.episodes
and f"{ctl_r.clamp_wd.engagements} engagement(s)" in rows_r[0])
and messages.count(ctl_r.clamp_wd.engagements, "engagement")
in rows_r[0])
# ⚠️ REGRESSION, AND IT WAS A REAL BUG BEFORE IT WAS A CHECK. `log_event()` keeps
# 40 rows. A row per engagement flushes that ring inside 40 cycles of a winding
@@ -525,20 +544,53 @@ check(f"a long winding episode does NOT flood the 40-row event log "
f"({len(rows_l)} rows for {ctl_l.clamp_wd.engagements} engagements)",
ctl_l.clamp_wd.engagements > 40 and len(rows_l) <= ctl_l.clamp_wd.episodes)
check("...and the count in the row is the counter's, so nothing is lost",
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1))
messages.count(ctl_l.clamp_wd.engagements, "engagement")
in nth(rows_l, -1))
# The RED row exists only while engaged, and the episode above is short, so it
# is captured mid-run rather than asserted after the hysteresis has cleared it.
check("the status page goes red WHILE it is engaged",
any(not c["ok"] and not c["warn"] and "OUTPUT CLAMP ENGAGED" in c["text"]
check("the status page flags it WHILE it is engaged - amber, since it is the "
"integrator bound (SAFETY-40; red is part 9's over-rating case)",
any(not c["ok"] and c["warn"] and c.get("id") == "not_keeping_up"
and c["text"].startswith(messages.T_LOOP_BOUND)
for c in ctl_r.red_snapshot))
check("...and once clear, a warn row still says how many times it engaged",
any(c["warn"] and f"{ctl_r.clamp_wd.engagements} time(s)" in c["text"]
for c in ctl_r.checks()))
check("...and once clear, a warn row still says how many times it engaged, "
"under the integrator bound's own title",
any(c["warn"] and c.get("id") == "not_keeping_up"
and c["text"].startswith(messages.T_LOOP_BOUND)
and messages.times(ctl_r.clamp_wd.bound) in c["text"]
and "fine now" in c["text"] for c in ctl_r.checks()))
# UX-07 item 1: the after-episode row used to say "output clamp ... engaged N
# time(s)" for integrator-bound cycles. Each count now sits under its own name.
check("UX-07 item 1: bound-only engagements are counted as the bound, and no "
"row calls them 'Power request too high'",
ctl_r.clamp_wd.bound == ctl_r.clamp_wd.engagements > 0
and ctl_r.clamp_wd.over_rating == 0
and not any(c.get("id") == "power_too_high"
or messages.T_OUTPUT_CLAMP in c["text"]
for c in ctl_r.checks()))
# ...and an earlier over-rating count stays on the page while a LATER bound
# episode runs: it is the software-fault evidence, and that episode (bug False)
# cannot have added to it, so "fine now" is true of it.
_w = ctl_r.clamp_wd
_saved = (_w.active, _w.bug, _w.over_rating)
_w.active, _w.bug, _w.over_rating = True, False, 2
_rows = ctl_r.checks()
_w.active, _w.bug, _w.over_rating = _saved
check("UX-07 item 1: during a bound episode an earlier over-rating count is "
"still shown, amber, under its own title",
any(c.get("id") == "not_keeping_up" for c in _rows)
and any(c.get("id") == "power_too_high" and c["warn"] and not c["ok"]
and c["text"] == messages.row_before(messages.T_OUTPUT_CLAMP, 2)
for c in _rows))
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
# keeps writing the clamped value. If a later cut changes that it must come here.
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W, not 0 W",
ctl_r.target != 0.0 and ctl_r.target == hass_r.writes[-1])
# SAFETY-52: the loop itself now asks 0 W of an inverter that gives nothing,
# with its own reason. That is the loop's decision, not a stop.
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W because "
f"the loop says '{ctl_r.loop_reason}', not because of a stop",
ctl_r.loop_reason == "battery refuses"
and ctl_r.target == hass_r.writes[-1])
check("...and the controller still reports itself running, not failsafe",
ctl_r.auto and not ctl_r.sign_wd.tripped)
@@ -580,7 +632,8 @@ check("AC 2: the clamp counted ZERO engagements", ctl_s.clamp_wd.engagements ==
check("AC 2: no OUTPUT_CLAMP row in the action log",
not [e for e in ctl_s.events if REASON_CODE in e])
check("AC 2: and no clamp row on the status page",
not [c for c in ctl_s.checks() if "output clamp" in c["text"].lower()])
not [c for c in ctl_s.checks()
if c.get("id") in ("power_too_high", "not_keeping_up")])
print(" ...and the two alarms do not share a notification_id")
ids_clamp = {d["notification_id"] for d in clamp_notes(hass_r)}
@@ -806,11 +859,11 @@ class _SpyWatchdog(ClampWatchdog):
self.seen = [] # clamp_last.requested_w at each consultation
self.seen_write = [] # was the WRITE-path half engaged, per cycle
def note(self, engaged, reason, now):
def note(self, engaged, reason, now, **kw):
cl = self.owner.clamp_last
self.seen.append(None if cl is None else cl.requested_w)
self.seen_write.append(bool(cl and cl.engaged))
return super().note(engaged, reason, now)
return super().note(engaged, reason, now, **kw)
def probed(cls=Controller, grid=9000.0, cycles=5, **over):
@@ -986,14 +1039,17 @@ check("PRECONDITION: the write really did clamp, on the DEVICE bound",
and hass_w.writes[-1] == 2500.0)
check("...and the loop-side half is SILENT here, so self.clamp_last is the "
"only evidence that this happened at all",
ctl_w.loop_clamped == (False, ""))
ctl_w.loop_clamped == (False, "", False))
check("AC 3: the guard alarmed on write-path evidence alone",
ctl_w.clamp_wd.engagements == 1 and len(notes_w) == 1)
check("...and the operator is told WHICH bound bit, so a device-rating clamp "
"is not reported as a max_w one",
bool(notes_w) and "device bound" in notes_w[0]["message"])
bool(notes_w)
and messages.LIMIT_WORDS["device"] in notes_w[0]["message"]
and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"])
check("...and the action log carries the row, written by write_setpoint()",
any(REASON_CODE in e and "device bound" in e for e in ctl_w.events))
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
# in one pair of runs.
@@ -1081,21 +1137,26 @@ 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 - "
"past the 2000 W rating printed four words later - which is the alarm "
"reading backwards on the one line an operator is woken by",
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
f"{_sent_x:+.0f} W" in e for e in _rows_x))
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
"which is a SECOND surface off the same sentence and has to be pinned "
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
"site leaves the other shipping",
bool(notes_x)
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
any(f"asked {_asked_x:+.0f} W and sent {_sent_x:+.0f} W" in e
for e in _rows_x))
# UX-08: the notification is a SECOND surface off the same numbers, in its own
# words (messages.output_clamp): asked first, sent second.
def _asked_then_sent(msg, asked, sent):
return (f"asked for {messages.watts(asked)}," in msg
and f"and sent {messages.watts(sent)} instead" in msg
and msg.index(messages.watts(asked)) < msg.index("and sent"))
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION, which is a SECOND surface "
"off the same numbers and has to be pinned separately - SAFETY-38 round "
"3's whole lesson was that pinning one call site leaves the other "
"shipping",
bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x))
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
"checks above discriminate rather than decorate",
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
f"{_asked_x:+.0f} W" in e for e in _rows_x)
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
f"{_asked_x:+.0f} W" not in nth(notes_x, 0)["message"]
not any(f"asked {_sent_x:+.0f} W and sent {_asked_x:+.0f} W" in e
for e in _rows_x)
and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x)
and present(nth(notes_x, 0)))
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
# unit of coverage is the call site, and `bound` is the only thing that differs
@@ -1105,10 +1166,9 @@ 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 "
"two also render differently",
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
f"{_sent_w:+.0f} W" in nth(notes_w, 0)["message"])
and any(f"asked {_asked_w:+.0f} W and sent {_sent_w:+.0f} W" in e
for e in ctl_w.events)
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
# ==============================================================================
@@ -1705,6 +1765,35 @@ check("SAFETY-28's gate still OBSERVES too - it refused nothing on this run, "
"implemented by making every write unauthorised",
not [e for e in _cl_c.events if "WRITE REFUSED" in e])
# UX-09: what the over-rating alarm and its row SAY in each mode. The loop
# bounds the command before the write clamp here, so an over-rating is injected
# as the write path records one, and check_output_clamp() is run on it. Nothing
# reaches the wire in shadow mode, so neither text may claim a command was sent.
def _said(mode):
h, c = _s10(mode, cycles=2)
c.inverters[0].clamp_last = ClampResult(7412.0, 5000.0, 5000.0, True,
"max_w")
asyncio.run(c.check_output_clamp())
notes = [d["message"] for d in clamp_notes(h)
if d["notification_id"] == CLAMP_ID]
rows = [r["text"] for r in c.checks() if r.get("id") == "power_too_high"]
return notes, rows
_ux_live, _ux_shad = _said("off"), _said("on")
print(f" over-rating, live: {_ux_live}\n over-rating, shadow: {_ux_shad}")
check("UX-09: live, the over-rating alarm and row say what was SENT",
_ux_live[0] and all(" and sent 5 000 W" in m for m in _ux_live[0])
and _ux_live[1] and all("sent 5 000 W" in t and "would" not in t
for t in _ux_live[1]))
check("...in shadow mode they say what WOULD have been sent and that the "
"inverter stays at 0 W, never that it was sent",
_ux_shad[0] and all("would have sent 5 000 W" in m
and " and sent " not in m and "stays at 0 W" in m
for m in _ux_shad[0])
and _ux_shad[1] and all("would send 5 000 W" in t and " sent " not in t
for t in _ux_shad[1]))
# ⚠️ AND SAFETY-18 IS GRADED ON WHAT IT WAS HANDED, WHICH IS AN ORDERING
# CLAIM. `check_meter_frozen()` takes `self.target` - the arbiter's RESOLVED
# INTENT - and `_shadow_settle()` re-seeds that to 0 W at the very end of the
@@ -1789,17 +1878,17 @@ check("the status page has a Shadow mode row in BOTH states, so its ABSENCE is "
_ROW in _shad_rows and _ROW in _live_rows)
check("AC 3: the banner states plainly that the battery is not being "
"controlled",
"SHADOW MODE" in _shad_pay["banner"]
and "NOT being controlled" in _shad_pay["banner"])
"Shadow mode" in _shad_pay["banner"]
and "not controlled" in _shad_pay["banner"])
check("...and it is not green. A page that reads `ok` while nothing is being "
"commanded is the 'looks healthy but is inert' failure itself",
_shad_pay["level"] == "warn")
check("...and it is FIRST in the cascade: the same controller has other "
"conditions to report and none of them displaces this sentence",
_live_pay["banner"] != _shad_pay["banner"]
and "SHADOW MODE" not in _live_pay["banner"])
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
r"([-\d]+) W", _shad_rows[_ROW])
and "Shadow mode" not in _live_pay["banner"])
_slot = re.search(r"This cycle it would ask for ([-+][\d ]+) W, the inverters "
r"stay at ([-\d]+) W", _shad_rows[_ROW])
check("the row answers 'what would it have done' with a NUMBER in its own "
"named slot, and says where the inverter actually is in a second one - "
"two slots in one sentence, so a transposition moves them",
@@ -1813,32 +1902,37 @@ if _slot is not None:
check("...and the second slot is the STANDING command, so the two cannot "
"be read as one number",
float(_slot.group(2)) == 0.0)
check("the row answers 'and why' by carrying the arbiter's own sentence, "
"rather than a re-derivation of it",
_shad_c.reason in _shad_rows[_ROW])
check("the row answers 'and why' by carrying the page's own Why line, the "
"arbiter's answer in plain words (UX-08), rather than a re-derivation",
_shad_c.why_words in _shad_rows[_ROW]
and dict(_shad_pay["rows"])["Why"] == _shad_c.why_words)
check("with shadow mode off the row says so, and says what follows from it - "
"which is the arrangement that makes the one above a claim about this "
"controller and not about the string",
"NOT in shadow mode" in _live_rows[_ROW]
and "DOES reach the inverter" in _live_rows[_ROW]
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
_live_rows[_ROW].startswith("Off")
and "reaches the inverters" in _live_rows[_ROW]
and "would ask for" not in _live_rows[_ROW])
# ⚠️ A STANDING LATCH IS NAMED RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to carry what they would have said - a
# ⚠️ A STANDING LATCH IS SHOWN RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to say what they would have said - a
# latch that is up while the controller is inert is up the moment it stops
# being inert.
# being inert. UX-09: the banner (12 words) says a safety stop outlasts shadow
# mode, and the latch's own red row, still on the list, names it.
_shad_c.inverters[0].sign_wd.tripped = True
_latched = _shad_c.status_payload()
print(f" banner with a latch up: {_latched['banner']!r}")
check("a failsafe latch that is standing is NAMED in the shadow banner and "
"turns it red, so nobody turns shadow mode off into a battery that will "
"not move and a page that never mentioned why",
check("a failsafe latch that is standing turns the shadow banner red and the "
"banner says a safety stop stays on, so nobody turns shadow mode off "
"into a battery that will not move and a page that never mentioned why",
_latched["level"] == "bad"
and "LATCH IS STILL UP" in _latched["banner"]
and _shad_c.inverters[0].name in _latched["banner"])
and "safety stop stays on" in _latched["banner"])
check("...and the latch is NAMED on its own red row, which says how it clears",
any(not r["ok"] and not r["warn"] and r.get("id") == "direction"
and "A restart does not clear this" in r["text"]
for r in _shad_c.checks()))
check("...and it still says the battery is not being controlled, so the latch "
"has not displaced the sentence that may never be displaced",
"NOT being controlled" in _latched["banner"])
"not controlled" in _latched["banner"])
_shad_c.inverters[0].sign_wd.tripped = False
# --- 8i. THE RECORD A LATER ANALYSIS READS ----------------------------------
@@ -1858,6 +1952,201 @@ check("...EDGE-logged, not level-logged: 25 cycles did not flush the 40-row "
len(_shadow_lines) < 25 and len(_shad_c.events) <= 40)
# ==============================================================================
print()
print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
"are the clamp tick's own")
# Three defects, each graded on the wired Controller.cycle() with compute()
# spied, so every number is compared with the Decision of the SAME tick:
# 1. `i_clamped` (normal anti-windup) used the over-rating BUG SIGNAL text;
# 2. "the loop asked for" printed `Decision.i_w`, the integrator, as a request;
# 3. "was clamped to" printed `self.target`, read after slew/freeze rewrote it.
# The fixtures are chosen so the numbers the old text used DIFFER from the true
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
import logging
import app.main as _main_mod
_real_compute = _main_mod.compute
class _LogCap(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(record)
def s40_run(grid, cycles, track, **over):
"""Per tick: (Decision, self.target, clamp_wd.reason, engaged) + hass + log."""
ticks, decisions = [], []
def spy(*a, **k):
d = _real_compute(*a, **k)
decisions.append(d)
return d
cap = _LogCap()
lg = logging.getLogger("goodwe")
lg.addHandler(cap)
_main_mod.compute = spy
async def go():
hass = _Hass(grid, track=track)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": LIVE_MAX_W, "gain": 0.6,
"slew_w": 1000, "deadband_w": 15, "target_grid_w": 0,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15},
**over), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE_MAX_W, LIVE_MAX_W
red = []
for _ in range(cycles):
n = len(decisions)
before = c.clamp_wd.engagements
await c.read_inputs()
await c.cycle()
d = decisions[-1] if len(decisions) > n else None
ticks.append((d, c.target, c.clamp_wd.reason,
c.clamp_wd.engagements > before))
if c.clamp_wd.active and not red:
red = c.checks()
return hass, c, red
try:
hass, c, red = asyncio.run(go())
finally:
_main_mod.compute = _real_compute
lg.removeHandler(cap)
return ticks, hass, c, red, cap.records
def _f(d, name):
"""A Decision field, NaN when absent - so an old tree goes red, not crashes."""
return getattr(d, name, float("nan"))
# --- 9a. default install: only the INTEGRATOR bound can engage ---------------
ticks_i, hass_i, ctl_i, red_i, log_i = s40_run(6000.0, 20, track=False)
wind = [(d, t, r) for d, t, r, e in ticks_i
if e and d is not None and d.i_clamped and not d.clamped]
for d, t, r in wind:
print(f" tick: bound {_f(d, 'i_bound_w'):+.0f} i_w {d.i_w:+.0f} command "
f"{d.target_w:+.0f} self.target {t:+.0f}\n -> {r}")
check("PRECONDITION: the integrator bound engaged, and on a tick where the "
"carried integrator AND the emitted command both differ from the bound",
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
for d, t, r in wind))
check("defect 1: the integrator bound is NOT reported as an over-rating",
wind and all(" rating" not in r and "fault" not in r
for d, t, r in wind))
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))
check("defect 2: the integrator accumulator is not printed as a request",
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
for d, t, r in wind))
check("the command quoted is that tick's Decision, not a later read",
wind and all(f"command now {d.target_w:+.0f} W" in r
for d, t, r in wind))
check("at the bound nothing is described as 'above' or 'over' the rating",
wind and all(" above " not in r and " over " not in r
for d, t, r in wind))
notes_i = clamp_notes(hass_i)
check("it still NOTIFIES (not suppressed), under a title that claims no bug",
notes_i and all(n["title"] == messages.T_LOOP_BOUND
and "fault" not in n["message"] for n in notes_i))
check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
"notice still on screen",
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
# UX-11: the WARNING quotes the code. The reason is on the DEBUG line beside
# it (test_messages.py part 6).
check("...logged as a WARNING, not an ERROR",
any(x.levelno == logging.WARNING
and x.getMessage().startswith("The battery gives less than asked: "
"OUTPUT_CLAMP.") for x in log_i)
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
for x in log_i))
check("...and the status row is amber, not red",
any(c["warn"] and c.get("id") == "not_keeping_up" for c in red_i)
and not any(not c["warn"] and c.get("id") == "power_too_high"
for c in red_i))
# --- 9b. the genuine over-rating: integrator_max_w above max_w ----------------
ticks_o, hass_o, ctl_o, red_o, log_o = s40_run(9000.0, 12, track=True,
integrator_max_w=9000)
over = [(d, t, r) for d, t, r, e in ticks_o
if e and d is not None and d.clamped]
for d, t, r in over[:2]:
print(f" tick: requested {_f(d, 'requested_w'):+.0f} clamped {_f(d, 'clamped_w'):+.0f}"
f" i_w {d.i_w:+.0f} self.target {t:+.0f}\n -> {r}")
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
"rewrote the command AND the integrator differs from the request",
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
check("the over-rating says it was cut to the rating",
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",
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
check("defect 3: 'cut to' is the clamp's own result, not self.target",
over and all(f"cut to the {_f(d, 'clamped_w'):+.0f} W rating" in r
for d, t, r in over))
check("...and its ERROR says it is a software fault and that control keeps "
"running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
"it, which is still in main.py)",
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
and "control 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 "
"numbers in that order, with a red status row",
clamp_notes(hass_o)
and all(n["title"] == messages.T_OUTPUT_CLAMP
and n["notification_id"] == CLAMP_ID
and any(_asked_then_sent(n["message"], _f(d, 'requested_w'),
_f(d, 'clamped_w')) for d, t, r in over)
for n in clamp_notes(hass_o))
and any(not c["warn"] and c.get("id") == "power_too_high"
and c["text"].startswith(messages.T_OUTPUT_CLAMP)
for c in red_o))
# The red row quotes the same tick's numbers as the card, asked before sent.
_row_o = [c["text"] for c in red_o if c.get("id") == "power_too_high"]
check("...and the red row quotes asked before sent, from one of those ticks",
_row_o and any(
f"asked {messages.watts(_f(d, 'requested_w'))}" in _row_o[0]
and f"sent {messages.watts(_f(d, 'clamped_w'))}" in _row_o[0]
and _row_o[0].index("asked") < _row_o[0].index("sent")
for d, t, r in over))
# --- 9c. the text, directly, and the two budgets -------------------------------
from app.clamp import loop_clamp_note # noqa: E402
d_at = compute(prev_w=1000.0, grid_w=9000.0, actual_w=1000.0,
tuning=Tuning(max_w=5000.0, slew_w=1000.0, gain=0.6), i_w=4500.0)
check(f"exactly at the rating ({d_at.i_bound_w:+.0f} W) is 'reached', never "
f"'above' - {loop_clamp_note(d_at)!r}",
d_at.i_clamped and not d_at.clamped and abs(d_at.i_bound_w) == 5000.0
and "above" not in loop_clamp_note(d_at))
check("a healthy decision produces no text",
loop_clamp_note(compute(0.0, 100.0, 0.0, Tuning(max_w=5000.0))) == "")
# Anti-windup notices must never spend the budget a BUG SIGNAL needs.
wd = ClampWatchdog()
fired_w = wd.note(True, "wind", T0, bug=False)
fired_b = wd.note(True, "bug", T0 + timedelta(seconds=5), bug=True)
wd.note(True, "wind again", T0 + timedelta(seconds=10), bug=False)
check("an anti-windup notice does not delay a bug in the same episode, and "
"does not overwrite its text",
fired_w and fired_b and wd.bug and wd.reason == "bug")
wd2 = ClampWatchdog()
seq = [wd2.note(True, "b", T0 + timedelta(seconds=5 * i)) for i in range(40)]
check("bug-only cadence is unchanged: rising edge, then every REASSERT_S",
seq[0] and sum(seq) == 1 + int(5 * 39 // REASSERT_S))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+14 -10
View File
@@ -189,13 +189,13 @@ check(f"the wind-down ran: 0 W written AFTER the fault (writes after = {after})"
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
["cleanup"] in ev[(fault_at or 0):])
died_line = next((i for i, ln in enumerate(err.splitlines())
if "CONTROL LOOP DIED (RuntimeError('" + FAULT + "'))" in ln), None)
if "Control stopped unexpectedly (RuntimeError('" + FAULT + "'))" in ln), None)
wind_line = next((i for i, ln in enumerate(err.splitlines())
if "shutting down - commanding 0 W" in ln), None)
check("a CRITICAL line names the exception and the exit status",
died_line is not None
and "CRITICAL" in err.splitlines()[died_line]
and "commanding 0 W and exiting with status 70" in err.splitlines()[died_line])
and "set to 0 W and the add-on exits with code 70" in err.splitlines()[died_line])
check("the traceback is logged, down to the frame that raised",
"Traceback (most recent call last)" in err
and f"RuntimeError: {FAULT}" in err and "in cycle" in err)
@@ -211,7 +211,7 @@ print("2. the same entry point stopped by SIGTERM - the callback must stay quiet
rc2, out2, err2, ev2 = run_child("clean")
print(f" exit status {rc2}; events {ev2}")
check("an ordinary stop still exits 0", rc2 == 0)
check("an ordinary stop logs no CONTROL LOOP DIED", "CONTROL LOOP DIED" not in err2)
check("an ordinary stop logs no unexpected stop", "Control stopped unexpectedly" not in err2)
check("an ordinary stop still winds down to 0 W, from a non-zero command",
any(e[0] == "w" and e[1] != 0.0 for e in ev2)
and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]])
@@ -222,7 +222,7 @@ rc3, out3, err3, ev3 = run_child("refuse")
print(f" exit status {rc3}")
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
rc3 == 78)
check("and is not reported as a death", "CONTROL LOOP DIED" not in err3)
check("and is not reported as a death", "Control stopped unexpectedly" not in err3)
if rc3 != 78:
print(tail(err3))
@@ -337,15 +337,19 @@ def rows_for(pass_age):
r_new, r_29, r_31 = rows_for(None), rows_for(29), rows_for(31)
print(f" {r_new[0]} | {r_29[0]} | {r_31[0]}")
# UX-08: the wording is app/messages.py's; these pin the meaning - which row,
# its colour, and the two numbers (age, veto) an operator acts on.
check("before the first pass the row is ok and says 'starting'",
r_new[0] == {"ok": True, "warn": False, "text": "control loop: starting"})
r_new[0]["id"] == "controller" and r_new[0]["ok"] and not r_new[0]["warn"]
and "starting" in r_new[0]["text"])
check("29 s after a pass it is ok",
r_29[0] == {"ok": True, "warn": False,
"text": "control loop: last pass 29 s ago"})
r_29[0]["id"] == "controller" and r_29[0]["ok"] and not r_29[0]["warn"]
and "29 seconds ago" in r_29[0]["text"])
check("31 s after a pass it is RED and first, naming the age and the veto",
r_31[0] == {"ok": False, "warn": False,
"text": "control loop: NO PASS FOR 31 s (layer 1 zeroes the "
"inverter after 30 s without a heartbeat)"})
r_31[0]["id"] == "controller" and not r_31[0]["ok"]
and not r_31[0]["warn"]
and "not run for 31 seconds" in r_31[0]["text"]
and "0 W after 30 seconds" in r_31[0]["text"])
async def one_pass():
+56 -11
View File
@@ -33,6 +33,7 @@ from datetime import datetime, timedelta, timezone
from named_absence import given, present
from app import messages
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
@@ -929,14 +930,18 @@ check("...but the banner does NOT go on saying 'Running - holding grid at "
"zero'. That sentence is a claim the site cannot make while a unit is "
"absent, and 'the page reads healthy while the fault runs' is the shape "
"this whole card exists to remove. It names the unit and stays a WARNING",
_pay["level"] == "warn" and "NOT DELIVERING" in _pay["banner"]
_pay["level"] == "warn" and "is not delivering" in _pay["banner"]
and "inverter-b" in _pay["banner"]
and "holding grid at zero" not in _pay["banner"])
print(f" banner: {_pay['level']!r} / {_pay['banner']!r}")
_row = [r for r in _cw.checks() if "NOT DELIVERING" in r["text"]]
_row = [r for r in _cw.checks()
if r.get("id") == "delivery" and "not delivering" in r["text"]]
check("the status page carries it as a CAUTION, not a failure: the site is "
"still running and short, which a red cross would misdescribe",
len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True)
len(_row) == 1 and _row[0]["ok"] is False and _row[0]["warn"] is True
and "inverter-b" in _row[0]["text"])
check("...and the row says how it clears: a stop and start of control",
bool(_row) and "stopping and starting control" in _row[0]["text"])
# ===========================================================================
@@ -1142,8 +1147,10 @@ async def _one():
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6,
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
# MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock
# against a 1 s window, so a hold is not what stops this firing -
# MAKES THIS SECTION NON-VACUOUS. The run below is 1.1 s of wall
# clock against a 1 s window (SAFETY-52 shortened it from 2 s, and
# "the window was never even opened" below is what still makes it
# non-vacuous), so a hold is not what stops this firing -
# the reduction is. With a shipped-length hold "nothing was
# reported" would be true of a fixture that simply ran out of time.
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
@@ -1153,7 +1160,10 @@ async def _one():
c.inverters[0].dev_min, c.inverters[0].dev_max = -2000.0, 2000.0
c.auto = True
hass.ctrl = c
await _drive(c, n=20, dt=0.1)
# SAFETY-52: from SETTLE_CYCLES of delivering nothing the loop asks 0 W
# (the battery refuses), so the run stops just before that. 11 cycles is
# still 1.1 s against the 1 s hold.
await _drive(c, n=11, dt=0.1)
return c, hass, store
@@ -1172,10 +1182,11 @@ check("...and no store key was written, so a one-inverter install's state file "
"is byte-identical to what it was before this card",
unit_key(_only.name, "delivery_tripped") not in _st1.data
and unit_key(_only.name, "delivery_reason") not in _st1.data)
_n1row = [r for r in _c1.checks() if "delivery watchdog" in r["text"]]
_n1row = [r for r in _c1.checks() if r.get("id") == "delivery"]
check("...and the status page says n/a rather than 'armed', which would be a "
"claim to be watching something that cannot happen",
len(_n1row) == 1 and _n1row[0]["ok"] and "n/a at one inverter" in _n1row[0]["text"])
len(_n1row) == 1 and _n1row[0]["ok"]
and "not needed with one inverter" in _n1row[0]["text"])
check("...no persistent notification of this kind was raised either",
not [c for c in _h1.calls
if "not_delivering" in str(c[2].get("notification_id", ""))])
@@ -1195,8 +1206,8 @@ async def _restart():
c1 = _ctrl(h1, store=shared)
await _drive(c1)
# A NEW process, a NEW HomeAssistant object (so the in-memory notification
# is gone with it), the SAME durable file. That is what a Home Assistant
# restart is from this add-on's point of view: the add-on goes with it.
# is gone with it), the SAME durable file: an ADD-ON restart. A Core-only
# restart keeps the process; graded in part 8b (SAFETY-34).
h2 = _DeliveryHass(b_soc=50.0)
c2 = _ctrl(h2, store=shared)
await c2.check_not_delivering()
@@ -1232,7 +1243,7 @@ check("the notification NAMES THE UNIT - in its id, its title and its body - "
check("...and it says, in the operator's own sentence, that nothing has been "
"commanded differently. An alarm that let somebody believe the add-on "
"had handled it would be worse than no alarm",
"NOTHING HAS BEEN COMMANDED DIFFERENTLY" in _note[2]["message"])
"nothing was changed and control keeps running" in _note[2]["message"])
print(f" id={_note[2]['notification_id']!r} title={_note[2]['title']!r}")
_before_calls = len(_rh2.calls)
await_again = asyncio.run(_r2.check_not_delivering())
@@ -1250,6 +1261,40 @@ check("the operator's START press is the only way out, and it clears the "
and _rs.data.get(unit_key("inverter-b", "delivery_reason")) == ""
and _r2._delivery_announced == set())
print()
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
import app.main as _main_mod # noqa: E402
async def _core_restart():
shared = _MemStore()
c = _ctrl(_DeliveryHass(b_soc=50.0), store=shared)
await _drive(c)
return c
_c = asyncio.run(_core_restart())
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
try:
_c.hass = _DeliveryHass(b_soc=50.0)
asyncio.run(_c.check_not_delivering())
_early = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
_clock[0] += _main_mod.REANNOUNCE_S
asyncio.run(_c.check_not_delivering())
_late = [x for x in _c.hass.calls
if "not_delivering" in str(x[2].get("notification_id", ""))]
finally:
_main_mod._mono = _real_mono
check("SAFETY-34: the latch tripped in this process",
_c.inverters[1].delivery_wd.tripped)
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
len(_early) == 0)
check("SAFETY-34: after REANNOUNCE_S the SAME process re-asserts the card, "
"because a Core-only restart wiped it and did not restart the add-on",
len(_late) == 1 and "inverter_b" in _late[0][2]["notification_id"])
print()
if fails:
+55 -40
View File
@@ -85,6 +85,7 @@ from pathlib import Path
import aiohttp
from app import messages
from app.control import Tuning, compute
from app.freeze import (
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen,
@@ -773,7 +774,7 @@ def _is_freeze_note(d):
calls itself frozen, which would be a confusable alarm on its own account.
"""
return (d.get("notification_id") == FREEZE_NOTE_ID
or "frozen" in str(d.get("title", "")).lower())
or d.get("title") == messages.T_METER_FROZEN)
def notes_of(hass):
@@ -788,7 +789,7 @@ def notes_by_text_of(hass):
both would be true by construction.
"""
return [d for d in all_notes_of(hass)
if "frozen" in str(d.get("title", "")).lower()]
if d.get("title") == messages.T_METER_FROZEN]
def wired(unchanged_s, grid, cycles=60, dev=(-5000.0, 5000.0), **over):
@@ -831,11 +832,12 @@ all_notes = all_notes_of(hass_f) # the whole surface
# install has. Flipped here rather than plumbing a whole P1 transport in - the
# row's own content is what is under test.
ctl_f.p1_enabled = True
frozen_rows = [c for c in ctl_f.checks() if "METER MAY BE FROZEN" in c["text"]]
frozen_rows = [c for c in ctl_f.checks() if c.get("id") == "meter_frozen"]
print(f" frozen at +4000 W: command ran to {ctl_f.target:.0f} W, "
f"{len(notes)} notification(s), {len(frozen_rows)} red status row(s)")
# SURFACE-WIDE, deliberately, and the second guard has now landed. SAFETY-03
# ships `goodwe_output_clamp`, and this scenario - a runaway loop winding into
# ships `goodwe_output_clamp` (and, for the integrator bound on a default
# install, `goodwe_integrator_bound` - SAFETY-40), and this scenario - a runaway loop winding into
# the rail - raises it legitimately: the loop asks for more than the rating and
# is clamped. So the answer to "is this scenario still meant to raise exactly
# one alarm" is NO, it raises exactly two, and BOTH ARE NAMED here rather than
@@ -846,7 +848,7 @@ _ids = sorted(d.get("notification_id") for d in all_notes)
print(f" the whole notification surface this run: {_ids}")
check("the whole notification surface carried exactly two alarms this run - "
"SAFETY-18's and SAFETY-03's, both named",
_ids == ["goodwe_meter_frozen", "goodwe_output_clamp"])
_ids == ["goodwe_integrator_bound", "goodwe_meter_frozen"])
# THIS GUARD'S, keyed on the id.
check("...and the freeze detector is the one that raised it", len(notes) == 1)
# ⚠️ Selected by TEXT, not by id - see notes_by_text_of(). One of the two
@@ -857,9 +859,12 @@ check("...with a fixed notification_id so repeats replace, not stack",
[d.get("notification_id") for d in notes_by_text_of(hass_f)]
== [FREEZE_NOTE_ID])
check("...and it says the controller is still running",
bool(notes) and "STILL RUNNING" in notes[0]["message"])
bool(notes) and "Control is still running" in notes[0]["message"])
check("the status page carries a failed row saying so",
len(frozen_rows) == 1 and not frozen_rows[0]["ok"] and not frozen_rows[0]["warn"])
check("...and the row says control still follows the reading and what to do",
bool(frozen_rows) and "still follows it" in frozen_rows[0]["text"]
and "Check the meter" in frozen_rows[0]["text"])
check("the event log names it", any("METER FROZEN" in e for e in ctl_f.events))
# ⚠️ NOTIFY, NOT TRIP. If a later cut turns this into a trip it must come here
# and change this expectation deliberately, not discover it in the field.
@@ -1104,8 +1109,7 @@ def freeze_rows(unchanged_s, grid, **over):
# p1_enabled is what a homewizard_local install has; the row's own content
# is what is under test, not the transport that feeds it.
ctl.p1_enabled = True
return ctl, [r for r in ctl.checks()
if r["text"].startswith("freeze detector")]
return ctl, [r for r in ctl.checks() if r.get("id") == "freeze_check"]
_ctl_u, _rows_u = freeze_rows(None, 4000.0, meter_source="ha_dsmr")
@@ -1114,30 +1118,31 @@ print(f" uncovered: {_rows_u[0]['text'] if _rows_u else '(no row at all)'}")
print(f" covered: {_rows_c[0]['text'] if _rows_c else '(no row at all)'}")
check("an uncovered transport gets exactly one freeze-detector row",
len(_rows_u) == 1)
check("...and it says NOT COVERED",
bool(_rows_u) and "NOT COVERED" in _rows_u[0]["text"])
# UX-08: the row says the check is off and which source it needs, in words
# (STYLE.md: no option values). DOCS.md "Status page" maps them to the keys.
check("...and it says the check is off",
bool(_rows_u) and ": off" in _rows_u[0]["text"])
check("...and it does NOT claim the guard is armed - that would be the status "
"page lying on behalf of a guard that cannot run",
bool(_rows_u) and "armed" not in _rows_u[0]["text"])
check("...and it names the meter_source it is not covering, so the operator "
"knows which install this is",
bool(_rows_u) and "ha_dsmr" in _rows_u[0]["text"])
check("...and it names homewizard_local as the transport that would be covered",
bool(_rows_u) and "homewizard_local" in _rows_u[0]["text"])
bool(_rows_u) and "alerts after" not in _rows_u[0]["text"])
check("...and it names HomeWizard local as the source that would be covered",
bool(_rows_u) and "HomeWizard local" in _rows_u[0]["text"])
check("...flagged as a warning rather than a silent ok",
bool(_rows_u) and not _rows_u[0]["ok"] and _rows_u[0]["warn"])
check("a COVERED transport gets exactly one freeze-detector row",
len(_rows_c) == 1)
check("...and that one DOES say armed - without this, 'the uncovered row does "
"not say armed' is satisfied by deleting the armed branch",
bool(_rows_c) and "armed" in _rows_c[0]["text"]
and "NOT COVERED" not in _rows_c[0]["text"])
check("...and that one DOES say it is on - without this, 'the uncovered row "
"does not say armed' is satisfied by deleting the armed branch",
bool(_rows_c) and "alerts after" in _rows_c[0]["text"]
and ": off" not in _rows_c[0]["text"])
check("...and it quotes the hold and the rail it is armed at, derived from the "
"config the controller was actually built with",
bool(_rows_c)
and f"{_ctl_c.freeze_wd.cfg.hold_s:.0f} s unchanged" in _rows_c[0]["text"]
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" in _rows_c[0]["text"])
and f"{messages.duration(_ctl_c.freeze_wd.cfg.hold_s)} unchanged"
in _rows_c[0]["text"]
and f"above {messages.watts(rail_w(LIVE_INSTALL_MAX_W))}"
in _rows_c[0]["text"])
check("...as an ok row, since a covered armed guard is not a warning",
bool(_rows_c) and _rows_c[0]["ok"] and not _rows_c[0]["warn"])
@@ -1179,17 +1184,23 @@ check("...and at or above the device rail, which is why it must",
check("THE ALARM FIRES through the real cycle() - the rating reaches the "
"predicate from the controller, not just from a unit call",
len(_n20) == 1 and _c20.freeze_wd.active)
check("...and the notification the operator gets quotes the capped rail, "
"not the max_w one",
# UX-08: the notification no longer quotes a rail at all (the reader's action
# is "check the meter"), so it cannot quote the wrong one; the capped rail is
# still what the reason - the log line and the status row - carries.
check("...and the notification the operator gets names no max_w rail, and the "
"reason behind it quotes the capped rail",
bool(_n20)
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W"
not in str(_n20[0].get("message", ""))
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
in str(_n20[0].get("message", "")))
in _c20.freeze_wd.reason)
# p1_enabled is what a homewizard_local install has - set by hand here for the
# same reason part 4 sets it, see freeze_rows().
_c20.p1_enabled = True
check("...and the red status row is up too, so the page and the notification "
"agree",
any("METER MAY BE FROZEN" in r["text"] for r in _c20.checks()))
any(r.get("id") == "meter_frozen" and not r["ok"] and not r["warn"]
for r in _c20.checks()))
# --------------------------------------------------------------------------- #
@@ -1215,23 +1226,25 @@ check("a device smaller than max_w still gets exactly one freeze-detector row",
check("...and the page does NOT print the max_w rail the command can never "
"reach - THE DEFECT, in one assertion",
bool(_rows_s)
and f"{rail_w(LIVE_INSTALL_MAX_W):.0f} W" not in _rows_s[0]["text"])
and messages.watts(rail_w(LIVE_INSTALL_MAX_W)) not in _rows_s[0]["text"])
check("...it prints the rail the predicate actually uses",
bool(_rows_s)
and f"{rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
in _rows_s[0]["text"])
check("...and it names the device range as the reason, since the operator "
"configured max_w and is owed the reason the guard is armed elsewhere",
bool(_rows_s) and "4700" in _rows_s[0]["text"]
and "max_w" in _rows_s[0]["text"])
and messages.watts(rail_w(LIVE_INSTALL_MAX_W, dev_min=-4700.0,
dev_max=4700.0)) in _rows_s[0]["text"])
check("...and it names the inverter's own limit as the reason, since the "
"operator configured max_w and is owed the reason the guard is armed "
"elsewhere",
bool(_rows_s) and "the inverter's own limit" in _rows_s[0]["text"])
check("...and it does not say so when max_w itself sets the rail",
bool(_rows_c) and "own limit" not in _rows_c[0]["text"])
# ⚠️ THE NUMBER IS TAKEN OFF THE ROW AND FED BACK INTO THE SHIPPED PREDICATE.
# Asserting the page against `rail_w(...)` alone proves the two agree, which a
# revert of BOTH to bare max_w also satisfies. What a revert cannot satisfy is
# this: the rail the page advertises has to be a magnitude the wire can carry,
# and the wire stops at the device rating.
_m = re.search(r"command is over (\d+(?:\.\d+)?) W", _rows_s[0]["text"]) if _rows_s else None
_page_rail = float(_m.group(1)) if _m else None
_m = re.search(r"above (\d[\d ]*) W", _rows_s[0]["text"]) if _rows_s else None
_page_rail = float(_m.group(1).replace(" ", "")) if _m else None
print(f" the page advertises a rail of {_page_rail} W against a device that "
f"stops at {_ctl_s.dev_max:.0f} W")
check("...the row states a rail in watts at all, so the parse below means "
@@ -1249,11 +1262,13 @@ check("...as an ok row - a guard armed at a lower rail is still armed, not a "
"warning", bool(_rows_s) and _rows_s[0]["ok"] and not _rows_s[0]["warn"])
check("a device with no commandable range gets exactly one row", len(_rows_z) == 1)
check("...and it says NOT COVERED - the rail is not merely low there, it is "
"0 W, which would be TRUE of every command including a parked one",
bool(_rows_z) and "NOT COVERED" in _rows_z[0]["text"])
check("...and it NEVER says armed",
bool(_rows_z) and "armed" not in _rows_z[0]["text"])
check("...and it says the check is off for the power range - the rail is not "
"merely low there, it is 0 W, which would be TRUE of every command "
"including a parked one",
bool(_rows_z) and ": off" in _rows_z[0]["text"]
and "power range" in _rows_z[0]["text"])
check("...and it NEVER says armed",
bool(_rows_z) and "alerts after" not in _rows_z[0]["text"])
check("...flagged as a warning rather than a silent ok, like the uncovered "
"transport row it shares",
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
+42 -31
View File
@@ -69,6 +69,7 @@ from datetime import datetime, timezone
from pathlib import Path
from app import freshness as F
from app import messages
from named_absence import first, given, nth, present
from app.circulation import circulating_w
from app.freshness import BOUNDS, BATT_OBLIGED_S, BATT_UNCHANGED_S, MOVE_W, Freshness
@@ -359,7 +360,7 @@ check("...and `inputs_ok` goes False, so the site stops rather than regulating "
_cs.inputs_ok is False and _cf.inputs_ok is True)
check("...and the operator gets a line saying which unit and why, on the same "
"status list the liveness notes use",
any("STALE_READING" in n and "inverter-a" in n
any("stopped changing" in n and "inverter-a" in n
for n in _cs.liveness_notes())
and _cf.liveness_notes() == [])
check("...and the raw number is still recorded for diagnostics, so the "
@@ -1580,7 +1581,7 @@ check("...and the site stops holding inputs bad, which is what ends the "
_c57.inputs_bad_since is None and _c57.inputs_ok is True)
_ev57 = [e for e in _c57.events if "STALE" in e]
_msgs57 = [r.getMessage() for r in _LOGREC]
_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m]
_rel57 = [m for m in _msgs57 if "is used again and rechecked" in m]
print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}")
check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR "
"CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach "
@@ -1591,8 +1592,9 @@ check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATO
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
"happened: it says in its own words that nothing has been learned about "
"the register, and it prints the value the register is STILL holding",
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in nth(_rel57, 0)
and f"still holding {A_FROZEN_W:+.0f}" in nth(_rel57, 0))
"used again" in nth(_rel57, 0) and "recovered" not in nth(_rel57, 0)
and f"reading {A_FROZEN_W:+.0f} W is used again and rechecked"
in nth(_rel57, 0))
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
@@ -1601,7 +1603,8 @@ check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has
# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new
# escape clause, and 250 W and 17.0 s again in the release note - so each is
# asserted with its value embedded in its own literal position.
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
_stale57 = [m for m in _msgs57
if "battery power reading stopped changing" in m]
print(f" escape clause: ...{nth(_stale57, 0)[-150:]}")
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
"PAIR. It used to end 'It clears itself the moment the register reports "
@@ -1609,17 +1612,16 @@ check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDER
"before it is what stops the register being asked. The new clause names "
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
"red",
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
f"or {BOUNDS['batt'].release_s:.0f} s after the command drops below "
f"{BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
check("...and the release note names 250 W in the POWER slot and 17 s in the "
"TIME slot, in that order, which is the reverse arrangement of the same "
"two numbers",
f"sat below {BOUNDS['batt'].move_w:.0f} W for "
f"stayed below {BOUNDS['batt'].move_w:.0f} W for "
f"{BOUNDS['batt'].release_s:.0f} s" in nth(_rel57, 0))
check("...and the 35.0 s the register is re-graded over is named in the note "
"too, because it is what the operator is being told to wait out",
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window"
in nth(_rel57, 0))
f"rechecked over {BOUNDS['batt'].unchanged_s:.0f} s" in nth(_rel57, 0))
check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
"WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power "
"register is not following its own command' to still be present, so "
@@ -1631,7 +1633,7 @@ check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN,
present(nth(_stale57, 0))
and "battery-power register is not following its own command"
not in _stale57[0]
and "did not move under a command that did" in _stale57[0])
and "stopped changing" in _stale57[0])
check("...and a re-trip's reason names the TWO durations separately, each "
"beside its own value, because after a release the verdict's window and "
"the register's own unchanged time are different facts",
@@ -1642,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.stale and f.releases == 1
and "unchanged for 92 s" in f.reason
and "the 35 s window this verdict rests on opened when the previous "
"one was withdrawn" in f.reason
and "graded 2 times" in f.reason)[-1])(
and "checked 2 times over a 35 s window since the last stop was "
"withdrawn" in f.reason)[-1])(
Freshness(BOUNDS["batt"])))
@@ -1797,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
# stopped reporting reality" is a different and wrong claim.
_S56_EXPLANATIONS = [
("DEAD", "the register may have stopped reporting reality"),
("REFUSED", "the source may be unable to comply with the command"),
("DEAD", "reading may have stopped"),
("REFUSED", "battery may be unable to follow the command"),
]
# ⚠️ 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
@@ -1810,7 +1811,13 @@ _S56_ACCUSATIONS = [
("app/main.py _read_unit()", "register is not following its own command"),
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:] if "SAFETY-37" in r.getMessage()]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
if "battery power reading stopped changing" in r.getMessage()]
# UX-11: the ERROR line quotes the code, and the reason itself is on the DEBUG
# line logged beside it.
_dbg56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
if r.levelno == logging.DEBUG
and r.getMessage().startswith("Evidence for ")] or [""]
def _s56_row(c):
@@ -1823,7 +1830,8 @@ def _s56_row(c):
a red with no name attached, which reads like broken infrastructure and gets
retried rather than read. Measured: 0 named reds before, 2 after.
"""
hits = [r for r in c.checks() if r["text"].startswith("battery power")]
hits = [r for r in c.checks() if r.get("id") == "sensor"
and r["text"].startswith(messages.LABEL_BATTERY)]
if len(hits) == 1:
return hits[0]
return {"ok": None, "warn": None,
@@ -1852,7 +1860,7 @@ check("PRECONDITION: all four operator-facing surfaces were really produced by "
"two are the same string, which is 11b again on the log surface",
len(_log56) == 2 and _log56[0] == _log56[1]
and all(len(s) > 30 for s in _S56_SURFACES.values())
and _r56_ref in _log56[-1])
and _log56[-1].endswith(": STALE_READING.") and _r56_ref in _dbg56[-1])
_idx56 = [_r56_ref.find(t) for _w, t in _S56_EXPLANATIONS]
check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
f"{_S56_EXPLANATIONS[0][1]!r} at {_idx56[0]} then "
@@ -1864,7 +1872,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 "
"than leaving two explanations side by side for a reader to rank - an "
"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:
# 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,
@@ -1933,22 +1941,25 @@ check("⚠️ AND THE VERDICT IS UNCHANGED BY THIS CARD - both rows are still "
_row56_ref["ok"] is False and _row56_ref["warn"] is False
and _row56_abs["ok"] is False and _row56_abs["warn"] is False
and _row56_ref["text"] == _row56_abs["text"])
check("...and what it says instead is the observation plus BOTH causes, with "
f"{_BATT_ENT} in its own slot - `label` and `entity` are two "
f"interpolations in one sentence and swapping them costs zero bytes, so "
f"the literal around each is pinned (habit 14)",
f"battery power: no usable value this cycle from {_BATT_ENT} - and this "
f"row cannot tell which of two things that is" in _row56_ref["text"]
and "the entity is missing or not numeric" in _row56_ref["text"]
and "a number that this controller is withholding" in _row56_ref["text"])
# UX-08: the row names the setting by its label, never the entity id
# (STYLE.md), so there is no second interpolation left to transpose.
check("...and what it says instead is the observation plus BOTH causes, under "
"its own label, and the check to make before replacing the sensor - with "
f"no entity id ({_BATT_ENT}) on the page row",
_row56_ref["text"].startswith(f"{messages.LABEL_BATTERY}: no usable "
f"reading")
and "may be missing" in _row56_ref["text"]
and "held back as too old" in _row56_ref["text"]
and "Developer Tools before replacing it" in _row56_ref["text"]
and _BATT_ENT not in _row56_ref["text"])
check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which is "
"why the row does not need to ask `batt_fresh.stale` itself: `checks()` "
"carries a STALE_READING row in REFUSED and NONE in ABSENT, so 'is that "
"row below?' answers the question this one declines to answer. A row "
"that pointed at a surface present in both states would be worse than "
"the accusation it replaced",
any("STALE_READING" in r["text"] for r in _c56_ref.checks())
and not any("STALE_READING" in r["text"] for r in _c56_abs.checks()))
any("stopped changing" in r["text"] for r in _c56_ref.checks())
and not any("stopped changing" in r["text"] for r in _c56_abs.checks()))
check("...and the row's wording is NOT shared with the log line's, which is a "
"design choice rather than an omission: the two sentences have no clause "
"in common longer than the word 'entity', because one explains a "
@@ -1957,7 +1968,7 @@ check("...and the row's wording is NOT shared with the log line's, which is a "
"other caller does not need",
_S56_EXPLANATIONS[0][1] not in _row56_ref["text"]
and _S56_EXPLANATIONS[1][1] not in _row56_ref["text"]
and "no usable value this cycle" not in _r56_ref)
and "no usable reading" not in _r56_ref)
# --- 11e. what part 11 does NOT grade, with its shape (habit 13) -------------
# * It does not show the live episode was a BMS refusal beyond doubt. 11a
+227
View File
@@ -0,0 +1,227 @@
"""UX-13 P0: the entity list for the picker. `python3 test_haentities.py`
`GET ha/entities` reads Home Assistant's states through the add-on's own
session (hass.states()) and must never fail the page: part 1 drives the real
HomeAssistant client against a local server that answers 502, garbage, the
wrong shape, nothing at all, or too slowly. Part 2 checks the 30 s cache and
what is served while Home Assistant is down. Part 3 serves the real route.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import json
import sys
import time
from aiohttp import ClientSession, web as aioweb
from app import hass as H, haentities as E, web
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
STATES = [
{"entity_id": "sensor.grid", "state": "812",
"attributes": {"friendly_name": "Grid power", "unit_of_measurement": "W",
"device_class": "power"}},
{"entity_id": "sensor.soc", "state": "54",
"attributes": {"friendly_name": "Battery level", "unit_of_measurement": "%",
"device_class": "battery"}},
{"entity_id": "number.setpoint", "state": "0", "attributes": {}},
{"entity_id": "sensor.pv_kw", "state": "1.2",
"attributes": {"unit_of_measurement": "kW"}},
{"no_entity_id": True},
]
async def serve(handler):
app = aioweb.Application()
app.router.add_get("/api/states", handler)
runner = aioweb.AppRunner(app)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
return runner, site._server.sockets[0].getsockname()[1]
async def read_with(handler, timeout_s=10):
runner, port = await serve(handler)
H.CORE_API, H.STATES_TIMEOUT_S = f"http://127.0.0.1:{port}/api", timeout_s
try:
async with ClientSession() as session:
t0 = time.monotonic()
got = await H.HomeAssistant(session, "token").states()
read_with.took = time.monotonic() - t0
return got
finally:
await runner.cleanup()
async def ok(_r):
return aioweb.json_response(STATES)
async def bad_gateway(_r):
return aioweb.Response(status=502)
async def garbage(_r):
return aioweb.Response(text="<html>", content_type="application/json")
async def not_a_list(_r):
return aioweb.json_response({"message": "hi"})
async def slow(_r):
await asyncio.sleep(2)
return aioweb.json_response(STATES)
async def closed_port():
H.CORE_API = "http://127.0.0.1:9/api"
async with ClientSession() as session:
return await H.HomeAssistant(session, "token").states()
print("1. hass.states() answers None, never an exception")
check("a normal answer is the state list", asyncio.run(read_with(ok)) == STATES)
for name, handler in (("502 while HA restarts", bad_gateway),
("a body that is not JSON", garbage),
("JSON that is not a list", not_a_list)):
check(f"{name} -> None", asyncio.run(read_with(handler)) is None)
check("an answer slower than the timeout -> None, at the timeout",
asyncio.run(read_with(slow, timeout_s=0.3)) is None
and read_with.took < 1.0)
check("no server at all -> None", asyncio.run(closed_port()) is None)
check("the timeout for the list is the same 10 s as every other read",
"STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read())
class FakeHass:
def __init__(self):
self.answer, self.reads = STATES, 0
async def states(self):
self.reads += 1
await asyncio.sleep(0.01)
return self.answer
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
print("\n2. the cache, and Home Assistant being down")
_h, _c = FakeHass(), Clock()
_cache = E.EntityCache(_h, clock=_c)
async def burst():
return await asyncio.gather(*(_cache.payload("power") for _ in range(5)))
_p = asyncio.run(burst())
check("five pickers opening at once cost one read of Home Assistant",
_h.reads == 1 and all(x == _p[0] for x in _p))
_ids = [r["entity_id"] for r in _p[0]["entities"]]
check("power entities come first, then the rest, and a broken state is left "
f"out ({_ids})", _ids == ["sensor.grid", "sensor.pv_kw",
"number.setpoint", "sensor.soc"]
and [r["match"] for r in _p[0]["entities"]] == [True, True, False, False])
check("each row carries id, name, state, unit and device class",
_p[0]["entities"][0] == {"entity_id": "sensor.grid", "name": "Grid power",
"state": "812", "unit": "W",
"device_class": "power", "match": True})
check("the kinds filter as the pickers need",
[r["entity_id"] for r in _p[0]["entities"]
if E.matches(r, "pct")] == ["sensor.soc"]
and [r["entity_id"] for r in _p[0]["entities"]
if E.matches(r, "number")] == ["number.setpoint"])
_c.t += 10
asyncio.run(_cache.payload())
check("within 30 s the cached list is served", _h.reads == 1)
check("peek() hands the validator the fresh list without a read",
set(_cache.peek()) == set(_ids) and _h.reads == 1)
_h.answer = None
_c.t += 30
_down = asyncio.run(_cache.payload())
check("Home Assistant down after a good read: the old list, marked old",
_h.reads == 2 and _down["available"] and _down["text"] == E.STALE_TEXT
and len(_down["entities"]) == 4 and _down["age_s"] >= 30)
check("...and peek() then gives nothing, so no warning rests on an old list",
_cache.peek() is None)
async def down_windows():
"""One loop throughout: a contended asyncio.Lock binds to its loop."""
hass, clock = FakeHass(), Clock()
hass.answer = None
cache = E.EntityCache(hass, clock=clock)
counts = []
for step in (0, 1, 30):
clock.t += step
await asyncio.gather(*(cache.payload() for _ in range(5)))
counts.append(hass.reads)
return counts
check("five polls while Home Assistant is down cost one read, the polls just "
"after wait for the next window, and that window tries once",
asyncio.run(down_windows()) == [1, 1, 2])
_h2 = FakeHass()
_h2.answer = None
_empty = asyncio.run(E.EntityCache(_h2).payload("power"))
check("Home Assistant down from the start: an empty list that says why",
_empty == {"available": False, "text": E.DOWN_TEXT, "entities": []})
print("\n3. the route")
class FakeController:
def __init__(self, hass):
self.hass, self.o = hass, {}
async def route(hass, query=""):
app = web.build_app(FakeController(hass), selfcfg=object())
runner = aioweb.AppRunner(app)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
try:
async with ClientSession() as s:
async with s.get(f"http://127.0.0.1:{port}/ha/entities{query}") as r:
return r.status, await r.json()
finally:
await runner.cleanup()
_st, _body = asyncio.run(route(FakeHass(), "?kind=pct"))
check("GET ha/entities?kind=pct answers 200 with the matching entity first",
_st == 200 and _body["entities"][0]["entity_id"] == "sensor.soc")
_st, _body = asyncio.run(route(_h2))
check("with Home Assistant down it still answers 200, with the reason",
_st == 200 and _body["available"] is False and _body["entities"] == [])
check("the words shown are short and plain (STYLE: one sentence)",
all(len(t.split()) <= 12 and t.count(".") == 1
for t in (E.DOWN_TEXT, E.STALE_TEXT)))
print(json.dumps(_body))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+25 -22
View File
@@ -811,9 +811,11 @@ check("...and the status page shows BOTH per-inverter rows - one latched, one "
"'the sign watchdog is armed' printed over a latched sibling is the page "
"lying",
len([r for r in _c3r.checks()
if r["text"].startswith("sign watchdog [")]) == 1
if r.get("id") == "direction" and r["ok"]
and "inverter-a" in r["text"]]) == 1
and len([r for r in _c3r.checks()
if "SIGN-DISAGREEMENT FAILSAFE [" in r["text"]]) == 1)
if r.get("id") == "direction" and not r["ok"]
and not r["warn"] and "inverter-b" in r["text"]]) == 1)
check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At two "
"inverters there are three sign questions on this page and they are three "
"rows: is A doing what it was told, is B, and is the SITE'S TOTAL "
@@ -823,15 +825,15 @@ check("⚠️ AND SAFETY-30's SITE ROW IS THERE BESIDE THEM, EXACTLY ONCE. At tw
"per-unit rows would be advertising two guards where the install has "
"three",
len([r for r in _c3r.checks()
if r["text"].startswith("site sign watchdog:")]) == 1)
if r.get("id") == "site_direction"]) == 1)
check("...and the per-inverter rows now say WHAT the unit was compared "
"against, because at a roster of two it is no longer the meter. A row "
"still reading 'the command opposed the meter' would send an operator to "
"look at P1 for a fault that is visible only on one inverter's own power "
"sensor",
all("its own measured power" in r["text"] for r in _c3r.checks()
if r["text"].startswith(("sign watchdog [",
"SIGN-DISAGREEMENT FAILSAFE ["))))
len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
and all("own power" in r["text"] for r in _c3r.checks()
if r.get("id") == "direction"))
# The operator's clear, which is one button and has to reach every latch.
run(_c3r.handle_action("auto_toggle")) # -> stopped
@@ -1354,7 +1356,7 @@ check("⚠️ AND A UNIT WITH NO `setpoint_entity` IS DELIBERATELY STILL LIVE HE
inverter_b(setpoint="").is_live(_now)[0] is True)
check("...and the status page says so in its own row, which is the surface an "
"operator actually reads",
any("NO SETPOINT ENTITY" in n for n in
any("“Setpoint entity” is not set" in n for n in
built(store=MemStore(), second=inverter_b(setpoint=""))[0]
.liveness_notes()))
check("...and a healthy site adds NO liveness rows at all, so the page is not "
@@ -1627,7 +1629,7 @@ check("⚠️ AC 5: two inverters naming the SAME setpoint_entity are REFUSED. "
"life of this add-on because the schema had exactly one such key; this "
"is the first configuration in which it is expressible, and it is the "
"first time that guarantee has had to be a check",
_why(_p_same, "one actuator") and _why(_p_same, "number.sp_a"))
_why(_p_same, "point at the same inverter entity") and _why(_p_same, "number.sp_a"))
check("...and case-folded, so the hazard cannot be smuggled past on "
"NUMBER.SP_A versus number.sp_a - which is exactly how it would be "
"written by someone copying an entity id out of the HA UI",
@@ -1651,7 +1653,7 @@ check("a flat setpoint_entity left set BESIDE a declared roster is refused. It "
"names an actuator the roster does not, so nothing would ever write it - "
"a site running quietly smaller than its own configuration, which is the "
"'silently a different config' outcome this card ranks below a refusal",
_why(_p_flat, "AND an inverters list"))
_why(_p_flat, "but the “Inverter list” (inverters) is also filled in"))
_p_dup = validate_startup_config(_opts(setpoint_entity="", inverters=[
dict(name="twin", setpoint_entity="number.sp_a", max_w=A_RATING),
dict(name="twin", setpoint_entity="number.sp_b",
@@ -1664,13 +1666,14 @@ for _what, _bad, _needle in (
("with no name at all",
dict(setpoint_entity="number.sp_b", max_w=3000), "has no name"),
("this controller cannot command",
dict(name="inverter-b", max_w=3000), "has no setpoint_entity"),
dict(name="inverter-b", max_w=3000),
"has no “Inverter setpoint entity” (setpoint_entity)"),
("with no rating",
dict(name="inverter-b", setpoint_entity="number.sp_b"),
"no usable max_w"),
"no usable “Inverter rating” (max_w)"),
("whose rating is not a number",
dict(name="inverter-b", setpoint_entity="number.sp_b", max_w="lots"),
"no usable max_w")):
"no usable “Inverter rating” (max_w)")):
check(f"an entry {_what} is refused, and the refusal says which entry and "
f"which key ({_needle!r}) rather than that something is wrong",
_why(validate_startup_config(_opts(setpoint_entity="",
@@ -1678,7 +1681,7 @@ for _what, _bad, _needle in (
check("`inverters` that is not a list at all is refused with the shape it "
"should have been, rather than crashing inside build_roster()",
_why(validate_startup_config(_opts(inverters="number.sp_b")),
"must be a\nLIST".replace("\n", " ")))
"(inverters) is str 'number.sp_b', not a list"))
# --- the permissive readers INSIDE validate.py --------------------------------
# ⚠️ RUN BOTH WAYS, WHICH IS MUTATION.md's RULE. A check that only asserts the
@@ -1736,8 +1739,8 @@ check("the fixture actually desynchronises the two lists, or the two checks "
check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
"inverter-c's 1000 W; it sits at caps index 1, and `_unit_label(opts, 1)` "
"is inverter-B. The refusal says inverter-c",
_why(_p_skew, "max_w (inverter-c) is 1000 W")
and not _why(_p_skew, "max_w (inverter-b) is 1000 W"))
_why(_p_skew, "for inverter-c (inverters.max_w) is 1000 W")
and not _why(_p_skew, "for inverter-b (inverters.max_w) is 1000 W"))
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
@@ -1751,15 +1754,15 @@ check("⚠️ RULE 6 TOO, AND SINCE MAINT-05 IT IS THE WEAKEST PACK IT NAMES. "
"inverter-c's 1000 W sits at caps index 1, and `_unit_label(opts, 1)` is "
"inverter-B - so a caps index leaking into a roster index would say "
"inverter-b here. The refusal says inverter-c, with inverter-c's number",
_why(_p_skew, "maintenance_charge_w is 3000 W but max_w (inverter-c) is "
"1000 W")
and not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
"(inverter-b)"))
_why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” for "
"inverter-c (inverters.max_w) is 1000 W")
and not _why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” for "
"inverter-b"))
check("...and it is no longer graded against the FIRST usable cap at all, which "
"is the behaviour change MAINT-05 makes here: inverter-b's 2000 W was the "
"pre-card answer and appears nowhere in the maintenance refusal now",
not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
"(inverter-b) is 2000 W"))
not _why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” for "
"inverter-b (inverters.max_w) is 2000 W"))
check("...and the counterfactual, so this is not a check that would pass on a "
"validator that named nothing: the OLD expressions still evaluate, on "
"this same config, to the wrong names",
@@ -1782,7 +1785,7 @@ check("⚠️ A READER THAT SILENTLY PINNED INVERTER #1, IN main.py. run_control
"unit's max_w was graded against the device it is written to, and the "
"skip was silent. The per-unit form catches B against B's own ESPHome",
len(_p_rng) == 1 and _why(_p_rng, "inverter-b")
and _why(_p_rng, "THE DEVICE WINS"))
and _why(_p_rng, "Lower this inverter's rating to"))
check("...and it says INVERTER-B, not 'the setpoint device' - a site with "
"three inverters and one bad rating needs to be told which one",
_why(_p_rng, "number.sp_b") and not _why(_p_rng, "inverter-a"))
+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 "
"may overrule; one pack at a time is a property of the grid connection "
"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])
check("...and an unblocked forced start still works, so the refusal is a "
"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)
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",
"WAITING" in _r9[1].events[0]
"waits its turn" in _r9[1].events[0]
and "inverter-a" 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])
@@ -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 "
"written - and says so",
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
# 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 "
"operator's next question is 'which name, and which one should it have "
"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 repr(ROSTER) in _rep5.explain())
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 "
"NAME surviving into the second start, not about second calls",
_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)
finally:
logging.getLogger("goodwe").removeHandler(_h)
_s44_lines = [m for m in _records if "SAFETY-44" in m]
# UX-08 2d: the line is plain now, so it is found by its words, not a card id.
_s44_lines = [m for m in _records
if "which is not in the inverter list" in m]
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line "
check("⭐ the repair SAYS SO, at ERROR, in one plain line - deleting this line "
"left the whole suite green, and it is how an operator learns their "
"latch was re-armed rather than never lost",
len(_s44_lines) == 1)
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1432,7 +1432,7 @@ def p1_rows(ctl):
a traceback instead of reddening the check that names the rule.
"""
try:
return [c["text"] for c in ctl.checks() if c["text"].startswith("P1 meter")]
return [c["text"] for c in ctl.checks() if c.get("id") == "meter"]
except Exception as err: # noqa: BLE001 - a raise here is itself the failure
print(f" checks() raised {type(err).__name__}: {err}")
return err
+104 -77
View File
@@ -114,6 +114,7 @@ from datetime import datetime, timedelta, timezone
from math import inf, isinf
from pathlib import Path
from app import messages
from app import peakguard
from named_absence import first, given, nth, present
@@ -1037,7 +1038,7 @@ check(f"a Controller built with peak_cap_w ABSENT takes the shipped "
c.peak.cap_w == CAP_W)
check("...and before the first cycle the guard has NO decision, which the "
"status page must not spell as 'idle'", c.peak_guard is None)
pre = [r for r in c.checks() if "peak guard" in r["text"]]
pre = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row before the first cycle: "
+ (pre[0]["text"] if pre else "(none)"))
check("the status page says so in its own row",
@@ -1053,17 +1054,20 @@ c_off, hass_off = built(TMP / "legacy.db",
{"meter_source": "off", "meter_entity": "sensor.grid"})
c_off.auto, c_off.soc, c_off.batt, c_off.grid = True, 55.0, 0.0, -6000.0
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c_off.cycle())
off_rows = [r for r in c_off.checks() if "peak guard" in r["text"]]
off_rows = [r for r in c_off.checks() if r.get("id") == "peak_guard"]
print(f" meter_source off: guard={c_off.peak_guard}, wrote "
f"{nth(hass_off.writes, -1):+.0f} W")
print(" status row: " + (off_rows[0]["text"] if off_rows else "(none)"))
check("with no P1 the guard does not run at all - it files nothing, and the "
"legacy path can still charge", c_off.peak_guard is None
and hass_off.writes and hass_off.writes[-1] < 0)
check("...and the status page says NOT COVERED rather than 'idle' or a green "
"row, which is the same answer SAFETY-18 already gives on a transport "
"that cannot see it", len(off_rows) == 1
and "NOT COVERED" in off_rows[0]["text"] and off_rows[0]["warn"])
check("...and the status page says the guard is off, and why, rather than "
"'idle' or a green row - the same answer SAFETY-18 already gives on a "
"transport that cannot see it", len(off_rows) == 1
and ": off" in off_rows[0]["text"]
and "Where the meter reading comes from" in off_rows[0]["text"]
and "idle" not in off_rows[0]["text"]
and not off_rows[0]["ok"] and off_rows[0]["warn"])
# ⚠️ THE FIXTURE MUST LAND IN THE CURRENT QUARTER, AND THAT IS NOT PEDANTRY.
# `cycle()` reads the real clock, and `project()` measures the open block against
@@ -1213,23 +1217,36 @@ check("the real cycle files the peak claim and the real arbiter binds the real "
and not c.peak_guard.saturated and "peak" in (c.reason or ""))
check("...and the number written to the inverter is the guard's floor",
hass.writes and near(hass.writes[-1], c.peak_guard.floor_w, 1.5))
rows = [r for r in c.checks() if "peak guard" in r["text"].lower()]
rows = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows[0]["text"] if rows else "(none)"))
check("the status page carries exactly one peak-guard row, flagged",
len(rows) == 1 and rows[0]["warn"])
# UX-08 2d: the engaged row names what the battery is asked for FIRST and the
# limit SECOND. Both are watts, so only the ordered pair tells them apart.
from app import messages as _msg_on # noqa: E402
_on = (f"asks the battery for {_msg_on.watts(c.peak_guard.floor_w)} to stay "
f"under {_msg_on.watts(c.peak_guard.ceiling_w)}")
_on_swapped = (f"asks the battery for {_msg_on.watts(c.peak_guard.ceiling_w)} "
f"to stay under {_msg_on.watts(c.peak_guard.floor_w)}")
check("...and it names the battery's share first and the limit second, and "
"the two render differently so the swap is visible",
len(rows) == 1 and _on in rows[0]["text"]
and _on_swapped not in rows[0]["text"]
and _msg_on.watts(c.peak_guard.floor_w)
!= _msg_on.watts(c.peak_guard.ceiling_w))
check("an engagement that is not saturated raises NO notification - §10.3's "
"'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house "
"load bigger than the inverter is not a bug",
"goodwe_peak_saturated" not in hass.notifications)
check("...but it does reach the action log, which is where a control action "
"that moves energy belongs",
any("PEAK GUARD" in e for e in c.events))
n_events = sum(1 for e in c.events if "PEAK GUARD" in e)
any("PEAK_GUARD" in e for e in c.events))
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())
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 "
"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.
# A floor of 1.5x what the inverter can give: it saturates wherever in the
@@ -1644,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)
check("...and its one-line explanation tells the operator the consequence in "
"the words that matter - the quarter is going over the ceiling",
"FLOOR-LIMITED" in d_lim.explain()
and "WILL GO OVER THE CEILING" in d_lim.explain()
"FLOOR_LIMITED" in d_lim.explain()
and "will go over the limit" 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 "
"an operator reading one line can tell a flat battery from a dead sensor",
@@ -1921,13 +1938,13 @@ asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c24.cycle())
check("...once per episode, not once per cycle - a quarter of an hour of "
"identical cards is how an operator learns to dismiss the one that "
"mattered", hass24.notifications.count("goodwe_peak_soc_floor") == 1)
rows24 = [r for r in c24.checks() if "peak guard" in r["text"].lower()]
rows24 = [r for r in c24.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows24[0]["text"] if rows24 else "(none)"))
check("the status page carries exactly one peak-guard row and it is NOT ok - "
"the money is going to be spent and a human is the only actuator left",
len(rows24) == 1 and not rows24[0]["ok"]
and "FLOOR-LIMITED" in rows24[0]["text"])
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"]
and "peak limit will be passed" in rows24[0]["text"])
check("...and it reaches the action log too", any("FLOOR_LIMITED" in e
for e in c24.events))
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
# at 0 W in that state, so the guard's own answer is what is graded here.
@@ -2015,12 +2032,13 @@ check("...and so is a floor EXACTLY AT it, which is the interesting case: equal
"it leaves none", len(equal) == 1)
check("...while one point above is accepted, so this refuses the disarmed case "
"only and not 'close to the limit'", above == [])
check("...and the refusal NAMES BOTH NUMBERS and the shipped default, so the "
"operator can act on it without reading the source",
# UX-08 2c: the shipped default left the sentence (it is on the option's
# description). The two numbers the operator acts on stay.
check("...and the refusal NAMES BOTH NUMBERS, so the operator can act on it "
"without reading the source",
len(below) == 1
and f"{BMS_FLOOR:.0f} %" in below[0]
and f"{BMS_FLOOR - 5:.0f} %" in below[0]
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in below[0])
and f"{BMS_FLOOR - 5:.0f} %" in below[0])
check("...and a non-numeric floor is refused rather than defaulted, for the "
"reason every read in this file is: a value that cannot be parsed is not "
"an observation of anything",
@@ -2478,17 +2496,23 @@ check("...and DOCS.md sends the reader to it rather than leaving the open "
# comment is a scope sentence the operator never sees.
c25, hass25, NET25, _ = wired_over_limit(
TMP / "soc25.db", LIVE_MAX_W * 0.3, soc=SOC_FLOOR - 1.0)
rows25 = [r for r in c25.checks() if "peak guard" in r["text"].lower()]
rows25 = [r for r in c25.checks() if r.get("id") == "peak_guard"]
def _row_scope(text):
"""SAFETY-25 in messages.row_peak_reserve()'s words."""
return ("Home use still draws the battery below it" in text
and "down to the cut-out level" in text)
print(" status row: " + (rows25[0]["text"] if rows25 else "(none)"))
check("the STATUS PAGE row for a floor-limited guard says the floor bounds the "
"guard only and that self-consumption may keep discharging below it. "
"Without that sentence the page implies a pack-wide reserve at the exact "
"moment the replay above says the loop is still emptying the pack",
len(rows25) == 1
and "PEAK GUARD FLOOR-LIMITED" in rows25[0]["text"]
and "BOUNDS THE PEAK GUARD ONLY" in rows25[0]["text"].upper()
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
and "at its peak reserve" in rows25[0]["text"]
and _row_scope(rows25[0]["text"]))
# ⚠️ THE ROW KEEPS THE WHOLE SENTENCE; THE BANNER DOES NOT. status_payload()
# renders the first not-ok/not-warn row as the page headline, so before this the
# loudest element on the page was the same 550-character paragraph the row three
@@ -2500,35 +2524,36 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
# The guard's own line, which the banner has always carried and which is not
# this card's to shorten: `explain()` is SAFETY-24's sentence about a quarter
# that is going over the ceiling.
_expl25 = nth(rows25, 0)["text"].split(". NOTE:")[0]
check("the NOT READY banner is the guard's own line plus a SHORT scope tag - "
"under 90 characters of it - and NOT the row's whole paragraph repeated "
"at the top of the page. A headline that is a paragraph is a headline "
"nobody reads, and this one is followed three lines later by the same "
"text verbatim",
present(_expl25)
and _pay25["level"] == "bad"
and _pay25["banner"].startswith("NOT READY")
and _expl25 in _pay25["banner"]
and "BOUNDS THE PEAK GUARD ONLY" in _pay25["banner"].upper()
and "not a pack-wide reserve" not in _pay25["banner"]
and len(_pay25["banner"]) - len(_expl25) < 90)
check("...and shortening the banner did NOT shorten the row: the full AC 3 "
# UX-08: the banner is the notification's own title, and the row below keeps
# the SAFETY-25 sentence.
check("the Not ready banner is a short headline - the notification's title - "
"and NOT the row repeated at the top of the page. A headline that is a "
"paragraph is a headline nobody reads",
_pay25["level"] == "bad"
and _pay25["banner"] == (f"{messages.NOT_READY} — "
f"{messages.T_PEAK_RESERVE}")
and not _row_scope(_pay25["banner"]))
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
"sentence is still what the checks list carries, which is the surface the "
"operator reads to find out what the floor does not bound",
len(rows25) == 1
and len(rows25[0]["text"]) > 300
and "not a pack-wide reserve" in rows25[0]["text"]
and "BMS cut-out" in rows25[0]["text"])
"operator reads to find out what the reserve does not bound",
len(rows25) == 1 and _row_scope(rows25[0]["text"]))
_body25 = hass25.last_message("goodwe_peak_soc_floor")
def _says_scope(body):
"""SAFETY-25 in messages.peak_reserve()'s words: the reserve binds the peak
guard only, and home use goes on discharging below it to the cut-out."""
return ("The reserve only applies to keeping the peak down" in body
and "normal home use can still draw the battery below it" in body
and "down to the battery cut-out level" in body)
print(f" notification body tail: ...{_body25[-180:]}")
check("...and so does the persistent notification, which outlives the page and "
"is the thing an operator reads on a phone at 19:00. It names what has "
"stopped - the guard buying a lower bill - and what has not",
"peak_soc_floor bounds THE PEAK GUARD ONLY" in _body25
and "not a" in _body25 and "pack-wide reserve" in _body25
and "BMS cut-out" in _body25)
_says_scope(_body25))
# --- 10f. THE CLAIM-SOURCE SET, PINNED --------------------------------------
@@ -3883,13 +3908,12 @@ check("⚠️ AC 3. Rule 8 grades EVERY unit: a roster whose SECOND pack has a "
"inverter-b. Under the flat key this config STARTED, and the guard would "
"then have commanded a forced discharge that pack B's BMS - not the "
"operator's floor - is the thing that stops",
len(_r_second) == 1 and "(inverter-b)" in _msg(_r_second)
and "(inverter-a)" not in _msg(_r_second))
check("...and it names BOTH numbers and the shipped default, so the operator "
"can act on it without reading the source",
len(_r_second) == 1 and " for inverter-b" in _msg(_r_second)
and " for inverter-a" not in _msg(_r_second))
check("...and it names BOTH numbers, so the operator can act on it without "
"reading the source",
f"{B_PACK_FLOOR_DEEP_SIM:.0f} %" in _msg(_r_second)
and f"{SOC_FLOOR:.0f} %" in _msg(_r_second)
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in _msg(_r_second))
and f"{SOC_FLOOR:.0f} %" in _msg(_r_second))
# The mirror. A grader hardwired to index 1 - or one that reports the LAST
# offender - passes the check above and fails this one.
_r_first = rule8(validate_startup_config(
@@ -3897,8 +3921,8 @@ _r_first = rule8(validate_startup_config(
check("...and with the SAME two numbers in the OTHER ORDER it names "
"inverter-a instead. The pair of checks is what makes this an index "
"check: one of them goes red for any hardwired position",
len(_r_first) == 1 and "(inverter-a)" in _msg(_r_first)
and "(inverter-b)" not in _msg(_r_first))
len(_r_first) == 1 and " for inverter-a" in _msg(_r_first)
and " for inverter-b" not in _msg(_r_first))
# And both at once, because a loop that `break`s on the first offender reports
# one refusal for a config with two.
_r_both = rule8(validate_startup_config(
@@ -3906,8 +3930,8 @@ _r_both = rule8(validate_startup_config(
check("...and a roster where BOTH packs offend is refused TWICE, once per unit "
"and each naming its own - so an operator with two wrong packs is not "
"told about one of them and left to discover the other on the next boot",
len(_r_both) == 2 and "(inverter-a)" in _msg(_r_both, 0)
and "(inverter-b)" in _msg(_r_both, 1))
len(_r_both) == 2 and " for inverter-a" in _msg(_r_both, 0)
and " for inverter-b" in _msg(_r_both, 1))
# A missing per-entry cut-out is refused BY NAME rather than defaulted.
_r_missing = rule8(validate_startup_config(_roster32(A_PACK_FLOOR, None)))
print(f" entry with no bms_soc_floor -> {_msg(_r_missing)[:120] or '(none)'}...")
@@ -3916,7 +3940,7 @@ check("...and an entry that states NO `bms_soc_floor` is refused by name rather
"cut-out this add-on invented for a battery it has not been told about, "
"which is the same wrong number the flat key produced. See "
"DECISIONS/SAFETY-32-per-pack-bms-floor.md",
len(_r_missing) == 1 and "(inverter-b)" in _msg(_r_missing)
len(_r_missing) == 1 and " for inverter-b" in _msg(_r_missing)
and "must both be numbers" in _msg(_r_missing))
# --- 12f. nothing is inherited, with the flat key SET AND DIFFERENT ----------
@@ -4332,8 +4356,8 @@ check("the notification an operator reads at 19:00 NAMES THE PACK that stopped "
"the site, and names only that one. 'the battery is at its floor' on a "
"site with two batteries is an instruction to go and look at both, and "
"the wrong one is the one that is fine",
"SAFETY-32" in _body32 and "inverter-b" in _body32
and "inverter-a" not in _body32.split("WHICH PACK")[-1])
"The battery of inverter-b is at its peak reserve" in _body32
and "inverter-a" not in _body32)
# The mirror again, on the SoC axis: swap which pack is empty and the name
# swaps. A hardwired `self.inverters[1].name` passes the line above.
_body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
@@ -4341,8 +4365,8 @@ _body32b = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
check("...and with the SAME two readings swapped between the packs it names "
"inverter-a instead - so the sentence follows the measurement rather "
"than a position in the roster",
"inverter-a" in _body32b
and "inverter-b" not in _body32b.split("WHICH PACK")[-1])
"The battery of inverter-a is at its peak reserve" in _body32b
and "inverter-b" not in _body32b)
# ⚠️ AC 5, THE OTHER HALF: THE SAME METHOD ON A ROSTER OF ONE MUST PRINT WHAT IT
@@ -4366,13 +4390,14 @@ async def _notify32_one():
_body32one = asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_notify32_one())
check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack "
"sentence at all - byte for byte the message SAFETY-25 shipped. There is "
check("⚠️ AC 5. At a roster of ONE the same notification names NO pack - "
"byte for byte the two-pack message minus the name (UX-08). There is "
"one battery, naming it is noise, and this alarm reaches every existing "
"install",
"SAFETY-32" not in _body32one and "WHICH PACK" not in _body32one
and _body32one == _body32.split("\n\nSAFETY-32")[0]
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
"inverter-" not in _body32one
and _body32one == _body32.replace("The battery of inverter-b is",
"The battery is")
and _says_scope(_body32one))
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
@@ -5589,7 +5614,7 @@ MUT.append(("an unstated per-entry cut-out defaulted to 0 %",
# 19. rule 8 grading only the first unit, i.e. the flat rule left in place.
MUT.append(("rule 8 grading only the FIRST unit",
_r_second != [] and "(inverter-b)" in _msg(_r_second),
_r_second != [] and " for inverter-b" in _msg(_r_second),
"part 12e: 'a roster whose SECOND pack has a cut-out above "
"peak_soc_floor is refused'. ⚠️ The offender is the SECOND entry "
"for exactly this row"))
@@ -5597,15 +5622,14 @@ MUT.append(("rule 8 grading only the FIRST unit",
# 20. rule 8 naming a hardwired unit - CFG-04's rule 5/6 bug, which is why
# `unit_bms_floors_indexed()` carries the roster index.
MUT.append(("rule 8's refusal naming a hardwired inverter",
"(inverter-b)" in _msg(_r_second) and "(inverter-a)" in _msg(_r_first),
" for inverter-b" in _msg(_r_second) and " for inverter-a" in _msg(_r_first),
"part 12e: the two orders. Either hardwired index passes one of "
"them and fails the other"))
# 21. the notification naming a fixed roster position rather than the pack that
# is actually at its floor.
MUT.append(("the per-pack notification naming a fixed roster position",
"inverter-a" in _body32b
and "inverter-b" not in _body32b.split("WHICH PACK")[-1],
"inverter-a" in _body32b and "inverter-b" not in _body32b,
"part 12h: the same two readings swapped between the packs"))
# ---- the two PR #48 SURVIVORS, and why they survived -------------------------
@@ -5921,10 +5945,12 @@ _S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_OK, soc_floor=SOC_FLOOR)
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a "
f"{_S38_CEIL_W:.0f} W ceiling")
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a "
f"{_S38_PROJ.projected_w:.0f} W ceiling")
# UX-08 2d: the row is in plain words now, and the pair is still ordered.
from app import messages as _msg38 # noqa: E402
_S38_LITERAL = (f"heading for {_msg38.watts(_S38_PROJ.projected_w)}, under "
f"this month's {_msg38.watts(_S38_CEIL_W)} peak")
_S38_SWAPPED = (f"heading for {_msg38.watts(_S38_CEIL_W)}, under "
f"this month's {_msg38.watts(_S38_PROJ.projected_w)} peak")
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
print(f" {_S38_IDLE.explain()!r}")
@@ -5947,7 +5973,8 @@ check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
_S38_SWAPPED not in _S38_IDLE.explain())
check("...and the row still names WHERE the ceiling came from, which is the "
"difference between a cap an operator set and a peak the month already "
"banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain())
"banked", "under this month's" in _S38_IDLE.explain()
and "peak limit" not in _S38_IDLE.explain())
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
@@ -5955,7 +5982,7 @@ check("...and the row still names WHERE the ceiling came from, which is the "
# main.py:1951 is what the release edge writes.
_c38, _h38 = built(TMP / "s38pin.db")
_c38.peak_guard = _S38_IDLE
_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
_rows38 = [r for r in _c38.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
"off the shipped `checks()` rather than off `explain()` a second time. "
+88 -77
View File
@@ -2123,7 +2123,7 @@ check("...and so does a configured provider on its own",
-5000, 5000) == [])
check("an unknown provider id is refused rather than defaulted to 'off'",
why(validate_startup_config(vbase(price_provider="nordpool"), -5000, 5000),
"not a shipped plugin"))
"which does not exist"))
# ⚠️ TARIFF-07: A RETIRED ID IS REFUSED BY A DIFFERENT MESSAGE, AND EVERY CHECK
# HERE GRADES THE REASON RATHER THAN THE VERDICT. The failure this section
@@ -2144,26 +2144,21 @@ check(f"every retired id is refused at startup {sorted(_retired)}",
len(_retired) == 3 and all(len(v) == 1 for v in _retired.values()))
check("...and NOT by the unknown-id message, which would tell an operator they "
"mistyped an id this add-on offered them and accepted for months",
all("not a shipped plugin" not in v[0] for v in _retired.values())
and "WITHDRAWN" not in _unknown[0])
all("which does not exist" not in v[0] for v in _retired.values())
and "was withdrawn" not in _unknown[0])
check("...and each message NAMES ITS OWN ID, so the operator can tell it is "
"about their configuration and not a generic notice",
all(repr(pid) in v[0] for pid, v in _retired.items()))
check("...and (a) says the plugin never fetched a price, naming the endpoint "
"that does not exist",
all("NEVER FETCHED A PRICE" in v[0] for v in _retired.values())
and "api.engie.be/dynamic/be" in _retired["engie_dynamic"][0]
and "api.totalenergies.be/prices/dynamic"
in _retired["totalenergies_dynamic"][0]
and "api.eneco.be/eddy/prices" in _retired["eneco_eddy"][0])
# UX-08 2c: the endpoints that never existed are in DOCS.md under "Energy price
# providers"; the refusal keeps what the operator acts on.
check("...and (a) says the plugin never fetched a price",
all("never fetched a price" in v[0] for v in _retired.values()))
check("...and (b) says what the site has ACTUALLY been running instead - the "
"half §11.4 made invisible",
all("SILENT" in v[0] and "static_profile" in v[0]
for v in _retired.values()))
check("...and (c) says what to do, naming a live plugin and refusing to "
"pretend an alias could exist",
all("This site ran on" in v[0] for v in _retired.values()))
check("...and (c) says what to do, naming a live plugin",
all("generic_dayahead" in v[0] and "manual_csv" in v[0]
and "NO alias" in v[0] for v in _retired.values()))
for v in _retired.values()))
# ⚠️ AND THE SENTENCE CHANGES WITH THE SITE. "You have been on the static
# profile" is FALSE where no profile was ever set, and that site is in a WORSE
@@ -2177,11 +2172,12 @@ _with_profile = validate_startup_config(
_without = _retired["eneco_eddy"][0]
check("a retired id on a site WITH a static profile is told it has been "
"running that profile",
"the static day/night tariff in static_profile" in _with_profile)
"ran on the “Fixed or day/night tariff” (static_profile)"
in _with_profile)
check("...and a site WITHOUT one is told it has been running nothing at all, "
"which is a different and worse state",
"NOTHING AT ALL" in _without
and "the static day/night tariff in static_profile" not in _without)
"ran on no tariff at all" in _without
and "(static_profile)" not in _without)
check("...so the two renderings are not the same sentence",
_with_profile != _without)
@@ -2193,7 +2189,7 @@ _p = validate_startup_config(
check("a provider AND the Home Assistant price entities is refused - two price "
"sources on one site", len(_p) == 1)
check("...and the message says which one actually wins",
why(_p, "HOME ASSISTANT ENTITIES WIN"))
why(_p, "would win over it"))
# TARIFF-03 added a second source with the same silent-loser shape.
_ps = validate_startup_config(
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
@@ -2202,7 +2198,7 @@ _ps = validate_startup_config(
-5000, 5000)
check("a static_profile AND the Home Assistant price entities is refused too - "
"main.py reads one of them for `cheap` and the loser is silent",
len(_ps) == 1 and why(_ps, "HOME ASSISTANT"))
len(_ps) == 1 and why(_ps, "(price_now_entity) and “Average price entity” (price_avg_entity) would win"))
check("the Home Assistant entities alone still start, so nothing is taken away "
"from a live install",
validate_startup_config(vbase(price_now_entity="sensor.now",
@@ -2945,10 +2941,10 @@ check("...and BOTH translations tell an operator which switch does which - "
"the provider option says it is what starts the fetch, and the trading "
"option says turning it off does not turn the feed off. One language "
"updated and not the other is how this surface rots",
"STARTS THE PRICE FETCH" in _T10_TR["en"]
and "DOES NOT TURN THE PRICE FEED OFF" in _T10_TR["en"]
and "HET OPHALEN VAN PRIJZEN START" in _T10_TR["nl"]
and "ZET DE PRIJSFEED NIET UIT" in _T10_TR["nl"])
"starts the price fetch" in _T10_TR["en"]
and "does not turn the price feed off" in _T10_TR["en"]
and "start het ophalen van prijzen" in _T10_TR["nl"]
and "zet de prijsfeed niet uit" in _T10_TR["nl"])
# --------------------------------------------------------------------------- #
@@ -3431,8 +3427,8 @@ check(f"network charges folded into `offset` move the card's own price to "
isinstance(_double_price, float) and abs(_double_price - 15.55) > 10)
check(f"...and it is refused, by the CARD rule, naming the discrepancy and "
f"the network-charge cause ({_double[:60]!r})",
"does not reproduce its own card" in _double and "OUT BY" in _double
and "folded into `offset`" in _double)
"the card says" in _double
and "network charges are not in the offset" in _double)
_CORRUPTIONS = {
"VAT left out (vat_pct 0)": spoilt(vat_pct=0),
@@ -3451,7 +3447,7 @@ for _name, _blob in sorted(_CORRUPTIONS.items()):
_why = refusal(_blob)
check(f"{_name}: refused, and by the CARD rule rather than by another one "
f"({_why[:48]!r})",
"does not reproduce its own card" in _why and "OUT BY" in _why)
"the card says" in _why)
# ⚠️ THE TOLERANCE IS A REAL EDGE AND IT IS GRADED FROM BOTH SIDES, or
# "within 0.01" and "within anything" would be the same check.
@@ -3463,7 +3459,7 @@ _outside = refusal(spoilt(card_expect_incl_vat=round(
+ CARD_TOLERANCE * 2.0, 6)))
check(f"half a tolerance out passes and twice a tolerance out does not, so "
f"{CARD_TOLERANCE} is the number being enforced rather than a shape",
_inside == "" and "does not reproduce its own card" in _outside)
_inside == "" and "the card says" in _outside)
# A card that is absent is refused, and NOT by the generic unknown-field rule.
check(f"a formula with NO card block is refused, and the message says why the "
@@ -3569,32 +3565,31 @@ check("...and the hourly series' coverage is still DERIVED from the "
print()
print("15e. every refusal in the table, and the two that are WARNINGS")
_TABLE = {
"units absent": (spoilt(units=""), "units", "is REQUIRED"),
"units absent": (spoilt(units=""), "units", "Choose one of"),
"units not one of the enumerated ones":
(spoilt(units="cents"), "units", "must be one of"),
(spoilt(units="cents"), "units", "Choose one of"),
"factor == 0 (every spread becomes 0, arbitrage never fires)":
(spoilt(factor=0), "factor", "static_profile"),
"factor < 0 (the dearest hour becomes the cheapest)":
(spoilt(factor=-0.102), "factor", "inverts the market"),
(spoilt(factor=-0.102), "factor", "turns the market upside down"),
"vat_pct above 30 (the 6 / 1.06 / 106 confusion)":
(spoilt(vat_pct=106), "vat_pct", "between 0 and 30"),
"vat_pct below 0": (spoilt(vat_pct=-6), "vat_pct", "between 0 and 30"),
"a non-numeric coefficient": (spoilt(factor="0.102"), "factor",
"not a number"),
"a coefficient that is an EXPRESSION rather than a number":
(spoilt(factor="0.1*x"), "factor", "evaluates nothing"),
(spoilt(factor="0.1*x"), "factor", "Write a plain number"),
"a non-finite coefficient": (spoilt(offset=float("inf")), "offset",
"not finite"),
"an unknown index": (spoilt(index="belpex_h"), "index", "cannot get it"),
"an unknown index": (spoilt(index="belpex_h"), "index", "can only fetch"),
"an unknown resolution": (spoilt(resolution="daily"), "resolution",
"must be one of"),
"Choose one of"),
"an unknown top-level field (which is how 'expr' is answered)":
(spoilt(expr="0.102*spot+1"), "expr", "NO field that takes an "
"expression"),
(spoilt(expr="0.102*spot+1"), "expr", "It takes only"),
"an unknown field inside the card": ({**ENECO_CARD,
"card": dict(ENECO_CARD["card"],
vat="6")},
"vat", "no field(s)"),
"vat", "unknown field(s)"),
}
for _name, (_blob, _names, _needle) in sorted(_TABLE.items()):
_why = refusal(_blob)
@@ -3649,7 +3644,7 @@ check("...and it is warned about, naming the month and saying why it is not "
"refused",
"2019-01" in (stale_card(parse_price_formula(json.dumps(_old_card)))
or "")
and "Not refused" in (stale_card(
and "still starts" in (stale_card(
parse_price_formula(json.dumps(_old_card))) or ""))
check("...while a card from this month draws no warning, so the rule is the "
"AGE and not the presence of the field",
@@ -3850,18 +3845,18 @@ check("custom_formula with NO formula is refused, naming the option and "
"showing the shape",
why(validate_startup_config(vbase(price_provider="custom_formula"),
-5000, 5000),
"price_formula is empty"))
"(price_formula) is empty"))
check("a formula that does not reproduce its card is refused AT STARTUP, "
"with the discrepancy named",
why(validate_startup_config(
vbase(price_provider="custom_formula",
price_formula=json.dumps(spoilt(offset=13.0))),
-5000, 5000), "does not reproduce its own card"))
-5000, 5000), "the card says"))
check("...and a formula that is not JSON at all is refused with the shape it "
"wanted, rather than with a traceback",
why(validate_startup_config(
vbase(price_provider="custom_formula", price_formula="0.102*spot+1"),
-5000, 5000), "not valid JSON"))
-5000, 5000), "price_formula is not valid near character"))
check(f"...and a card outside the plausible band is refused too, WITH THE "
f"SHIPPED taxes in place - the flat "
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} must not be able to "
@@ -4105,13 +4100,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
# asserted.
_T11_SLOTS = {
"points": r"(\d+) points over \d+ h",
"hours": r"\d+ points over (\d+) h",
"firm": r"(\d+) firm / \d+ carried",
"carried": r"\d+ firm / (\d+) carried",
"horizon": r"for a (\d+) h horizon",
"age_s": r"fetched (\d+) s ago",
"spacings": r"([\d+]+) s spacing",
# UX-08 2d: the row is in plain words; every figure keeps its own slot.
"points": r"(\d+) prices over \d+ hours",
"hours": r"\d+ prices over (\d+) hours",
"firm": r"(\d+) published and \d+ estimated",
"carried": r"\d+ published and (\d+) estimated",
"horizon": r"for (\d+) hours ahead",
"age_s": r"Price plan from (\d+) s ago",
# 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
# 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,
@@ -4129,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):
"""The number in ONE NAMED SLOT of a rendered row, or `_Raised`.
@@ -4144,18 +4153,18 @@ def _slot(row, name):
if m is None:
raise ValueError(f"no {name!r} slot in {row[:90]!r}")
raw = m.group(1)
return ([int(x) for x in raw.split("+")] if name == "spacings"
else int(raw))
return (_spacing_s(raw) if name == "spacings" else int(raw))
return derived(_get)
_T11_GOOD = ("x (recomputed this tick); provider plan fetched 7 s ago for a "
"48 h horizon: dynamic_dayahead from generic_dayahead, "
"132 points over 33 h, 36 firm / 96 carried, 900+3600 s spacing")
_T11_SHIPPED_DEFECT = ("x (recomputed this tick); provider plan fetched 7 s "
"ago for a 48 h horizon: dynamic_dayahead from "
"generic_dayahead, 132 points over 132 h, "
"96 firm / 36 carried, 900+3600 s spacing")
_T11_GOOD = ("x. Price plan from 7 s ago, for 48 hours ahead: day-ahead "
"prices from the price feed, 132 prices over 33 hours, "
"36 published and 96 estimated, one every 15 minutes or every "
"hour")
_T11_SHIPPED_DEFECT = ("x. Price plan from 7 s ago, for 48 hours ahead: "
"day-ahead prices from the price feed, 132 prices over "
"132 hours, 96 published and 36 estimated, one every "
"15 minutes or every hour")
# The slots divide into the ones a *Tariff*/​*Arbitrage* ROW carries and the
# ones a REFUSAL sentence carries; each group is calibrated against its own
# well-formed example, because a slot asked of the wrong kind of sentence is
@@ -4403,12 +4412,16 @@ check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
f"`off (arbitrage_enabled)` for both - naming an option that is `true` "
f"in the options blob",
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
# ⚠️ THE "Why" ROW OF THE SAME RENDER, NOT A SECOND READ OF `why_words`. Since
# UX-10 the waiting sentence counts its seconds when it is read, so reading the
# property again here, after the other renders above, could cross a second
# ("1 second" in the row, "2 seconds" now) - CI run 953 on PR #141 did.
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[0]['Why']!r}), so the operator is told which input is "
f"missing rather than being sent to look at a switch that is already on. "
f"The reason comes from main.py, not from this file",
_T11_ON_HELD[1].reason
and _T11_ON_HELD[1].reason in _T11_ON_HELD[0]["Arbitrage"])
_T11_ON_HELD[0]["Why"].startswith("A reading is missing")
and _T11_ON_HELD[0]["Why"] in _T11_ON_HELD[0]["Arbitrage"])
check(f"...and a cycle that DID reach the step renders the strategy's own "
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
@@ -4454,10 +4467,10 @@ check(f"DRIVEN, no clock touched: a decision stands "
and _T11_DROP_STALE is True
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
check(f"...and it names the reason the cycle stopped "
f"({_T11_ON_DROPPED[1].reason!r}) beside the age of the decision it is "
f"({_T11_ON_DROPPED[1].why_words!r}) beside the age of the decision it is "
f"still showing, so 'the strategy decided this' and 'the strategy last "
f"decided this' are not one sentence",
_T11_ON_DROPPED[1].reason in _T11_ON_DROPPED[0]["Arbitrage"]
_T11_ON_DROPPED[1].why_words in _T11_ON_DROPPED[0]["Arbitrage"]
and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int))
check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than "
@@ -4590,8 +4603,8 @@ check(f"...and the row an operator reads names the SPACING that makes those "
check("...and this is the same wording FCST-04's *Price history* row already "
"used, so the two rows describing the same feed can be compared rather "
"than being two conventions on one page",
"points over" in _T11_E2E_ROW
and "points over" in ("" if isinstance(_T11_E2E, _Raised)
"prices over" in _T11_E2E_ROW
and "prices over" in ("" if isinstance(_T11_E2E, _Raised)
else _T11_E2E["Price history"]))
@@ -5362,18 +5375,17 @@ check("...and none of those rows is vacuous: every one really did refuse on "
_T16_DIFFER = [
("factor 0 -> where a FLAT price of THIS leg lives",
spoilt(factor=0.0), "static_profile", "export_price_eur_kwh"),
("a negative factor -> which market it inverts",
spoilt(factor=-0.102), "No consumption tariff",
"No Belgian injection tariff"),
("a card miss -> what a wrong `offset` costs on THIS leg",
spoilt(factor=0.120), "bills you for it twice",
"nothing is added to this leg"),
# UX-08 2c: a negative factor gets one sentence on both legs now ("put the
# minus sign on the offset"), so it left this table.
("a card miss -> what else to check on THIS leg",
spoilt(factor=0.120), "network charges are not in the offset",
"the VAT is 0 for injection"),
("invalid JSON -> WHEN this option is read at all, which is not the "
"same answer on the two legs: `price_formula` decides nothing unless "
"price_provider is 'custom_formula', and the injection leg is read "
"whatever the consumption provider is",
"{not json", "unless price_provider is 'custom_formula'",
"read whatever price_provider is set to"),
"{not json", "unless “Dynamic price source” is 'custom_formula'",
"only if your injection price follows the market"),
]
for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER:
_a, _b = ((_reason(lambda b=_blob: parse_price_formula(b)),
@@ -5490,10 +5502,10 @@ check(f"a factor 100x out is refused BY THE PASS-THROUGH RUNG, naming the "
f"share of the wholesale price it would pay ({_T16_PT_WHY[:60]!r})",
"passes through" in _T16_PT_WHY
and f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x" in _T16_PT_WHY
and "|offset| above" not in _T16_PT_WHY)
and "larger than" not in _T16_PT_WHY)
check(f"an offset 100x out is refused BY THE OFFSET RUNG, naming the EUR/kWh "
f"it works out at ({_T16_OFF_WHY[:60]!r})",
"|offset| above" in _T16_OFF_WHY
"larger than" in _T16_OFF_WHY
and "passes through" not in _T16_OFF_WHY)
check(f"...and the WHOLE card 100x out - which trips both - is refused, with "
f"the pass-through rung answering first ({_T16_BOTH_WHY[:44]!r})",
@@ -5555,9 +5567,8 @@ check(f"a card block ON THIS LEG is graded by the same rung at the same "
f"15.55) is refused, naming the option the operator typed "
f"({_T16_SPOILT_CARD[:64]!r})",
_T16_WITH_CARD == ""
and "does not reproduce its own card" in _T16_SPOILT_CARD
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION)
and f"the tolerance is {CARD_TOLERANCE}" in _T16_SPOILT_CARD)
and "the card says" in _T16_SPOILT_CARD
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION))
check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is "
f"enforced on this leg too rather than 'something'",
_reason(lambda: parse_export_formula(json.dumps(spoilt(
+11 -6
View File
@@ -66,6 +66,7 @@ import sys
from named_absence import first, nth
from datetime import datetime, timedelta, timezone
from app import messages as _msg_fc
from app import price as _price
from app import price_forecast as _fc
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",
raises(lambda: _nothing.points, Absence)
and raises(lambda: _nothing.get("points", []), Absence))
check("...and its reason names §11.4 for an operator",
"§11.4" in _nothing.reason)
check("...and its reason says the fixed tariff is used and there is no "
"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",
raises(lambda: fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
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)
check("...and the reason names §8.3, the offending hour, and when the real "
"price is expected",
"§8.3" in _gate_bad.reason
and bool(_first_prov) and _first_prov.isoformat() in _gate_bad.reason
and "14-16" in _gate_bad.reason)
"is only estimated" in _gate_bad.reason
and bool(_first_prov)
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.
_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)
check("no price data at all BLOCKS an irreversible action too - a missing "
"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)
_short = series_of(DAY[:4], lambda u: t_price(brussels(u).hour),
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")
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}",
"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()
_ph.PriceHistory._remember(
@@ -1217,7 +1217,7 @@ json.dump({"version": SCHEMA + 99, "days": {}}, open(_p7, "w", encoding="utf-8")
_h7 = PriceHistory(_p7)
check("a file from a schema this build does not know is discarded rather than "
"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")
# A record whose points are malformed. THE DEFECT IS A DEFAULTED PRICE.
@@ -1270,7 +1270,7 @@ json.dump({"version": SCHEMA, "days": {
_h14 = PriceHistory(_p14)
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",
_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"])
_w14 = derived(lambda: _h14.words)
_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__} "
f"{_shape!r}: dropped, reported, and every accessor answers "
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")
_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 "
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",
"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"holds yesterday, all 24 hours of it' are different facts, and only "
f"the second says the horizon will be full tomorrow morning",
words_of(_h_words) == f"{YDAY_D} (24 points over 24 h)")
words_of(_h_words) == "24 prices over 24 hours for 14 Jan")
# ⚠️ AND THE TWO NUMBERS ARE NOT THE SAME NUMBER - FCST-05's LOW. The row
# shipped as `f"{d} ({_n_points(rec)} h)"`, which counts POINTS and prints them
@@ -1362,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"figure that says whether tomorrow's fill has a whole day to carry is "
f"the HOUR one",
_qw == f"{YDAY_D} (96 points over 24 h)")
_qw == "96 prices over 24 hours for 14 Jan")
check(f"...and specifically the row does NOT say '96 h' {_qw!r} - a point "
f"count wearing the hour's name is H1's defect, and this row is where "
f"an operator would have read it",
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 points " in _qw)
isinstance(_qw, str) and "(96 h)" not in _qw and _qw.startswith("96 prices "))
check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the "
f"days it dropped read as days that were simply never written",
isinstance(_w14, str) and (_h14.last_error or "") in _w14
and "2026-01-13" in _w14)
and "13 Jan" in _w14)
check("NoHistory() has a sentence of its own rather than an empty cell",
NoHistory().words == "not kept")
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"rather than counted",
isinstance(_wload, str)
and _wload.startswith(f"{TODAY_D} (1 point over 1 h) [")
and "1 malformed point row(s) in" in _wload)
and _wload.startswith("1 price over 1 hour for 15 Jan [")
and "1 bad price in" in _wload)
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
# OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND
# 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"every tariff refresh was dying on it",
isinstance(_wafter, str)
and "malformed point row(s) dropped from" in _wafter
and TODAY_D in _wafter)
and "bad price dropped from" in _wafter
and "15 Jan" in _wafter)
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"two notes are not one note read twice",
isinstance(_wload, str) and isinstance(_wafter, str)
and "malformed point row(s) in" in _wload
and "malformed point row(s) in" not in _wafter
and "malformed point row(s) dropped from" not in _wload)
and "bad price in" in _wload
and "bad price in" not in _wafter
and "bad price dropped from" not in _wload)
# ...and a SECOND refresh over the same store is clean, which is the state
# a restarted add-on comes up in.
_hM2 = PriceHistory(_pM)
check(f"...and the store reopened from that file is CLEAN: the whole day "
f"held and nothing to report {words_of(_hM2)!r}, so the note clears "
f"itself rather than becoming permanent furniture",
words_of(_hM2) == f"{TODAY_D} ({_want} points over {_want} h)"
words_of(_hM2) == f"{_want} prices over {_want} hours for 15 Jan"
and _hM2.last_error is None)
# ...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"both name the write failure. They disagreed - `words` printed two "
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 _y16.kind == "no_data" and "could not write" in why(_y16))
check(f"...and it DEGRADES rather than raising: the carry still returned a "
+104 -111
View File
@@ -69,7 +69,7 @@ import asyncio
import sys
from pathlib import Path
from app import mqtt, pv
from app import messages, mqtt, pv
from named_absence import field, nth, present
from app.main import Controller
@@ -499,6 +499,11 @@ def rows_of(c):
return [r["text"] for r in c.checks()]
def pv_rows(c):
"""The solar rows of the status page (UX-08: selected by id, not wording)."""
return [r for r in c.checks() if r.get("id") == "pv"]
def page_rows(c):
return c.status_payload()["rows"]
@@ -510,43 +515,44 @@ check("⚠️ AN UNSET OPTION ADDS NO STATUS ROW AND NO PAGE ROW - not a caution
"is unset, because an unset forecast leaves a money guard without an "
"input; this is instrumentation and a permanent caution on every install "
"that never asked for it is the warning spam the card forbids",
not [t for t in _off_rows if "PV" in t]
and not [r for r in page_rows(c_off) if r[0] == "PV"])
not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "Solar production"])
check("...and the whole row list is otherwise the same length, so nothing else "
"moved either", len(_on_rows) == len(_off_rows) + 1)
_pv_row = [t for t in _on_rows if t.startswith("PV production")]
_pv_row = [r["text"] for r in pv_rows(c_on)]
print(f" {_pv_row}")
# habit 14: the ordered pair - value, entity and unit each in its OWN slot in
# the literal sentence, so a transposition is visible rather than merely
# present.
check("a good read says the watts, the entity it came from and the unit it was "
"reported in, each in its own slot",
_pv_row == ["PV production: 3210 W from sensor.pv_power "
"(reported in W)"]
and ("PV", "3 210 W") in page_rows(c_on))
# UX-08: the row says the watts, and no entity id (STYLE.md). The unit it was
# read in is DEBUG detail; a refused unit still gets its own caution below.
check("a good read says the watts, as a green row, and names no entity id",
_pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
and "sensor." not in _pv_row[0]
and pv_rows(c_on)[0]["ok"] is True
and ("Solar production", "3 210 W") in page_rows(c_on))
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
_bad_row = [r for r in c_bad.checks() if r["text"].startswith("PV production")]
_bad_row = pv_rows(c_bad)
print(f" {[r['text'] for r in _bad_row]}")
check("a refused read says WHICH refusal, and names the entity - 'no reading' "
"on its own sends an operator to look at a working sensor",
check("a refused read says WHICH refusal - 'no reading' on its own sends an "
"operator to look at a working sensor",
len(_bad_row) == 1
and _bad_row[0]["text"] == ("PV production: no reading from "
"sensor.pv_power - unit '%' is not one of "
"mW, W, kW, MW, GW, TW - it cannot be read "
"in watts"))
and _bad_row[0]["text"] == ("Solar production: no reading, the entity's "
"unit is not a power unit"))
check("...and every refusal pv.watts() can give has its own plain words",
len({messages.pv_problem(pv.watts(st)[1]) for st in (
None, state("unavailable", "W"), state("abc", "W"),
state("12", ""), state("12", "%"))}) == 5)
check("⭐ ...and it is a CAUTION, never a `bad` row: `status_payload()` builds "
"NOT READY out of rows that are neither ok nor warn, and a telemetry "
"channel that could put a battery site into NOT READY would be "
"observability with a foot in the control path",
len(_bad_row) == 1
and _bad_row[0] == {"ok": False, "warn": True, "text": _bad_row[0]["text"]}
and not c_bad.status_payload()["banner"].startswith("NOT READY")
and _bad_row[0]["ok"] is False and _bad_row[0]["warn"] is True
and not c_bad.status_payload()["banner"].startswith(messages.NOT_READY)
and c_bad.status_payload()["level"] != "bad"
and c_bad.status_payload()["banner"]
== c_off.status_payload()["banner"])
check("...and the page row renders an absence as an em dash, not as 0 W",
("PV", "—") in page_rows(c_bad))
("Solar production", "—") in page_rows(c_bad))
# ==============================================================================
@@ -691,13 +697,21 @@ print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
check(f"PRECONDITION: the scan found the whole package, not a subset - "
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
[f.name for f in APP])
check("⭐ EXACTLY THREE MODULES CAN SEE THE MATERIAL: `pv.py` (the reader), "
"`main.py` (the read and the two operator surfaces) and `mqtt.py` (the "
"publication). `control.py`, `arbiter.py`, `allocator.py`, `peakguard.py`, "
"`arbitrage.py`, `maintenance.py`, `clamp.py`, `freeze.py`, "
"`watchdog.py`, `delivery.py` and `circulation.py` do not mention it in "
"any spelling, comments included",
_touch == ["main.py", "mqtt.py", "pv.py"])
# UX-13 adds the settings surface: labels (messages.py), a settings warning
# (validate.py) and the page's layout and derivation (uilayout.py, uimodel.py).
# None of them decides anything, so the decision modules stay excluded.
_DECIDERS = ("control.py", "arbiter.py", "allocator.py", "peakguard.py",
"arbitrage.py", "maintenance.py", "clamp.py", "freeze.py",
"watchdog.py", "delivery.py", "circulation.py")
check("⭐ ONLY THE READER, THE PUBLISHER AND THE SETTINGS SURFACE SEE THE "
"MATERIAL: `pv.py` (the reader), `main.py` (the read and the two operator "
"surfaces), `mqtt.py` (the publication), and `messages.py`, "
"`validate.py`, `uilayout.py` and `uimodel.py` (settings). "
f"{', '.join(_DECIDERS)} do not mention it in any spelling, comments "
"included",
_touch == ["main.py", "messages.py", "mqtt.py", "pv.py", "uilayout.py",
"uimodel.py", "validate.py"]
and not set(_DECIDERS) & set(_touch))
# habit 4 in the instrument: a scanner that cannot go red is not a scanner.
check("...and the scan CAN go red - a module body with the material in it is "
"caught, so the green above is the tree speaking and not the scanner "
@@ -923,48 +937,39 @@ print()
print("10. the operator surfaces at a roster of three - and 0.11.0 at one")
# ==============================================================================
_c_many, _, _ = run(cycles=1, by_entity=FULL, **PV_MANY)
_many_row = [t for t in rows_of(_c_many) if t.startswith("PV production")]
_many_row = [r["text"] for r in pv_rows(_c_many)]
print(f" {_many_row}")
# habit 14 at a roster: the total in its own slot and EVERY contributor in its
# own slot with its own watts and its own unit, so a sum that disagrees with the
# addends printed beside it is visible in the sentence.
check("a healthy roster says the SITE total, how many entities it came from, "
"and then every entity with its own watts and its own unit - so a total "
"that disagrees with the numbers printed beside it is visible rather than "
"merely present",
_many_row == ["PV production: 3750 W from 3 entities "
f"({E1} 1200 W in W, {E2} 2100 W in kW, "
f"{E3} 450 W in W)"]
and ("PV", "3 750 W") in page_rows(_c_many))
# UX-08: the row carries the site total and the number of sources; the
# per-source figures are DOCS/DEBUG detail (DECISIONS/UX-08-short-notifications).
check("a healthy roster says the SITE total and how many sources it came from",
_many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
f"sources"]
and ("Solar production", "3 750 W") in page_rows(_c_many))
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
_gap_row = [r for r in _c_gap.checks() if r["text"].startswith("PV production")]
_gap_row = pv_rows(_c_gap)
print(f" {[r['text'] for r in _gap_row]}")
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, names the "
"entity that is missing and why - AND names what the others reported. The "
"partial information is not thrown away, it is put where a HUMAN reading "
check("⭐ A HOLED ROSTER SAYS THERE IS NO SITE TOTAL, IN THOSE WORDS, and names "
"the missing source by its place in the list - put where a HUMAN reading "
"the page live will see it, and kept out of the number a recorder hands "
"back cold a week later",
len(_gap_row) == 1
and _gap_row[0]["text"] == (
"PV production: NO SITE TOTAL - only 2 of 3 entities are reporting "
"and a partial sum would understate production: "
f"{E2} - no state (entity missing, or the read failed). "
f"Reporting: {E1} 1200 W, {E3} 450 W"))
and _gap_row[0]["text"] == ("Solar production: no total, source 2 of 3 "
"has no reading"))
check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at "
"one entity: `status_payload()` builds NOT READY out of rows that are "
"neither ok nor warn, and a telemetry channel that could put a battery "
"site into NOT READY would be observability with a foot in the control "
"path",
len(_gap_row) == 1
and _gap_row[0] == {"ok": False, "warn": True, "text": _gap_row[0]["text"]}
and _gap_row[0]["ok"] is False and _gap_row[0]["warn"] is True
and _c_gap.status_payload()["level"] != "bad"
and _c_gap.status_payload()["banner"]
== c_off.status_payload()["banner"])
check("...and the page row is an em dash, not a smaller number: the one row an "
"operator glances at cannot show a partial total either",
("PV", "—") in page_rows(_c_gap)
and ("PV", "3 300 W") not in page_rows(_c_gap))
("Solar production", "—") in page_rows(_c_gap)
and ("Solar production", "3 300 W") not in page_rows(_c_gap))
# ⚠️ THE ACCEPTANCE CRITERION THE REST OF THIS FILE CANNOT STATE: at a roster of
# ONE the surfaces are 0.11.0's, character for character. `c_on` above is the
@@ -974,27 +979,24 @@ _c_one_new, _, _ = run(cycles=3, by_entity={E1: [state("3210", "W")]},
pv_entities=[E1])
print(f" one entity via pv_entities : "
f"{[t for t in rows_of(_c_one_new) if 'PV' in t]}")
check("⭐ ONE ENTITY IS 0.11.0's PATH, however it was declared: the status "
"sentence, the page row and the published value are the same shape "
"through `pv_entity` and through a one-member `pv_entities`, and the "
"sentence is 0.11.0's own - `(reported in W)`, not `from 1 entities`",
[t.replace(E1, "sensor.pv_power")
for t in rows_of(_c_one_new) if "PV" in t]
== [t for t in rows_of(c_on) if "PV" in t]
== ["PV production: 3210 W from sensor.pv_power (reported in W)"]
check("⭐ ONE ENTITY IS ONE PATH, however it was declared: the status "
"sentence, the page row and the published value are the same through "
"`pv_entity` and through a one-member `pv_entities` - no `from 1 sources`",
[r["text"] for r in pv_rows(_c_one_new)]
== [r["text"] for r in pv_rows(c_on)]
== [f"Solar production: {messages.signed_watts(3210)}"]
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
.index("PV")] == ("PV", "3 210 W"))
check("...and a refused read at a roster of one is 0.11.0's refusal sentence "
"too, not the roster one - `no reading from <entity> - <reason>`",
[r["text"] for r in run(cycles=1, pv_entities=[E1],
by_entity={E1: [state("1234", "%")]})[0].checks()
if r["text"].startswith("PV production")]
== [f"PV production: no reading from {E1} - unit '%' is not one of "
"mW, W, kW, MW, GW, TW - it cannot be read in watts"])
.index("Solar production")] == ("Solar production", "3 210 W"))
check("...and a refused read at a roster of one is the one-sensor refusal "
"too, not the roster one - `no reading, <reason>`",
[r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
by_entity={E1: [state("1234", "%")]})[0])]
== [r["text"] for r in _bad_row])
# ⚠️ AND THE REPLACEMENT IS STATED RATHER THAN SILENT.
_c_both, _, _ = run(cycles=1, by_entity=FULL, **dict(PV_ON, **PV_MANY))
_ign = [r for r in _c_both.checks() if "IGNORED" in r["text"]]
_IGN = messages.row_pv_single_ignored()
_ign = [r for r in _c_both.checks() if r["text"] == _IGN]
print(f" {[r['text'] for r in _ign]}")
check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
"entities are summed instead. `entities()` REPLACES rather than unions - "
@@ -1002,21 +1004,18 @@ check("⭐ BOTH KEYS SET: the page SAYS `pv_entity` is ignored and how many "
"key still names - but a replacement nobody is told about is the 'one of "
"which silently wins' this config refuses elsewhere",
len(_ign) == 1 and _ign[0]["ok"] is False and _ign[0]["warn"] is True
and _ign[0]["text"] == ("PV production: `pv_entities` is set, so "
"`pv_entity` is IGNORED - the 3 entities in the "
"list are what is summed. Clear `pv_entity` to "
"remove this caution")
and "clear “Solar production entity”" in _ign[0]["text"]
and _c_both.pv_w == 3750.0)
check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
"configuration, not on a list-only one, and not on an install with "
"neither",
not [r for r in c_on.checks() if "IGNORED" in r["text"]]
and not [r for r in _c_many.checks() if "IGNORED" in r["text"]]
and not [r for r in c_off.checks() if "IGNORED" in r["text"]])
not [r for r in c_on.checks() if r["text"] == _IGN]
and not [r for r in _c_many.checks() if r["text"] == _IGN]
and not [r for r in c_off.checks() if r["text"] == _IGN])
check("...and an install with NO PV roster still adds no row of any kind - the "
"whole of TEL-12 is invisible at the shipped default",
not [t for t in rows_of(c_off) if "PV" in t]
and not [r for r in page_rows(c_off) if r[0] == "PV"]
not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "Solar production"]
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
and run(cycles=1)[1].state_calls == []
and run(cycles=1, pv_entities=[])[1].state_calls == []
@@ -1109,19 +1108,18 @@ check("⭐ END TO END: the controller reads the option, and the MQTT `pv` row "
"carries the corrected site total",
_c_mix.pv_w == MIX_TOTAL
and pv_msg(_f_mix) == ("goodwe_ctl/pv", "3270.0", False))
_mix_row = [r["text"] for r in _row(_c_mix, "W from 3 entities")]
_mix_row = [r["text"] for r in _row(_c_mix, "from 3 sources")]
print(f" {_mix_row}")
check("the status row prints each entity's watts AFTER the sign and marks "
"ONLY the flipped one",
_mix_row == [f"PV production: 3270 W from 3 entities ({E1} 1200 W in W, "
f"{E2} 2100 W in kW [sign inverted], {E3} -30 W in W)"])
check("the status row prints the total AFTER the sign",
_mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
f"from 3 sources"])
check("...and the page's PV row is the corrected total",
("PV", "3 270 W") in page_rows(_c_mix))
("Solar production", "3 270 W") in page_rows(_c_mix))
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
check("a holed roster's 'Reporting:' list marks the flipped entity too",
[r["text"] for r in _row(_c_hole_f, "Reporting:")]
and f"{E2} 2100 W [sign inverted]" in
(_at(_row(_c_hole_f, "Reporting:"), 0) or {}).get("text", ""))
check("a holed roster with a flipped member names the missing source, not "
"the flipped one",
[r["text"] for r in _row(_c_hole_f, "no total")]
== ["Solar production: no total, source 1 of 3 has no reading"])
# The owner's report, at a roster of one: a meter reading +13 W at night.
_OWN = "sensor.pv_meter_power"
@@ -1134,13 +1132,11 @@ check("⭐ THE REPORTED CASE: 13 W of night-time standby on a reversed meter is
"negative caution, because -13 W is standby and not a reversed sign - "
"and NO stray caution, because a lone `pv_entity` is in the roster",
_c_own.pv_w == -13.0
and [r["text"] for r in _row(_c_own, "PV production: -13 W")]
== [f"PV production: -13 W from {_OWN} (reported in W) [sign inverted]"]
and _row(_c_own, "probably reversed") == []
and _row(_c_own, "flips nothing") == [])
check("...and WITHOUT the option it is 0.12.0's row, byte for byte",
[r["text"] for r in _row(_c_own0, "PV production:")]
== [f"PV production: 13 W from {_OWN} (reported in W)"])
and [r["text"] for r in pv_rows(_c_own)]
== ["Solar production: -13 W, sign reversed"]
and _row(_c_own, "Reversed-sign solar entities") == [])
check("...and WITHOUT the option it is the plain row",
[r["text"] for r in pv_rows(_c_own0)] == ["Solar production: 13 W"])
# Whitespace: the roster entry and the invert entry differ only by padding. Both
# sides must normalise the same way, or the read path leaves the sign unflipped
@@ -1152,9 +1148,7 @@ check("⭐ PADDING: ` sensor.x` in the roster and `sensor.x ` in "
"`pv_invert_entities` are the same entity - flipped on the read path, "
"marked on the page, and no stray caution",
_c_pad.pv_w == MIX_TOTAL
and f"{E2} 2100 W in kW [sign inverted]"
in (_at(_row(_c_pad, "W from 3 entities"), 0) or {}).get("text", "")
and _row(_c_pad, "flips nothing") == [])
and _row(_c_pad, "Reversed-sign solar entities") == [])
# --- the negative caution -------------------------------------------------------
def _neg(watts_by_entity, flip=()):
@@ -1162,20 +1156,20 @@ def _neg(watts_by_entity, flip=()):
c, _, _ = run(cycles=1, by_entity={e: [state(str(w), "W")]
for e, w in watts_by_entity.items()},
**opts)
return _row(c, "probably reversed")
return _row(c, "reads ")
_n_add = _neg({E1: 1200, E2: -1500, E3: 450})
_n_rm = _neg({E1: 1200, E2: 1500, E3: 450}, flip=[E2])
print(f" {[r['text'] for r in _n_add + _n_rm]}")
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it, its "
"watts, and says to ADD it to `pv_invert_entities`",
check("⭐ AN UNFLIPPED ENTITY FAR BELOW ZERO gets a caution that names it by "
"its place in the list, its watts, and says to ADD it to the "
"setting 'Reversed-sign solar entities' (`pv_invert_entities`)",
[r["text"] for r in _n_add]
== [f"PV production: {E2} reads -1500 W - far below zero, so its sign "
"is probably reversed. If it is producing now, ADD it to "
"`pv_invert_entities` in the Configuration tab"])
== ["Solar production: source 2 reads -1 500 W, add it to "
"“Reversed-sign solar entities”"])
check("...and a FLIPPED one far below zero is told to REMOVE it instead",
len(_n_rm) == 1 and "REMOVE it from `pv_invert_entities`"
len(_n_rm) == 1 and "remove it from “Reversed-sign solar entities”"
in (_at(_n_rm, 0) or {}).get("text", ""))
check("⭐ ...and it is a CAUTION, never a `bad` row - a telemetry sign cannot "
"put a battery site into NOT READY",
@@ -1198,11 +1192,10 @@ _c_typo, _, _ = run(cycles=1, by_entity=FULL,
check("a `pv_invert_entities` entry naming no PV entity in use is a named "
"caution (a typo would otherwise leave the sign silently unflipped)",
[(r["text"], r.get("ok"), r.get("warn"))
for r in _row(_c_typo, "flips nothing")]
== [("PV production: `pv_invert_entities` names sensor.pv_wset, which "
"is not a PV entity in use, so it flips nothing - check the "
"spelling against `pv_entities`", False, True)]
and _row(_c_mix, "flips nothing") == [])
for r in _row(_c_typo, "not a solar entity in use")]
== [("Solar production: entry 1 of “Reversed-sign solar entities” is "
"not a solar entity in use", False, True)]
and _row(_c_mix, "not a solar entity in use") == [])
# --- default: byte-identical to 0.12.0 ------------------------------------------
_c_d0, _, _p_d0 = run(cycles=1, by_entity=MIX, **PV_MANY)
+475
View File
@@ -0,0 +1,475 @@
"""Runnable check for SAFETY-52. `python3 test_refusal.py`
A battery that refuses to give power (the BMS cut-out) used to leave the
command standing at its last ask for hours: +5000 W for 11.87 h on 2026-09-06,
+1940 W for 3 h 55 m on the bus on 2026-09-19. Now a refused direction is held
at 0 W, a 200 W probe asks every 15 minutes, and the refusal ends on charge, on
an answered probe, on stop/start and when another writer takes the wire.
DECISIONS/SAFETY-52 has the record and the replay figures.
Mutating the modules this file grades? Purge `__pycache__` first, or run
`tools/mutation_run.sh`, which does - see MUTATION.md.
Parts 1-5 drive the REAL `Controller` (read_inputs + cycle, the real
SAFETY-37/57 gate) against a scripted battery whose reading stands at exactly
-4 W for any request >= -4 W at the cut-out, and follows otherwise. Time is the
`now_mono` handed to `read_inputs()`, so 12 hours run in seconds. `OLD` runs the
same fixture with `refused=None` forced into `compute()` - release/1.0's law -
so every "new" figure is shown beside the one it replaces. Part 4 also runs
`compute()` alone, as the bus saw 09-19 before the gate existed.
Mutations, each on a copy of this tree purged of `__pycache__`, graded with
`python3 -B` over test_refusal, test_control, test_clamp, test_telemetry and
test_messages (MUTATION.md). Reds are failing checks, on this tree:
# mutation reds
-- ------------------------------------------------------- ---------------
R1 control.py: the engage `if` -> `if False and ...` 8 here + 1 clamp
(release/1.0's behaviour)
R2 control.py: the exit on the other direction removed 1
R3 control.py: the exit on an answered probe removed 2
R4 control.py: `PROBE_W = 300.0`, above the gate's 250 W 4
R5 control.py: the hold keeps the old target, not 0 W 4
R6 main.py: `_refusal_from_stale()` returns at once 1
R7 main.py: another writer no longer ends the refusal 1
R8 main.py: stop/start no longer ends the refusal 1
R9 main.py: the probe window never opens 4
R10 main.py: seed from a stale stop below `saturation_w` again, 1
not below REFUSED_BELOW_W (review of #141). The same mutation
in control.py's engage clause SURVIVES, and that is expected:
the delivery exit (R11) ends such a refusal in the same cycle.
R11 control.py: only an answered PROBE ends the refusal 2
(`and probe` back in the delivery exit; review of #141)
"""
import asyncio
import sys
from datetime import datetime, timedelta, timezone
from app import control, freshness
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W, REFUSED, PROBING
from app.main import Controller, why_key
from app.arbiter import Claim
from app import messages
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
LIVE = {"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
"deadband_w": 15, "step_w": 10}
TICK = 5.0 # the meter's measured median tick (replay_recorded_loop)
CUT_W = -4.0 # what the register reads at the cut-out, 2026-09-06
H = 3600.0
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
class _Store:
def __init__(self):
self.data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _Pub:
enabled = False
def publish(self, values):
pass
def close(self):
pass
class _Hass:
"""House load, and a battery that follows unless `cut(t)` says it refuses.
At the cut-out the reading is exactly CUT_W for any request >= CUT_W (a flat
register, which is what arms or does not arm the stale-reading gate). A
charge request is followed: the pack accepts charge at the floor.
`cap(t)` limits discharge while not cut (the 09-06 pack gave 4570 W at 10 %).
"""
def __init__(self, load, cut, cap=lambda t: 1e9):
self.load, self.cut, self.cap = load, cut, cap
self.t = 0.0
self.cmd = 0.0
self.writes = []
def batt(self):
if self.cut(self.t) and self.cmd >= CUT_W:
return CUT_W
return float(round(min(self.cmd, self.cap(self.t))))
async def limits(self, entity):
return (-LIVE["max_w"], LIVE["max_w"], 1.0)
async def number(self, entity, invert=False):
b = self.batt()
return {"sensor.grid": self.load(self.t) - b, "sensor.soc": 9.0,
"sensor.batt": b}.get(entity)
async def set_number(self, entity, value):
self.writes.append((self.t, float(value)))
self.cmd = float(value)
return True
async def call(self, *a):
return True
def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
"""Real cycles every TICK seconds of `now_mono`. `events[t] = fn(c)` pokes
the controller (stop, start, another writer) at that instant.
Returns [(t, grid, batt, setpoint, i_w, loop_reason, batt_refused)], the
number of gate trips, and the controller."""
async def go():
hass = _Hass(load, cut, cap)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15},
**LIVE), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
import app.main as m
real, real_dt = m.compute, m.datetime
clock = [0.0]
class _DT(real_dt):
"""`cycle()`'s wall clock follows `now_mono`, so the 15 s input hold
and the gate's release run in simulated seconds too."""
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
if old:
m.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
rows, trips, was = [], 0, False
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
for et, fn in list((events or {}).items()):
if et <= t:
fn(c)
del events[et]
await c.read_inputs(now_mono=t)
stale = c.inverters[0].batt_fresh.stale
trips += stale and not was
was = stale
await c.cycle()
rows.append((t, hass.load(t) - hass.batt(), hass.batt(),
hass.cmd, c.i_w, c.loop_reason, c.batt_refused))
t += TICK
finally:
m.compute, m.datetime = real, real_dt
return rows, trips, c
return asyncio.run(go())
def at(rows, t):
return min(rows, key=lambda r: abs(r[0] - t))
def longest_hold_h(rows, floor_w=250.0, until=None):
"""Longest run of one identical request at or above `floor_w`, in hours."""
best = cur = 0.0
last = None
for r in rows:
if until is not None and r[0] >= until:
break
cur = cur + TICK if (r[3] == last and abs(r[3]) >= floor_w) else 0.0
last = r[3]
best = max(best, cur)
return best / H
# ==============================================================================
print("1. the 09-06 shape: a load step takes the request to 5000 W, the battery "
"cuts out, the load ends")
# ==============================================================================
def load906(t):
return 1020 if t < 60 else 4800 if t < 162 else 940 if t < 11.9 * H else -600
def cut906(t):
return t >= 132
def cap906(t):
return 4570.0
old906, otrips906, _ = run(load906, cut906, 12.2 * H, cap906, old=True)
new906, ntrips906, c906 = run(load906, cut906, 12.2 * H, cap906)
for name, rows, tr in (("old", old906, otrips906), ("new", new906, ntrips906)):
print(f" {name}: request at 2 min {at(rows, 120)[3]:+.0f} W, at 6 h "
f"{at(rows, 6 * H)[3]:+.0f} W (i_w {at(rows, 6 * H)[4]:+.0f}), longest "
f"hold {longest_hold_h(rows):.2f} h, gate trips {tr}")
check("PRECONDITION: the old law holds +5000 W for the whole night on this "
"fixture, as it did on 2026-09-06 - the fixture reproduces the card",
at(old906, 6 * H)[3] == 5000.0 and longest_hold_h(old906) > 11.5)
check("the request reaches the rail before the cut, so the new law starts from "
"the same place", at(new906, 120)[3] == 5000.0)
t0 = next((r[0] for r in new906 if r[5] == REFUSED), None)
check(f"after the cut the battery-refuses hold takes the request to 0 W within "
f"about a minute (at t={t0})", t0 is not None and 132 < t0 <= 132 + 90
and at(new906, t0)[3] == 0.0)
check("...and it stays refused overnight, with the integrator at 0, and no "
"request of 250 W or more stands for longer than one probe interval",
at(new906, 6 * H)[6] == 1 and at(new906, 6 * H)[4] == 0.0
and longest_hold_h(new906, until=11.9 * H) < PROBE_EVERY_S / H)
check(f"...and the stale-reading gate does not cycle: {ntrips906} trips in "
f"12 hours (the old law's latch also kept it quiet: {otrips906})",
ntrips906 == 0)
morning = [r for r in new906 if r[0] >= 11.9 * H + 60]
check("when solar makes the grid export, the refusal ends and the battery "
"charges - the exit on the other direction",
morning and morning[-1][6] == 0 and morning[-1][3] < -400)
# ==============================================================================
print("2. the probe: 200 W every 15 minutes, below the gate's 250 W")
# ==============================================================================
probes = []
for r in new906:
if r[3] == PROBE_W and (not probes or r[0] - probes[-1][1] > TICK):
probes.append([r[0], r[0]])
elif r[3] == PROBE_W:
probes[-1][1] = r[0]
starts = [p[0] for p in probes]
gaps = [b - a for a, b in zip(starts, starts[1:])]
print(f" {len(probes)} probes, first at {starts[0] / 60 if starts else 0:.1f} "
f"min, gaps {sorted(set(round(g) for g in gaps))} s, lengths "
f"{sorted(set(round(p[1] - p[0] + TICK) for p in probes))} s")
check("the probe asks exactly PROBE_W, which is below the stale-reading gate's "
"threshold, so a probe can never arm it",
PROBE_W < freshness.MOVE_W and all(r[3] in (0.0, PROBE_W)
for r in new906 if r[6] == 1))
check("there is a probe every 15 minutes through the night (at least 40 in "
"11.8 hours), each about 34 s long",
len(probes) >= 40 and all(PROBE_EVERY_S <= g <= PROBE_EVERY_S + PROBE_S
+ 2 * TICK for g in gaps)
and all(PROBE_S - TICK <= p[1] - p[0] + TICK <= PROBE_S + TICK
for p in probes))
check("...and a probe reads as a probe on the loop's own reason row",
any(r[5] == PROBING for r in new906))
# ==============================================================================
print("3. recovery with no sign change: the battery gives power again at 2 h")
# ==============================================================================
def cut_rec(t):
return 132 <= t < 2 * H
oldr, _, _ = run(load906, cut_rec, 2.6 * H, cap906, old=True)
newr, _, crec = run(load906, cut_rec, 2.6 * H, cap906)
after_o = [r for r in oldr if 2 * H <= r[0] < 2 * H + 600]
after_n = [r for r in newr if 2 * H <= r[0] < 2.6 * H]
exp_o = sum(max(0.0, -r[1]) * TICK for r in after_o) / 3600
exp_n = sum(max(0.0, -r[1]) * TICK for r in after_n) / 3600
print(f" old: worst grid {min(r[1] for r in after_o):+.0f} W, export "
f"{exp_o:.0f} Wh; new: worst grid {min(r[1] for r in after_n):+.0f} W, "
f"export {exp_n:.0f} Wh")
check("PRECONDITION: the old law answers a recovering battery with its "
"standing +5000 W and exports kilowatts into a 940 W house",
min(r[1] for r in after_o) < -3000)
check("the new law never exports more than the probe while it waits, and the "
"answered probe ends the refusal so control resumes",
min(r[1] for r in after_n) > -PROBE_W - 50
and after_n[-1][6] == 0 and abs(after_n[-1][1] - (-10)) < 100)
check("...and the event log says the probe was answered",
any("BATTERY REFUSES ENDED: the probe was answered" in e
for e in crec.events))
# ==============================================================================
print("4. the 09-19 shape: a stale-reading stop on a frozen ~0 W reading "
"is a refusal")
# ==============================================================================
# The request steps +1540 -> +1940 and more as the battery cuts out mid-ramp:
# the request moves after the reading went flat, so the gate trips before the
# hold's 12 cycles - the ~70 s cycle SAFETY-59 measured. The trip seeds the hold.
def load919(t):
return 1530 if t < 60 else 2400 if t < 1800 else 900 if t < 4 * H else -600
def cut919(t):
return t >= 65
def run919(old):
return run(load919, cut919, 4.2 * H, old=old)
old919, otr919, _ = run919(True)
new919, ntr919, c919 = run919(False)
print(f" old: gate trips {otr919}, longest hold {longest_hold_h(old919):.2f} h;"
f" new: gate trips {ntr919}, longest hold {longest_hold_h(new919):.2f} h")
check("PRECONDITION: on the old law the gate trips over and over all night "
"(the SAFETY-59 cycle) or the request stands for hours",
otr919 > 20 or longest_hold_h(old919) > 1.0)
check("the new law stops after at most two trips and holds 0 W, and the event "
"log says the battery refuses",
ntr919 <= 2 and at(new919, 2 * H)[3] in (0.0, PROBE_W)
and any("BATTERY REFUSES" in e for e in c919.events))
# The same shape through `compute()` alone, as the bus saw it on 2026-09-19
# before the gate existed: +1540 -> +1940, then a battery giving nothing.
T = control.Tuning(**LIVE)
res = {}
for name, carry in (("old", None), ("new", 0)):
# sat_count 2: the +1540 and +1940 cycles were already short (the plan)
prev, i_w, sat, ref, cmds = 1940.0, 2340.0, 2, carry, []
for _ in range(2000): # ~2.8 h at 5 s
d = control.compute(prev, 1950.0, CUT_W, T, sat, i_w, refused=ref)
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
cmds.append(prev)
res[name] = cmds
print(f" compute() alone: old stands at {res['old'][-1]:+.0f} W "
f"({sum(v == 1940.0 for v in res['old'])} of 2000 cycles at +1940 W), "
f"new at {res['new'][-1]:+.0f} W from cycle "
f"{res['new'].index(0.0) if 0.0 in res['new'] else None}")
check("PRECONDITION: without the gate the old law parks at +1940 W, the 7 065 "
"identical writes the bus recorded", set(res["old"]) == {1940.0})
check("...and the new law holds 0 W within SETTLE_CYCLES cycles",
res["new"].index(0.0) < control.SETTLE_CYCLES
and set(res["new"][control.SETTLE_CYCLES:]) == {0.0})
# ==============================================================================
print("5. the other exits: stop, start, another writer")
# ==============================================================================
def stop(c):
c.auto = False
def start(c):
c.auto = True
rows, _, c = run(load906, cut906, 1200, cap906, events={600.0: stop,
700.0: start})
check("stopping control ends the refusal, and starting again begins from "
"nothing refused", at(rows, 590)[6] == 1 and at(rows, 650)[6] == 0
and at(rows, 705)[6] == 0)
def other_writer(c):
c.loop_w = 1234.0 # the arbiter gave the wire something the loop did not ask
rows, _, c = run(load906, cut906, 900, cap906, events={600.0: other_writer})
check("another writer on the wire ends the refusal (the arbiter reseed)",
at(rows, 590)[6] == 1 and at(rows, 605)[6] == 0)
# ==============================================================================
print("5b. review of #141: a pack that gives SOME power, and a reading that is "
"only late, are not a refusal")
# ==============================================================================
# A pack derated to 100-499 W (cold, BMS taper) gives what it can against a
# larger ask. Engaging below `saturation_w` (500 W) held it at 0 W for ~93 % of
# the time; "gives nothing" is below the probe's own answer.
# `compute()` alone: through the Controller the stale-reading gate trips on the
# flat 300 W first, and that fixture then passes whatever this threshold is.
def derated(carry, cap_w=300.0, n=1800):
prev, i_w, sat, ref, gave, held = 0.0, 0.0, 0, carry, 0.0, 0
b = 0.0
for _ in range(n): # 2.5 h at 5 s
d = control.compute(prev, 2000.0 - b, b, T, sat, i_w, refused=ref)
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
held += bool(ref)
b = max(0.0, min(prev, cap_w)) # one tick late
gave += b
return gave / n, held
T = control.Tuning(**LIVE)
gave_old, _ = derated(None)
gave_new, held_new = derated(0)
print(f" pack derated to 300 W: old law {gave_old:.0f} W average, new "
f"{gave_new:.0f} W, held as refusing on {held_new} cycles")
check("a pack that gives a steady 300 W against a 2000 W ask is never held at "
"0 W as refusing, and keeps giving what the old law got from it",
held_new == 0 and gave_new == gave_old and gave_old > 290.0)
def lagging(tick, dead_s=6.0, publish_s=10.0, phase_s=5.0, dur=600.0):
"""`compute()` on a HEALTHY pack whose reading lags: it follows the command
of `dead_s` ago, published every `publish_s`. A 1 s meter (`meter_poll_s`
1) makes SETTLE_CYCLES only 12 s, shorter than that lag."""
prev, i_w, sat, ref, hist, reg, pub = 0.0, 0.0, 0, 0, [], 0.0, -phase_s
held = engaged = 0
t = 0.0
while t < dur:
then = [c for tc, c in hist if tc <= t - dead_s]
b = then[-1] if then else 0.0
if t - pub >= publish_s:
reg, pub = b, t
d = control.compute(prev, (3000.0 if t >= 10 else 0.0) - b, reg, T,
sat, i_w, refused=ref)
engaged += bool(d.refused) and not ref
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
hist.append((t, prev))
held += tick * bool(ref)
t += tick
return engaged, held
lag1 = lagging(1.0)
print(f" 1 s meter, healthy pack with a late reading: engaged {lag1[0]} "
f"time(s), held {lag1[1]:.0f} s")
check("a healthy pack whose reading is merely late is let go as soon as the "
"reading shows it delivering - not held until the next probe",
lag1[1] <= 10.0)
async def _seed(held_w):
"""`_refusal_from_stale()` on a unit standing at +2000 W, frozen."""
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off"}, **LIVE),
_Hass(lambda t: 0.0, lambda t: False), _Store(), _Pub(),
stop=asyncio.Event())
c.auto, c.sat_count = True, c.tuning.saturation_cycles
inv = c.inverters[0]
inv.last_write, inv.batt_fresh.held = 2000.0, held_w
c._refusal_from_stale(inv, 100.0)
return c.batt_refused
check("a stale-reading stop on a register standing at 300 W is not a refusal "
"(it gives something), and one at -4 W is",
asyncio.run(_seed(300.0)) == 0 and asyncio.run(_seed(CUT_W)) == 1)
d = control.compute(0.0, 1500.0, 1400.0, T, 0, 0.0, refused=1, probe=False)
check("...i.e. a held direction that reads 100 W or more ends the refusal "
"without waiting for a probe", d.refused == 0)
# ==============================================================================
print("6. words")
# ==============================================================================
w = messages.why(why_key("loop", [Claim.set("loop", 0, 0.0, REFUSED)]), 0.0)
wp = messages.why(why_key("loop", [Claim.set("loop", 0, PROBE_W, PROBING)]),
PROBE_W)
print(f" {w!r} / {wp!r}")
check("the page's Why line says the battery refuses, not 'Keeping the grid "
"near zero: 0 W'", w == "The battery refuses to give power: 0 W")
check("...and a probe says what it is", wp.startswith("Testing if the battery"))
check("...and an ordinary loop cycle still reads as the loop",
why_key("loop", [Claim.set("loop", 0, 10.0, "tracking")]) == "loop")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+29 -13
View File
@@ -172,7 +172,7 @@ class FakeController:
self.inverters = []
self.events = []
def log_event(self, text):
def log_event(self, text, hint=None):
self.events.append(text)
def status_payload(self):
@@ -236,7 +236,7 @@ check("the stored options are byte-identical to what they were",
check("...and the refusal carries the validator's OWN sentence, unedited",
refused.problems == validate_startup_config(bad, None, None))
check("...which is the sentence that names the key and says what it costs",
any("setpoint_entity is not set" in p for p in refused.problems))
any("(setpoint_entity) is empty" in p for p in refused.problems))
_good = base()
res = run(selfconfig.save(sup, _good, fp))
@@ -316,7 +316,7 @@ _probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None))
check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the "
"rail collapsing to the 2000 default under an explicit 400 W slew, so "
"the box cannot be emptied into a quietly halved rail",
any("site rail of 2000 W" in p for p in _probs))
any("for a 2000 W site" in p for p in _probs))
# HALF TWO: the backstop cannot report "no problems" for a config it could not
# compose. Returning [] there enables Save - a crash became a green.
@@ -430,7 +430,7 @@ _probs = run(selfconfig.problems_for(base(max_w=4000), FakeHass(_ranges)))
_by, _un = configui.problems_by_field(_probs, ALL_KEYS)
check("the device-range rule is REACHED from the panel - the setpoint entity's "
"own range is read, so rule 1 fires here exactly as it does at startup",
any("THE DEVICE WINS" in p for p in _probs))
any("Lower the rating to" in p for p in _probs))
check("...and it lands on `max_w`, which is the field the operator has to "
"change", "max_w" in _by)
# ⚠️ THE COLLISION, CHECKED HERE AND NOT ONLY IN THE PROBE. `sign_disagree_w`
@@ -467,7 +467,7 @@ _p2, _unread2 = run(selfconfig.problems_and_ranges(base(max_w=4000),
FakeHass(_ranges)))
check("...and with the same device answering, the same config IS refused - "
"which is what makes the line above a skip rather than a pass",
any("THE DEVICE WINS" in p for p in _p2) and _unread2 == [])
any("Lower the rating to" in p for p in _p2) and _unread2 == [])
check("the note is about the config UNDER TEST, not about the running roster: "
"a newly typed entity that does not answer is the one named",
run(selfconfig.problems_and_ranges(
@@ -495,8 +495,9 @@ _leaked = sorted(k for k in ALL_KEYS
_script))
check(f"the page's script names NOT ONE option key - so it cannot be rendering "
f"a field list of its own. Leaked: {_leaked}", not _leaked)
_phrases = ["THE DEVICE WINS", "has no actuator", "two writers",
"setpoint_entity is not set", "would silently never happen"]
_phrases = ["Lower the rating to", "cannot control the inverter",
"point at the same inverter entity", "(setpoint_entity) is empty",
"accepts at most"]
_copied = sorted(p for p in _phrases
for f, text in _ui_files.items() if p in text)
check(f"no phrase out of a validate.py refusal appears anywhere in the three "
@@ -551,7 +552,7 @@ class H:
async def limits(self, e): return (None, None, None)
class C:
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
def log_event(self, t): pass
def log_event(self, t, hint=None): pass
def status_payload(self): return {}
async def handle_action(self, w): raise AssertionError("no")
@@ -635,12 +636,18 @@ _app = web.build_app(FakeController(), FakeSupervisor())
_routes = sorted({(r.method, r.get_info().get("path"))
for r in _app.router.routes() if r.method != "HEAD"})
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
check("the configuration surface adds five routes and touches none of the "
"three that were there",
check("the configuration surface adds five routes and UX-13 one GET, and "
"none touches the three that were there",
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
("GET", "/status"), ("POST", "/action"),
("POST", "/config/restart"), ("POST", "/config/save"),
("POST", "/config/validate")])
("GET", "/ha/entities"), ("GET", "/status"),
("POST", "/action"), ("POST", "/config/restart"),
("POST", "/config/save"), ("POST", "/config/validate")])
_ents_src = io.open(os.path.join(HERE, "app", "haentities.py"),
encoding="utf-8").read()
check("UX-13's entity list only reads: haentities.py calls hass.states() and "
"no service, setter or action",
"hass.states()" in _ents_src
and not re.search(r"\.(call|set_number|handle_action|post)\(", _ents_src))
_selfconfig_src = io.open(os.path.join(HERE, "app", "selfconfig.py"),
encoding="utf-8").read()
for forbidden in ("set_number", "write_setpoint", "handle_action",
@@ -691,6 +698,10 @@ async def _e2e():
async with s.post(f"{root}/config/validate",
json={"options": base(setpoint_entity="")}) as r:
out["invalid"] = await r.json()
async with s.post(f"{root}/config/validate",
json={"options": base(integrator_max_w=3000)}
) as r:
out["warned"] = await r.json()
# ⚠️ THE COUNTS ARE SNAPSHOTTED AT EACH STAGE. Asserting
# `sup.writes == []` after the whole sequence would be asserting
# about the LAST request, not this one - the first draft did, and
@@ -748,6 +759,11 @@ check("...and it honours the browser's language per key, falling back to "
check("POST config/validate returns the validator's sentences, anchored",
E["invalid"]["problems"]
and "setpoint_entity" in E["invalid"]["by_key"])
check("UX-13: POST config/validate also returns the validator's warnings, "
"beside no refusal",
E["warned"]["problems"] == [] and [n["keys"] for n in
E["warned"]["notes"]]
== [["integrator_max_w"]] and E["warned"]["notes"][0]["tier"] == "warn")
check("a save of an invalid blob answers 422 and the Supervisor got nothing",
E["save_invalid"][0] == 422
and E["save_invalid"][1]["kind"] == "invalid"
+3 -3
View File
@@ -527,7 +527,7 @@ check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "no static profile"))
static_status(opts(None), THU) == (False, "No fixed tariff set"))
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
# serve it are to invent one or to fail when the clock reaches it.
@@ -850,7 +850,7 @@ check("a good static_profile starts",
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000),
"static_profile cannot be used"))
"“Fixed or day/night tariff” cannot be used"))
check("...and the refusal names the uncovered range",
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
-5000, 5000), "22:00-23:00"))
@@ -859,7 +859,7 @@ check("static_profile alongside the Home Assistant price entities is refused - "
why(validate_startup_config(
vbase(static_profile=json.dumps(TWO_PERIOD),
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
-5000, 5000), "HOME ASSISTANT"))
-5000, 5000), "(price_avg_entity) would win over it"))
check("...and the Home Assistant pair ALONE still starts, so nothing is taken "
"away from a live install",
validate_startup_config(vbase(price_now_entity="sensor.now",
+42 -29
View File
@@ -116,6 +116,7 @@ from pathlib import Path
from named_absence import given, nth, present
from app import messages
from app.main import Controller
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
from app.tariff import (
@@ -447,7 +448,7 @@ print(f" restore() across the boundary said: {said}")
aug7, sep7 = st7.peak(month="2026-08"), st7.peak(month="2026-09")
check("a restart that straddles the month boundary BANKS the dead August "
"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 = "
f"{RUN_W * 399 / QUARTER_S:.2f} W, the seconds it actually covered)",
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
# 2026-09-01 - on a commit nobody had touched. The suite must grade the month
# it created, not the month the machine happens to be running in.
rows = [r for r in c.checks(when=B) if "capacity-tariff" in r["text"] or "peak" in r["text"]]
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
# UX-08 2d: the rows are in plain words and are found by their `id`. The peak
# and the coverage figure (TARIFF-13) are two rows now, one sentence each.
rows = c.checks(when=B)
peak_rows = [r for r in rows if r.get("id") == "month_peak"]
cov_rows = [r for r in rows if r.get("id") == "periods_measured"]
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row",
len(peak_rows) == 1)
check("...naming the peak, the soft limit and a coverage figure, all derived "
"from the controller that was actually built",
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
and f"{given(SHIPPED_CAP_W):.0f} W soft limit" in peak_rows[0]["text"]
and "quarter(s) banked" in peak_rows[0]["text"])
print(" status row: " + (cov_rows[0]["text"] if cov_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row, and one "
"coverage row beside it",
len(peak_rows) == 1 and len(cov_rows) == 1)
check("...naming the peak and its share of the peak limit, and the coverage "
"figure, all derived from the controller that was actually built",
bool(peak_rows) and "Month peak: 4 998 W on " in peak_rows[0]["text"]
and f"{4998 / given(SHIPPED_CAP_W) * 100:.0f} % of the peak limit"
in peak_rows[0]["text"]
and bool(cov_rows) and "periods measured" in cov_rows[0]["text"])
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
# NOT TO `when` - TARIFF-13, and it is a KNOWN GAP stated rather than rounded
@@ -1007,22 +1014,23 @@ print(f" the row read at {month_key(B)} carries the tally for "
check("the row's coverage figure is the LIVE month's tally - graded against a "
"row count taken straight from quarter_blocks, so this pins the gap "
"rather than restating the property that produces it",
bool(peak_rows)
and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
bool(cov_rows)
and cov_rows[0]["text"].endswith(f"this month: {_live_rows}"))
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
f"on a {given(SHIPPED_CAP_W):.0f} W limit",
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
# The half that matters: the same page with a store that cannot answer.
c2 = built(TMP / "no-such-dir" / "nope.db")
u_rows = [r for r in c2.checks(when=B) if "capacity-tariff peak" in r["text"]]
u_rows = [r for r in c2.checks(when=B) if r.get("id") == "month_peak"]
print(" status row (unreadable store): "
+ (u_rows[0]["text"] if u_rows else "(none)"))
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1)
check("an unreadable store gets its OWN row, saying it cannot be read",
len(u_rows) == 1)
check("...and it does NOT read as a quiet month at 0 W - without this, a broken "
"instrument looks exactly like a month with no peak",
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"]
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"])
bool(u_rows) and "cannot be read" in u_rows[0]["text"]
and "%" not in u_rows[0]["text"] and u_rows[0]["warn"])
check("...and the healthy row and the unreadable row are different text",
bool(u_rows) and bool(peak_rows)
and u_rows[0]["text"] != peak_rows[0]["text"])
@@ -1040,7 +1048,7 @@ check("...and the healthy row and the unreadable row are different text",
# (That asymmetry in the row's own text is worth a look on its own; it is what
# made the original filter brittle. Not changed here - it is product text with
# other suites reading it.)
_peak_rows = lambda rows: [r for r in rows if "quarter(s) banked" in r["text"]]
_peak_rows = lambda rows: [r for r in rows if r.get("id") == "month_peak"]
_later = B + timedelta(days=45) # same fixture, a later billing month
_other_peak = _peak_rows(c.checks(when=_later))
@@ -1048,12 +1056,12 @@ print(f" the SAME controller read at {_later.date()}: "
+ (_other_peak[0]["text"] if _other_peak else "(no peak row)"))
check("asked for a LATER month, the same controller reports that month's "
"absence of a quarter, not August's 4998 W - the row follows `when`",
bool(_other_peak) and "4998 W" not in _other_peak[0]["text"]
and "no completed quarter recorded yet" in _other_peak[0]["text"])
bool(_other_peak) and "4 998 W" not in _other_peak[0]["text"]
and "none recorded yet" in _other_peak[0]["text"])
check("...and that is NO_RECORD, distinguishable from BOTH an observed peak and "
"an unreadable store - a month with no quarter is not a broken instrument",
bool(_other_peak) and bool(peak_rows)
and "UNREADABLE" not in _other_peak[0]["text"]
and "cannot be read" not in _other_peak[0]["text"]
and _other_peak[0]["text"] != peak_rows[0]["text"])
check("...and it is not FLAGGED, because an empty month is not an exceedance",
bool(_other_peak) and _other_peak[0]["ok"] and not _other_peak[0]["warn"])
@@ -1076,7 +1084,7 @@ check("calling checks() with NO argument still reads the month being billed "
bool(_now_peak) and (bool(peak_rows)
and _now_peak[0]["text"] == peak_rows[0]["text"]
if _today_is_fixture_month
else "no completed quarter recorded yet"
else "none recorded yet"
in _now_peak[0]["text"]))
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
@@ -1253,21 +1261,25 @@ for _q in range(_N13):
# Ground truth from the OTHER tier, which the tracker did not supply - the count
# under test must be graded against something that is not itself (habit 2).
_truth_before = len(c13.peak.store.blocks_in(_mon13))
_cov_row = lambda c: [r for r in c.checks()
if r.get("id") == "periods_measured"][0]["text"]
_row_before = _peak_rows(c13.checks())[0]["text"]
print(" before restart: " + _row_before)
_cov_before = _cov_row(c13)
print(" before restart: " + _row_before + " / " + _cov_before)
check(f"precondition: the fixture banked {_N13} quarters in {_mon13}, so a "
f"count of 0 and a count of the truth are different numbers",
_truth_before == _N13 and _N13 > 1)
check(f"...and the row says so before the restart",
f"{_N13} quarter(s) banked" in _row_before)
_cov_before.endswith(f"this month: {_N13}"))
c13.peak.store.close()
# THE RESTART.
c13b = built(_T13)
_row_after = _peak_rows(c13b.checks())[0]["text"]
_cov_after = _cov_row(c13b)
_truth_after = len(c13b.peak.store.blocks_in(_mon13))
print(" after restart: " + _row_after)
print(" after restart: " + _row_after + " / " + _cov_after)
print(f" rows in quarter_blocks: {_truth_before} before, "
f"{_truth_after} after; the restarted tracker has banked "
f"{len(c13b.peak._seen)} block(s) of its own through sync()")
@@ -1279,16 +1291,17 @@ check("precondition: the second Controller is a REAL restart - it shares no "
check("the peak and its attributed quarter survive the restart - the durable "
"half, asserted so that the coverage claim below is not graded against a "
"reading that had already collapsed",
_row_before.split(" - ")[0] == _row_after.split(" - ")[0]
and f"{_W13:.0f} W" in _row_after)
_row_before == _row_after
and f"Month peak: {messages.watts(_W13)} on" in _row_after)
check("...and the COUNT BESIDE IT DOES NOT COLLAPSE TO ZERO, which is the "
"defect: a coverage figure that resets while the value beside it "
"persists actively misinforms",
"; 0 quarter(s) banked" not in _row_after)
not _cov_after.endswith("this month: 0"))
check(f"...and it is the store's own tally, {_truth_after}, not a "
f"process-lifetime counter - graded against a COUNT OF ROWS in the "
f"other tier rather than against the property under test",
f"{_truth_after} quarter(s) banked" in _row_after and _truth_after >= _N13)
_cov_after.endswith(f"this month: {_truth_after}")
and _truth_after >= _N13)
# ⚠️ The restarted run banks one MORE than it drove, and that is correct: the
# checkpointed open block was stale, so `restore()` closed and banked it. The
@@ -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 "
"old counter incremented only in sync() and missed it entirely",
_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 --------------------
# ⚠️ TWO MONTHS AT DIFFERENT COUNTS. Equal counts would be satisfied by a
+1 -1
View File
@@ -86,7 +86,7 @@ check("baseline: the bare options object is CLEARED (exit 0)",
code == 0 and "CLEARED for deploy" in out)
code, out = gate(dump("bare-bad.json", dict(BARE, slew_w=5000)))
check("baseline: the bare object with slew_w 5000 is REFUSED by the validator",
code == 1 and "slew_w is 5000 W" in out)
code == 1 and "(slew_w) is 5000 W" in out)
for label, doc in (("wrapped", ha_apps_info(BARE)),
("wrapped, slew_w 5000", ha_apps_info(dict(BARE, slew_w=5000)))):
+86 -73
View File
@@ -12,10 +12,11 @@ the sentence that says WHETHER TO TOUCH THE VALUE, because the tab presents a
constant derived from 1890 closed-loop test runs and the operator's own inverter
rating as the same kind of field.
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE
CITATIONS - that every option has a name and a description in both languages,
that no translation names an option that does not exist, and that each derived
constant's English description cites the record it was derived from. It does NOT
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE -
that every option has a name and a description in both languages, that no
translation names an option that does not exist, that each derived constant's
English description says to leave it, and that DOCS.md's "For installers"
entry for it names the record it came from (moved there by UX-08). It does NOT
grade whether a sentence is true, whether the Dutch is good Dutch, or whether
the result looks right on screen. The last of those is measured, not asserted,
by `tools/supervisor_shape_probe.py` section F, which loads these files with
@@ -107,83 +108,95 @@ check("...and no Dutch string was left as a copy of the English one, which is "
== TR["nl"]["configuration"][k]["description"]])
print("\n3. ⭐ the description says WHETHER TO TOUCH IT")
# ⚠️ THE HIGHEST-VALUE ITEM IN THE CARD, AND THE ONE A COVERAGE COUNT CANNOT
# REACH. Sixty-odd descriptions that all say "the threshold, in watts" would
# pass part 2 completely. What makes this surface safe is that a DERIVED
# constant names the campaign it came from and says the value is not a knob,
# while a number that really is the operator's says so too.
#
# ⚠️ AND THIS IS A TEXT CHECK, WHICH IS WEAK ON PURPOSE AND IS SAID TO BE. It
# cannot know whether a citation is TRUE; it can only know that the citation is
# there and names the right record, so that deleting the provenance - or
# renaming the card it points at - goes red instead of shipping. The table is
# written out rather than derived because there is nothing in config.yaml that
# distinguishes a derived constant from an operator's number; that distinction
# is the whole of what this card had to add.
# The value of this surface is the sentence that says whether a number is a
# knob. Since UX-08 phase 2c the tab keeps that sentence short ("Measured value,
# leave it unchanged.") and the record it came from lives in DOCS.md under
# "For installers: where the numbers come from", where UX-01's citations moved.
# So each derived key is pinned twice:
# its English description tells the reader what to do, and its DOCS entry names
# the record. A text check, weak on purpose: it cannot know a citation is TRUE,
# only that deleting it, or the instruction, goes red.
DOCS = (HERE / "DOCS.md").read_text(encoding="utf-8")
_inst = DOCS.split("\n### For installers: where the numbers come from\n", 1)
INSTALLERS = _inst[-1].split("\n#", 1)[0] if len(_inst) == 2 else ""
check("DOCS.md has the 'For installers: where the numbers come from' section",
bool(INSTALLERS.strip()))
def docs_entry(key):
"""The DOCS bullet that names `key` in backticks, lower-cased."""
hits = [b for b in INSTALLERS.split("\n- ") if f"`{key}`" in b]
return hits[0].lower() if hits else ""
LEAVE_M, LEAVE_D = "Measured value, leave it unchanged", \
"Derived value, leave it unchanged"
# key: (what the English description must say, what its DOCS entry must name)
DERIVED = {
"sign_disagree_w": ("SAFETY-02", "MEASURED"),
"sign_disagree_s": ("SAFETY-02", "MEASURED"),
"unit_sign_disagree_w": ("SAFETY-30", "1890"),
"unit_sign_disagree_s": ("SAFETY-30", "1890"),
"site_sign_disagree_w": ("SAFETY-30", "CARRIED"),
"site_sign_disagree_s": ("SAFETY-30", "1890"),
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
"circulation_w": ("SAFETY-31", "924"),
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
# SAFETY-42. Two needles rather than one: the campaign it was derived from,
# and the word that says what it does NOT do. An operator who reads
# "not-delivering report" and assumes the add-on has handled it is the
# failure this alarm's whole wording is written against.
"delivery_hold_s": ("SAFETY-42", "DERIVED FROM", "REPORTS ONLY"),
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
"bms_soc_floor": ("0x0560", "MEASURED"),
"integrator_max_w": ("MEASURED", "Do not raise it"),
"gain": ("MEASURED ON", "do not raise it"),
# SAFETY-39. `slew_w` joins the table because it stopped being an
# operator's convenience and became a DERIVED value the moment it was
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
# ~18 s measured step response. It is the one entry here whose value is
# a RATIO, so the description has to say what it follows as well as
# where the ratio came from - a citation alone would leave an operator
# reading "0" on the tab with no idea it is not "off".
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
# TARIFF-18. `price_taxes_eur_kwh` joins the table for `slew_w`'s reason:
# it stopped being a figure somebody picked and became one SUMMED FROM
# NAMED CARD ROWS. Three needles, because three different things about it
# can go wrong on the Configuration tab and only the first is provenance:
# where the number came from, that the operator's own DSO gives a
# different one, and that changing it moves no trade - which is the claim
# an operator is most likely to assume the opposite of when a number
# doubles.
"price_taxes_eur_kwh": ("TARIFF-18", "YOUR OWN DSO",
"MOVES NO TRADING DECISION"),
"sign_disagree_w": (LEAVE_M, ("SAFETY-02", "measured")),
"sign_disagree_s": (LEAVE_M, ("SAFETY-02", "measured")),
"unit_sign_disagree_w": (LEAVE_D, ("SAFETY-30", "1890")),
"unit_sign_disagree_s": (LEAVE_D, ("SAFETY-30", "1890")),
"site_sign_disagree_w": (LEAVE_M, ("SAFETY-30", "carried")),
"site_sign_disagree_s": (LEAVE_D, ("SAFETY-30", "1890")),
"meter_freeze_s": (LEAVE_M, ("SAFETY-18", "measured")),
"circulation_w": (LEAVE_M, ("SAFETY-31", "924")),
"circulation_hold_s": (LEAVE_D, ("SAFETY-31", "derived")),
# SAFETY-42: the alarm changes nothing, and both surfaces say so.
"delivery_hold_s": (LEAVE_D, ("SAFETY-42", "derived from",
"reports only")),
"peak_soc_floor": (LEAVE_D, ("SAFETY-24", "derived")),
"bms_soc_floor": ("Read it off your battery", ("0x0560", "measured")),
"integrator_max_w": ("Do not raise it above", ("measured",)),
"gain": ("do not raise it", ("measured on",)),
# SAFETY-39: 0 is not "off", it follows the rating.
"slew_w": ("0 follows", ("SAFETY-39", "derived", "0 means follow")),
# TARIFF-18: where the figure came from, that your DSO differs, and that
# changing it moves no trade.
"price_taxes_eur_kwh": ("Check it against your own grid operator",
("TARIFF-18", "your own dso",
"moves no trading decision")),
}
_uncited = []
for key, needles in sorted(DERIVED.items()):
desc = TR["en"]["configuration"][key]["description"]
for key, (says, needles) in sorted(DERIVED.items()):
if says not in TR["en"]["configuration"][key]["description"]:
_uncited.append(f"{key} description does not say {says!r}")
for needle in needles:
if needle not in desc:
_uncited.append(f"{key} does not cite {needle!r}")
print(f" {len(DERIVED)} derived constants must cite their campaign; "
f"{len(_uncited)} do not")
check(f"every derived constant's English description names WHERE THE NUMBER "
f"CAME FROM, so an operator reading the tab can tell it apart from their "
f"own inverter rating. Uncited: {_uncited}", not _uncited)
# The other direction, and it matters as much: a number that IS the operator's
# must not be dressed up as derived, or the labels stop meaning anything.
if needle.lower() not in docs_entry(key):
_uncited.append(f"{key}: DOCS does not name {needle!r}")
print(f" {len(DERIVED)} derived constants; {len(_uncited)} problem(s)")
check(f"every derived constant's description says what to do with it, and its "
f"DOCS entry names where the number came from. Problems: {_uncited}",
not _uncited)
_theirs = ["max_w", "peak_cap_w"]
check("...and the numbers that really are the OPERATOR'S say so instead of "
"carrying a citation - `max_w` and `peak_cap_w` are the two the card "
"names, and both read 'THIS IS YOUR NUMBER'",
all("THIS IS YOUR NUMBER" in TR["en"]["configuration"][k]["description"]
check("...and the numbers that really are the OPERATOR'S say so instead - "
"`max_w` and `peak_cap_w` start 'Your own number', here and in DOCS",
all(TR["en"]["configuration"][k]["description"].startswith(
"Your own number") and "your own number" in docs_entry(k)
for k in _theirs))
check("...and the one threshold that is NOT derived from a measurement admits "
"it rather than borrowing the others' authority - "
"`cycle_cost_threshold_eur` is an open product question (OPT-08)",
"NOT DERIVED FROM A MEASUREMENT"
"it - `cycle_cost_threshold_eur` is a product choice, and DOCS names the "
"open question (OPT-08)",
"product choice, not a measured one"
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
and "OPT-08"
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"])
and "opt-08" in docs_entry("cycle_cost_threshold_eur")
and "not derived from a measurement"
in docs_entry("cycle_cost_threshold_eur"))
# DOCS.md's "Settings at a glance" shows the tab's text. It must not drift.
_glance = DOCS.split("\n### Settings at a glance\n", 1)[-1].split("\n#", 1)[0]
_rows = {}
for line in _glance.splitlines():
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) == 4 and cells[2].startswith("`"):
_rows[cells[2].strip("`")] = (cells[1], cells[3])
_drift = []
for key in ORDER:
conf = TR["en"]["configuration"][key]
want = (conf["name"].split(" · ")[-1], conf["description"])
if _rows.get(key) != want:
_drift.append(key)
check(f"DOCS.md 'Settings at a glance' shows every option's English label and "
f"description exactly as the tab does. Drifted: {_drift}", not _drift)
print("\n4. task ordering, and the labels that carry it")
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced
+202
View File
@@ -0,0 +1,202 @@
"""UX-13 P0: the layout table places every option once. `python3 test_uilayout.py`
app/uilayout.py is the one place UI code names an option key
(DECISIONS/UX-13-one-layout-table.md). This file fails CI when config.yaml
gains an option the table does not place, and checks each row against the
sources UX-02 keeps authoritative: config.yaml for ranges, translations and
app/ui_text for words, validate.py for which entities are read as watts.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import io
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import translations_lint as TL # noqa: E402
from app import uilayout as L, uimodel, validate # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
CONFIG = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
ORDER, NESTED = TL.config_surface(CONFIG)
TYPES = dict(re.findall(r"^ ([a-z_0-9]+): *(\S.*?) *$",
CONFIG[CONFIG.index("\nschema:"):], re.M))
EN = TL.parse_translation_file(os.path.join(HERE, "translations", "en.yaml"))
UI_TEXT = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))
ROWS = list(L.fields())
KEYS = [f.key for _g, _p, f in ROWS]
def coverage_problems(order, nested):
"""Every way the table and config.yaml's schema disagree."""
out = []
placed = [k for k in KEYS if not k.startswith(("ui.", "inverters."))]
for k in order:
n = placed.count(k) + (k in L.DERIVED) + (k in L.HIDDEN)
if n != 1:
out.append(f"{k} is placed {n} times")
out += [f"{k} is not in config.yaml" for k in
set(placed) | set(L.DERIVED) | set(L.HIDDEN) if k not in order]
sub = [k.split(".", 1)[1] for k in KEYS if k.startswith("inverters.")]
out += [f"inverters.{k} has no card field" for k in nested.get("inverters", [])
if sub.count(k) != 1]
out += [f"inverters.{k} is not in config.yaml" for k in sub
if k not in nested.get("inverters", [])]
return out
print("1. every option has exactly one place")
_p = coverage_problems(ORDER, NESTED)
_fields = sum(1 for k in ORDER if k in KEYS)
print(f" {len(ORDER)} options: {_fields} fields, {len(L.DERIVED)} derived, "
f"{len(L.HIDDEN)} hidden. Problems: {_p}")
check(f"config.yaml's {len(ORDER)} options are each a field, derived or hidden, "
"once", not _p and len(ORDER) >= 78)
check("a new option without a place fails this check",
coverage_problems(ORDER + ["new_option_w"], NESTED)
== ["new_option_w is placed 0 times"])
check("...and so does a new inverter field",
coverage_problems(ORDER, {"inverters": NESTED["inverters"] + ["mode"]})
== ["inverters.mode has no card field"])
check("no key is both a field and derived",
not set(KEYS) & (set(L.DERIVED) | set(L.HIDDEN)))
check("each derived key names the page field that writes it",
all(any(f.key == how for _g, _p2, f in ROWS)
for how in L.DERIVED.values()))
print("\n2. each row is well formed")
check("every control, entity kind and show operator is a known one",
all(f.control in L.CONTROLS and (not f.kind or f.kind in L.ENTITY_KINDS)
and all(op in L.SHOW_OPS for _n, op, _v in f.show)
for _g, _p2, f in ROWS))
_names = set(ORDER) | set(KEYS) | {"inverter_count"}
check("every show condition reads an option, a page field or inverter_count",
all(n in _names for _g, _p2, f in ROWS for n, _o, _v in f.show))
check("every entity control has a kind, and no other control has one",
all((f.control in (L.ENTITY, L.ENTITY_LIST, L.SOLAR_LIST)) == bool(f.kind)
for _g, _p2, f in ROWS))
check("everything applies after a restart: options are read at start, and "
"OPT-11's runtime switches are not built yet",
all(f.applies == L.RESTART for _g, _p2, f in ROWS))
check("no unit states a number, so no range is written here",
all(not re.search(r"\d", f.unit) for _g, _p2, f in ROWS))
_SCHEMA = CONFIG[CONFIG.index("\nschema:"):]
_ROSTER = re.search(r"^ inverters:\n((?: {4,}.*\n)+)", _SCHEMA, re.M).group(1)
ROSTER_TYPES = dict(re.findall(r"^ {4,}(?:- )?([a-z_0-9]+): *(\S+)", _ROSTER,
re.M))
def _type(key):
if key.startswith("inverters."):
return ROSTER_TYPES.get(key.split(".", 1)[1], "")
return TYPES.get(key, "")
_ranged = [f.key for _g, _p2, f in ROWS
if f.control in (L.SLIDER, L.NUMBER, L.LOCKED)
and not f.key.startswith("ui.")]
_unranged = [k for k in _ranged if not re.match(r"(int|float)\(", _type(k))]
check(f"every slider, number and locked value has a range in config.yaml "
f"({len(_ranged)} of them, without: {_unranged})", not _unranged)
_SUFFIX = (("_eur_kwh", "€/kWh"), ("_eur", "€/kWh"), ("_kwh", "kWh"),
("_w", "W"), ("_s", "s"), ("_h", "h"), ("_min", "min"),
("_days", "days"), ("_floor", "%"), ("_ceiling", "%"),
("_target", "%"))
def _unit_problem(f):
want = next((u for suf, u in _SUFFIX if f.key.endswith(suf)), None)
if want is None or f.control not in (L.SLIDER, L.NUMBER, L.LOCKED):
return False
return f.unit != want and not (want == "W" and f.unit == "kW"
and f.scale == 1000)
_mism = [(f.key, f.unit) for _g, _p2, f in ROWS if _unit_problem(f)]
check(f"each unit matches its key's suffix (mismatched: {_mism})", not _mism)
check("the peak limit is shown in kW and stored in W",
L.field("peak_cap_w").unit == "kW" and L.field("peak_cap_w").scale == 1000)
_watt = sorted({f.key.split(".")[-1] for _g, _p2, f in ROWS
if f.kind == "power_w"})
check(f"the pickers that expect watts are exactly the entities validate.py "
f"reads as watts ({_watt})",
_watt == sorted(validate.WATT_ENTITY_KEYS))
check("range_of() reads a Supervisor-shaped schema, nested ones too",
L.range_of("max_w", [{"name": "max_w", "lengthMin": 100,
"lengthMax": 5000}]) == (100, 5000)
and L.range_of("inverters.max_w", [{"name": "inverters", "schema": [
{"name": "max_w", "lengthMin": 100, "lengthMax": 5000}]}])
== (100, 5000)
and L.range_of("p1_host", [{"name": "p1_host"}]) is None)
print("\n3. the words come from translations and app/ui_text")
_conf = EN["configuration"]
_no_tr = [k for k in KEYS if not k.startswith("ui.") and not (
k in _conf if "." not in k
else k.split(".")[1] in _conf["inverters"]["fields"])]
check(f"every option field has an en.yaml name (missing: {_no_tr})", not _no_tr)
_no_ui = [k for k in KEYS if k.startswith("ui.")
and k[3:] not in UI_TEXT["fields"]]
check(f"every page-only field has words in ui_text (missing: {_no_ui})",
not _no_ui)
_titles = [g.id for g in L.GROUPS if g.id not in UI_TEXT["groups"]] + [
f"{g.id}.{p.id}" for g in L.GROUPS for p in g.parts
if p.id and f"{g.id}.{p.id}" not in UI_TEXT["parts"]]
check(f"every group and part has a title (missing: {_titles})", not _titles)
_contract = {"none", "fixed", "daynight", "dynamic", "custom"}
check("every contract, fallback and region the model can hold has words",
set(UI_TEXT["choices"]["contract"]) == _contract
and set(UI_TEXT["choices"]["fallback"]) == _contract - {"dynamic"}
and set(UI_TEXT["choices"]["region"])
== set(uimodel.REGION_PRESETS) | {uimodel.OWN})
check("both 'applies' labels have words",
set(UI_TEXT["applies"]) == {L.RESTART, L.NOW})
print("\n4. visibility follows the page model")
_m = uimodel.options_to_ui({"meter_source": "ha_signed", "meter_phases": 3,
"inverters": [], "maintenance_enabled": False})
check("a signed meter shows its net entity, and three phases its net list",
L.visible(L.field("p1_net_entity"), _m)
and L.visible(L.field("p1_phase_net_entities"), _m)
and not L.visible(L.field("meter_entity"), _m)
and not L.visible(L.field("p1_phase_import_entities"), _m))
check("one inverter shows the one-inverter stops, not the per-inverter ones",
L.visible(L.field("sign_disagree_w"), _m)
and not L.visible(L.field("unit_sign_disagree_w"), _m))
check("maintenance details hide while maintenance is off",
not L.visible(L.field("maintenance_charge_w"), _m))
print("\n5. UI code names option keys in two files only")
_KEY = re.compile(r"""["']([a-z0-9]+(?:_[a-z0-9]+)+)["']""")
_src = io.open(os.path.join(HERE, "app", "uimodel.py"), encoding="utf-8").read()
_owned = set(L.DERIVED) | {"inverters", "price_provider",
"price_taxes_eur_kwh", "arbitrage_enabled"}
_extra = sorted({k for k in _KEY.findall(_src) if k in ORDER} - _owned)
check(f"uimodel.py names only the keys its derivations write (others: "
f"{_extra})", not _extra)
_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py")
for k in _KEY.findall(io.open(os.path.join(HERE, "app", name),
encoding="utf-8").read())
if k in ORDER})
check(f"haentities.py and web.py name no option key ({_leak})", not _leak)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+224
View File
@@ -0,0 +1,224 @@
"""UX-13 P0: stored options <-> page model, exactly. `python3 test_uimodel.py`
app/uimodel.py derives the keys the page does not show (the flat inverter,
the solar keys, static_profile, the levies figure, arbitrage_enabled). Part 1
round-trips every options blob the test suite hands the validator
(scenarios/ux13/, recorded by tools/ux13_validator_calls.py at 652097a), the
TEST-23 option files and config.yaml's defaults: options -> model -> JSON ->
options must be identical. Parts 2-6 edit one page field and check the blob.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import glob
import gzip
import io
import json
import logging
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
logging.disable(logging.WARNING) # the validator's own log lines
from app import uimodel as U # noqa: E402
from app.price import (FLEMISH_TAXES_COMPONENTS_EUR_KWH, # noqa: E402
PRICE_OPTION_DEFAULTS)
from app.static_tariff import parse_static_profile # noqa: E402
from app.validate import validate_startup_config # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def trip(options):
return U.ui_to_options(json.loads(json.dumps(U.options_to_ui(options))))
def edit(options, **fields):
model = json.loads(json.dumps(U.options_to_ui(options)))
for k, v in fields.items():
model[k.replace("__", ".")] = v
return U.ui_to_options(model)
def raises(fn):
try:
fn()
except U.DeriveError:
return True
return False
def defaults():
"""config.yaml's `options:` block, read without PyYAML (not in CI)."""
text = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
block = text[text.index("\noptions:"):text.index("\nschema:")]
out = {}
for key, raw in re.findall(r"^ ([a-z_0-9]+):(.*)$", block, re.M):
raw = raw.split(" #")[0].strip()
try:
out[key] = json.loads(raw)
except ValueError:
out[key] = raw
return out
DEFAULTS = defaults()
SITE = dict(DEFAULTS, setpoint_entity="number.goodwe_setpoint",
soc_entity="sensor.soc", batt_entity="sensor.batt")
print("1. every known blob survives the round trip unchanged")
_calls = [json.loads(line) for line in gzip.open(os.path.join(
HERE, "scenarios", "ux13", "validator-calls-652097a.jsonl.gz"), "rt")]
_test23 = sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
"*.json")))
_blobs = ([("config.yaml", DEFAULTS)]
+ [(c["source"], c["opts"]) for c in _calls
if isinstance(c["opts"], dict)]
+ [(os.path.basename(f), json.load(open(f))) for f in _test23])
_bad = []
for _src, _o in _blobs:
try:
_back = trip(_o)
except Exception as e: # noqa: BLE001
_bad.append(f"{_src}: {type(e).__name__} {e}")
continue
if json.dumps(_back, sort_keys=True) != json.dumps(_o, sort_keys=True):
_bad.append(f"{_src}: {sorted(set(_back) ^ set(_o)) or 'values'}")
print(f" {len(_calls)} validator calls, {len(_test23)} TEST-23 files, "
f"config.yaml defaults")
check(f"all {len(_blobs)} are identical after options -> model -> JSON -> "
f"options (not: {_bad[:3]})", not _bad and len(_calls) >= 200
and len(_test23) >= 5 and len(DEFAULTS) >= 78)
check("config.yaml was read whole: the defaults hold every option",
"inverters" in DEFAULTS and DEFAULTS["inverters"] == []
and DEFAULTS["price_taxes_eur_kwh"] == 0.11736)
_m = U.options_to_ui(DEFAULTS)
check("the page model never shows a derived key: it keeps them aside",
not {"setpoint_entity", "pv_entity", "pv_entities", "static_profile",
"price_taxes_profile"} & set(_m)
and set(_m["keep"]) >= {"static_profile", "pv_entity", "max_w"})
check("a blob that lacks keys gains none", trip({"max_w": 900}) == {"max_w": 900})
print("\n2. one inverter or a list")
_one = edit(SITE, inverters=[dict(U.options_to_ui(SITE)["inverters"][0],
max_w=1500)])
check("editing the one inverter writes the flat keys and keeps the list empty",
_one["max_w"] == 1500 and _one["inverters"] == []
and _one["setpoint_entity"] == "number.goodwe_setpoint")
_card = U.options_to_ui(SITE)["inverters"][0]
_two = edit(SITE, inverters=[
dict(_card, name="inverter-a", capacity_kwh=None),
dict(_card, name="inverter-b", setpoint_entity="number.goodwe_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b")])
check("a second inverter writes the list and clears the flat power request "
"entity, which the validator refuses beside a list",
len(_two["inverters"]) == 2 and _two["setpoint_entity"] == ""
and "capacity_kwh" not in _two["inverters"][0]
and not validate_startup_config(_two))
_listed = dict(SITE, setpoint_entity="", inverters=[
{"name": "garage", "setpoint_entity": "number.g", "max_w": 2000,
"bms_soc_floor": 10}])
_l1 = edit(_listed, inverters=[dict(_listed["inverters"][0], max_w=1800)])
check("a list of one stays a list, so the inverter keeps its name and stored "
"state", _l1["inverters"] == [dict(_listed["inverters"][0], max_w=1800)])
check("editing a listed inverter keeps the unread flat keys as stored",
all(_l1[k] == _listed[k] for k in ("setpoint_entity", "soc_entity",
"batt_entity", "max_w")))
check("a name on the flat inverter is refused rather than dropped",
raises(lambda: edit(SITE, inverters=[dict(_card, name="garage")])))
_raw = dict(SITE, inverters="garage")
check("a roster no card can show is kept when another field is saved, and "
"cannot be edited here",
edit(_raw, gain=0.5)["inverters"] == "garage"
and raises(lambda: edit(_raw, inverters=[_card])))
print("\n3. solar rows")
check("a stored flip reads as a reversed row",
U.options_to_ui(dict(DEFAULTS, pv_entity="sensor.pv",
pv_invert_entities=["sensor.pv"]))["ui.solar"]
== [{"entity": "sensor.pv", "reversed": True}])
check("one row writes pv_entity",
edit(DEFAULTS, ui__solar=[{"entity": "sensor.pv", "reversed": False}])
["pv_entity"] == "sensor.pv")
_pv2 = edit(dict(DEFAULTS, pv_entity="sensor.a"), ui__solar=[
{"entity": "sensor.a", "reversed": False},
{"entity": "sensor.b", "reversed": True}])
check("two rows write pv_entities, clear pv_entity and flip the reversed one",
_pv2["pv_entities"] == ["sensor.a", "sensor.b"] and _pv2["pv_entity"] == ""
and _pv2["pv_invert_entities"] == ["sensor.b"])
_stray = dict(DEFAULTS, pv_entities=["sensor.a", "sensor.b"],
pv_invert_entities=["sensor.b", "sensor.old"])
_pv1 = edit(_stray, ui__solar=[{"entity": "sensor.a", "reversed": False}])
check("removing a reversed row drops its flip, and a flip that was never a row "
"stays as stored", _pv1["pv_entities"] == ["sensor.a"]
and _pv1["pv_invert_entities"] == ["sensor.old"])
print("\n4. contract and prices")
_fixed = edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=0.31)
check("a fixed price writes a profile the tariff parser reads as one price",
_fixed["price_provider"] == "off"
and [p.eur_per_kwh for p in parse_static_profile(
_fixed["static_profile"]).periods] == [0.31]
and U.options_to_ui(_fixed)["ui.contract"] == "fixed")
_dn = edit(SITE, ui__contract="daynight", ui__day_price=0.34,
ui__night_price=0.26)
check("day and night write a profile that starts and reads back the same",
not validate_startup_config(_dn)
and U.options_to_ui(_dn)["ui.day_price"] == 0.34
and U.options_to_ui(_dn)["ui.night_price"] == 0.26)
_dyn = edit(_dn, ui__contract="dynamic", price_provider="generic_dayahead")
check("a dynamic contract keeps the day and night profile as its fallback",
_dyn["price_provider"] == "generic_dayahead"
and _dyn["static_profile"] == _dn["static_profile"])
check("a dynamic contract without a price source is refused here",
raises(lambda: edit(DEFAULTS, ui__contract="dynamic")))
_odd = json.dumps({"periods": [
{"from": "00:00", "to": "12:00", "days": "mon-sun", "eur_per_kwh": 0.2},
{"from": "12:00", "to": "24:00", "days": "mon-sun", "eur_per_kwh": 0.3}]})
_om = U.options_to_ui(dict(DEFAULTS, static_profile=_odd))
check("odd windows show as 'custom' and survive saving another field",
_om["ui.contract"] == "custom"
and edit(dict(DEFAULTS, static_profile=_odd), gain=0.5)
["static_profile"] == _odd)
print("\n5. region presets")
_fl = U.REGION_PRESETS["flanders"]
check("the Flanders preset is the sum of app/price.py's components, and is the "
"shipped default", _fl["taxes_eur_kwh"] == round(sum(
v for _n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5)
== PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == DEFAULTS[
"price_taxes_eur_kwh"])
check("every preset carries its year", all(
isinstance(p["year"], int) and p["year"] >= 2026
for p in U.REGION_PRESETS.values()))
_own = dict(DEFAULTS, price_taxes_eur_kwh=0.06)
check("a figure that is no preset reads as 'own', and choosing Flanders "
"writes the preset", U.options_to_ui(_own)["ui.region"] == U.OWN
and edit(_own, ui__region="flanders")["price_taxes_eur_kwh"] == 0.11736)
_prof = dict(DEFAULTS, price_taxes_eur_kwh=0.12,
price_taxes_profile='{"day": 0.14, "night": 0.1}')
check("'own' keeps a stored levies table when the figure is edited",
edit(_prof, price_taxes_eur_kwh=0.13)["price_taxes_profile"]
== _prof["price_taxes_profile"])
check("'own' stores the typed figure",
edit(DEFAULTS, ui__region=U.OWN, price_taxes_eur_kwh=0.13)
["price_taxes_eur_kwh"] == 0.13)
print("\n6. arbitrage_enabled: the OPT-11 hook changes nothing yet")
check("the hook answers None while battery modes do not exist",
U.arbitrage_from_batteries(U.options_to_ui(DEFAULTS)) is None)
check("so the toggle is stored as the page has it",
edit(DEFAULTS, arbitrage_enabled=True)["arbitrage_enabled"] is True)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+109 -67
View File
@@ -77,7 +77,7 @@ p = validate_startup_config(base(max_w=8000), -5000, 5000)
check("max_w above the device maximum is refused", len(p) == 1)
check("...and the message names the configured value", why(p, "8000"))
check("...and the device's value", why(p, "5000"))
check("...and says which one wins", why(p, "THE DEVICE WINS"))
check("...and says what to lower it to", why(p, "Lower the rating to 5000 W"))
check("max_w exactly at the device maximum is fine",
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
check("max_w under it is fine",
@@ -85,7 +85,7 @@ check("max_w under it is fine",
# The charge side is a separate rail and an asymmetric device is legal.
p = validate_startup_config(base(max_w=4000), -2500, 5000)
check("a max_w the device cannot take on the CHARGE side is refused too",
len(p) == 1 and why(p, "charge side"))
len(p) == 1 and why(p, "when charging"))
check("...naming the device's floor", why(p, "-2500"))
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
# re-runs this the moment the real rating arrives.
@@ -122,7 +122,7 @@ check("...and rule 2 REFUSES that whole configuration at startup, which is why "
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
check("...on the rating side specifically, not incidentally through some other "
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
"THE DEVICE WINS"))
"Lower the rating to"))
check("...and the case the refusal CANNOT reach - the rating unread because "
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
"answer had to be a runtime status row and not a startup gate",
@@ -136,9 +136,9 @@ print("2. an actuator to write to")
for blank in ("", " ", None):
p = validate_startup_config(base(setpoint_entity=blank), None, None)
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
check("...and the message says why it is dangerous rather than untidy",
check("...and the message says what it costs: nothing is controlled",
why(validate_startup_config(base(setpoint_entity=""), None, None),
"heartbeat"))
"cannot control the inverter"))
# --------------------------------------------------------------------------- #
# specs §1.5: TWO WRITERS ON ONE ACTUATOR, AND WHAT ACTUALLY GRADES IT
# --------------------------------------------------------------------------- #
@@ -181,7 +181,7 @@ check("...and the replacement SEES it, because it counts writers per ACTUATOR "
and len(_census["number.goodwe_setpoint"]) == 3)
_hazard_problems = validate_startup_config(_PER_INVERTER_HAZARD, -5000, 5000)
check("AC 3: and the config is REFUSED, where today it starts",
why(_hazard_problems, "one actuator"))
why(_hazard_problems, "point at the same inverter entity"))
check("...and the refusal names the actuator and every writer aimed at it, so "
"an operator can see WHICH declarations to remove rather than being told "
"only that something is wrong",
@@ -243,7 +243,8 @@ check("...and entity ids are folded to lower case, so a second writer cannot "
"be smuggled past the census on Number.X versus number.x",
why(validate_startup_config(base(inverters=[{"setpoint_entity":
"NUMBER.GOODWE_SETPOINT"}]),
-5000, 5000), "one actuator"))
-5000, 5000),
"point at the same inverter entity"))
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
# the whole config ACCEPTED - "blank is how an operator leaves a slot
@@ -265,7 +266,8 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
"max_w": 2000,
"setpoint_entity": " "}]),
-5000, 5000), "has no setpoint_entity"))
-5000, 5000),
"has no “Inverter setpoint entity” (setpoint_entity)"))
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
@@ -303,9 +305,9 @@ check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left emp
"weighing a dimensionless 1.0 is not a comparison, so the key would "
"silently change nothing at all",
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
"not PARTIAL")
"Set it on every inverter, or on none")
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
"not PARTIAL"))
"Set it on every inverter, or on none"))
check("...and the refusal names WHICH entry is which, because an operator with "
"three inverters told 'one of them is wrong' is sent to the wrong "
"cupboard",
@@ -339,7 +341,7 @@ check("a phase list disagreeing with meter_phases is refused",
why(validate_startup_config(base(meter_source="ha_signed",
p1_net_entity="sensor.n", meter_phases=3,
p1_phase_net_entities=["a", "b"]),
-5000, 5000), "phase-count"))
-5000, 5000), "Make them match"))
check("mqtt_p1 with no topic is refused",
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
"meter_mqtt_topic"))
@@ -349,7 +351,7 @@ check("ha_dsmr with only half its entities is refused",
"p1_export_entity"))
check("a misspelt meter_source is refused rather than silently ignored",
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
"is not one of"))
"Choose one of"))
# --------------------------------------------------------------------------- #
print("4. meter_max_age_s against the firmware's compile-time veto")
@@ -361,7 +363,8 @@ p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
check("...naming the configured value", why(p, "120"))
check("...naming the firmware's value", why(p, "30"))
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
check("...and saying that the control board stops the inverter first",
why(p, "control board stops the inverter after 30 s"))
check("the tested pair 30/30 is accepted",
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
check("one second above it is refused",
@@ -391,7 +394,7 @@ p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
check("maintenance_charge_w above max_w is refused", len(p) == 1)
check("...and the message names the charge rate", why(p, "2500"))
check("...and the ceiling", why(p, "2000"))
check("...and says which one wins", why(p, "THE CEILING WINS"))
check("...and says what it blocks", why(p, "monthly recharge cannot run"))
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
# deleted, and it is the boundary the rule is written `>` for.
@@ -407,7 +410,8 @@ check("below it starts",
check("...and it is refused with maintenance_enabled off, as shipped",
why(validate_startup_config(
base(max_w=2000, maintenance_charge_w=2500,
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
maintenance_enabled=False), -5000, 5000),
"monthly recharge cannot run"))
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
# The comment this replaces in validate.py said, in as many words, that it took
@@ -448,11 +452,11 @@ check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even thoug
"recalibrated, so its cap could not contradict anything; it is now, and "
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
len(_p_two) == 1 and why(_p_two, "max_w (inverter-b) is 2000 W"))
len(_p_two) == 1 and why(_p_two, "for inverter-b (inverters.max_w) is 2000 W"))
check("...and the message names the pack the number came off, not the first "
"entry - an operator told to lower a number on the wrong device is being "
"sent to the wrong device by the message that exists to stop that",
not why(_p_two, "max_w (inverter-a)"))
not why(_p_two, "for inverter-a (inverters.max_w)"))
check("...while the same rate against two packs that can both take it starts, "
"so this is a comparison and not a blanket refusal of rosters",
[p for p in validate_startup_config(
@@ -463,7 +467,7 @@ check("...while the same rate against two packs that can both take it starts, "
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
"message, the same boundary, the same refusal the flat config always got",
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
-5000, 5000), "THE CEILING WINS")
-5000, 5000), "monthly recharge cannot run")
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
-5000, 5000) == [])
@@ -539,17 +543,15 @@ check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSE
"It crosses the whole output range in 10 s against a plant measured at "
"18 s, which is not a fast loop, it is a loop that commands its entire "
"range before it can observe its answer to the first step",
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
"crossing takes, because 'slew_w is too high' sends an operator to lower "
"a number when raising max_w or setting 0 are both correct answers too",
why(_broken, "site rail of 5000 W")
and why(_broken, "crosses its whole output range in 5.0 cycles"))
check("...and it names the plant measurement it is graded against, so the "
"number is traceable to a step response rather than to a preference",
why(_broken, "goodwe/NOTES.md §5.10"))
len(_broken) == 1 and why(_broken, "(slew_w) is 1000 W"))
check("...and the refusal says WHICH RAIL it graded against, because 'slew_w "
"is too high' sends an operator to lower a number when raising max_w or "
"setting 0 are both correct answers too",
why(_broken, "for a 5000 W site"))
# UX-08 2c: the plant measurement the rule is graded against (the ~18 s step
# response) is in DOCS.md "For installers", beside slew_w, not in the refusal.
check("...and it says what to DO, including the setting that cannot go stale",
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
why(_broken, "Set it to 0") and why(_broken, "556 W"))
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
@@ -600,7 +602,7 @@ check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
"is a magnitude, and 0 already has a meaning here",
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
"must not be negative"))
"cannot be negative"))
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
@@ -649,7 +651,7 @@ check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
check("...and a slew that outruns even the doubled rail is still refused there, "
"so the roster path is not simply un-graded",
why(validate_startup_config(_roster(slew_w=2000)),
"site rail of 9600 W"))
"for a 9600 W site"))
# --------------------------------------------------------------------------- #
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
@@ -777,9 +779,10 @@ print("max_w must be a positive magnitude")
# sim/inject.py's entry point and can be handed any dict at all.
for bad in (0, -1, -2000):
p = validate_startup_config(base(max_w=bad), -5000, 5000)
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
check("...and the message explains it would read as a trip, not a mistake",
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "Set it above 0 W"))
check("...and the message names the setting by its label and its key",
why(validate_startup_config(base(max_w=0), -5000, 5000),
"“Inverter rating” (max_w) is 0 W"))
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
@@ -792,7 +795,7 @@ check("the smallest sane max_w still starts",
unit_sign_disagree_w=0.5,
maintenance_charge_w=1), -5000, 5000) == [])
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
why(validate_startup_config(base(max_w=1), -5000, 5000), "can never trip"))
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
# and the fixture above now has to name BOTH thresholds - which is the check
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
@@ -1188,9 +1191,9 @@ def _roster_n(caps, **over):
# The three rules that reduce over `unit_caps_indexed()`, each as
# (option name, the token its refusal is recognised by).
_CAP_RULES = (
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
("maintenance_charge_w", "maintenance charge in a cheap window"),
("sign_disagree_w", "(sign_disagree_w) is"),
("unit_sign_disagree_w", "(unit_sign_disagree_w) is"),
("maintenance_charge_w", "monthly recharge cannot run"),
)
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
@@ -1220,7 +1223,8 @@ def _verdict(opts, token):
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
if not ps:
return (False, None, None)
m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0])
m = re.search(r"for (\S+) \(inverters\.max_w\),? (?:is|at) (\d+) W",
ps[0])
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
@@ -1330,7 +1334,7 @@ check("...and at a ROSTER OF ONE every candidate that still has a member to "
and {n for n, v in _at_one.items() if v is None}
== {"min(caps[1:])", "min(caps[:-1])"}
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
"SAFETY-02 sign-disagreement")[0])
"(sign_disagree_w) is")[0])
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
@@ -1342,7 +1346,7 @@ check("...and at a ROSTER OF ONE every candidate that still has a member to "
_leftover = _roster_n([_SMALL, _SMALL],
**dict(_QUIET, unit_sign_disagree_w=_RATE))
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
_lv = _verdict(_leftover, "(unit_sign_disagree_w) is")
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
@@ -1358,7 +1362,7 @@ check("...and the over-refusal in the OTHER direction is gone too: a roster of "
"reach. It starts",
_verdict(_roster_n([_BIG, _BIG],
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
"SAFETY-30 per-inverter sign-disagreement")
"(unit_sign_disagree_w) is")
== (False, None, None))
check("...and the flat single-inverter config it always refused is refused "
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
@@ -1411,7 +1415,7 @@ print(f" {(_T17_P_GAP or [''])[0][:120]}")
check("a table that leaves 00:00-07:00 unpriced is REFUSED, by the same tiling "
"rule static_profile has - and the refusal names the KEY and the HOURS, "
"not merely that something was wrong",
why(_T17_P_GAP, "price_taxes_profile cannot be used:")
why(_T17_P_GAP, "“Day/night levies and VAT” cannot be used.")
and why(_T17_P_GAP, "leaves mon 00:00-07:00 with no period"))
check("...and it is refused for THAT reason and not as a side effect of "
"something else this fixture happens to break: exactly one problem",
@@ -1427,9 +1431,9 @@ print(f" {(_T17_P_BOTH or [''])[0][:120]}")
check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
"refusal says WHICH ONE WINS and what to do - not merely that the two "
"are incompatible",
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
"0.1198")
and why(_T17_P_BOTH, "THE TABLE WINS"))
why(_T17_P_BOTH, "(price_taxes_profile) is set, so")
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used. Set it "
"back to"))
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
"Walloon site would have to clear a key it never touched. The refusal is "
"about a value the operator chose, and the shipped default is the one "
@@ -1479,19 +1483,16 @@ check("...while a value that is NEITHER the shipped default NOR a superseded "
"one is still REFUSED beside the table, naming it - the set of "
"'untouched' figures widened by exactly the old defaults and by nothing "
"else",
why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is "
"0.1198")
why(_T17_P_BOTH, "from 0.1198, or clear")
and 0.1198 not in _T18_SUPERSEDED
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
# both which figure to return to and that the old default is not the problem.
check("...and the refusal names the SHIPPED default to return to AND says the "
"superseded default is accepted, so the sentence is actionable on both "
"sides of the upgrade",
# UX-08 2c: that a superseded default is accepted is said in DOCS.md "For
# installers" now. The refusal keeps the one figure the operator acts on.
check("...and the refusal names the SHIPPED default to return to",
bool(_T18_SUPERSEDED)
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}")
and why(_T17_P_BOTH, "SUPERSEDED")
and all(why(_T17_P_BOTH, str(u)) for u in _T18_SUPERSEDED))
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}"))
check("...and the same non-default scalar WITHOUT the table still starts, "
"which is what makes the line above about the PAIR rather than about "
"0.1198 being disliked",
@@ -1531,18 +1532,18 @@ print(f" ORES : {(_T17_P_ORES or [''])[0][:110]}")
print(f" Sibelga : {(_T17_P_SIB or [''])[0][:110]}")
check("ORES's card typed in CENTS is refused by the PARSER's bound, and the "
"sentence is the parser's - `beyond +-10.0`, naming the row",
why(_T17_P_ORES, "price_taxes_profile cannot be used:")
why(_T17_P_ORES, "“Day/night levies and VAT” cannot be used.")
and why(_T17_P_ORES, "beyond +-10.0"))
check("...and SIBELGA's whole card - 9,39 and 7,10, BOTH under that bound - is "
"refused by THIS rung instead, naming both bands and the factor. Without "
"it a Brussels site would price a 9.39 EUR/kWh levy and start",
why(_T17_P_SIB, "price_taxes_profile carries [7.1, 9.39] EUR/kWh")
and why(_T17_P_SIB, "factor of 100"))
why(_T17_P_SIB, "(price_taxes_profile) has [7.1, 9.39] EUR/kWh")
and why(_T17_P_SIB, "Divide the figures by 100"))
check("...and the two refusals are DIFFERENT sentences, so the rungs cannot "
"mask each other: deleting the units rung leaves the Sibelga line red "
"while the ORES line goes on passing for the parser's reason",
_T17_P_ORES != _T17_P_SIB
and not why(_T17_P_ORES, "factor of 100")
and not why(_T17_P_ORES, "Divide the figures by 100")
and not why(_T17_P_SIB, "beyond +-10.0"))
check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
"above are about the units and not about Brussels or Wallonia",
@@ -1639,13 +1640,13 @@ check("PRECONDITION: each of those two blobs differs from the accepted "
== _T16_ENECO)
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
"naming the option the operator typed",
why(_T16_P_PT, "price_export_formula cannot be used:")
why(_T16_P_PT, "“Your own injection formula” cannot be used.")
and why(_T16_P_PT, "passes through")
and not why(_T16_P_PT, "|offset| above"))
and not why(_T16_P_PT, "larger than"))
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
"rungs are graded on their reasons and cannot mask each other",
why(_T16_P_OFF, "price_export_formula cannot be used:")
and why(_T16_P_OFF, "|offset| above")
why(_T16_P_OFF, "“Your own injection formula” cannot be used.")
and why(_T16_P_OFF, "larger than")
and not why(_T16_P_OFF, "passes through"))
check("...and a malformed blob is refused as a malformed blob, naming the "
"field rather than 'invalid configuration'",
@@ -1659,7 +1660,7 @@ check("...and a malformed blob is refused as a malformed blob, naming the "
# coefficients against arithmetic done with the same coefficients. So the leg
# starts, and the operator is told WHICH check did not run.
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
if lvl == "WARNING" and "UNGRADED" in m]
if lvl == "WARNING" and "not checked against a printed price" in m]
print(f" {(_T16_UNGRADED or [''])[0][:118]}")
check("a leg with no card block starts, and says at startup that its "
"coefficients are UNGRADED - naming what was checked instead, so the "
@@ -1682,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 "
"which check ran",
_T16_WITH_CARD == []
and not [m for lvl, m in _T16_CARD_LOG if "UNGRADED" in m])
and not [m for lvl, m in _T16_CARD_LOG if "not checked against a printed price" in m])
# ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung.
check("...and a card ON THIS LEG that the coefficients do not reproduce is "
"REFUSED at startup, so the optional block is optional and not inert",
why(_t16_start(price_export_formula=_T16_CONS.replace(
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
"price_export_formula does not reproduce its own card"))
"price_export_formula gives"))
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
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 "
"ADD-ON READS IT - the operator is told what the option does not do in "
"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
# 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
@@ -1736,7 +1737,7 @@ for _v in _S10_BAD:
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
"configui.problems_by_field() can file it under the right control",
why(_p, "shadow_mode is") and why(_p, "'auto'")
why(_p, "(shadow_mode) is") and why(_p, "'auto'")
and why(_p, "'on'") and why(_p, "'off'"))
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "
@@ -1807,6 +1808,47 @@ check("an UNREADABLE shadow_mode resolves to SHADOW, not to live - the "
and shadow.engaged({"shadow_mode": ""}, control_started=True) is True
and shadow.mode({"shadow_mode": "garbage"}) == "on")
# --------------------------------------------------------------------------- #
print("\nUX-13 - the warning tier: notes for the settings page, never refusals")
from app.validate import Note, WATT_ENTITY_KEYS, config_notes # noqa: E402
_warned = base(integrator_max_w=3000, maintenance_discharge_w=2500,
price_provider="generic_dayahead", static_profile="")
_notes = []
_with = validate_startup_config(_warned, -5000, 5000, notes=_notes)
check("a config with three logged warnings starts, and gives three notes, "
"each filed under the setting it names",
_with == [] and validate_startup_config(_warned, -5000, 5000) == []
and sorted(n.keys for n in _notes) == [
("integrator_max_w",), ("maintenance_discharge_w",),
("static_profile",)]
and all(n.tier == "warn" and f"({n.keys[0]})" in n.text for n in _notes))
_refused = base(setpoint_entity="", integrator_max_w=3000)
_n2 = []
check("collecting notes changes no refusal",
validate_startup_config(_refused, notes=_n2)
== validate_startup_config(_refused) != [] and len(_n2) == 1)
_rows = {"number.goodwe_setpoint": {"unit": None},
"sensor.soc": {"unit": "%"}, "sensor.batt": {"unit": "kW"}}
_cn = config_notes(base(), entities=_rows)
check("a battery power sensor in kW is warned about: the add-on takes it as "
"watts", [n.keys for n in _cn] == [("batt_entity",)]
and "kW" in _cn[0].text)
_cn = config_notes(base(meter_entity="sensor.grid"), entities={})
check("an entity Home Assistant does not have is named, and only the ones "
"this meter source and roster shape read",
sorted(n.keys[0] for n in _cn) == ["batt_entity", "meter_entity",
"setpoint_entity", "soc_entity"])
check("with no entity list (Home Assistant down) those checks are skipped",
config_notes(base(), entities=None) == [])
check("a peak limit below this month's peak is warned about",
[n.keys for n in config_notes(base(peak_cap_w=2500),
month_peak_w=2900)] == [("peak_cap_w",)]
and config_notes(base(peak_cap_w=3500), month_peak_w=2900) == [])
check("pv.py converts solar units itself, so no solar key is read as watts",
not [k for k in WATT_ENTITY_KEYS if k.startswith("pv_")]
and isinstance(_cn[0], Note))
# --------------------------------------------------------------------------- #
print()
if fails:
+1 -1
View File
@@ -138,7 +138,7 @@ r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
check("refuses a config the validator rejects", r5.returncode == 1)
check("...and warns it is exit 78", "78" in r5.stdout)
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
check("...and prints the validator's own words", "(slew_w) is 1000 W" in r5.stdout)
print()
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
+128 -42
View File
@@ -22,6 +22,7 @@ from datetime import datetime, timedelta, timezone
from named_absence import nth, present
from app import messages # noqa: E402
from app.watchdog import (
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
@@ -153,7 +154,7 @@ p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 300},
check("300 W under a 2000 W ceiling is accepted", p == [])
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
-5000, 5000)
check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
check("a threshold AT max_w is refused", len(p) == 1 and "can never trip" in p[0])
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W
# max_w is also below the 2000 W shipped maintenance charge - so without this
@@ -161,7 +162,7 @@ check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
# `len(p) == 1` below would be measuring the interaction rather than this rule.
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000,
"maintenance_charge_w": 500}, -5000, 5000)
check("a threshold above max_w is refused", len(p) == 1 and "never fire" in p[0])
check("a threshold above max_w is refused", len(p) == 1 and "can never trip" in p[0])
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
-5000, 5000)
check("one watt under the ceiling is reachable, so accepted", p == [])
@@ -368,10 +369,19 @@ check("0 W actually reached the device", 0.0 in hass.writes)
check("and it got there through the failsafe claim, not by accident",
"failsafe" in ctrl.reason)
print(f" status line: {ctrl.reason!r}")
check("the status page says so in words",
any("SIGN-DISAGREEMENT FAILSAFE" in c["text"] for c in ctrl.checks()))
check("the status page says so in words: a red row, at 0 W, cleared by start",
any(c.get("id") == "direction" and not c["ok"] and not c["warn"]
and "set to 0 W" in c["text"] and "start control" in c["text"]
for c in ctrl.checks()))
check("the banner leads with it",
"FAILSAFE" in ctrl.status_payload()["banner"])
ctrl.status_payload()["banner"] == messages.banner_direction(False))
check("...the line under the buttons says it in plain words, and the action "
"log with the raw reason is still reachable in the status JSON",
ctrl.status_payload()["hint"].endswith(
messages.hint("direction", unit=ctrl.inverters[0].name))
and ctrl.status_payload()["events"] == ctrl.events
and any(e.split(" ", 1)[1].startswith("FAILSAFE ")
for e in ctrl.status_payload()["events"]))
check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
@@ -478,7 +488,7 @@ check("...commands 0 W from its very first cycle", post.target == 0.0)
check("...and never commanded anything else after the restart",
hass2.writes != [] and set(hass2.writes) == {0.0})
check("the banner still leads with the failsafe",
"FAILSAFE" in post.status_payload()["banner"])
post.status_payload()["banner"] == messages.banner_direction(False))
# ⚠️ TWO PRESSES, and the first one must NOT clear it. `auto` is True while
# tripped, so the first press stops control; only the transition back to True
# clears. That is the documented behaviour and it is worth an assertion, because
@@ -951,12 +961,25 @@ check("...and it got there through the `circulation` claim, by name, rather "
"circulation" in _c.reason)
print(f" status line: {_c.reason!r}")
check("the status page says so in words, and says the meter cannot see it",
any("CIRCULATING POWER" in ck["text"] and "meter cannot see" in ck["text"]
for ck in _c.checks()))
any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
and "meter cannot see" in ck["text"] for ck in _c.checks()))
check("the banner leads with it - above the sign failsafe, because a sign "
"disagreement is visible on every dashboard in the house and this is "
"visible nowhere at all",
"CIRCULATING POWER" in _c.status_payload()["banner"])
_c.status_payload()["banner"] == messages.banner_circulating())
# UX-09: the red shadow banner no longer names the latch, so the latch's own
# red row must stay on the list in shadow mode, name it and say how it clears.
_c_mode = _c.o.get("shadow_mode")
_c.o["shadow_mode"] = "on"
check("in shadow mode the banner goes red and says a safety stop stays on, and "
"the circulating row is still red and says only starting control clears "
"it",
_c.status_payload()["level"] == "bad"
and "safety stop" in _c.status_payload()["banner"]
and any(ck.get("id") == "circulating" and not ck["ok"] and not ck["warn"]
and "Only starting control clears this" in ck["text"]
for ck in _c.checks()))
_c.o["shadow_mode"] = _c_mode
_pub = {}
_c.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
@@ -1027,7 +1050,8 @@ check("...no `circulation` claim is ever filed, so the arbitration is "
check("...and the status page says n/a rather than claiming to watch something "
"that cannot happen - an 'armed' row for an identically-zero quantity is "
"a guard advertising work it is not doing",
any("circulating power: n/a at one inverter" in ck["text"]
any(ck.get("id") == "circulating" and ck["ok"]
and "not needed with one inverter" in ck["text"]
for ck in _c1.checks()))
_pub1 = {}
_c1.mqtt = type("P", (), {"enabled": True,
@@ -1055,8 +1079,8 @@ async def _circ_restart():
await a.read_inputs()
await a.cycle()
await asyncio.sleep(0.25)
# THE RESTART. Same store, brand new Controller and Hass - which is exactly
# what a Home Assistant restart does to this add-on. The house has stopped
# THE RESTART. Same store, brand new Controller and Hass - an ADD-ON
# restart. A Core-only restart keeps the process; graded below (SAFETY-34). The house has stopped
# circulating in the meantime, so nothing in the new process's own
# measurements could re-derive the trip.
h2 = _CircHass(a_w=0.0, b_w=0.0)
@@ -1105,17 +1129,49 @@ check("...under a fixed notification_id of its own, so a second episode "
for c in _notes1 + _notes2)
and len({c[2]["notification_id"] for c in _notes1 + _notes2}) == 1)
_msg = nth(_notes2, 0)[2]["message"]
def _says_three(m):
"""UX-08: the three things only this alarm can say, in its plain words
(messages.circulating): the meter cannot see it, control is stopped at
0 W, and if the grid does not move a person at the isolator is next."""
return ("meter cannot see this" in m and "Control stays stopped" in m
and "Every inverter is set to 0 W" in m and "isolator" in m
and "if the grid power does not change" in m)
check("...and the message tells the operator the three things only this alarm "
"can: that the meter cannot see it, that control has been stopped, and "
"that if the grid reading does not move afterwards one inverter is no "
"longer listening and a human at the isolator is the last actuator",
"METER CANNOT SEE" in _msg and "CONTROL HAS BEEN STOPPED" in _msg
and "isolator" in _msg)
_says_three(_msg))
check("...and it does not raise the same card on every cycle - a per-PROCESS "
"flag, deliberately not persisted, is what makes 'has a human been shown "
"this' a different question from 'is this site tripped'",
len(_notes2) == 1)
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
import app.main as _main_mod # noqa: E402
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
_main_mod._mono = lambda: _clock[0]
try:
_h3 = _CircHass(a_w=0.0, b_w=0.0)
_b.hass = _h3
asyncio.run(_b.check_circulating())
_early = [c for c in _h3.calls if c[0] == "persistent_notification"]
_clock[0] += _main_mod.REANNOUNCE_S
asyncio.run(_b.check_circulating())
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
finally:
_main_mod._mono = _real_mono
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
"storm on the event bus", len(_early) == 0)
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
"survives it, so after REANNOUNCE_S the SAME process re-asserts it",
len(_late) == 1
and _late[0][2]["notification_id"] == "goodwe_circulating_power")
print()
print("6a. AC 4: THE ALARM NAMES WHICH PACKS, AND WHICH WAY ROUND")
@@ -1240,8 +1296,8 @@ check("PRECONDITION: both runs actually tripped and actually raised the card, "
and len(_notes_g) == 1 and len(_notes_s) == 1)
# --- AC 4: THE ATTRIBUTION ITSELF -------------------------------------------
_WHO_G = "Right now garage is discharging while shed charges."
_WHO_S = "Right now shed is discharging while garage charges."
_WHO_G = "garage was giving power while shed was charging"
_WHO_S = "shed was giving power while garage was charging"
check("⚠️ THE ALARM NAMES THE TWO PACKS AND SAYS WHICH WAY EACH ONE IS PULLING. "
"`circulation.py` calls this the first thing somebody woken at 3 a.m. "
"needs and impossible to reconstruct from the watts alone, and until this "
@@ -1262,12 +1318,14 @@ check("...and the RESTING pack is not named. `cellar` is on the roster, is read
"beside a 6 kW loop is not reported as one of its two participants",
"cellar" not in _msg_g and "cellar" not in _msg_s
and _cg.inverters[2].batt == _REST_W)
check("...and the sentence sits with the figure, not instead of it: the same "
"card still carries the watts, the window and the three things only this "
"alarm can say",
all("CIRCULATING_POWER" in m and "METER CANNOT SEE" in m
and "CONTROL HAS BEEN STOPPED" in m and "isolator" in m
for m in (_msg_g, _msg_s)))
# UX-08: the watts and the window moved out of the card into the reason the
# log line and the status row carry; the card keeps the three things.
check("...and the sentence sits with the three things only this alarm can say, "
"while the watts and the window stay in the reason behind it",
all(_says_three(m) for m in (_msg_g, _msg_s))
and all("CIRCULATING_POWER" in c.circ_wd.reason
and "W measured going round" in c.circ_wd.reason
for c in (_cg, _cs)))
print()
print(" the operator's press is the only way out, and it clears BOTH halves")
@@ -1364,14 +1422,28 @@ for _t in range(0, 400, 5):
_wd_unit.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
print(f" site: {_wd_site.reason!r}")
print(f" unit: {_wd_unit.reason!r}")
check("⚠️ THE SITE INSTANCE'S SENTENCE IS BYTE-IDENTICAL TO THE ONE THAT "
"SHIPPED. It is what an operator reads on the status page, what goes in "
"the action log and what is persisted to /data/state.json, so it is "
"asserted against the literal rather than against a format string",
_wd_site.reason == (
"SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
"+1500 W for 180 s continuously (limits: both over 300 W, held over "
"180 s)"))
# UX-11: this was a byte pin. The reason is now the evidence: the action
# log, the DEBUG line and /data/state.json carry it, and the ERROR line quotes
# only its code (`messages.reason_code()`). So what is pinned is what it says:
# the code in front, where every saved reason has had it and where the ERROR
# line finds it, then the command and the reading in that order (habit 14),
# the time held and both limits.
_sr = _wd_site.reason
_order = [_sr.find(t) for t in ("commanded -1000 W", "meter reading +1500 W",
"for 180 s", "both over 300 W",
"held over 180 s")]
check("⚠️ THE SITE INSTANCE'S REASON SAYS WHAT SHIPPED, IN THE SAME ORDER: "
"the code first, then what was commanded, what the meter read, how "
f"long, and the two limits ({_order})",
_sr.startswith("SIGN_DISAGREEMENT: ")
and messages.reason_code(_sr) == "SIGN_DISAGREEMENT"
and all(i > 0 for i in _order) and _order == sorted(_order))
# UX-11 review: the text itself did not change, and it is what state.json
# holds, so the byte pin stays beside the meaning pin.
check("...and it is still byte-identical to the one that shipped",
_sr == ("SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
"+1500 W for 180 s continuously (limits: both over 300 W, held "
"over 180 s)"))
check("...and the per-unit instance names what it ACTUALLY compared instead. "
"'against a meter reading' there would be a false statement about which "
"instrument produced the evidence, on the one line somebody woken by "
@@ -1876,9 +1948,23 @@ check("...the latch is FLAT - `site_sign_tripped`, beside SAFETY-31's pair - "
check("the status page carries a SITE row of its own, and the banner says the "
"site's TOTAL command opposed the meter rather than naming an inverter - "
"naming one would send an operator to swap the wrong box",
any("SITE SIGN-DISAGREEMENT FAILSAFE" in ck["text"]
any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"] and "every inverter" in ck["text"]
for ck in _c31.checks())
and "total command" in _c31.status_payload()["banner"])
and "Total power" in _c31.status_payload()["banner"]
and "inverter-" not in _c31.status_payload()["banner"])
_c31_mode = _c31.o.get("shadow_mode")
_c31.o["shadow_mode"] = "on"
check("UX-09: in shadow mode the banner goes red and says a safety stop stays "
"on, and the site row is still red and says only starting control "
"clears it",
_c31.status_payload()["level"] == "bad"
and "safety stop" in _c31.status_payload()["banner"]
and any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"]
and "Only starting control clears this" in ck["text"]
for ck in _c31.checks()))
_c31.o["shadow_mode"] = _c31_mode
_pub30 = {}
_c31.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub30.update(v),
@@ -2107,16 +2193,16 @@ check("⚠️ ...AND THE SITE DETECTOR IS NEVER ARMED. Not 'did not trip', and n
_n1_site_calls == []
and not _c1s.site_sign_wd.tripped
and _c1s.site_sign_wd.disagree_since is None)
check("...the banner is the sentence that shipped",
_c1s.status_payload()["banner"]
== "FAILSAFE — the command opposed the meter. Inverter held at 0 W.")
check("...the banner is the one-inverter sentence, naming the meter",
_c1s.status_payload()["banner"] == messages.banner_direction(False)
and "meter" in messages.banner_direction(False))
check("...the status page carries ONE sign row, with no unit tag and no site "
"row - an extra row for an identically-duplicated question is a page "
"advertising two guards where the install has one",
len([ck for ck in _c1s.checks()
if "sign watchdog" in ck["text"] or "SIGN-DISAGREEMENT" in ck["text"]
]) == 1
and not any("site sign" in ck["text"].lower() for ck in _c1s.checks()))
len([ck for ck in _c1s.checks() if ck.get("id") == "direction"]) == 1
and not any(ck.get("id") == "site_direction" for ck in _c1s.checks())
and not any("inverter-" in ck["text"] for ck in _c1s.checks()
if ck.get("id") == "direction"))
check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason)
@@ -2288,12 +2374,12 @@ check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED,
"the page then leads with whatever the next branch says",
_bn_b["level"] == _bn_a["level"] == "bad"
and _bn_b["banner"] == _bn_a["banner"]
and _bn_b["banner"].startswith("FAILSAFE")
and "its own measured power" in _bn_b["banner"])
and _bn_b["banner"] == messages.banner_direction(True)
and "own power" in _bn_b["banner"])
check("...and the counterfactual again: the same site with nothing latched "
"carries neither that level nor that sentence, so the banner really is "
"being produced by the reduction under test",
not _bn_n["banner"].startswith("FAILSAFE")
_bn_n["banner"] != messages.banner_direction(True)
and _bn_n["banner"] != _bn_b["banner"])
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""SAFETY-52's evidence: the 16-day capture replayed with and without the
battery-refuses hold. `python3 tools/replay_refusal.py`
Same disturbance, fitted plant and pack floor as `replay_recorded_loop.py`,
plus main.py's reseed, 15 s input hold and 0 W rules and the REAL SAFETY-37/57
gate on a register published every 10 s. `old` is `compute()` with
`refused=None`, which is release/1.0's law unchanged; `new` carries the refusal,
runs the probe on main.py's clock and seeds a refusal from a gate trip.
Modelled watt-hours: read the deltas, not the absolutes (see that tool).
"""
import math
import pathlib
import sys
ADDON = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ADDON))
sys.path.insert(0, str(ADDON / "tools"))
import replay_recorded_loop as R # noqa: E402
from app import freshness, control as NEW # noqa: E402
def go(which, rows, fit, publish_s=10.0, stale_input_s=15.0):
law = NEW
tun = law.Tuning(**R.LIVE)
t0, g0, b0, sp0, soc0 = rows[0]
pl = R.Plant(*fit[:4], soc0); pl.seed(t0, b0, sp0)
st = dict(target=sp0, i_w=sp0, sat=0, loop_w=sp0, ref=0, anchor=None)
fr = freshness.Freshness(freshness.BOUNDS["batt"]); reg, reg_t = None, -1e18
bad, trips, was = None, 0, False
M = dict(und=0.0, imp=0.0, exp=0.0, q={}, run=0.0, mx=0.0, last=None, refh=0.0, probes=0)
for i in range(1, len(rows)):
t, gr, ba, sp, soc = rows[i]
dt = t - rows[i - 1][0]
if dt > R.TICK_CAP_S:
pl = R.Plant(*fit[:4], soc); pl.seed(t, ba, sp)
st.update(target=sp, i_w=sp, sat=0, loop_w=sp, ref=0, anchor=None)
fr = freshness.Freshness(freshness.BOUNDS["batt"]); reg_t = -1e18; bad = None
continue
b = pl.step(t, dt, st["target"]); grid = gr + ba - b
if t - reg_t >= publish_s:
reg, reg_t = float(round(b)), t
fr.submit(reg, st["target"], t); batt = fr.reading()
if fr.stale and not was:
trips += 1
if (which == "new" and not st["ref"] and st["sat"] >= tun.saturation_cycles
and abs(reg) < NEW.REFUSED_BELOW_W and abs(st["target"]) > tun.saturation_w):
st["ref"], st["anchor"] = (1 if st["target"] > 0 else -1), t
was = fr.stale
if batt is None:
bad = t if bad is None else bad
if t - bad >= stale_input_s:
st.update(target=0.0, i_w=0.0, loop_w=None)
else:
bad = None
if st["loop_w"] is None or st["target"] != st["loop_w"]:
st["i_w"] = st["target"]
if which == "old":
d = law.compute(st["target"], grid, batt, tun, st["sat"], st["i_w"],
refused=None)
else:
probe = False
if st["ref"] and st["anchor"] is not None:
h = t - st["anchor"]
if h >= NEW.PROBE_EVERY_S + NEW.PROBE_S: st["anchor"] = t
else: probe = h >= NEW.PROBE_EVERY_S
d = law.compute(st["target"], grid, batt, tun, st["sat"], st["i_w"], refused=st["ref"], probe=probe)
if d.refused and not st["ref"]: st["anchor"] = t
if not d.refused: st["anchor"] = None
if probe and d.target_w == NEW.PROBE_W and M["last"] != NEW.PROBE_W: M["probes"] += 1
st["ref"] = d.refused
st.update(sat=d.sat_count, i_w=d.i_w, loop_w=d.target_w, target=d.target_w)
tg = st["target"]
if tg - b > 1000: M["und"] += dt / 3600
if st["ref"]: M["refh"] += dt / 3600
M["imp"] += max(0, grid) * dt / 3.6e6; M["exp"] += max(0, -grid) * dt / 3.6e6
k = math.floor(t / 900); a = M["q"].setdefault(k, [0.0, 0.0]); a[0] += grid * dt; a[1] += dt
M["run"] = M["run"] + dt if (M["last"] == tg and abs(tg) >= 250) else 0.0
M["mx"] = max(M["mx"], M["run"]); M["last"] = tg
worst = max(max(0.0, s / d) for s, d in M["q"].values() if d > 600)
return trips, M, worst
def main():
rows = R.stream()
fit = R.fit_plant(rows, stride=7)
print(f"fit {[round(x, 2) for x in fit]}, {len(rows)} meter instants")
for label, f in (("fitted plant", fit),
("plant offset 0 W", (fit[0], fit[1], fit[2], 0.0, fit[4]))):
for which in ("old", "new"):
tr, M, w = go(which, rows, f)
print(f"{label:17s} {which}: gate trips {tr:4d} | request >1 kW above "
f"delivery {M['und']:6.1f} h | longest held >=250 W "
f"{M['mx'] / 3600:5.2f} h | import {M['imp']:6.1f} kWh export "
f"{M['exp']:5.1f} kWh | worst 15-min {w:5.0f} W | refused "
f"{M['refh']:5.1f} h, probes {M['probes']}", flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,106 @@
"""UX-13: record every `validate_startup_config()` call the test suite makes,
and compare two validators over those calls.
python3 tools/ux13_validator_calls.py record OUT.jsonl.gz
python3 tools/ux13_validator_calls.py compare OLD_APP_PARENT OUT.jsonl.gz
`record` runs each test_*.py with the validator wrapped (in this directory, so
run it where the suite runs: the CI image). `compare` runs the recorded calls
through the validator under OLD_APP_PARENT (a directory holding an `app/`,
e.g. from `git archive <base> goodwe_controller/app`) and through this tree's,
and prints every call whose refusals differ. P0 of UX-13 used it to show that
adding the warning tier changed no refusal. scenarios/ux13/ holds the record
test_uimodel.py round-trips.
"""
import gzip
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_WRAP = r"""
import json, runpy, sys
sys.path.insert(0, ".")
from app import validate
real = validate.validate_startup_config
out = open(sys.argv[1], "a")
def rec(opts, dev_min=None, dev_max=None, *a, **kw):
try:
out.write(json.dumps({"opts": opts, "dev_min": dev_min,
"dev_max": dev_max}) + "\n")
except (TypeError, ValueError):
pass
return real(opts, dev_min, dev_max, *a, **kw)
validate.validate_startup_config = rec
sys.argv = sys.argv[2:]
try:
runpy.run_path(sys.argv[0], run_name="__main__")
except SystemExit:
pass
"""
def record(out_path):
tmp = out_path + ".part"
open(tmp, "w").close()
rows, seen = [], set()
for name in sorted(f for f in os.listdir(HERE)
if f.startswith("test_") and f.endswith(".py")):
before = sum(1 for _ in open(tmp))
subprocess.run([sys.executable, "-B", "-c", _WRAP, tmp, name],
cwd=HERE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
with open(tmp) as fh:
for line in list(fh)[before:]:
call = json.loads(line)
key = json.dumps(call, sort_keys=True)
if key not in seen:
seen.add(key)
rows.append(dict(call, source=name))
os.remove(tmp)
with gzip.open(out_path, "wt") as fh:
for row in rows:
fh.write(json.dumps(row, sort_keys=True) + "\n")
print(f"{len(rows)} distinct validator calls -> {out_path}")
def _run(app_parent, path):
code = r"""
import gzip, json, logging, sys
logging.disable(logging.CRITICAL)
sys.path.insert(0, sys.argv[1])
from app.validate import validate_startup_config as v
out = []
for line in gzip.open(sys.argv[2], "rt"):
c = json.loads(line)
try:
out.append(v(c["opts"], c["dev_min"], c["dev_max"]))
except Exception as e:
out.append("raised " + type(e).__name__)
print(json.dumps(out))
"""
got = subprocess.run([sys.executable, "-B", "-c", code, app_parent, path],
capture_output=True, text=True, check=True)
return json.loads(got.stdout)
def compare(old_parent, path):
old, new = _run(old_parent, path), _run(HERE, path)
differ = [i for i, (a, b) in enumerate(zip(old, new)) if a != b]
print(f"{len(old)} calls, {sum(1 for r in old if r)} refused by the old "
f"validator, {len(differ)} with different refusals")
for i in differ:
print(f" call {i}:\n old {old[i]}\n new {new[i]}")
return 1 if differ else 0
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "record":
record(sys.argv[2])
elif len(sys.argv) == 4 and sys.argv[1] == "compare":
sys.exit(compare(sys.argv[2], sys.argv[3]))
else:
sys.exit(__doc__)
+139 -141
View File
@@ -34,269 +34,267 @@
# is approximated by ORDER plus the `Group · ` prefix in each label. Do not
# promise the operator visual sections; there are none.
#
# ⚠️ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM. The Configuration tab
# presents a number read off 1890 closed-loop test runs and the operator's own
# inverter rating as the same kind of field. The descriptions are the only place
# that difference can be stated, so a derived value names its campaign and says
# "do not change without re-deriving", and a value that is genuinely the
# operator's says so too.
# Names and descriptions follow STYLE.md (UX-08): a short label with its unit,
# and one or two plain sentences. A measured or derived value says "leave it
# unchanged", and where it came from is in DOCS.md under "For installers:
# where the numbers come from".
configuration:
setpoint_entity:
name: "Hardware · Inverter setpoint entity"
description: "The ESPHome number entity this add-on writes the power setpoint to, normally number.*_goodwe_setpoint_w. This is the only thing the add-on commands. Leave it empty if you fill in the inverter list below: a site with a roster must not also name one loose actuator, and the add-on refuses to start when both are set."
description: "The ESPHome number entity the add-on writes the inverter's power to. Leave it empty when you fill in the inverter list."
soc_entity:
name: "Hardware · Battery charge level entity (%)"
description: "The sensor that reports the pack's state of charge in percent. Use the ESP32's own reading, not a figure computed somewhere else. The maintenance cycle and every charge-level floor read this."
name: "Hardware · Battery level entity (%)"
description: "The sensor with the battery level in percent. Use the control board's own reading."
batt_entity:
name: "Hardware · Battery power entity (W)"
description: "The sensor that reports this inverter's own battery power, positive while discharging. On this installation that is holding register 0x0518, read by the ESP32. The sign watchdog and the circulating-power alarm both grade the inverter against this reading."
description: "The sensor with this inverter's battery power. Positive must mean the battery is giving power."
batt_invert:
name: "Hardware · Battery power sign is reversed"
description: "Only switch this on if your battery power sensor reports discharging as a negative number. The add-on expects positive to mean discharging."
description: "Switch this on only if your battery power sensor shows giving power as a negative number."
inverters:
name: "Hardware · Inverter list (leave empty if you have one inverter)"
description: "Leave this empty unless the site really has more than one inverter. EMPTY DOES NOT MEAN 'not configured yet': it means the four entity settings above, together with the inverter rating and the battery cut-out under Limits, describe the one inverter you have. That is what every existing installation reads, and nobody has to touch anything. Filling this in REPLACES those settings instead of extending them, and nothing is inherited: each entry has to name its own actuator, its own rating and its own battery cut-out, so that no inverter is ever judged against another unit's numbers. The flat setpoint entity above must then be left empty."
name: "Hardware · Inverter list"
description: "Leave it empty if you have one inverter. With two or more, add one entry per inverter, which replaces the inverter settings above."
fields:
name:
name: "Name"
description: "A short identifier for this inverter, for example inverter-a. It gives this unit its own namespace for its safety latch, heartbeat and write bookkeeping in the add-on's state file, so two units may not share one."
description: "A short, unique name for this inverter, for example inverter-a."
setpoint_entity:
name: "Setpoint entity"
description: "This inverter's own ESPHome number.*_goodwe_setpoint_w. Two entries may not name the same one - two writers on one actuator is refused at startup."
description: "This inverter's own ESPHome setpoint entity. Two entries may not use the same one."
max_w:
name: "Inverter rating (W)"
description: "The supervised ceiling on what may be written to THIS inverter; the site's ceiling is the sum over the list. Required of every entry: an entry without one would have to borrow a rating, and judging one inverter against another unit's numbers is exactly the fault this per-entry list exists to remove."
description: "The most power the add-on may ask of this inverter. The site total is the sum of the list."
bms_soc_floor:
name: "Battery cut-out level (%)"
description: "The percent at which THIS pack's own battery management system cuts out. Read it off the pack that is actually installed (register 0x0560) before that pack is ever commanded; do not copy another unit's figure. Required of every entry, deliberately: an optional one would be a defaulted one, and one default for two batteries of different models protects the shallower pack with the deeper pack's number."
description: "The level at which this battery's own management system stops giving power. Read it off the battery that is installed."
soc_entity:
name: "Battery charge level entity (%) (optional)"
description: "This pack's own state of charge. Leave it empty if this unit reports none - that is what it means, and it never falls back to another unit's sensor."
name: "Battery level entity (%) (optional)"
description: "This battery's own level sensor. Leave it empty if this inverter has none."
batt_entity:
name: "Battery power entity (W) (optional)"
description: "This unit's own battery power, positive while discharging. Leave it empty if this unit reports none; nothing is inherited from another entry."
description: "This inverter's own battery power sensor. Leave it empty if it has none."
batt_invert:
name: "Battery power sign is reversed (optional)"
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
description: "Switch this on if this inverter's sensor shows giving power as a negative number."
capacity_kwh:
name: "Usable battery capacity (kWh) (optional)"
description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in."
description: "This battery's usable capacity from its datasheet. Fill it in on every inverter or on none. The add-on then shares the work by the energy each battery has left."
estop_fitted:
name: "Hardware · RS485 emergency stop is fitted"
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
description: "Switch this on if the emergency stop is installed. It only changes a warning on the status page."
pv_entity:
name: "Hardware · PV production entity (W)"
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING - if yours reads negative while producing, list it under 'sign is reversed' below. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
name: "Hardware · Solar production entity (W)"
description: "Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control."
pv_entities:
name: "Hardware · PV production entities, one per inverter (W)"
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page names which entity is missing, why, and what the others reported. Leave it empty and nothing is read, nothing is published and nothing is shown."
name: "Hardware · Solar production entities, per inverter (W)"
description: "Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded."
pv_invert_entities:
name: "Hardware · PV entities whose sign is reversed"
description: "Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a power clamp in the PV circuit - read NEGATIVE while producing and a few watts POSITIVE at night. Add each such entity here, spelled exactly as it is above, and its sign is flipped before it is added up; leave the others out. The status page marks every flipped entity. History recorded before you set this keeps the old sign."
name: "Hardware · Reversed-sign solar entities"
description: "Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up."
meter_source:
name: "Meter · Where the meter reading comes from"
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
description: "How the add-on reads your meter. 'off' uses “Grid power entity” below, and only homewizard_local can spot a stuck meter."
meter_entity:
name: "Meter · Grid power entity (W)"
description: "The net grid power sensor, used when the meter source is 'off'. POSITIVE MUST MEAN DRAWING FROM THE GRID. Get this the wrong way round and the loop drives the battery in the wrong direction; use the sign switch below rather than a template sensor."
description: "Used when the meter source is 'off'. Positive must mean drawing from the grid."
meter_invert:
name: "Meter · Grid power sign is reversed"
description: "Switch this on if your grid sensor reports drawing from the grid as a negative number."
description: "Switch this on if your grid sensor shows drawing from the grid as a negative number."
meter_phases:
name: "Meter · Number of phases (1 or 3)"
description: "Must match your connection. A telegram whose phase count disagrees is rejected as it arrives and logged, so a wrong value shows up straight away as 'no telegrams accepted' rather than as a quietly wrong number. The range accepts 2, but that is not a real Belgian connection."
description: "Must match your grid connection. Meter readings with a different number of phases are rejected."
p1_host:
name: "Meter · HomeWizard P1 address"
description: "For meter source homewizard_local only: the meter's own address on your network, as host or host:port, for example 192.168.2.250."
description: "For homewizard_local only. The meter's address on your network, as host or host:port."
meter_poll_s:
name: "Meter · Seconds between meter polls"
description: "For meter source homewizard_local only. 5 s is the meter's own update rate, so there is nothing to gain below it. It must stay well under the staleness limit below - the reading is never fresher than this interval - and the add-on refuses to start if it is at or above that limit."
name: "Meter · Time between meter reads (s)"
description: "For homewizard_local only. The meter updates every 5 s, so lower gains nothing. Keep it well below “Reading too old after”."
meter_max_age_s:
name: "Meter · A reading counts as stale after (s)"
description: "Beyond this the grid figure counts as missing. On its own it does not command 0 W: the 'inputs missing' timer under Advanced starts only after this one has expired, and the two stack. 30 s is six polls of headroom at the default poll interval."
name: "Meter · Reading too old after (s)"
description: "After this a meter reading counts as missing. The “Missing inputs time limit” starts only after this one runs out."
meter_mqtt_topic:
name: "Meter · MQTT topic"
description: "For meter source mqtt_p1 only: the topic the P1 readings are published on."
description: "For mqtt_p1 only. The topic your meter readings are published on."
p1_import_entity:
name: "Meter · Import register entity (W)"
description: "The UNSIGNED consumption register. Do not point this at a signed template sensor: the add-on works out net power itself as import minus export, so the sign convention lives in tested code rather than in unreviewed YAML."
name: "Meter · Grid import entity (W)"
description: "The sensor for power drawn from the grid, never negative. The add-on works out the net value itself."
p1_export_entity:
name: "Meter · Export register entity (W)"
description: "The UNSIGNED injection register, the counterpart of the import register above. Same rule: no signed template sensor."
name: "Meter · Grid export entity (W)"
description: "The sensor for power fed into the grid, never negative. It pairs with “Grid import entity”."
p1_net_entity:
name: "Meter · Signed net power entity (W)"
description: "For meter source ha_signed only. ONE signed sensor: positive is drawn from the grid, negative is fed back into it. Do not split it into two template sensors - the add-on does that split itself, so that it is tested."
description: "For ha_signed only. One sensor that is positive when drawing from the grid and negative when feeding in."
p1_phase_import_entities:
name: "Meter · Per-phase import entities (L1, L2, L3)"
description: "Optional, in L1..L3 order. Needed for the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases above."
name: "Meter · Import entities per phase (L1-L3)"
description: "Optional, in L1, L2, L3 order. Needed for the peak limit on a three-phase connection, one entry per phase."
p1_phase_export_entities:
name: "Meter · Per-phase export entities (L1, L2, L3)"
description: "Optional, in L1..L3 order, alongside the per-phase import entities above. The number of entries must equal the number of phases."
name: "Meter · Export entities per phase (L1-L3)"
description: "Optional, in L1, L2, L3 order, next to the import entities per phase. One entry per phase."
p1_phase_net_entities:
name: "Meter · Per-phase signed net entities (L1, L2, L3)"
description: "For meter source ha_signed only. Optional, in L1..L3 order, each signed the same way as the net entity above. Same job as the per-phase import and export entities: the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases."
name: "Meter · Net entities per phase (L1-L3)"
description: "For ha_signed only. Optional, in L1, L2, L3 order, signed like “Signed net power entity”, one entry per phase."
max_w:
name: "Limits · Inverter rating (W)"
description: "THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may ever be commanded, in watts. Set it to what your inverter is actually rated for, start low and raise it once the site has been commissioned. It is not read at all when the inverter list is filled in - each entry then carries its own, and the site ceiling is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not."
description: "Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning."
bms_soc_floor:
name: "Limits · Battery cut-out level (%)"
description: "MEASURED, not chosen: the percent at which the pack's OWN battery management system stops discharging, whatever this add-on is set to. Register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure. It is here so the add-on can refuse a capacity-guard floor at or below it: such a floor could never bind, the BMS would win first, and the status page would report an energy backstop that does not exist. Not read when the inverter list is filled in - each entry then states its own pack's cut-out."
description: "The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure."
peak_cap_w:
name: "Limits · Capacity-tariff target (W)"
description: "THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tries to stay under. Take it from your grid contract - in Belgium it is the capacity-tariff peak your distribution charge is billed on."
name: "Limits · Peak limit (W)"
description: "Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract."
peak_forecast_entity:
name: "Limits · Quarter-hour demand forecast entity (W)"
description: "Optional. A forecast of the current billing quarter's average demand. Leave it empty and there is no capacity cap at all."
name: "Limits · 15-minute demand forecast entity (W)"
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
peak_soc_floor:
name: "Limits · Peak guard will not discharge below (%)"
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
name: "Limits · Peak reserve (%)"
description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged."
price_provider:
name: "Tariff · Dynamic price source"
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed. ⚠️ THREE IDS IN THIS LIST ARE WITHDRAWN AND WILL NOT START: engie_dynamic, totalenergies_dynamic and eneco_eddy. Their endpoints do not exist and never did, so those plugins never once fetched a price - a site set to one of them has silently been running on the fixed or day/night tariff below, or on nothing, since the day it was set. They are still offered here only so that such a site gets that explanation at startup instead of an unreadable schema error. Choose generic_dayahead for the wholesale curve with a flat markup, custom_formula to enter your own contract's B x index + A coefficients off your tariff card, manual_csv for a file you write yourself, or off."
description: "Where prices come from. Choosing a source starts the price fetch, even with trading off."
price_markup_eur_kwh:
name: "Tariff · Supplier markup (EUR/kWh)"
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under manual_csv, whose figures are all-in, it is ignored, and logged as ignored, never applied twice."
description: "Your supplier's margin. It is added only when the price source does not already include it."
price_taxes_eur_kwh:
name: "Tariff · Distribution, levies and VAT (EUR/kWh)"
description: "Everything on your bill that is not the energy itself - distribution, levies, excise and VAT - per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them. ⚠️ THIS NUMBER IS NOT A PREFERENCE AND IT IS NOT YOUR INVERTER'S. It ships at 0.11736, DERIVED in TARIFF-18 from the Eneco residential card for Flanders of September 2026 including 6% VAT: Fluvius distribution for Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. The energiebijdrage is 0 since 1 August 2026 - it was merged into the excise, so a figure copied from an older guide would count it twice. ⚠️ CHECK IT AGAINST YOUR OWN DSO. Only the distribution row moves: across the eight Fluvius DSOs it runs from 5,28 c/kWh in Midden-Vlaanderen to 6,70 in West-Vlaanderen, so the Flemish total runs from about 0.117 to 0.131. In Wallonia or Brussels the distribution tariff has a separate day and night rate and does not belong in this field at all - use the levies-by-time-of-day option below instead. ⚠️ CHANGING IT MOVES NO TRADING DECISION: the same amount is added to every hour, so it cancels out of both the cheapest hour and the spread. What it does move is every price you are shown, and the rule that refuses to discharge into an hour whose all-in price is below zero."
description: "Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates."
export_price_eur_kwh:
name: "Tariff · Injection price (EUR/kWh)"
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
description: "What you are paid per kWh fed into the grid, all in. It may be negative."
static_profile:
name: "Tariff · Fixed or day/night tariff (one line of JSON)"
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
name: "Tariff · Fixed or day/night tariff"
description: "Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback."
price_taxes_profile:
name: "Tariff · Levies and VAT by time of day (one line of JSON)"
description: "Leave this empty unless your network tariff really does charge a different day and night rate - in Flanders it does not, Fluvius distribution is one rate, and the flat figure above is correct. In Wallonia and Brussels it does: ORES charges 13,27 c/kWh by day against 7,39 at night, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10. Written as a single line of JSON in exactly the same shape as the fixed or day/night tariff above, with the same rule that the periods must cover every day type once with no gap and no overlap. ⚠️ THE NUMBERS HERE ARE THE LEVIES ALONE, NOT A FULL PRICE - the add-on adds them to whatever your price source publishes, exactly as it adds the flat figure. Why it matters: a flat figure is the same in every hour, so it moves every hour equally and cannot change which hour is cheapest - and a night network rate is cheap in the same hours the market is cheap, so flattening it makes the add-on think the day is flatter than it is and turn down battery cycles that would have paid for themselves. Set this or the flat figure above, not both: the add-on refuses to start if the two disagree."
name: "Tariff · Day/night levies and VAT"
description: "Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both."
price_formula:
name: "Tariff · Your own tariff formula (one line of JSON)"
description: "For price source custom_formula only: your own contract's two coefficients, written as a single line of JSON. Your supplier's tariff card prints the formula as B x index + A - those two numbers exist only on your card, there is no public index of them anywhere, and you are the only person who can read them. Type what the card prints, IN THE UNITS THE CARD PRINTS: every unit conversion and the VAT step are the add-on's job, because those are the two places a human silently loses a factor of 10, 100 or 1.06. Example: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ THE card BLOCK IS REQUIRED AND IT IS WHAT MAKES THIS SAFE: your card also prints the reference index it assumed and the price that comes out, so the add-on recomputes that price from your numbers and REFUSES TO START if it does not come out, telling you by how much. That catches a wrong unit, VAT left out or counted twice, a transposed digit, and the expensive one - network charges folded into the offset, which the add-on would then bill you for a second time. ⚠️ AND ITS LIMIT, IN THE SAME BREATH: it checks your coefficients against your card, not your card against reality, so two numbers copied off the wrong product agree with each other and pass. Fixed numbers only - no expression of any kind is evaluated, and an unrecognised field is refused rather than ignored."
name: "Tariff · Your own tariff formula"
description: "For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card."
price_export_formula:
name: "Tariff · Your own INJECTION formula (one line of JSON)"
description: "Optional, and only if your supplier pays you a price that moves with the market. Your tariff card has a SECOND formula for what you are paid for the electricity you export, and it is not the first one with a minus in front: Eneco prints `0,1 x BELPEX-H - 1,188` c/kWh for injection against `0,102 x ...` for consumption, and Aspiravi `0,07 x Belpex - 2`. Written in exactly the same shape as your own tariff formula above. ⚠️ INJECTION IS EXEMPT FROM VAT in Belgium while consumption is at 6%, so vat_pct here is normally 0 - that is the one field people copy across from the other formula and get wrong. ⚠️ THE PRICE THIS GIVES CAN BE NEGATIVE, and that is not an error: Eneco pays below zero whenever BELPEX-H is under 11,88 EUR/MWh, which is the middle of a sunny day - you can be charged to export. The add-on accepts that rather than hiding it. The card block is OPTIONAL here and required above, because Eneco prints the injection formula with NO example price beside it and there would be nothing to check your numbers against; if your card does print one, fill it in and the add-on will refuse to start unless your numbers reproduce it. ⚠️ WHAT THIS DOES NOT DO: no part of the add-on trades on an injection price today - it charges cheap to avoid buying dear, and both sides of that comparison are consumption prices. This is what you are shown, and what a future export strategy would read. Leave it empty and the flat injection price above is used, which is what every installation does now."
name: "Tariff · Your own injection formula"
description: "Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price."
price_csv_path:
name: "Tariff · Price CSV file"
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
description: "For manual_csv only. Where in /share the add-on reads your price file."
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere. WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN: the add-on adds nothing to it, not even the date range it normally asks its own feed for, so an override gets whatever horizon that URL returns - often a single day, with the rest of the plan carried over from yesterday and excluded from price arbitrage. A warning in the log says so on every price refresh. Clear this option to get the full horizon back."
description: "Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day."
price_horizon_h:
name: "Tariff · How far ahead to fetch prices (hours)"
name: "Tariff · Price look-ahead (hours)"
description: "How many hours of prices to ask the source for."
price_timeout_s:
name: "Tariff · Price request timeout (s)"
description: "How long a single request to the price source may take before it is given up on."
description: "How long one request to the price source may take before it is given up."
price_now_entity:
name: "Tariff · Current price entity (older price path)"
description: "The older way of getting a price, kept because retiring it would take the maintenance cycle's only price signal away from a live installation. The add-on reads this and the average below and derives exactly one thing from them - whether now is cheaper than average - which only the maintenance cycle's cheap-window charging uses. It may not be combined with a dynamic price source."
name: "Tariff · Current price entity (older)"
description: "The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source."
price_avg_entity:
name: "Tariff · Average price entity (older price path)"
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
name: "Tariff · Average price entity (older)"
description: "The average the current price is compared with. Use it only with the current price entity, not with a dynamic price source."
arbitrage_enabled:
name: "Tariff · Trade on the price spread"
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. ⚠️ LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF. A price source selected above is fetched, checked and recorded either way; this switch decides only whether the add-on ACTS on those prices. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
description: "Charge in the cheapest hour and draw the battery down in the dearest, when the spread covers the cost. Turning it off does not turn the price feed off."
cycle_cost_threshold_eur:
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
name: "Tariff · Battery cycle cost (EUR/kWh)"
description: "What one full battery cycle costs per kWh. A smaller price spread is not traded, and the value is a product choice, not a measured one."
arbitrage_soc_floor:
name: "Tariff · Trading may not discharge below (%)"
description: "The reserve price trading may not spend. Deliberately not the same key as the maintenance floor: pointing a trading strategy at the monthly balancing cycle's numbers would couple two unrelated policies through one setting."
name: "Tariff · Trading keeps at least (%)"
description: "Price trading will not draw the battery below this level. It is separate from the maintenance settings."
arbitrage_soc_ceiling:
name: "Tariff · Trading may not charge above (%)"
description: "The headroom price trading may not fill. The counterpart of the trading floor above, and kept separate from the maintenance target for the same reason."
name: "Tariff · Trading charges at most (%)"
description: "Price trading will not charge the battery above this level."
maintenance_enabled:
name: "Maintenance · Run the monthly battery cycle"
description: "A deliberate deep discharge followed by a full recharge and a hold, so the battery management system can recalibrate its charge estimate and balance the cells. Off by default."
description: "Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default."
maintenance_interval_days:
name: "Maintenance · Minimum days between cycles"
description: "The shortest gap between two maintenance cycles."
maintenance_start_hour:
name: "Maintenance · Hour of day a due cycle starts"
description: "Local hour, 0 to 23, at which a cycle that has become due begins."
name: "Maintenance · Start hour"
description: "The local hour, 0 to 23, at which a due cycle starts."
maintenance_discharge_w:
name: "Maintenance · Discharge rate (W)"
description: "How fast the cycle drains the pack; the surplus is exported. Above the inverter rating it is CLAMPED to the rating rather than refused - the cycle still completes, just more slowly, and the status page says so. It ships at 2000 to match the shipped rating; raise the rating first, then this."
name: "Maintenance · Draw-down rate (W)"
description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer."
maintenance_charge_w:
name: "Maintenance · Recharge rate (W)"
description: "⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses to start otherwise. Above it, the cheap-window charge shaping asks for more than the supervised limit allows, the arbiter resolves that contradiction to 0 W, and maintenance would silently never charge in a cheap window on a default installation. It is also capped again by whatever headroom the peak guard leaves."
description: "How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start."
maintenance_soc_floor:
name: "Maintenance · Discharge down to (%)"
description: "The depth the monthly recalibration deliberately goes to, once a month, on purpose. ⚠️ THIS IS NOT THE PEAK GUARD'S FLOOR: that one is the depth a money-saving discharge may never reach on any day. The right values genuinely differ - 11 here against 20 there - which is why they are two separate settings and must not be merged."
name: "Maintenance · Draw down to (%)"
description: "The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose."
maintenance_soc_target:
name: "Maintenance · Recharge up to (%)"
description: "The level the recharge phase aims for before the hold begins."
description: "The level the recharge aims for before the hold starts."
maintenance_hold_min:
name: "Maintenance · Hold at full for (minutes)"
description: "How long to sit at the target so the battery management system can finish balancing the cells."
description: "How long the battery stays full so its cells can balance."
sign_disagree_w:
name: "Safety · Sign watchdog threshold (W) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and the grid reading must exceed this before their opposite signs count as a disagreement at all. On the captured healthy day (2026-08-23) sign agreement over the gated samples is 93.4 %; on the captured fault day (2026-08-20) the clusters run 0.0-10.5 % with opposed runs up to 1752 s. There is no reason to move this without a new capture that says so. It must stay below the inverter rating or the watchdog can never fire, and the add-on refuses that configuration."
name: "Safety · Wrong-direction threshold (W)"
description: "Part of the safety stop for an inverter pushing the wrong way. It counts only when command and meter reading both exceed this. Measured value, leave it unchanged."
sign_disagree_s:
name: "Safety · Sign watchdog hold (s) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter must stay opposed, continuously, before the add-on commands 0 W and latches until an operator starts control again. 180 s sits in the middle of a very wide measured gap: the longest legitimate opposed run on the healthy day is 30 s, while the fault day still trips 13 times at 300 s. Do not change it without a new capture."
name: "Safety · Wrong-direction time (s)"
description: "How long a wrong direction may last before a safety stop sets every inverter to 0 W. Measured value, leave it unchanged."
unit_sign_disagree_w:
name: "Safety · Per-inverter sign threshold (W) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothing at all. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS on the two-master rig (SAFETY-30), in which nothing was faulty, swept across the whole range this add-on accepts for loop gain and slew rate. 250 W is exactly half the saturation threshold, so the smallest divergence this can see is precisely the gap at which the control law itself already says the inverter is not following. Do not change it without re-deriving it. It must stay below the inverter rating or it can never fire."
name: "Safety · Per-inverter wrong-direction threshold (W)"
description: "For two or more inverters: the wrong-direction threshold for each inverter. Derived value, leave it unchanged."
unit_sign_disagree_s:
name: "Safety · Per-inverter sign hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs produce (10.0 s), 2.8 times the firmware's own 30 s link veto, and under half the 180 s the site-wide check takes. Do not change it without re-deriving it."
name: "Safety · Per-inverter wrong-direction time (s)"
description: "For two or more inverters: how long one inverter may push the wrong way before it is set to 0 W. Derived value, leave it unchanged."
site_sign_disagree_w:
name: "Safety · Site-wide sign threshold (W) — measured, do not change"
description: "TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter threshold, CARRIED ACROSS DELIBERATELY (SAFETY-30): the argument behind it does not depend on how many inverters there are - near zero the sign is noise, the deadband is 15 W, and there is one meter whatever is behind it. Do not change it without a capture that says so."
name: "Safety · Site wrong-direction threshold (W)"
description: "For two or more inverters: the wrong-direction threshold for the whole site. Measured value, leave it unchanged."
site_sign_disagree_s:
name: "Safety · Site-wide sign hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30), and deliberately NOT carried across from the single-inverter 180 s. On those runs the worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, because the command has to travel the whole site rail at a slew rate that is not the site's - so carrying 180 s across would have shipped a false trip reachable at the lowest gain and slew with an ordinary 2.2 kW load reversal. 325 s is 1.51 times that, still inside the 900 s billing quarter, and still inside every episode of the captured fault day. Do not change it without re-deriving it."
name: "Safety · Site wrong-direction time (s)"
description: "For two or more inverters: how long the whole site may push the wrong way before a safety stop. Derived value, leave it unchanged."
meter_freeze_s:
name: "Safety · Frozen-meter alarm after (s) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answering with an UNCHANGED reading, while the command stands at the rail, before the add-on raises an alarm. Over 2399 s of this house at its settled operating point - 1194 polls, 735 of them repeating the previous reading - the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that and still inside the 900 s billing quarter. ALARM ONLY: it does not command 0 W and does not latch. ⚠️ Only the homewizard_local meter source can see this at all; on every other source the status page says NOT COVERED rather than armed. Do not change it without re-deriving it."
name: "Safety · Stuck meter alarm after (s)"
description: "An alarm when the meter repeats one reading this long while the command is at full power, with homewizard_local only. Measured value, leave it unchanged."
circulation_w:
name: "Safety · Circulating-power threshold (W) — measured, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identically zero. Two inverters pushing against each other net out to zero at the meter and are invisible to every other guard in this add-on. MEASURED ON 924 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-31): the worst legitimate opposition collapses from 505 s at a 100 W threshold to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below - under it, ordinary operation holds the sensor over the threshold for minutes and nothing shorter than the billing quarter could tell that from a fault. 700 W is 1.75 times the knee, and the extra margin is there because the second inverter's ramp and lag are still copied from the first one rather than measured. Do not change it without re-deriving it."
name: "Safety · Opposing inverters threshold (W)"
description: "For two or more inverters: how much power one may push against another before it counts. Measured value, leave it unchanged."
circulation_hold_s:
name: "Safety · Circulating-power hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
name: "Safety · Opposing inverters time (s)"
description: "For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged."
delivery_hold_s:
name: "Safety · Not-delivering report after (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
name: "Safety · Not delivering alarm after (s)"
description: "For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged."
shadow_mode:
name: "Safety · Shadow mode — run every decision, command nothing"
description: "Runs the whole decision path — strategies, arbiter, clamps and guards — and sends NOTHING to the inverter. Every inverter is held at 0 W, refreshed on the ordinary heartbeat, and the status page says plainly that the battery is not being controlled; the decision it WOULD have commanded, and why, is on the status page and in the action log. This is how you watch a site for a day before letting it trade. 'auto' (the shipped value) means shadow mode while control is stopped and ordinary control once you press Start — so a NEW install observes before it ever commands anything, and an install that is already controlling is not changed by one watt. 'on' means shadow mode always, whatever the Start / stop button says: that is the setting to pick when you want the loop and the strategies running on a live site with nothing reaching the battery. 'off' is ordinary control. ⚠️ While this is on the battery is NOT being regulated — the grid is not held at zero, no arbitrage trade is executed and no capacity peak is shaved. ⚠️ A failsafe latch that was already up stays up: shadow mode does not clear it, and the banner names it."
name: "Safety · Shadow mode"
description: "The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never."
auto_start:
name: "Advanced · Start controlling automatically on boot"
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
name: "Advanced · Start control on boot"
description: "Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart."
gain:
name: "Advanced · Loop gain"
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
name: "Advanced · Control gain"
description: "How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it."
slew_w:
name: "Advanced · Largest change per cycle (W)"
description: "How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW THE INVERTER RATING and is the recommended setting: the limit becomes about a ninth of the rating, so the loop takes the plant's own MEASURED settling time to cross its whole output range and the number cannot be left behind when the rating changes. DERIVED (SAFETY-39) from an ~18 s measured step response against a 2 s meter tick. A fixed number is accepted and is never silently reduced, but a number faster than the rating allows is refused at startup - at 1000 W against a 5000 W rating this loop drove the meter from 3.4 kW of export to 2.8 kW of import on 2026-09-05. Lower is always accepted."
description: "How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended."
deadband_w:
name: "Advanced · Deadband (W)"
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
name: "Advanced · Ignore errors below (W)"
description: "Errors smaller than this are ignored. Keep it small, since any resting point inside it holds."
target_grid_w:
name: "Advanced · Resting point at the meter (W)"
description: "What the meter should sit at when nothing is happening; negative means a slight export. -10 W is deliberate: resting at 0 W leaks about half the deadband in billed import all day - roughly 0.2 to 0.35 kWh at a 15 W deadband - and biasing the rest point below zero moves that leak into the export register, which is not billed. It costs about this many watts of given-away export, so keep it small."
name: "Advanced · Meter resting point (W)"
description: "What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day."
step_w:
name: "Advanced · Setpoint quantisation (W)"
description: "The setpoint is rounded to a multiple of this before it is written, so the inverter is not handed a new number for every watt of noise."
name: "Advanced · Command rounding (W)"
description: "The command is rounded to a multiple of this, so noise does not change it every cycle."
saturation_w:
name: "Advanced · Saturation threshold (W)"
description: "The gap between what was commanded and what the inverter delivers that counts as 'this inverter is at a limit'. ⚠️ The per-inverter sign threshold under Safety is derived as exactly half of this, so moving this moves that measurement's argument with it."
name: "Advanced · At-limit shortfall (W)"
description: "When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this."
saturation_cycles:
name: "Advanced · Saturated cycles before freezing (count)"
description: "How many consecutive saturated cycles before the loop stops winding up. ⚠️ Do not set this to 1: a single noisy cycle would then freeze the loop."
name: "Advanced · Cycles at limit before freezing"
description: "How many cycles in a row at the limit before control stops building up error. Do not set it to 1."
integrator_max_w:
name: "Advanced · Integrator bound (W, 0 means the same as the rating)"
description: "Caps how much stale error can be waiting to unwind when the sign flips. MEASURED on the closed-loop simulator with a 4000 W load dropped to zero: at a bound equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating it is 1800 W. ⚠️ Do not raise it above the inverter rating - the output clamp already bounds what reaches the wire, so extra headroom buys nothing but wrong-direction watts after every saturation event. Lowering it below the rating is the useful direction, and the only reason this is a separate setting."
name: "Advanced · Catch-up limit (W)"
description: "Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating."
heartbeat_s:
name: "Advanced · Setpoint refresh interval (s)"
description: "How often the setpoint is rewritten even when it has not changed. It must stay well under the firmware's own watchdog, which vetoes a link that has been quiet for 30 s."
name: "Advanced · Command refresh interval (s)"
description: "How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit."
stale_input_s:
name: "Advanced · Command 0 W after inputs missing for (s)"
description: "How long the inputs may be missing before the add-on commands 0 W. With a P1 meter this clock starts only AFTER the meter staleness limit has already expired, so the two stack: the total is longer than this number on its own."
name: "Advanced · Missing inputs time limit (s)"
description: "How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”."
cycle_floor_s:
name: "Advanced · Longest gap between control cycles (s)"
description: "A deadline on the gap between cycles, reset by every cycle. On a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than about 10 s, and keep it under the meter staleness limit - past that there is no reading left to evaluate anyway."
name: "Advanced · Longest time between cycles (s)"
description: "A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s."
log_level:
name: "Advanced · Log level"
description: "How much detail the add-on writes to its log. 'info' is the shipped setting; 'debug' and 'trace' are for diagnosing a problem and are noisy."
description: "How much detail the add-on writes to its log. 'debug' and 'trace' are for finding a problem."
+149 -150
View File
@@ -16,267 +16,266 @@
# volgorde van het `schema:`-blok van config.yaml; de volgorde hieronder is
# alleen een spiegel daarvan, zodat beide bestanden gelijk lezen.
#
# ⚠️ AFGELEIDE CONSTANTEN VERMELDEN WAAR ZE VANDAAN KOMEN. Het tabblad toont een
# getal dat uit 1890 gesloten-lustests komt en het omvormervermogen van de
# eigenaar als hetzelfde soort veld. De beschrijving is de enige plek waar dat
# verschil gezegd kan worden.
# Namen en beschrijvingen volgen STYLE.md (UX-08): een kort label met de
# eenheid, en een of twee eenvoudige zinnen. Waar een gemeten of afgeleide
# waarde vandaan komt, staat in DOCS.md onder "For installers".
configuration:
setpoint_entity:
name: "Hardware · Setpoint-entiteit van de omvormer"
description: "De ESPHome number-entiteit waar deze add-on het vermogen naartoe schrijft, meestal number.*_goodwe_setpoint_w. Dit is het enige wat de add-on aanstuurt. Laat leeg als u hieronder de lijst met omvormers invult: een installatie met een lijst mag niet óók één losse aansturing hebben, en de add-on start dan niet op."
description: "De ESPHome-entiteit waar de add-on het vermogen van de omvormer naartoe schrijft. Laat dit leeg als u de lijst met omvormers invult."
soc_entity:
name: "Hardware · Entiteit met het laadniveau van de batterij (%)"
description: "De sensor die de laadtoestand van de batterij in procent doorgeeft. Gebruik de meting van de ESP32 zelf, geen waarde die elders berekend wordt. De onderhoudscyclus en alle ondergrenzen voor het laadniveau lezen deze sensor."
name: "Hardware · Entiteit laadniveau (%)"
description: "De sensor met het laadniveau van de batterij in procent. Gebruik de meting van de stuurmodule zelf."
batt_entity:
name: "Hardware · Entiteit met het batterijvermogen (W)"
description: "De sensor die het batterijvermogen van deze omvormer doorgeeft, positief bij ontladen. Op deze installatie is dat holding register 0x0518, uitgelezen door de ESP32. Zowel de tekenbewaking als het alarm voor rondpompend vermogen beoordelen de omvormer aan de hand van deze meting."
name: "Hardware · Entiteit batterijvermogen (W)"
description: "De sensor met het batterijvermogen van deze omvormer. Positief moet betekenen dat de batterij stroom levert."
batt_invert:
name: "Hardware · Teken van het batterijvermogen omkeren"
description: "Zet dit alleen aan als uw sensor ontladen als een negatief getal doorgeeft. De add-on gaat ervan uit dat positief ontladen betekent."
name: "Hardware · Teken batterijvermogen omkeren"
description: "Zet dit alleen aan als uw sensor het leveren van stroom als een negatief getal toont."
inverters:
name: "Hardware · Lijst met omvormers (leeg laten bij één omvormer)"
description: "Laat dit leeg tenzij er echt meer dan één omvormer staat. LEEG BETEKENT NIET 'nog niet ingesteld': het betekent dat de vier entiteiten hierboven, samen met het omvormervermogen en de uitschakelgrens van de batterij onder Grenzen, de ene omvormer beschrijven die u hebt. Dat is wat elke bestaande installatie leest, zonder dat iemand iets hoeft aan te passen. Vult u de lijst wel in, dan VERVANGT die de instellingen hierboven in plaats van ze aan te vullen, en wordt er niets overgenomen: elk item moet zijn eigen aansturing, zijn eigen vermogen en zijn eigen uitschakelgrens vermelden, zodat geen enkele omvormer ooit beoordeeld wordt op de cijfers van een andere. De losse setpoint-entiteit hierboven moet dan leeg blijven."
name: "Hardware · Lijst met omvormers"
description: "Laat dit leeg als u één omvormer hebt. Hebt u er meer, voeg dan per omvormer een item toe. De lijst vervangt dan de instellingen hierboven."
fields:
name:
name: "Naam"
description: "Een korte aanduiding voor deze omvormer, bijvoorbeeld inverter-a. Ze geeft deze omvormer een eigen naamruimte voor haar veiligheidsvergrendeling, hartslag en schrijfadministratie in het statusbestand van de add-on, dus twee omvormers mogen er niet één delen."
description: "Een korte, unieke naam voor deze omvormer, bijvoorbeeld inverter-a."
setpoint_entity:
name: "Setpoint-entiteit"
description: "De eigen ESPHome-entiteit number.*_goodwe_setpoint_w van deze omvormer. Twee items mogen niet dezelfde entiteit noemen - twee schrijvers op één aansturing wordt bij het opstarten geweigerd."
description: "De eigen ESPHome-setpoint-entiteit van deze omvormer. Twee items mogen niet dezelfde gebruiken."
max_w:
name: "Vermogen van deze omvormer (W)"
description: "De bewaakte bovengrens van wat er naar DEZE omvormer geschreven mag worden; het plafond van de installatie is de som over de lijst. Verplicht bij elk item: een item zonder deze waarde zou een vermogen moeten lenen, en een omvormer beoordelen op de cijfers van een andere is precies de fout die deze lijst per omvormer moet wegnemen."
description: "Het hoogste vermogen dat de add-on aan deze omvormer mag vragen. Het totaal van de installatie is de som van de lijst."
bms_soc_floor:
name: "Uitschakelniveau van de batterij (%)"
description: "Het percentage waarbij het batterijmanagementsysteem van DEZE batterij uitschakelt. Lees dit af van de batterij die er werkelijk staat (register 0x0560) voordat die batterij ooit aangestuurd wordt; neem geen waarde van een andere omvormer over. Bewust verplicht bij elk item: optioneel maken zou neerkomen op een standaardwaarde, en één standaardwaarde voor twee verschillende batterijen beschermt de kleinste batterij met het getal van de grootste."
description: "Het niveau waarop het beheersysteem van deze batterij stopt met leveren. Lees het af van de batterij die er staat."
soc_entity:
name: "Entiteit met het laadniveau (%) (optioneel)"
description: "Het laadniveau van deze batterij zelf. Laat leeg als deze omvormer dat niet doorgeeft - dat is wat het betekent, en er wordt nooit teruggevallen op de sensor van een andere omvormer."
name: "Entiteit laadniveau (%) (optioneel)"
description: "De eigen sensor voor het laadniveau van deze batterij. Laat leeg als deze omvormer er geen heeft."
batt_entity:
name: "Entiteit met het batterijvermogen (W) (optioneel)"
description: "Het batterijvermogen van deze omvormer zelf, positief bij ontladen. Laat leeg als deze omvormer dat niet doorgeeft; er wordt niets van een ander item overgenomen."
name: "Entiteit batterijvermogen (W) (optioneel)"
description: "De eigen sensor voor het batterijvermogen van deze omvormer. Laat leeg als die er niet is."
batt_invert:
name: "Teken van het batterijvermogen omkeren (optioneel)"
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
name: "Teken batterijvermogen omkeren (optioneel)"
description: "Zet dit aan als de sensor van deze omvormer leveren als een negatief getal toont."
capacity_kwh:
name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)"
description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren."
name: "Bruikbare capaciteit (kWh) (optioneel)"
description: "De bruikbare capaciteit van deze batterij, van het datablad. Vul ze bij elke omvormer in of bij geen. Dan verdeelt de add-on het werk volgens de energie in elke batterij."
estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst"
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
pv_entity:
name: "Hardware · Zonneproductie-entiteit (W)"
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN - leest de uwe negatief tijdens productie, zet hem dan ook bij 'teken omgekeerd' hieronder. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
name: "Hardware · Entiteit zonneproductie (W)"
description: "Optioneel. De vermogenssensor van uw zonne-omvormer, positief tijdens productie. De add-on houdt ze bij en regelt er niet mee."
pv_entities:
name: "Hardware · Zonneproductie-entiteiten, één per omvormer (W)"
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina noemt welke entiteit ontbreekt, waarom, en wat de andere doorgaven. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
name: "Hardware · Entiteiten zonneproductie, per omvormer (W)"
description: "Optioneel. Eén rij per zonne-omvormer, in plaats van de losse entiteit hierboven, als u er meerdere hebt. Valt er één weg, dan wordt er geen totaal bijgehouden."
pv_invert_entities:
name: "Hardware · Zonneproductie-entiteiten met omgekeerd teken"
description: "Optioneel, UITSLUITEND EEN METING. Sommige sensoren - meestal een kWh-meter of een stroomtang in het PV-circuit - lezen NEGATIEF tijdens productie en 's nachts enkele watt POSITIEF. Zet elke zulke entiteit hier, exact gespeld zoals hierboven, en haar teken wordt omgedraaid vóór het optellen; laat de andere weg. De statuspagina markeert elke omgedraaide entiteit. Geschiedenis van vóór deze instelling behoudt het oude teken."
name: "Hardware · Zonne-entiteiten met omgekeerd teken"
description: "Optioneel. Zet hier elke zonne-entiteit van hierboven die negatief leest tijdens productie, precies zo gespeld. Het teken wordt omgedraaid voor het optellen."
meter_source:
name: "Meter · Waar de meterstand vandaan komt"
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."
description: "Hoe de add-on uw meter uitleest. Met 'off' gebruikt ze de entiteit netvermogen hieronder. Alleen homewizard_local merkt een vastgelopen meter op."
meter_entity:
name: "Meter · Entiteit met het netvermogen (W)"
description: "De sensor met het netto netvermogen, gebruikt wanneer de meterbron 'off' is. POSITIEF MOET AFNAME VAN HET NET BETEKENEN. Staat dit omgekeerd, dan stuurt de regeling de batterij de verkeerde kant op; gebruik daarvoor de tekenschakelaar hieronder en geen template-sensor."
name: "Meter · Entiteit netvermogen (W)"
description: "Gebruikt als de meterbron 'off' is. Positief moet afname van het net betekenen."
meter_invert:
name: "Meter · Teken van het netvermogen omkeren"
description: "Zet dit aan als uw netsensor afname van het net als een negatief getal doorgeeft."
name: "Meter · Teken netvermogen omkeren"
description: "Zet dit aan als uw netsensor afname als een negatief getal toont."
meter_phases:
name: "Meter · Aantal fasen (1 of 3)"
description: "Moet overeenkomen met uw aansluiting. Een telegram met een ander aantal fasen wordt bij binnenkomst geweigerd en gelogd, zodat een verkeerde waarde meteen zichtbaar wordt als 'geen telegrammen aanvaard' in plaats van als een stil verkeerd getal. Het bereik aanvaardt 2, maar dat is geen echte Belgische aansluiting."
description: "Moet overeenkomen met uw aansluiting. Meterstanden met een ander aantal fasen worden geweigerd."
p1_host:
name: "Meter · Adres van de HomeWizard P1"
description: "Alleen voor meterbron homewizard_local: het adres van de meter zelf op uw netwerk, als host of host:poort, bijvoorbeeld 192.168.2.250."
description: "Alleen voor homewizard_local. Het adres van de meter op uw netwerk, als host of host:poort."
meter_poll_s:
name: "Meter · Seconden tussen twee uitlezingen"
description: "Alleen voor meterbron homewizard_local. 5 s is het tempo waarin de meter zelf ververst, dus lager instellen levert niets op. Deze waarde moet ruim onder de verouderingsgrens hieronder blijven - de meting is nooit verser dan dit interval - en de add-on start niet op als ze eraan gelijk is of erboven ligt."
name: "Meter · Tijd tussen twee uitlezingen (s)"
description: "Alleen voor homewizard_local. De meter ververst elke 5 s, lager levert niets op. Houd dit ruim onder de grens voor een te oude meting."
meter_max_age_s:
name: "Meter · Een meting is verouderd na (s)"
description: "Daarna geldt de netwaarde als ontbrekend. Op zichzelf stuurt dit nog geen 0 W aan: de teller 'invoer ontbreekt' onder Geavanceerd begint pas te lopen nadat deze verstreken is, en de twee stapelen. 30 s is zes uitlezingen speling bij het standaard uitleesinterval."
name: "Meter · Meting te oud na (s)"
description: "Daarna telt een meterstand als ontbrekend. De tijdslimiet voor ontbrekende invoer begint pas daarna te lopen."
meter_mqtt_topic:
name: "Meter · MQTT-topic"
description: "Alleen voor meterbron mqtt_p1: het topic waarop de P1-metingen gepubliceerd worden."
description: "Alleen voor mqtt_p1. Het topic waarop uw meterstanden verschijnen."
p1_import_entity:
name: "Meter · Entiteit van het afnameregister (W)"
description: "Het afnameregister ZONDER teken. Wijs dit niet aan een template-sensor mét teken toe: de add-on berekent het nettovermogen zelf als afname min injectie, zodat de tekenafspraak in geteste code zit en niet in ongecontroleerde YAML."
name: "Meter · Entiteit afname (W)"
description: "De sensor voor afname van het net, nooit negatief. De add-on berekent het nettovermogen zelf."
p1_export_entity:
name: "Meter · Entiteit van het injectieregister (W)"
description: "Het injectieregister ZONDER teken, de tegenhanger van het afnameregister hierboven. Zelfde regel: geen template-sensor mét teken."
name: "Meter · Entiteit injectie (W)"
description: "De sensor voor injectie in het net, nooit negatief. Hoort bij de entiteit afname hierboven."
p1_net_entity:
name: "Meter · Entiteit met het nettovermogen mét teken (W)"
description: "Alleen voor meterbron ha_signed. ÉÉN sensor mét teken: positief is afname van het net, negatief is injectie. Splits die niet in twee template-sensoren op - de add-on doet die splitsing zelf, juist zodat ze getest is."
name: "Meter · Entiteit nettovermogen met teken (W)"
description: "Alleen voor ha_signed. Eén sensor die positief is bij afname en negatief bij injectie."
p1_phase_import_entities:
name: "Meter · Afname-entiteiten per fase (L1, L2, L3)"
description: "Optioneel, in de volgorde L1..L3. Nodig voor de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen hierboven."
name: "Meter · Afname-entiteiten per fase (L1-L3)"
description: "Optioneel, in de volgorde L1, L2, L3. Nodig voor de piekgrens bij een driefasige aansluiting, één item per fase."
p1_phase_export_entities:
name: "Meter · Injectie-entiteiten per fase (L1, L2, L3)"
description: "Optioneel, in de volgorde L1..L3, naast de afname-entiteiten per fase hierboven. Het aantal items moet gelijk zijn aan het aantal fasen."
name: "Meter · Injectie-entiteiten per fase (L1-L3)"
description: "Optioneel, in de volgorde L1, L2, L3, naast de afname-entiteiten per fase. Eén item per fase."
p1_phase_net_entities:
name: "Meter · Netto-entiteiten per fase mét teken (L1, L2, L3)"
description: "Alleen voor meterbron ha_signed. Optioneel, in de volgorde L1..L3, elk met hetzelfde teken als de netto-entiteit hierboven. Zelfde rol als de afname- en injectie-entiteiten per fase: de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen."
name: "Meter · Netto-entiteiten per fase (L1-L3)"
description: "Alleen voor ha_signed. Optioneel, in de volgorde L1, L2, L3, met hetzelfde teken als de netto-entiteit. Eén item per fase."
max_w:
name: "Grenzen · Vermogen van de omvormer (W)"
description: "DIT IS UW GETAL, geen afgeleide waarde: de harde bovengrens van wat er ooit gestuurd mag worden, in watt. Zet het op wat uw omvormer werkelijk aankan, begin laag en verhoog het pas na de indienstname. Het wordt helemaal niet gelezen zodra de lijst met omvormers is ingevuld - elk item draagt dan zijn eigen waarde en het plafond van de installatie is hun som. Verschillende veiligheidsdrempels moeten eronder blijven; de add-on start niet op als er één niet aan voldoet."
description: "Uw eigen getal: het hoogste vermogen dat de add-on ooit aan de omvormer mag vragen. Begin laag en verhoog het na de indienstname."
bms_soc_floor:
name: "Grenzen · Uitschakelniveau van de batterij (%)"
description: "GEMETEN, niet gekozen: het percentage waarbij het batterijmanagementsysteem van de batterij ZELF stopt met ontladen, wat u hier ook instelt. Register 0x0560 leest 10 op de referentie-GW5000S-BP. Lees deze waarde af van de batterij die er werkelijk staat in plaats van dit getal over te nemen. Ze staat hier zodat de add-on een ondergrens voor de piekbewaking op of onder deze waarde kan weigeren: zo'n ondergrens kan nooit werken, het BMS wint altijd eerst, en de statuspagina zou een reserve tonen die niet bestaat. Wordt niet gelezen zodra de lijst met omvormers is ingevuld - elk item vermeldt dan de uitschakelgrens van zijn eigen batterij."
description: "Het niveau waarop het beheersysteem van de batterij zelf stopt met leveren. Lees het af van uw batterij in plaats van een getal over te nemen."
peak_cap_w:
name: "Grenzen · Streefwaarde capaciteitstarief (W)"
description: "DIT IS UW GETAL: het gemiddelde kwartiervermogen waar de piekbewaking onder probeert te blijven. Neem het over uit uw netcontract - in België is dit de kwartierpiek waarop het capaciteitstarief van uw distributiefactuur berekend wordt."
name: "Grenzen · Piekgrens (W)"
description: "Uw eigen getal: het gemiddelde vermogen per kwartier waaronder de add-on de afname houdt. Neem het over uit uw netcontract."
peak_forecast_entity:
name: "Grenzen · Entiteit met de kwartierprognose (W)"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende factuurkwartier. Laat u dit leeg, dan is er helemaal geen piekbegrenzing."
name: "Grenzen · Entiteit kwartierprognose (W)"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."
peak_soc_floor:
name: "Grenzen · Piekbewaking ontlaadt niet onder (%)"
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
name: "Grenzen · Piekreserve (%)"
description: "De piekbeperking laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."
price_provider:
name: "Tarief · Bron van dynamische prijzen"
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten. ⚠️ DRIE IDS IN DEZE LIJST ZIJN INGETROKKEN EN STARTEN NIET MEER: engie_dynamic, totalenergies_dynamic en eneco_eddy. Hun endpoints bestaan niet en hebben nooit bestaan, dus die plugins hebben nooit één prijs opgehaald - een installatie die op zo'n bron stond, draait sindsdien stilzwijgend op het vaste of dag/nacht-tarief hieronder, of op niets. Ze staan hier alleen nog zodat zo'n installatie bij het opstarten die uitleg krijgt in plaats van een onleesbare schemafout. Kies generic_dayahead voor de groothandelscurve met een vaste marge, custom_formula om de coëfficiënten B x index + A van uw eigen contract van uw tariefkaart over te typen, manual_csv voor een bestand dat u zelf schrijft, of off."
description: "Waar de prijzen vandaan komen. Een bron kiezen start het ophalen van prijzen, ook als handelen uit staat."
price_markup_eur_kwh:
name: "Tarief · Marge van de leverancier (EUR/kWh)"
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij manual_csv, waarvan de bedragen alles inbegrepen zijn, wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
description: "De marge van uw leverancier. Ze wordt alleen opgeteld als de prijsbron ze nog niet bevat."
price_taxes_eur_kwh:
name: "Tarief · Distributie, heffingen en btw (EUR/kWh)"
description: "Alles op uw factuur dat niet de energie zelf is - distributie, heffingen, accijns en btw - per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat. ⚠️ DIT GETAL IS GEEN VOORKEUR EN HET IS NIET DAT VAN UW OMVORMER. Het staat standaard op 0.11736, AFGELEID in TARIFF-18 van de Eneco-kaart voor residentiele klanten in Vlaanderen van september 2026, inclusief 6% btw: Fluvius-distributie voor Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. De energiebijdrage is sinds 1 augustus 2026 nul - ze is opgegaan in de accijns, dus een bedrag uit een oudere gids telt ze dubbel. ⚠️ CONTROLEER HET BIJ UW EIGEN DISTRIBUTIENETBEHEERDER. Alleen de distributieregel verschilt: over de acht Fluvius-netgebieden loopt die van 5,28 c/kWh in Midden-Vlaanderen tot 6,70 in West-Vlaanderen, zodat het Vlaamse totaal ongeveer van 0.117 tot 0.131 loopt. In Wallonie en Brussel heeft het distributietarief een apart dag- en nachttarief en hoort het hier helemaal niet thuis - gebruik dan de optie voor heffingen per tijdstip hieronder. ⚠️ DIT WIJZIGEN VERANDERT GEEN ENKELE HANDELSBESLISSING: hetzelfde bedrag komt bij elk uur, dus het valt weg uit zowel het goedkoopste uur als het prijsverschil. Wat het wel verandert is elke prijs die u te zien krijgt, en de regel die weigert te ontladen in een uur waarvan de totaalprijs onder nul ligt."
description: "Netkosten, heffingen en btw per kWh, ingesteld voor Vlaanderen. Controleer het bij uw eigen netbeheerder."
export_price_eur_kwh:
name: "Tarief · Injectievergoeding (EUR/kWh)"
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."
description: "Wat u krijgt per kWh die u op het net zet, alles inbegrepen. Dit mag negatief zijn."
static_profile:
name: "Tarief · Vast of dag/nacht-tarief (één regel JSON)"
description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief."
name: "Tarief · Vast of dag/nacht-tarief"
description: "Uw vaste of dag/nacht-contract op één regel, prijzen alles inbegrepen, zoals de documentatie toont. Vul dit ook in bij een dynamisch contract. Het is de reserve als de prijsfeed uitvalt."
price_taxes_profile:
name: "Tarief · Heffingen en btw per dagdeel (één regel JSON)"
description: "Laat dit leeg tenzij uw netbeheerder werkelijk een ander dag- en nachttarief aanrekent - in Vlaanderen is dat niet zo, de Fluvius-distributie is één tarief, en het vaste bedrag hierboven klopt. In Wallonië en Brussel is dat wél zo: ORES rekent 13,27 c/kWh overdag tegen 7,39 's nachts, AIESH 15,17 tegen 8,21, Sibelga 9,39 tegen 7,10. Geschreven als één regel JSON in precies dezelfde vorm als het vaste of dag/nacht-tarief hierboven, met dezelfde regel dat de periodes elk dagtype één keer moeten bedekken zonder gat en zonder overlap. ⚠️ DE BEDRAGEN HIER ZIJN ALLEEN DE HEFFINGEN, GEEN VOLLEDIGE PRIJS - de add-on telt ze op bij wat uw prijsbron publiceert, net zoals bij het vaste bedrag. Waarom het uitmaakt: een vast bedrag is in elk uur hetzelfde, verschuift dus elk uur evenveel en kan niet veranderen welk uur het goedkoopste is - en een nachttarief van het net is goedkoop in dezelfde uren waarin de markt goedkoop is, dus door het plat te slaan lijkt de dag vlakker dan hij is en weigert de add-on batterijcycli die zichzelf zouden terugverdienen. Vul dit in óf het vaste bedrag hierboven, niet allebei: de add-on weigert te starten als de twee elkaar tegenspreken."
name: "Tarief · Heffingen en btw dag/nacht"
description: "Laat dit leeg, tenzij uw netbeheerder een ander dag- en nachttarief rekent, zoals in Wallonië en Brussel. Vul dit of het vaste bedrag hierboven in, niet allebei."
price_formula:
name: "Tarief · Uw eigen tariefformule (één regel JSON)"
description: "Alleen voor prijsbron custom_formula: de twee coëfficiënten van uw eigen contract, als één regel JSON. De tariefkaart van uw leverancier drukt de formule af als B x index + A - die twee getallen staan alleen op uw kaart, er bestaat nergens een publieke lijst van, en u bent de enige die ze kan lezen. Typ wat de kaart afdrukt, IN DE EENHEID DIE DE KAART GEBRUIKT: elke eenheidsomrekening en de btw-stap zijn het werk van de add-on, want dat zijn precies de twee plaatsen waar een mens stilzwijgend een factor 10, 100 of 1,06 verliest. Voorbeeld: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ HET BLOK card IS VERPLICHT EN MAAKT DIT VEILIG: uw kaart vermeldt ook de referentie-index die ze veronderstelt en de prijs die eruit volgt, dus de add-on berekent die prijs opnieuw uit uw getallen en START NIET als het resultaat niet klopt - met vermelding van het verschil. Dat vangt een verkeerde eenheid, btw die ontbreekt of dubbel geteld is, een omgewisseld cijfer, en de dure fout: nettarieven die in de offset verwerkt zitten en die de add-on er dan nog een tweede keer bovenop zou rekenen. ⚠️ EN DE GRENS DAARVAN, IN DEZELFDE ZIN: ze toetst uw coëfficiënten aan uw kaart, niet uw kaart aan de werkelijkheid - twee getallen van het verkeerde product kloppen onderling en komen er dus door. Alleen vaste getallen: er wordt geen enkele expressie geëvalueerd, en een onbekend veld wordt geweigerd in plaats van genegeerd."
name: "Tarief · Uw eigen tariefformule"
description: "Alleen voor custom_formula. Typ de getallen van de tariefkaart van uw leverancier op één regel, zoals de documentatie toont. De add-on controleert ze met de kaart."
price_export_formula:
name: "Tarief · Uw eigen INJECTIEformule (één regel JSON)"
description: "Optioneel, en alleen als uw leverancier u een prijs betaalt die met de markt meebeweegt. Op uw tariefkaart staat een TWEEDE formule voor wat u krijgt voor de stroom die u injecteert, en dat is niet de eerste met een minteken ervoor: Eneco drukt `0,1 x BELPEX-H - 1,188` c/kWh af voor injectie tegenover `0,102 x ...` voor afname, en Aspiravi `0,07 x Belpex - 2`. Geschreven in precies dezelfde vorm als uw eigen tariefformule hierboven. ⚠️ INJECTIE IS IN BELGIË VRIJGESTELD VAN BTW terwijl afname op 6% staat, dus vat_pct is hier normaal 0 - dat is het ene veld dat men van de andere formule overneemt en verkeerd invult. ⚠️ DE PRIJS DIE HIERUIT VOLGT KAN NEGATIEF ZIJN, en dat is geen fout: Eneco betaalt onder nul zodra BELPEX-H lager is dan 11,88 EUR/MWh, en dat is het midden van een zonnige dag - u kunt moeten betalen om te injecteren. De add-on aanvaardt dat in plaats van het te verbergen. Het blok card is hier OPTIONEEL en hierboven verplicht, omdat Eneco de injectieformule afdrukt ZONDER voorbeeldprijs ernaast en er dan niets is om uw getallen aan te toetsen; drukt uw kaart er wél een af, vul ze dan in en de add-on start niet als uw getallen die prijs niet reproduceren. ⚠️ WAT DIT NIET DOET: geen enkel onderdeel van de add-on handelt vandaag op een injectieprijs - hij laadt goedkoop op om duur te vermijden, en beide zijden van die vergelijking zijn afnameprijzen. Dit is wat u te zien krijgt, en wat een toekomstige exportstrategie zou lezen. Laat het leeg en de vaste injectieprijs hierboven wordt gebruikt, zoals elke installatie nu doet."
name: "Tarief · Uw eigen injectieformule"
description: "Optioneel. De injectieformule van uw tariefkaart, in dezelfde vorm als uw eigen tariefformule. Laat leeg om de vaste injectievergoeding te gebruiken."
price_csv_path:
name: "Tarief · CSV-bestand met prijzen"
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
description: "Alleen voor manual_csv. Waar in /share de add-on uw prijsbestand leest."
price_feed_url:
name: "Tarief · Afwijkende URL voor de prijsfeed"
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen. WAT U HIER TYPT WORDT LETTERLIJK VERSTUURD: de add-on voegt er niets aan toe, ook niet het datumbereik dat ze normaal aan haar eigen feed vraagt. Een afwijkende URL levert dus de horizon op die die URL zelf teruggeeft - vaak één dag, waarna de rest van het plan uit de prijzen van gisteren wordt overgenomen en buiten de prijsarbitrage valt. Bij elke prijsvernieuwing verschijnt daarover een waarschuwing in het logboek. Maak dit veld leeg om de volledige horizon terug te krijgen."
description: "Laat leeg om de eigen feed van de bron te gebruiken. Een afwijkende URL wordt letterlijk verstuurd en geeft vaak maar één dag terug."
price_horizon_h:
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
name: "Tarief · Prijzen vooruit (uren)"
description: "Hoeveel uur aan prijzen de add-on bij de bron opvraagt."
price_timeout_s:
name: "Tarief · Time-out voor een prijsaanvraag (s)"
description: "Hoe lang één aanvraag bij de prijsbron mag duren voordat ze wordt opgegeven."
name: "Tarief · Time-out prijsaanvraag (s)"
description: "Hoe lang één aanvraag bij de prijsbron mag duren voor ze wordt opgegeven."
price_now_entity:
name: "Tarief · Entiteit met de huidige prijs (oudere prijsweg)"
description: "De oudere manier om aan een prijs te komen, behouden omdat ze weghalen de onderhoudscyclus op een draaiende installatie zijn enige prijssignaal zou afnemen. De add-on leest deze entiteit en het gemiddelde hieronder en leidt er precies één ding uit af - of het nu goedkoper is dan gemiddeld - wat alleen gebruikt wordt om tijdens de onderhoudscyclus in een goedkoop venster te laden. Mag niet gecombineerd worden met een dynamische prijsbron."
name: "Tarief · Entiteit huidige prijs (oud)"
description: "De oudere manier om een prijs te krijgen, alleen gebruikt om tijdens onderhoud goedkoop te laden. Niet combineren met een dynamische prijsbron."
price_avg_entity:
name: "Tarief · Entiteit met de gemiddelde prijs (oudere prijsweg)"
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
name: "Tarief · Entiteit gemiddelde prijs (oud)"
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen samen met de entiteit huidige prijs, niet met een dynamische prijsbron."
arbitrage_enabled:
name: "Tarief · Handelen op het prijsverschil"
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. ⚠️ DIT UIT LATEN STAAN ZET DE PRIJSFEED NIET UIT. Een prijsbron die hierboven gekozen is, wordt hoe dan ook opgehaald, gecontroleerd en bewaard; deze schakelaar bepaalt alleen of de add-on op die prijzen HANDELT. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
description: "Laden in het goedkoopste uur en de batterij laten leveren in het duurste, als het verschil de kosten dekt. Dit uitzetten zet de prijsfeed niet uit."
cycle_cost_threshold_eur:
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
name: "Tarief · Kosten van een batterijcyclus (EUR/kWh)"
description: "Wat één volledige batterijcyclus per kWh kost. Een kleiner prijsverschil wordt niet verhandeld. De waarde is een productkeuze, geen meting."
arbitrage_soc_floor:
name: "Tarief · Handel ontlaadt niet onder (%)"
description: "De reserve die de prijshandel niet mag aanspreken. Bewust een andere instelling dan de ondergrens van het onderhoud: een handelsstrategie op de cijfers van de maandelijkse balanceercyclus richten, zou twee ongerelateerde keuzes aan één getal koppelen."
name: "Tarief · Handel houdt minstens (%)"
description: "Prijshandel laat de batterij niet onder dit niveau zakken. Dit staat los van de onderhoudsinstellingen."
arbitrage_soc_ceiling:
name: "Tarief · Handel laadt niet boven (%)"
description: "De ruimte die de prijshandel niet mag volmaken. De tegenhanger van de ondergrens hierboven, en om dezelfde reden losgehouden van het onderhoudsdoel."
name: "Tarief · Handel laadt hoogstens (%)"
description: "Prijshandel laadt de batterij niet boven dit niveau."
maintenance_enabled:
name: "Onderhoud · Maandelijkse batterijcyclus uitvoeren"
description: "Een bewuste diepe ontlading, gevolgd door volledig opladen en een rustperiode op vol, zodat het batterijmanagementsysteem zijn schatting van het laadniveau kan herijken en de cellen kan balanceren. Staat standaard uit."
name: "Onderhoud · Maandelijkse batterijcyclus"
description: "Eén keer per maand de batterij diep leegmaken, volladen en vol houden, zodat het laadniveau juist blijft. Staat standaard uit."
maintenance_interval_days:
name: "Onderhoud · Minimum aantal dagen tussen cycli"
name: "Onderhoud · Minimum dagen tussen cycli"
description: "De kortste tijd tussen twee onderhoudscycli."
maintenance_start_hour:
name: "Onderhoud · Uur waarop een geplande cyclus start"
description: "Lokaal uur, 0 tot 23, waarop een cyclus die aan de beurt is begint."
name: "Onderhoud · Startuur"
description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint."
maintenance_discharge_w:
name: "Onderhoud · Ontlaadvermogen (W)"
description: "Hoe snel de cyclus de batterij leegtrekt; het overschot gaat naar het net. Boven het vermogen van de omvormer wordt dit BEGRENSD tot dat vermogen en niet geweigerd - de cyclus loopt gewoon door, alleen trager, en de statuspagina meldt dat. Standaard 2000, afgestemd op het standaardvermogen; verhoog eerst het omvormervermogen en dan pas dit."
description: "Hoe snel de cyclus de batterij leegmaakt. Boven het vermogen van de omvormer wordt het daartoe beperkt, en duurt de cyclus gewoon langer."
maintenance_charge_w:
name: "Onderhoud · Laadvermogen (W)"
description: "⚠️ MOET GELIJK AAN OF LAGER ZIJN dan het vermogen van de omvormer; anders start de add-on niet op. Erboven vraagt het laden in een goedkoop venster meer dan de bewaakte grens toelaat, lost de arbiter die tegenstrijdigheid op naar 0 W, en zou het onderhoud op een standaardinstallatie stilzwijgend nooit laden in een goedkoop venster. Daarnaast wordt dit nog eens begrensd door de ruimte die de piekbewaking overlaat."
description: "Hoe snel de cyclus de batterij weer vollaadt. Het moet gelijk aan of lager zijn dan het vermogen van de omvormer, anders start de add-on niet."
maintenance_soc_floor:
name: "Onderhoud · Ontladen tot (%)"
description: "De diepte waar de maandelijkse herijking bewust naartoe gaat, één keer per maand, met opzet. ⚠️ DIT IS NIET DE ONDERGRENS VAN DE PIEKBEWAKING: die geeft aan hoe diep een besparende ontlading op geen enkele dag mag komen. De juiste waarden verschillen echt - 11 hier tegenover 20 daar - en daarom zijn het twee aparte instellingen die niet samengevoegd mogen worden."
description: "Het niveau tot waar de maandelijkse cyclus de batterij leegmaakt. Dit is niet de piekreserve, en de twee verschillen bewust."
maintenance_soc_target:
name: "Onderhoud · Opladen tot (%)"
description: "Het niveau waar de laadfase naartoe werkt voordat de rustperiode begint."
description: "Het niveau waartoe de cyclus oplaadt voor het vasthouden begint."
maintenance_hold_min:
name: "Onderhoud · Op vol houden gedurende (minuten)"
description: "Hoe lang er op het streefniveau gebleven wordt zodat het batterijmanagementsysteem het balanceren van de cellen kan afmaken."
name: "Onderhoud · Vol houden gedurende (minuten)"
description: "Hoe lang de batterij vol blijft zodat de cellen in evenwicht komen."
sign_disagree_w:
name: "Veiligheid · Drempel tekenbewaking (W) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Zowel het uitgestuurde setpoint als de netmeting moet boven deze waarde liggen voordat hun tegengestelde tekens überhaupt als onenigheid tellen. Op de vastgelegde gezonde dag (23-08-2026) komt het teken in 93,4 % van de beoordeelde metingen overeen; op de vastgelegde foutdag (20-08-2026) ligt dat tussen 0,0 en 10,5 %, met tegengestelde periodes tot 1752 s. Er is geen reden om hieraan te sleutelen zonder een nieuwe meetcampagne die dat onderbouwt. De waarde moet onder het omvormervermogen blijven, anders kan de bewaking nooit aanslaan, en de add-on weigert die instelling."
name: "Veiligheid · Drempel verkeerde richting (W)"
description: "Deel van de veiligheidsstop voor een omvormer die de verkeerde kant op duwt. Het telt pas als commando en meterstand allebei boven deze waarde liggen. Gemeten waarde, laat ze ongewijzigd."
sign_disagree_s:
name: "Veiligheid · Wachttijd tekenbewaking (s) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Hoe lang het setpoint en de meter onafgebroken tegengesteld moeten blijven voordat de add-on 0 W stuurt en vergrendelt tot iemand de regeling opnieuw start. 180 s ligt midden in een zeer brede gemeten marge: de langste terechte tegengestelde periode op de gezonde dag duurt 30 s, terwijl de foutdag bij 300 s nog altijd 13 keer aanslaat. Verander dit niet zonder nieuwe metingen."
name: "Veiligheid · Tijd verkeerde richting (s)"
description: "Hoe lang een verkeerde richting mag duren voor een veiligheidsstop elke omvormer op 0 W zet. Gemeten waarde, laat ze ongewijzigd."
unit_sign_disagree_w:
name: "Veiligheid · Tekendrempel per omvormer (W) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer doet deze instelling niets. AFGELEID UIT 1890 GESLOTEN-LUSTESTS op de testopstelling met twee omvormers (SAFETY-30), waarbij niets defect was, over het volledige bereik dat deze add-on toelaat voor versterking en volgsnelheid. 250 W is precies de helft van de verzadigingsdrempel, zodat het kleinste verschil dat hier zichtbaar is, exact de afwijking is waarbij de regeling zelf al zegt dat de omvormer niet volgt. Verander dit niet zonder de afleiding opnieuw te maken. De waarde moet onder het omvormervermogen blijven, anders kan ze nooit aanslaan."
name: "Veiligheid · Drempel verkeerde richting per omvormer (W)"
description: "Bij twee of meer omvormers: de drempel voor verkeerde richting per omvormer. Afgeleide waarde, laat ze ongewijzigd."
unit_sign_disagree_s:
name: "Veiligheid · Wachttijd per omvormer (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30): 85 s is 8,5 keer de langste terechte tegengestelde periode die die tests opleveren (10,0 s), 2,8 keer het eigen veto van 30 s in de firmware, en minder dan de helft van de 180 s die de installatiebrede controle neemt. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Tijd verkeerde richting per omvormer (s)"
description: "Bij twee of meer omvormers: hoe lang één omvormer de verkeerde kant op mag duwen voor ze op 0 W gaat. Afgeleide waarde, laat ze ongewijzigd."
site_sign_disagree_w:
name: "Veiligheid · Tekendrempel voor de hele installatie (W) — gemeten, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. Dezelfde 300 W als de drempel voor één omvormer, BEWUST OVERGENOMEN (SAFETY-30): de onderbouwing hangt niet af van het aantal omvormers - rond nul is het teken ruis, de dode zone is 15 W, en er is één meter, wat er ook achter hangt. Verander dit niet zonder metingen die dat onderbouwen."
name: "Veiligheid · Drempel verkeerde richting installatie (W)"
description: "Bij twee of meer omvormers: de drempel voor verkeerde richting van de hele installatie. Gemeten waarde, laat ze ongewijzigd."
site_sign_disagree_s:
name: "Veiligheid · Wachttijd voor de hele installatie (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30), en bewust NIET overgenomen van de 180 s voor één omvormer. In die tests duurt de langste terechte tegengestelde periode bij 300 W 110 s met één omvormer en 215 s met twee, omdat het commando de volledige installatiegrens moet doorlopen aan een volgsnelheid die niet die van de installatie is. 180 s overnemen zou dus een vals alarm hebben opgeleverd bij de laagste versterking en volgsnelheid en een gewone belastingomkering van 2,2 kW. 325 s is 1,51 keer die waarde, blijft binnen het factuurkwartier van 900 s en binnen elke episode van de vastgelegde foutdag. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Tijd verkeerde richting installatie (s)"
description: "Bij twee of meer omvormers: hoe lang de hele installatie de verkeerde kant op mag duwen voor een veiligheidsstop. Afgeleide waarde, laat ze ongewijzigd."
meter_freeze_s:
name: "Veiligheid · Alarm vastgelopen meter na (s) — gemeten, niet wijzigen"
description: "GEMETEN, NIET GEKOZEN (SAFETY-18). Hoe lang de meter mag blijven antwoorden met een ONVERANDERDE waarde, terwijl het commando op de grens staat, voordat de add-on alarm slaat. Over 2399 s van deze woning op haar ingeregelde werkpunt - 1194 uitlezingen, waarvan 735 een herhaling van de vorige waarde - duurt de langste periode zonder een nieuwe waarde 15,4 s; 300 s is 19,5 keer zoveel en blijft binnen het factuurkwartier van 900 s. ALLEEN EEN ALARM: er wordt geen 0 W gestuurd en er wordt niets vergrendeld. ⚠️ Alleen de meterbron homewizard_local kan dit überhaupt zien; bij elke andere bron meldt de statuspagina NIET GEDEKT in plaats van actief. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Alarm vastgelopen meter na (s)"
description: "Een alarm als de meter zo lang dezelfde waarde herhaalt terwijl het commando op vol vermogen staat, alleen met homewizard_local. Gemeten waarde, laat ze ongewijzigd."
circulation_w:
name: "Veiligheid · Drempel rondpompend vermogen (W) — gemeten, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer is deze grootheid altijd nul. Twee omvormers die tegen elkaar in duwen, heffen elkaar aan de meter op en zijn onzichtbaar voor elke andere bewaking in deze add-on. GEMETEN OVER 924 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-31): de langste terechte tegenwerking valt terug van 505 s bij een drempel van 100 W naar 5 s bij 400 W, en 400 W is de scherpe knik waaronder deze add-on u niet laat gaan - daaronder houdt gewone werking de sensor minutenlang boven de drempel en kan niets korter dan het factuurkwartier dat van een storing onderscheiden. 700 W is 1,75 keer de knik; die extra marge is er omdat het aanloopgedrag van de tweede omvormer nog gekopieerd is van de eerste en niet gemeten. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Drempel tegenwerkende omvormers (W)"
description: "Bij twee of meer omvormers: hoeveel vermogen de ene tegen de andere in mag duwen voor het telt. Gemeten waarde, laat ze ongewijzigd."
circulation_hold_s:
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
name: "Veiligheid · Tijd tegenwerkende omvormers (s)"
description: "Bij twee of meer omvormers: hoe lang ze tegen elkaar in mogen duwen voor een veiligheidsstop. Afgeleide waarde, laat ze ongewijzigd."
delivery_hold_s:
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
name: "Veiligheid · Alarm niet-leverende omvormer na (s)"
description: "Bij twee of meer omvormers: hoe lang één omvormer te weinig mag leveren voor een alarm. Het alarm verandert niets. Afgeleide waarde, laat ze ongewijzigd."
shadow_mode:
name: "Veiligheid · Schaduwmodus — alles beslissen, niets sturen"
description: "Doorloopt het volledige beslispad — strategieën, arbiter, begrenzers en bewakingen — en stuurt NIETS naar de omvormer. Elke omvormer wordt op 0 W gehouden, ververst op de gewone hartslag, en de statuspagina zegt onomwonden dat de batterij niet wordt aangestuurd; wat er gestuurd ZOU zijn, en waarom, staat op de statuspagina en in het actielogboek. Zo bekijkt u een installatie een dag lang voordat u hem laat handelen. 'auto' (de meegeleverde waarde) betekent schaduwmodus zolang de regeling gestopt is en gewone regeling zodra u op Start drukt — een NIEUWE installatie kijkt dus eerst toe voordat ze ooit iets stuurt, en een installatie die al regelt verandert geen watt. 'on' betekent altijd schaduwmodus, wat de Start/stop-knop ook zegt: die stand kiest u als u de lus en de strategieën wilt laten draaien op een werkende installatie zonder dat er iets bij de batterij aankomt. 'off' is gewone regeling. ⚠️ Zolang dit aanstaat wordt de batterij NIET geregeld — het net wordt niet op nul gehouden, er wordt geen prijshandel uitgevoerd en er wordt geen capaciteitspiek afgevlakt. ⚠️ Een vergrendeling die al aanstond blijft aanstaan: schaduwmodus heft die niet op, en de banner noemt hem."
name: "Veiligheid · Schaduwmodus"
description: "De add-on neemt elke beslissing maar houdt elke omvormer op 0 W. 'auto' doet dit zolang de regeling gestopt is, 'on' altijd, 'off' nooit."
auto_start:
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
name: "Geavanceerd · Regeling starten bij opstarten"
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is, zodat iemand na elke herstart op Start drukt."
gain:
name: "Geavanceerd · Versterking van de regellus"
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
name: "Geavanceerd · Versterking van de regeling"
description: "Welk deel van de resterende afwijking de regeling per cyclus wegwerkt. Gemeten op echte hardware, verhoog dit niet."
slew_w:
name: "Geavanceerd · Grootste verandering per cyclus (W)"
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. ⚠️ 0 BETEKENT VOLG HET OMVORMERVERMOGEN en is de aanbevolen instelling: de grens wordt ongeveer een negende van dat vermogen, zodat de regeling er de GEMETEN insteltijd van de installatie over doet om haar volledige bereik te doorlopen en het getal niet achterblijft wanneer het vermogen verandert. AFGELEID (SAFETY-39) uit een gemeten stapresponsie van ~18 s bij een meterinterval van 2 s. Een vast getal wordt aanvaard en wordt nooit stilzwijgend verlaagd, maar een getal sneller dan het vermogen toelaat wordt bij het starten geweigerd - bij 1000 W tegen 5000 W dreef deze regeling de meter op 2026-09-05 van 3,4 kW injectie naar 2,8 kW afname. Lager wordt altijd aanvaard."
name: "Geavanceerd · Grootste stap per cyclus (W)"
description: "Hoe ver het commando in één cyclus mag verschuiven. 0 volgt het vermogen van de omvormer en is aanbevolen."
deadband_w:
name: "Geavanceerd · Dode zone (W)"
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."
name: "Geavanceerd · Negeer afwijkingen onder (W)"
description: "Kleinere afwijkingen worden genegeerd. Houd dit klein, want elk rustpunt daarbinnen blijft staan."
target_grid_w:
name: "Geavanceerd · Rustpunt aan de meter (W)"
description: "Waar de meter moet staan wanneer er niets gebeurt; negatief betekent een lichte injectie. -10 W is bewust gekozen: rusten op 0 W lekt de hele dag door ongeveer de helft van de dode zone aan gefactureerde afname - grofweg 0,2 tot 0,35 kWh bij een dode zone van 15 W - en het rustpunt onder nul leggen verplaatst dat lek naar het injectieregister, dat niet gefactureerd wordt. Het kost ongeveer evenveel watt weggegeven injectie, dus houd het klein."
description: "Waar de meter moet staan als er niets gebeurt. Een kleine injectie voorkomt dat u de hele dag kleine afname betaalt."
step_w:
name: "Geavanceerd · Afronding van het setpoint (W)"
description: "Het setpoint wordt op een veelvoud hiervan afgerond voordat het geschreven wordt, zodat de omvormer niet voor elke watt ruis een nieuw getal krijgt."
name: "Geavanceerd · Afronding van het commando (W)"
description: "Het commando wordt afgerond op een veelvoud hiervan, zodat ruis het niet elke cyclus verandert."
saturation_w:
name: "Geavanceerd · Verzadigingsdrempel (W)"
description: "Het verschil tussen wat gevraagd werd en wat de omvormer levert dat geldt als 'deze omvormer zit aan een grens'. ⚠️ De tekendrempel per omvormer onder Veiligheid is afgeleid als precies de helft hiervan, dus deze waarde verschuiven, verschuift ook de onderbouwing daarvan."
name: "Geavanceerd · Tekort aan de grens (W)"
description: "Levert een omvormer zoveel minder dan gevraagd, dan zit ze aan haar grens. De drempel verkeerde richting per omvormer is de helft hiervan."
saturation_cycles:
name: "Geavanceerd · Verzadigde cycli voor bevriezen (aantal)"
description: "Hoeveel opeenvolgende verzadigde cycli er nodig zijn voordat de regeling stopt met verder oplopen. ⚠️ Zet dit niet op 1: één ruiscyclus zou de regeling dan al bevriezen."
name: "Geavanceerd · Cycli aan grens voor bevriezen"
description: "Hoeveel cycli na elkaar aan de grens voor de regeling stopt met afwijking opbouwen. Zet dit niet op 1."
integrator_max_w:
name: "Geavanceerd · Begrenzing van de integrator (W, 0 betekent gelijk aan het vermogen)"
description: "Begrenst hoeveel oude afwijking er kan klaarstaan om af te bouwen wanneer het teken omslaat. GEMETEN op de gesloten-lussimulator met een belasting van 4000 W die naar nul valt: bij een grens gelijk aan het omvormervermogen staat het commando twee cycli later op 1000 W, bij anderhalf keer dat vermogen op 1800 W. ⚠️ Zet dit niet hoger dan het omvormervermogen - de uitgangsbegrenzing bepaalt toch al wat er naar de omvormer gaat, dus extra ruimte levert alleen watts in de verkeerde richting op na elke verzadiging. Lager zetten dan het omvormervermogen is de nuttige richting, en de enige reden dat dit een aparte instelling is."
name: "Geavanceerd · Inhaalgrens (W)"
description: "Begrenst hoe hard de regeling blijft duwen als de batterij achterloopt. 0 betekent het vermogen van de omvormer. Zet dit niet hoger dan dat vermogen."
heartbeat_s:
name: "Geavanceerd · Verversingsinterval van het setpoint (s)"
description: "Hoe vaak het setpoint opnieuw geschreven wordt, ook als het niet veranderd is. Dit moet ruim onder de eigen bewaking van de firmware blijven, die een verbinding die 30 s stil is geweest, blokkeert."
name: "Geavanceerd · Commando opnieuw sturen na (s)"
description: "Hoe vaak het commando opnieuw gestuurd wordt, ook als het gelijk bleef. Houd dit ruim onder de eigen grens van 30 s van de stuurmodule."
stale_input_s:
name: "Geavanceerd · 0 W sturen nadat invoer ontbreekt gedurende (s)"
description: "Hoe lang de invoer mag ontbreken voordat de add-on 0 W stuurt. Bij een P1-meter begint deze teller pas te lopen NADAT de verouderingsgrens van de meter al verstreken is, dus de twee stapelen: het totaal is langer dan dit getal op zich."
name: "Geavanceerd · Tijdslimiet ontbrekende invoer (s)"
description: "Hoe lang invoer mag ontbreken voor elke omvormer op 0 W gaat. Bij een P1-meter begint dit pas na de grens voor een te oude meting."
cycle_floor_s:
name: "Geavanceerd · Langste tijd tussen twee regelcycli (s)"
description: "Een uiterste termijn voor de tijd tussen twee cycli, die door elke cyclus opnieuw begint. Bij een gezonde meter verstrijkt hij nooit en verandert hij niets. Verhoog hem alleen als uw meter trager is dan ongeveer 10 s, en houd hem onder de verouderingsgrens van de meter - daarna is er toch geen meting meer om te beoordelen."
name: "Geavanceerd · Langste tijd tussen cycli (s)"
description: "Een uiterste termijn tussen twee regelcycli die een gezonde meter nooit haalt. Verhoog dit alleen voor een meter trager dan ongeveer 10 s."
log_level:
name: "Geavanceerd · Logniveau"
description: "Hoeveel detail de add-on naar zijn logboek schrijft. 'info' is de standaardinstelling; 'debug' en 'trace' dienen om een probleem te onderzoeken en leveren veel tekst op."
description: "Hoeveel detail de add-on in zijn logboek schrijft. 'debug' en 'trace' zijn om een probleem te zoeken."
+5
View File
@@ -0,0 +1,5 @@
# UX-08 ratchet (test_messages.py part 3). Each line would be a user-facing
# string that does not follow STYLE.md yet: "<kind> <sha1-12 of its text> #
# where :: start". Phase 2d emptied it: every row, banner, refusal, option text
# and WARNING+ log line now follows STYLE.md, and ALLOWLIST_MAX is 0. Never add
# a line for a new string: write the new string to STYLE.md instead.