Compare commits

..
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 ffc9a4d721 OPT-11 P1 review: a mode never beats the peak floor; SAFETY-03 kept at the rail
checks / unit (pull_request) Successful in 11m4s
infra-pin / fresh (pull_request) Successful in 2s
- arbiter: a mode now gives way only as far as a site limit above P_MODE
  needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
  released only when the peak floor bound S, so a trade won above the floor
  kept them and placed less than the floor (an Off battery at N=1 went to 0 W
  under a 2000 W floor), and a capped grid charge released an Off battery.
  While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
  rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
  (peak floor, trades, maintenance, refusals); a5dddad digests pinned.
- tests: test_arbiter part 11, test_control (g), test_modes (0/1 refused,
  loop bound counts live units only). DOCS and DECISIONS say the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 19:41:36 +02:00
Claude dev agent d69fd8fbfd OPT-11 P1: per-battery mode limits, on/off, solar order
checks / unit (pull_request) Successful in 10m59s
infra-pin / fresh (pull_request) Successful in 1s
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.

compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).

Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:51:29 +02:00
admin a5dddadf45 UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor (#148)
infra-pin / fresh (push) Successful in 2s
2026-09-28 17:06:18 +02:00
review-agent ede01b8a04 UX-13 P5 review: every STYLE rule has a mutation check, fuller jargon lists, Dutch wording, overlap partner test
checks / unit (pull_request) Successful in 10m44s
infra-pin / fresh (pull_request) Successful in 1s
- test_ui_text.py: key parity, placeholder parity, English-left-in-Dutch,
  semicolon, 25-word description and 2-sentence line each now fail on a
  spoiled input (the English-left check was not a real mutation).
- Jargon lists now cover the whole 'Not' column of STYLE.md section 4, in
  English, with the Dutch equivalents.
- nl.json: 'gewijzigd worden' where 'wijzigen' read as intransitive,
  'Bezig met bewaren' for the saving state, 'Metingen kloppen niet'.
- test_uimodel.py: an overlap that changes partners is named piece by piece
  (a surviving mutant of _tiling's partner check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:54:45 +02:00
Claude dev agent 667b8e9145 UX-13 P5: Dutch text, STYLE check, DOCS screenshots, period editor
checks / unit (pull_request) Successful in 10m45s
infra-pin / fresh (pull_request) Successful in 1s
- app/ui_text/nl.json and app/uitext.py: every page word in Dutch, English
  fallback per key; the page picks by browser language. Home's sentences
  move into ui_text `home_words`, so /status?lang=nl builds them in Dutch.
- test_ui_text.py: EN/NL key and placeholder parity, STYLE limits, jargon
  lists for both languages, no inline details, untranslated Dutch caught.
  Shared rules moved unchanged to tools/style_rules.py.
- tools/ux13_screenshots.sh/.py: DOCS screenshots from the demo fixtures in
  headless Chromium; docs/img/ux13/. Battery modes (P3): no screenshot.
- "Other time windows": a list editor (ui.periods) that builds
  static_profile and names bad times, empty prices, overlaps and gaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 16:33:32 +02:00
adminandClaude Opus 5.5 8c34647d05 Merge pull request 'UX-15: setup-only mode, and wizard fixes F1, F2, meter entity, step 1' (#147) from UX-15-setup-only into release/1.0
infra-pin / fresh (push) Successful in 1s
Reviewed and live-tested on TEST-23: 0 service calls in setup-only mode; review comment 1896.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:27:27 +02:00
Claude review agent 39e6c260fe UX-15 review: DECISIONS states the TEST-23 run that happened (PR #147)
checks / unit (pull_request) Successful in 11m8s
infra-pin / fresh (pull_request) Successful in 1s
It said the PR had TEST-23 evidence, but the PR says TEST-23 was not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:15:51 +02:00
Claude review agent 30cba1e58d UX-15 review: shorter setup banner (PR #147)
The Home banner title was ten words; UX-08 asks for about six.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:06:12 +02:00
adminandClaude Opus 5.5 63e7e8a133 UX-15: mutations fail named checks, not crashes
checks / unit (pull_request) Successful in 10m38s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:54:26 +02:00
adminandClaude Opus 5.5 159d7461d8 UX-15: setup-only mode, and the wizard fixes F1, F2, meter, step 1
A new install names no inverter. When that is the startup gate's only
refusal, the add-on now serves the setup wizard in setup-only mode instead
of exiting 78. It builds no controller, MQTT client or meter task, its
HomeAssistant is read-only (every service call refuses before sending),
Controller refuses a read-only HomeAssistant, and /action answers 409.
Any other refusal still exits 78. DECISIONS/UX-15-setup-only-mode.md.

Wizard fixes from the UX-13 P4 test:
- F1: /config/validate converts what it can, so a conversion error blocks
  its own step and the live checks still run; save 422 carries steps.
- F2: saves send the model the page opened (base); only keys this page
  changed are written onto the blob stored now.
- A meter entity missing from Home Assistant is red on the last step.
- Step 1 says the reading went down after a skip that followed a fall.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 08:48:21 +02:00
admin 4de96752ff Merge pull request 'UX-13 P4: the setup wizard' (#146) from UX-13-p4-setup into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-28 06:23:16 +02:00
62 changed files with 4958 additions and 427 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ This applies to everything a home owner or installer reads: notifications, phone
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words from `app/homeview.py`, checked by `test_messages.py` part 8.
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words in `app/ui_text/<lang>.json` under `home_words`, built by `app/homeview.py`, checked by `test_messages.py` part 8. The page's own words, English and Dutch, are checked by `test_ui_text.py`.
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
+9
View File
@@ -0,0 +1,9 @@
# UX-13 screenshots
Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not
edit them by hand: run the script again.
- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup.
- `settings-*.png`: Settings (Installer view), and the time-window editor with an overlap.
- `setup-step-1*.png`: Setup, step 1.
- Battery modes (the Batteries screen and the Simple view): not built yet (UX-13 P3), so no screenshot.
Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

@@ -0,0 +1,21 @@
# OPT-11 - battery modes, per battery
A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at
`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides
it; a limit above 35 (the peak guard) makes it give way only as far as that
limit needs, and a battery its mode holds back then carries only what the
others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27);
P1 is its section 6 P1, and the pull request lists what was measured.
- Off by default (`battery_modes_enabled`); off, commands are byte-identical to
a5dddad (`test_modes.py` part 1).
- Off is a mode range, so the peak guard can still use an Off battery when the
others cannot cover the floor (design sections 2-3). Whether Off should be
absolute is an open owner question.
- Review (PR #149): releasing only when the peak floor bound S let a trade won
above the floor keep the modes and place less than the floor, and let a
capped grid charge release an Off battery. Both are graded in
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the
rail to the rail, so SAFETY-03 still flags a loop winding into it.
- In P1 only Off closes a direction; Leftover solar differs by charging last.
Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4.
@@ -148,6 +148,11 @@ exits 78 and the panel goes with it. Recovery is the Configuration tab. That is
the reason it stays, and the reason the panel refuses to write a blob the
validator rejected: this surface must never be the thing that removes itself.
*Amended by [[UX-15-setup-only-mode]] (2026-09-28):* one refused configuration
keeps the panel: options that name no inverter at all and have no other
refusal, which is what a new install ships. The add-on then serves the page in
setup-only mode and commands nothing. Every other refusal still exits 78.
**The panel shows fewer refusals than the startup gate can, never more.** Two
of `validate.py`'s rules compare a configured ceiling against the setpoint
device's *own* reported range, which is only knowable by asking Home Assistant.
@@ -70,6 +70,23 @@ What UX-02 made authoritative stays authoritative:
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
names no key. See [[UX-13-setup-wizard]].
- P5: the page words are in `app/ui_text/en.json` and `nl.json`, one key set,
loaded by `app/uitext.py` with an English fallback per key. The page picks
Dutch when the browser's language is `nl`, as the classic page does. Home's
sentences moved from `homeview.py` into `home_words`, so `/status?lang=nl`
builds them in Dutch. Status rows, banners, hints and validator sentences
(`messages.py`, `validate.py`) stay English: they are shared with
notifications, the log and the start refusal. `test_ui_text.py` checks both
files against STYLE.md and each other, using the rules in
`tools/style_rules.py`, which it shares with test_messages.py.
- P5: "Other time windows" is a list editor (`ui.periods`, control
`period_list`). `uimodel._periods_json()` builds `static_profile` and names
the first bad time, empty price, overlap or gap. Its tiling rule matches
`static_tariff._check_tiling`. A stored profile whose days the editor has no
choice for (for example "mon,wed") is still kept as stored and edited in the
Configuration tab. Supervisor 2026.08.0's `AppOptions` accepted all 133
wizard blobs, 17 of them with time windows
(`tools/ux13_wizard_blobs.py --supervisor`, run offline on 2026-09-28).
## 4. What this costs
@@ -25,8 +25,9 @@ The page opens `#setup` only when it is opened with no route, and only when
- **`no_power_request`**: the stored options name no inverter to control.
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
Store has no `setup_done` and no `auto` key, and control is not started.
`auto` is the key Start/Stop writes. The startup gate exits 78 before the
controller writes the Store, so a new install's first good boot is fresh.
`auto` is the key Start/Stop writes. Neither the startup gate's exit 78 nor
setup-only mode (UX-15) writes the Store, so a new install's first good boot
is fresh.
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
@@ -37,14 +38,12 @@ started or stopped it, is never sent. Without a Store the answer is no. The wiza
`setup_done` (an ISO time) to the Store. A save from the Installer view does
not.
**Not decided here, for the owner:** the add-on exits 78 before it serves the
page when the stored options name no power request entity, which is what a
brand-new install ships (config.yaml's `setpoint_entity: ""`). So on a truly
new install the installer fills in "Inverter setpoint entity" on the
Configuration tab first, and the wizard opens on the next page visit.
Serving a setup-only page from a refused configuration would change the CFG-01
startup gate and UX-02 §4 ("the panel is not available when it is most
needed"), so it is left as an open question.
**Decided since, in [[UX-15-setup-only-mode]] (owner, 2026-09-28):** a
brand-new install ships `setpoint_entity: ""`, and it no longer exits 78. When
naming no inverter is the gate's only refusal, the add-on serves the page in
setup-only mode, which commands nothing, and the wizard opens with
`no_power_request`. Setup-only mode writes no Store file, so the first-run
rule above still holds for an install set up on the Configuration tab.
## 3. The kettle test and the sign checks
@@ -96,6 +95,7 @@ fingerprint was not handed out by this process, it falls back to `changed`.
it), step blocking, the first-run rule on new and TEST-23 sites, the live
checks, the save through the P2 path, the stale fix, and the words.
- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new
install and three TEST-23 sites (88 accepted by the shipped validator). At P4
install and three TEST-23 sites (88 accepted by the shipped validator).
UX-15 added the shipped defaults as a start (setup-only mode). At P4
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
keys each.
@@ -0,0 +1,114 @@
# UX-15 — setup-only mode: a new install serves the wizard
**Status: DECIDED 2026-09-28 (owner decision; UX-15 dev).** Card
6aba0053d27a32cb3f210dcc. Changes the CFG-01 startup gate and closes the open
question in [[UX-13-setup-wizard]] §2. Amends [[UX-02-two-config-surfaces]] §4.
## 1. The rule
`config.yaml` ships `setpoint_entity: ""`, so a new install's options are
refused by rule 1 of `validate_startup_config()`: no inverter to control.
Until now that was exit 78 before any page was served, and the installer had
to fill in the entity on the Configuration tab before the wizard could open.
The add-on now starts in **setup-only mode** when, and only when
(`setuponly.applies()`):
- the options name no inverter at all: no flat `setpoint_entity` and no
`inverters` entry, and
- rule 1's refusal (`no_power_request_problem()`) is the gate's **only**
refusal.
In setup-only mode it serves the page, says it is in setup (banner, `/status`
`setup_only: true`, a WARNING log line) and opens the wizard when the page is
opened with no route (`setup_needed` is `no_power_request`). After the
wizard's save and restart the normal gate applies to the saved options.
## 2. What still exits 78, and why
Everything else the gate refuses, including:
- a complete configuration that is refused, e.g. `maintenance_charge_w` above
`max_w`;
- no power request entity **and** any other refusal;
- an `inverters` list whose entry has a blank power request entity.
These are configurations somebody edited. The Configuration tab stored them,
the log's refusal banner names each problem, and the tab is where they are
fixed (UX-02 §4). Serving the wizard there would put a six-step setup in
front of someone who needs to change one field. A narrow rule also keeps the
setup-only decision to one comparison against one known sentence, so a new
validator rule cannot widen it by accident: any other refusal fails the
equality and exits 78.
The cost: an installer who first sets, say, a DSMR meter with no entities on
the Configuration tab and leaves the power request entity empty gets exit 78,
not the wizard. The log then names both fields.
## 3. Why it cannot command
Setup-only mode builds nothing that commands. `amain()` branches to
`setuponly.serve()` before the MQTT broker, the controller, the meter task and
the tariff task are built, and it returns from there when the Supervisor
stops it. What serves the page is `SetupOnly`, which holds no inverter.
Three checks make it structural rather than a convention:
1. **The HomeAssistant is read-only** (`HomeAssistant(read_only=True)`).
`call()` refuses before it sends anything, and every service call goes
through it: `number.set_value` (`set_number()`), notifications, all of
them. The flag is fixed at construction, and there is no setter.
2. **`SetupOnly` refuses a HomeAssistant that can write, and `Controller`
refuses one that cannot.** So neither can be built on the wrong one.
3. **`/action` answers 409** in setup-only mode, before
`handle_action()`. Start, Stop and the battery cycle do nothing.
No MQTT client is created, so nothing is published. The page's other routes
already read only, or write the add-on's own options (UX-02 §6).
Setup-only mode writes no Store file. The wizard's save writes `setup_done`,
as in any mode. So a new install that is set up on the Configuration tab
instead still has a fresh Store on its first normal boot, and the first-run
rule (UX-13 §2) sends it to the wizard as before.
## 4. How it is held
- `test_setup_only.py` part 1: when it applies, including the three exit-78
cases above. Part 2: the read-only write path refuses with nothing sent,
and the two constructors refuse. Part 3 boots the real `main()` on the
shipped defaults against a fake Supervisor and Home Assistant over HTTP and
logs every request: the page is served, `/status` says setup-only, Start
and the cycle get 409, and there are 0 service calls and no MQTT request,
also with `auto_start` on and a Store that says control was started. The
wizard's save is then booted: it passes the normal gate, does not open the
wizard again, and every write is 0 W (shadow). Part 4: the refused
configurations exit 78 and serve nothing.
- Mutations, each red: `applies()` always false or always true, the
read-only refusal removed, the Controller's check removed, `amain()` giving
setup-only mode a writable HomeAssistant, `/action`'s 409 removed, and the
`return` after `serve()` removed.
- TEST-23 (PR #147 review, 2026-09-28): the add-on image on the shipped
defaults and an empty `/data` made 0 service calls and no MQTT request in
3.5 minutes of setup-only mode, counted in both the stand-in and the rig
shim logs. The wizard save then passed Supervisor 2026.08.0's `AppOptions`,
and after the restart it ran in shadow: 11 of 11 writes were
`set_value 0.0`.
## 5. The wizard fixes in the same card
- **F1.** `/config/validate` converts what it can of a page model
(`uimodel.ui_to_options_partial()`), so a conversion error blocks its own
step beside its field and the live checks still run on the rest. A refusal
about a key the failed conversion writes is left out, because it would
judge the stored value. The save still refuses such a model (422, with
`steps`).
- **F2.** A save from the page model sends `base`, the model it opened. The
add-on writes only the keys whose value differs from `base`, onto the blob
stored now (`uipage.only_edits()`). So "save again to keep yours" after a
409 keeps the other surface's changes. A key both surfaces changed takes
this page's value, which is what the second press after the banner means.
- **Meter entity.** On the last step a meter entity Home Assistant does not
have is red, like a missing power request entity, so a skipped kettle test
cannot let it through. In the Installer view it stays a warning.
- **Step 1 after a skip.** When the kettle test saw the reading fall and was
then skipped, step 1 says the reading went down. The skip stays (UX-13 §3).
+55 -6
View File
@@ -183,6 +183,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| 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 | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. |
| 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. |
@@ -512,6 +513,17 @@ two of a thing there is one of, and `P1 sample age` is a safety input. Same for
status, the maintenance phase, the capacity-tariff peak and the three loop rows
below.
### Battery modes (`battery_modes_enabled`)
Off by default. Turn it on, restart the add-on, and the Batteries screen gives each battery two controls. They apply at once, with no restart.
- **On or off.** Off asks the battery for 0 W and starts no monthly cycle on it. A cycle that is running stops, and the add-on log says so. The peak limit may still use it, but only for what the other batteries cannot cover.
- **Mode.** *Solar first* charges from spare solar before the other batteries. *Leftover solar* charges from the solar they cannot take. Both cover the house at any hour. Charging from the grid in cheap hours, and selling, come in a later update.
The peak limit, the safety stops and the monthly cycle always come before a battery's mode.
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
The control law is a bounded integrator: an accumulator that is **not** the
@@ -2886,6 +2898,12 @@ works under hourly pricing is an EMS for a minority. `static_profile` is how a
two-period day/night tariff or a single fixed price becomes a first-class
configuration rather than "turn the price feature off and hope".
On the add-on page, choose **Other time windows** under Settings, Tariff and
prices, or in Setup step 4. Add one row per time window, with its start, end,
days and price. The page names any overlap, gap or empty price before you can
save. You only need the JSON below for days the page cannot choose, such as
Monday and Wednesday only.
It is one option, holding a JSON object, on one line:
```json
@@ -3221,6 +3239,18 @@ action log (`Controller.events`). The page no longer shows that log, but the
page and add `status` to its address. The action-log tables in this document
refer to it.*
### Language and screenshots
The page is in Dutch when your browser's language is Dutch, and in English
otherwise. Status lines, alarms and the start checks' messages stay in English.
Screenshots of each screen are in the add-on's repository, under
`docs/img/ux13/`. The battery-mode screens are not built yet, so there are no
screenshots of them.
*For installers: regenerate the screenshots with
`tools/ux13_screenshots.sh`, which serves the page from test fixtures.*
### Setup, for a new install
The **Setup** tab walks an installer through six steps: the meter, the
@@ -3233,19 +3263,34 @@ the last step.
sign for you; with a net or import/export meter it tells you what to change.
- **The last step** runs the add-on's own start checks and reads Home
Assistant: the meter reading, that each power request entity exists, the
battery levels, and whether prices are known. A missing power request entity
is red, so it cannot be saved.
battery levels, and whether prices are known. A missing power request or
meter entity is red, so it cannot be saved.
- **Save and restart** saves, then restarts the add-on. Every inverter is set
to 0 W while it restarts, and with "Start in shadow mode" on (the default) it
stays at 0 W until you turn shadow mode off under Settings.
Setup opens by itself on a new install's first start, until Setup finishes or
control is started, and whenever the stored settings name no inverter to
control. A site that already ran before is never sent there. The add-on does
not start with no power request entity at all, so on a brand-new install fill
in "Inverter setpoint entity" on the Configuration tab first.
control. A site that already ran before is never sent there.
*For installers: card UX-13, and `DECISIONS/UX-13-setup-wizard.md`.*
A new install names no inverter yet, so the add-on starts **in setup**: it
serves the page and sends nothing to any inverter. Home says "Setup: nothing
is controlled yet", and there is no Start button. The installer's steps:
1. Install the add-on, turn on **Watchdog** on its Info tab, and start it.
Leave the Configuration tab as it is.
2. Open the add-on's page. Setup opens by itself.
3. Go through the six steps and press **Save and restart**. With "Start in
shadow mode" on, every inverter stays at 0 W after the restart.
4. On Home, check the meter direction and what the add-on would do. Turn
shadow mode off under Settings when it looks right.
Any other problem in the stored settings still stops the add-on (exit code 78),
and the log names each one. Fix those on the Configuration tab.
*For installers: cards UX-13 and UX-15, `DECISIONS/UX-13-setup-wizard.md` and
`DECISIONS/UX-15-setup-only-mode.md`. In setup the `status` JSON has
`setup_only: true`, and no MQTT entity is published.*
## Status page
@@ -3260,6 +3305,10 @@ 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.
In **setup** (a new install that names no inverter yet) the banner says
nothing is controlled yet, and the one row says to finish Setup. See *Setup,
for a new install*.
| 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. |
+106 -4
View File
@@ -98,6 +98,12 @@ DROP_STALE = "stale link"
DROP_CONTRADICTION = "contradicts itself"
DROP_NO_INTEGER = "no integer setpoint in its interval"
# OPT-11: why a unit's MODE layer was not applied this tick. Only the first is
# a fault; the other two are the rule working.
MODE_FAULT = "mode range leaves out 0 W"
MODE_RELEASED = "mode released by a higher claim"
MODE_OUTRANKED = "hard limit leaves no room for the mode"
REFUSE_SCOPE = "mis-routed claim"
REFUSE_EMPTY = "no unit available"
REFUSE_SIGN = "opposite directions"
@@ -213,6 +219,10 @@ class Allocation:
# that calls `plan()` for its arithmetic gets None and does not care;
# main.py stamps every record it puts on the write path.
tick: int | None = None
# OPT-11: ((unit, why), ...) for each unit whose mode range left out 0 W
# this tick. That mode was dropped (see `unit_interval()`); main.py logs it
# as a software fault. Empty on every tick without a mode.
mode_faults: tuple = ()
@property
def ok(self) -> bool:
@@ -290,6 +300,87 @@ class Allocation:
return out
def unit_interval(b, deciding_priority=None):
"""OPT-11. `(lo, hi, note)`: the interval a unit is allocated into.
The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range
(`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is
above the mode's own priority (arbiter._unit_modes() passes None when the
modes hold, and the yielding limit's priority when they give way). `note` says why the
mode did not apply, or is None when it did or there is none:
* MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction
and never force one, so this is a software fault and the mode is dropped.
It is what keeps REFUSE_SIGN unreachable from a mode: the fill below
moves every free unit away from 0 in ONE direction, and it starts from 0
only while every interval holds 0.
* MODE_RELEASED: a higher claim (the peak floor) decided the target.
* MODE_OUTRANKED: the hard limits and the mode do not overlap. The hard
limits win, as they always do.
A unit without mode fields (`mode_priority` absent or None) gets its hard
limits, unchanged: that is every unit when battery modes are off.
"""
lo, hi = b.lo, b.hi
prio = getattr(b, "mode_priority", None)
if prio is None:
return lo, hi, None
mlo, mhi = b.mode_lo, b.mode_hi
if not (mlo <= 0.0 <= mhi):
return lo, hi, MODE_FAULT
if deciding_priority is not None and deciding_priority > prio:
return lo, hi, MODE_RELEASED
nlo, nhi = max(lo, mlo), min(hi, mhi)
if nlo > nhi:
return lo, hi, MODE_OUTRANKED
return nlo, nhi, None
class _Narrowed:
"""A unit's bounds with its interval replaced by `unit_interval()`'s."""
__slots__ = ("lo", "hi")
def __init__(self, lo, hi):
self.lo, self.hi = lo, hi
def loop_bound(bounds, live=None):
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
The sum, over live units that do not contradict themselves, of each unit's
interval with its mode applied (`unit_interval()` with no higher claim). A
pinned unit adds the span from 0 to its pin, so a probe or a maintenance
pin stays reachable. Every term holds 0, so the sum does too.
`compute()` keeps the site target inside this, so the loop never asks for
watts no battery may give: the allocator would report them as unmet and the
saturation freeze would park the target on them (SAFETY-73 section 1).
Pure, like `plan()`: it reads numbers and intervals and nothing else.
"""
lo_sum, hi_sum = 0.0, 0.0
for u in sorted(bounds):
b = bounds[u]
if live is not None and u not in live:
continue
if getattr(b, "contradiction", False):
continue
pv = getattr(b, "pinned_w", None)
if getattr(b, "gate", None) is not None and pv is not None:
lo, hi = _int_envelope(b)
p = float(pv) if lo > hi else float(_clamp_int(round(float(pv)),
lo, hi))
lo_sum += min(0.0, p)
hi_sum += max(0.0, p)
continue
lo, hi, _ = unit_interval(b)
lo, hi = _int_envelope(_Narrowed(lo, hi))
if lo > hi:
continue
lo_sum += min(0.0, lo)
hi_sum += max(0.0, hi)
return lo_sum, hi_sum
def _int_envelope(b):
"""A unit's interval, narrowed to the integers a 1 W register can hold.
@@ -385,7 +476,8 @@ def _fill(order, weights, cap, amount):
def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
site_priority=None, site_key="site", tick=None) -> Allocation:
site_priority=None, site_key="site", tick=None,
deciding_priority=None) -> Allocation:
"""Divide the site grid target `target_w` across the units in `bounds`.
`bounds` unit name -> that unit's interval for THIS TICK (arbiter stage 1,
@@ -411,6 +503,9 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
`site_priority` the priority of the SITE claim that chose `target_w`. It is
what a unit's own winning SET is weighed against - see PINS.
`site_key` the name to use when there are no units at all.
`deciding_priority` OPT-11: the priority that decided `target_w`
(`Resolution.deciding_priority`). Each unit's mode range applies
only at or below it - see `unit_interval()`. None applies them.
`tick` an opaque token identifying the tick this record is FOR, handed
straight back on the record. SAFETY-28's write gate compares it
against the tick it is writing on, so that a record cannot be
@@ -497,6 +592,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
dropped = []
ilo, ihi = {}, {}
considered = []
mode_faults = []
for u in units:
b = bounds[u]
if live is not None and u not in live:
@@ -512,7 +608,12 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
# has no interval to allocate into.
dropped.append((u, DROP_CONTRADICTION))
continue
lo, hi = _int_envelope(b)
# OPT-11: the mode layer, when it applies, narrows the hard limits.
# A unit with no mode gets exactly its hard limits, as before.
nlo, nhi, note = unit_interval(b, deciding_priority)
if note == MODE_FAULT:
mode_faults.append((u, MODE_FAULT))
lo, hi = _int_envelope(_Narrowed(nlo, nhi))
if lo > hi:
# An interval that contains no integer. The register is 1 W, so
# there is no number this unit could be commanded that both bounds
@@ -526,7 +627,7 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
return Allocation(
target_w=float(target_w), sum_w=sum_w, shares={}, plans=(),
dropped=tuple(dropped), unmet_w=sum_w, quantisation_w=quant,
refused=why, tick=tick)
refused=why, tick=tick, mode_faults=tuple(mode_faults))
# --- REFUSAL 2: nobody is available ------------------------------------
if not considered:
@@ -659,4 +760,5 @@ def plan(target_w, bounds, *, weights=None, live=None, scope_errors=(),
return Allocation(target_w=float(target_w), sum_w=sum_w, shares=shares,
plans=tuple(plans), dropped=tuple(dropped),
unmet_w=int(unmet), quantisation_w=quant, tick=tick)
unmet_w=int(unmet), quantisation_w=quant, tick=tick,
mode_faults=tuple(mode_faults))
+185 -9
View File
@@ -86,8 +86,17 @@ P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
# because trading against the meter is the entire point - the grid-following
# loop would otherwise cancel every charge the moment it created import.
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
# OPT-11. A battery's mode (Solar first, Leftover solar, Off): a SOFT unit
# limit, kept apart from the hard ones. Above arbitrage, so a trade stays inside
# each battery's mode; below the peak guard, to which a mode gives way only as
# far as the floor needs (`_unit_modes()`, `_site_mode()`).
# DECISIONS/OPT-11-battery-modes.md.
P_MODE = 35
P_LOOP = 10 # ordinary grid-following
# The one source filed at P_MODE. pre_resolve() keeps it out of the hard limits.
MODE_SOURCE = "mode"
SET = "set"
LIMIT = "limit"
@@ -238,6 +247,13 @@ CLAIM_SCOPE = {
# this pack to its floor, charge this pack to full, recalibrate this pack's
# coulomb counter. Two packs recalibrate on their own schedules.
"maintenance": SCOPE_UNIT,
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
# battery's mode, chosen per battery by its owner. A unit claim that never
# enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the
# hard limits, and it gives way to a site limit above P_MODE only as far as
# that limit needs (`_unit_modes()`). At N=1 it routes to the site, and
# `resolve()` takes it out of stage 2 for the same reason.
"mode": SCOPE_UNIT,
}
@@ -365,6 +381,13 @@ class UnitBounds:
pinned_w: float | None = None
pinned_by: str | None = None
contradiction: bool = False
# OPT-11: the mode layer, APART from `lo`/`hi` above, which stay the hard
# limits. `mode_priority` is None when no mode claim applies. Whether the
# mode binds is decided at stage 3 (`allocator.unit_interval()`), because it
# depends on the priority that decided the SITE target.
mode_lo: float = -inf
mode_hi: float = inf
mode_priority: int | None = None
def explain(self) -> str:
if self.contradiction:
@@ -410,6 +433,15 @@ class Resolution:
# rules drifting apart: this is set by the same line that picks the winner
# `explain()` names.
winner_priority: int | None = None
# OPT-11. The higher of the winning set's priority and the priority of the
# limit that bound `target_w`. Whether the modes hold is decided from the
# limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way
# only as far as a limit above it (the peak floor) needs, so a mode `hi`
# never meets a site `lo`.
deciding_priority: int | None = None
# OPT-11 at N=1: why the site's mode layer was dropped this tick, or None.
# At N>1 the same fact is on `allocation.mode_faults`, per unit.
mode_fault: str | None = None
def explain(self) -> str:
"""One line an operator can act on - this is the point of the module."""
@@ -433,7 +465,8 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
of the fault this card removes, and the reason `resolve()` partitions rather
than filters twice.
"""
sets = sorted((c for c in unit_claims if c.kind == SET),
sets = sorted((c for c in unit_claims
if c.kind == SET and c.source != MODE_SOURCE),
key=lambda c: c.priority, reverse=True)
gate = pinned_w = pinned_by = None
if sets:
@@ -441,19 +474,30 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
lo, hi = -inf, inf
lo_src = hi_src = None
# OPT-11: a mode claim never joins the hard limits, so a mode cannot make a
# unit contradict itself or narrow a pin. A mode SET is not a thing: only
# limits are read. The lowest mode priority is kept (the first to release).
mode_lo, mode_hi, mode_priority = -inf, inf, None
for c in unit_claims:
if c.kind != LIMIT or (gate is not None and c.priority < gate):
continue
if c.source == MODE_SOURCE:
mode_lo, mode_hi = max(mode_lo, c.lo), min(mode_hi, c.hi)
mode_priority = (c.priority if mode_priority is None
else min(mode_priority, c.priority))
continue
if c.lo > lo:
lo, lo_src = c.lo, c.source
if c.hi < hi:
hi, hi_src = c.hi, c.source
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
contradiction=lo > hi)
contradiction=lo > hi, mode_lo=mode_lo, mode_hi=mode_hi,
mode_priority=mode_priority)
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None) -> tuple:
scope_errors=(), site_priority=None,
deciding_priority=None) -> tuple:
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
@@ -491,11 +535,13 @@ def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
"""
return allocate_record(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority)[:2]
site_priority=site_priority,
deciding_priority=deciding_priority)[:2]
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None, tick=None) -> tuple:
scope_errors=(), site_priority=None, tick=None,
deciding_priority=None) -> tuple:
"""`allocate()` plus the record - `(shares, allocated, allocation)`.
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
@@ -507,7 +553,7 @@ def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority, site_key=ACTUATOR_SITE,
tick=tick)
tick=tick, deciding_priority=deciding_priority)
return rec.shares, rec.ok, rec
@@ -544,19 +590,26 @@ def site_resolve(claims: list) -> Resolution:
considered = [f"{c.source}:{c.kind}" for c in claims]
if lo > hi:
# OPT-11: 0 W here, and every limit that took part counts as deciding.
return Resolution(0.0, winner.source, bound_by=None,
contradiction=True, considered=considered,
winner_priority=winner.priority)
winner_priority=winner.priority,
deciding_priority=max(winner.priority,
lo_src.priority,
hi_src.priority))
target = max(lo, min(hi, winner.value))
bound_by = None
deciding = winner.priority
if target != winner.value:
binder = lo_src if target == lo else hi_src
if binder is not None:
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
deciding = max(deciding, binder.priority)
return Resolution(float(target), winner.source, bound_by, False, considered,
winner_priority=winner.priority)
winner_priority=winner.priority,
deciding_priority=deciding)
def resolve(claims: list, *, units=None, weights=None, live=None,
@@ -606,6 +659,12 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
written down.
"""
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
# OPT-11: a mode claim on the site (N=1, where every source routes there)
# never enters stage 2's `lo > hi` test. It is applied after it, below, by
# the same release rule stage 3 uses.
site_modes = [c for c in site_claims if c.source == MODE_SOURCE]
if site_modes:
site_claims = [c for c in site_claims if c.source != MODE_SOURCE]
if units is None:
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
units = tuple(sorted(units))
@@ -617,6 +676,8 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
# `claims`, so this is the shipped arbiter on the shipped list.
out = site_resolve(site_claims)
if site_modes:
_site_mode(out, site_modes, site_claims)
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
#
@@ -649,10 +710,125 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
# nothing. See `packshare.weights()` on the residual this leaves (the fill's
# own direction can differ from the target's sign under a pin, in which case
# the weighting is a worse preference and still cannot be unsafe).
# OPT-11: whether each unit's mode holds this tick (`_unit_modes()`).
# Before the weights, because a limit above the modes may move the target.
gate, held = _unit_modes(out, site_claims, bounds, live)
if callable(weights):
weights = weights(out.target_w)
if held:
weights = {u: (0.0 if u in held else
(1.0 if weights is None else weights.get(u, 1.0)))
for u in bounds}
out.shares, out.allocated, out.allocation = allocate_record(
out.target_w, bounds, weights=weights, live=live,
scope_errors=out.scope_errors, site_priority=out.winner_priority,
tick=tick)
tick=tick, deciding_priority=gate)
return out
def _limits_above(site_claims: list, prio: int) -> tuple:
"""OPT-11: `(lo, lo_src, hi, hi_src)` over the site LIMITs above `prio`.
These are what a mode may never take the site target outside of: the peak
guard (P_PEAK), maintenance shaping (P_MAINT_SHAPE) and the hard limits.
"""
lo, hi = -inf, inf
lo_src = hi_src = None
for c in site_claims:
if c.kind != LIMIT or c.priority <= prio:
continue
if c.lo > lo:
lo, lo_src = c.lo, c
if c.hi < hi:
hi, hi_src = c.hi, c
return lo, lo_src, hi, hi_src
def _yield_to(out: Resolution, t1: float, above: tuple):
"""OPT-11: the target `t1` the modes allow, moved only as far as the limits
above the mode need (`_limits_above()`). Returns the claim that moved it,
or None when `t1` already satisfies them, and sets `out.target_w`."""
lo, lo_src, hi, hi_src = above
t2 = max(lo, min(hi, t1))
out.target_w = float(t2)
if t2 == t1:
return None
binder = lo_src if t2 == lo else hi_src
out.bound_by = (f"{binder.source}({binder.reason})" if binder.reason
else binder.source)
out.deciding_priority = max(out.deciding_priority or binder.priority,
binder.priority)
return binder
def _site_mode(out: Resolution, modes: list, site_claims: list) -> None:
"""OPT-11 at N=1: the one battery's mode bounds the SITE target, after stage 2.
A SET above the mode (a safety stop) decides alone. Otherwise the target is
clamped into the mode range and then back into the site limits above the
mode, so the mode gives way only as far as those limits need: a peak floor
of 2000 W over an Off battery asks it for 2000 W, whether a trade or the
loop won stage 2, and a charge the peak guard merely caps stays at 0 W. A
range without 0 W is a software fault that drops the mode
(`out.mode_fault`). Clamping one number cannot contradict anything.
"""
out.considered = out.considered + [f"{c.source}:{c.kind}" for c in modes]
lims = [c for c in modes if c.kind == LIMIT]
if not lims or out.contradiction:
return
lo = max(c.lo for c in lims)
hi = min(c.hi for c in lims)
prio = min(c.priority for c in lims)
if not (lo <= 0.0 <= hi):
out.mode_fault = f"mode range [{lo:.0f}, {hi:.0f}] leaves out 0 W"
return
if out.winner_priority is not None and out.winner_priority > prio:
return
before = out.target_w
t1 = max(lo, min(hi, before))
if _yield_to(out, t1, _limits_above(site_claims, prio)) is None \
and t1 != before:
c = lims[0]
out.bound_by = f"{c.source}({c.reason})" if c.reason else c.source
def _unit_modes(out: Resolution, site_claims: list, bounds: dict, live):
"""OPT-11 at N>1: `(gate, held)` for stage 3.
`gate` is the `deciding_priority` handed to the allocator: None applies
every unit's mode, a number above P_MODE releases them. `held` is the set
of units whose mode holds them back in the target's direction; while the
modes are released they weigh 0, so they carry only what the other units
cannot (the allocator's last-resort pass).
The rule matches N=1: a SET above the mode decides alone. Otherwise the
target is clamped into what the units can place with their modes
(`allocator.loop_bound()`) and back into the site limits above the mode.
If that point is still placeable with the modes, they hold and the target
is unchanged (what they cannot place is reported unmet, as before). If a
limit above the mode needs more, the target moves only that far, and the
modes are released for it.
"""
mode_units = [u for u, b in bounds.items()
if getattr(b, "mode_priority", None) is not None]
if not mode_units or out.contradiction:
return out.deciding_priority, set()
prio = min(bounds[u].mode_priority for u in mode_units)
if out.winner_priority is not None and out.winner_priority > prio:
return out.deciding_priority, set()
clo, chi = allocator.loop_bound(bounds, live)
t1 = max(clo, min(chi, out.target_w))
before = out.target_w
binder = _yield_to(out, t1, _limits_above(site_claims, prio))
if binder is None:
out.target_w = float(before)
return None, set()
held = set()
for u in mode_units:
b = bounds[u]
nlo, nhi, note = allocator.unit_interval(b)
if note is not None:
continue
if (nhi < b.hi) if out.target_w > 0 else (nlo > b.lo):
held.add(u)
return binder.priority, held
+54
View File
@@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure.
"""
import math
from math import inf
from dataclasses import dataclass
# ---------------------------------------------------------------------------
@@ -293,6 +294,10 @@ REFUSED_BELOW_W = PROBE_ANSWER_W
PROBE_EVERY_S = 900.0
PROBE_S = 34.0
# OPT-11. The loop's reason when the battery modes, not the meter, set the
# command: every battery that may move this way is at its mode's limit.
MODE_BOUND = "mode-bound"
@dataclass(frozen=True)
class Tuning:
@@ -475,6 +480,7 @@ def compute(
i_w: float | None = None,
refused: int | None = None,
probe: bool = False,
bound: tuple | None = None,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -485,8 +491,24 @@ def compute(
`refused` SAFETY-52's carried refused direction, or None to leave it out
(the `i_w=None` equivalence path does)
`probe` the caller's clock says a refused direction may try PROBE_W now
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
their modes applied (`allocator.loop_bound()`), or None. See
MODE_BOUND below. None is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
# mode may do. It is dropped rather than obeyed (a software fault, and
# main.py never builds one: every term of `loop_bound()` holds 0).
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
bound = None
# ...and a side of it that reaches the rail is left to the rail, so that
# SAFETY-03's `i_clamped` still fires when the loop winds into it. With
# every battery on, the bound IS the rail, and the law is exactly as it was.
if bound is not None:
_rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None
else tuning.integrator_max_w)
bound = (float(bound[0]) if bound[0] > -_rail else -inf,
float(bound[1]) if bound[1] < _rail else inf)
# --- saturation, WITH the duration term -------------------------------
# Command and actual diverging means the inverter cannot follow - it is at
@@ -589,6 +611,15 @@ def compute(
else:
i_w = max(moved, i_w)
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Only a side tighter than the rail is left in `bound`
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
if bound is not None:
held = max(float(bound[0]), min(float(bound[1]), i_w))
if held != i_w:
i_w = held
reason = MODE_BOUND
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
# bound is not. It is what makes the worst-case unwind time finite and
# knowable instead of a function of how long the error happened to stand.
@@ -796,6 +827,22 @@ def compute(
target = refused * PROBE_W if probe and wants else 0.0
reason = PROBING if probe and wants else REFUSED
# --- OPT-11: the command stays inside what the modes allow -------------
# Applied after the slew limit and the freeze, so a mode that closes drops
# the ask at once rather than one slew step per cycle. That moves no
# battery further than the allocator would: it places at most this bound
# when the loop decides. What it removes is the unmet part, which would
# otherwise hold `prev_w` away from `actual_w` and trip the saturation
# freeze on watts no battery was allowed to give.
if bound is not None:
inside = max(float(bound[0]), min(float(bound[1]), target))
if inside != target:
target = inside
reason = MODE_BOUND
# SAFETY-48's lead cap above may pull the integrator back toward the
# slewed command; what is CARRIED stays inside the bound too.
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
# --- quantise ----------------------------------------------------------
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
@@ -803,6 +850,13 @@ def compute(
# 10 W simply avoids a visible staircase on dashboards.
step = max(1, int(tuning.step_w))
target = round(target / step) * step
if bound is not None:
# OPT-11: rounding must not step outside the bound. The bound holds
# 0 W, so one step toward 0 is always inside it.
if target > bound[1]:
target -= step
elif target < bound[0]:
target += step
return Decision(float(target), sat_count, frozen, reason, float(i_w),
i_clamped, clamped, float(i_bound_w), float(want),
+19 -1
View File
@@ -51,10 +51,18 @@ class Outage:
OUTAGE = Outage()
class ReadOnly(RuntimeError):
"""A service call on a read-only HomeAssistant (UX-15 setup-only mode)."""
class HomeAssistant:
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
def __init__(self, session: aiohttp.ClientSession, token: str | None = None,
*, read_only: bool = False):
self.session = session
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
# UX-15: setup-only mode reads Home Assistant and calls no service.
# Fixed at construction; `call()` refuses before it sends anything.
self._read_only = bool(read_only)
self._last_moan: dict[str, float] = {}
if not self.token:
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
@@ -181,7 +189,17 @@ class HomeAssistant:
return None
return (_f("min"), _f("max"), _f("step"))
@property
def read_only(self) -> bool:
return self._read_only
async def call(self, domain: str, service: str, data: dict) -> bool:
if self._read_only:
# Every service call, `number.set_value` included, goes through
# here, so setup-only mode has no way to command (DECISIONS UX-15).
_LOG.error("Setup-only mode called no service, because it may not "
"command anything.")
raise ReadOnly(f"{domain}.{service} refused: setup-only mode")
try:
async with self.session.post(
f"{CORE_API}/services/{domain}/{service}",
+199 -179
View File
@@ -9,14 +9,15 @@ note, section 3.3.
The plan shows only what the add-on does today: trading on price (the
arbitrage verdict, the same call the control cycle makes), the monthly battery
cycle, the peak limit and "follows the house". Battery modes (OPT-11) are not
built, so nothing here invents a mode plan. Every sentence is in the words
cycle, the peak limit and "follows the house". OPT-11 P1 adds each battery's
switch and mode to its row; the mode plan arrives with P3/P4, so nothing here
invents one. Every sentence is in the words
section at the bottom and is held to STYLE.md by test_messages.py.
"""
from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard
from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE
from .price import Absence, Uncertainty, brussels
@@ -54,18 +55,6 @@ def hhmm(t: datetime) -> str:
return f"{brussels(t):%H:%M}"
def _kw(w) -> str:
return f"{abs(float(w)) / 1000:.1f} kW"
def _eur(v) -> str:
return f"€{float(v):.2f}"
def _kwh(v) -> str:
return f"{float(v):.1f} kWh"
# --------------------------------------------------------------------------- #
# Now
# --------------------------------------------------------------------------- #
@@ -490,8 +479,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
"day": brussels(now).date().isoformat(),
"now_h": round(_hours(now, day0), 3),
"hours": int(round(_hours(day1, day0))),
"verdict": (VERDICT[verdict_key] if settled or verdict_key != "held"
else ZERO_GOING),
"verdict": verdict_words(verdict_key, settled),
"verdict_key": verdict_key,
"head": plan_head(verdict_key, shadow, settled),
"prices": bars,
@@ -537,40 +525,26 @@ def battery_rows(c, shadow, running, held=False):
"stored_kwh": (None if cap is None or inv.soc is None
else round(cap * float(inv.soc) / 100.0, 2)),
"state": flow(inv.batt, shadow=shadow),
"mode": MODE_HOUSE,
"mode": mode_house(),
"maintenance": phase,
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
running=running, held=held,
would_w=shares.get(inv.name),
settled=at_zero(inv)),
# Per-battery reasons arrive with battery modes (OPT-11).
# Per-battery reasons arrive with the plan (OPT-11 P3/P4).
"why": None,
})
if getattr(c, "modes_on", False):
# OPT-11 P1: the battery's switch and mode, for the Batteries
# screen. Only with battery modes on, so the page is unchanged
# without them. UX-13 P3 builds the real screen on these keys.
pm = c.pack_mode(inv)
rows[-1].update({"use": pm.use, "mode_key": pm.key,
"mode_choice": pm.mode,
"mode": mode_word(pm.key)})
return rows
# Row id -> the title on Home. The row's own sentence is the body.
ATTN_TITLES = {
"controller": "Control is not running",
"meter": "Meter reading",
"meter_frozen": messages.T_METER_FROZEN,
"freeze_check": "Stuck meter check",
"month_peak": "This month's peak",
"peak_guard": "Peak limit",
"peak_forecast": "Peak limit forecast",
"sensor": "Battery readings",
"control": "Inverter control",
"power_too_high": messages.T_OUTPUT_CLAMP,
"not_keeping_up": messages.T_LOOP_BOUND,
"write": "Power request not accepted",
"direction": "Power went the wrong way",
"site_direction": "Power went the wrong way",
"circulating": messages.T_CIRCULATING,
"delivery": messages.S_NOT_DELIVERING,
"estop": "Emergency stop",
"pv": "Solar reading",
None: "Inverter connection",
}
# Row id -> the DOCS.md heading under Notifications, where one exists.
ATTN_DOCS = {
"meter_frozen": messages.T_METER_FROZEN,
@@ -594,7 +568,7 @@ def attention(checks):
sev = INFO if ok else (WARNING if warn else ALARM)
rid = row.get("id")
out.append({"severity": sev, "id": rid,
"title": ATTN_TITLES.get(rid, ATTN_TITLES[None]),
"title": attn_title(rid),
"body": row.get("text", ""),
"docs": ATTN_DOCS.get(rid)})
out.sort(key=lambda a: rank[a["severity"]])
@@ -605,7 +579,7 @@ def month_peak(c, now):
"""This month's peak against the limit, and the 15-minute period so far."""
limit_w = c.peak.cap_w # the limit the controller parsed at start
local = brussels(now)
out = {"month": f"{local:%B}", "limit_w": limit_w, "peak_w": None,
out = {"month": month_name(local), "limit_w": limit_w, "peak_w": None,
"pct": None, "period_w": None, "state": "not_covered",
"line": ""}
if not c.p1_enabled:
@@ -644,8 +618,18 @@ def state_of(c, level, shadow):
return "running"
def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict:
"""The structured half of `/status`. Pure reads of the controller."""
def build(c, *, level: str, checks: list, now: datetime | None = None,
lang: str = "en") -> dict:
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
the controller."""
_LANG[0] = uitext.lang_of(lang)
try:
return _build(c, level=level, checks=checks, now=now)
finally:
_LANG[0] = "en"
def _build(c, *, level, checks, now):
now = now or datetime.now(timezone.utc)
shadow = bool(c.shadow)
kind = state_of(c, level, shadow)
@@ -667,7 +651,7 @@ def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict:
"plan": build_plan(c, now, shadow, running, held=held,
settled=settled),
"attention": ([{"severity": INFO, "id": "shadow",
"title": ATTN_SHADOW, "body": attn_shadow_body(),
"title": attn_shadow(), "body": attn_shadow_body(),
"docs": None}] if shadow else []) + attention(checks),
"month_peak": month_peak(c, now),
"control": {"running": bool(c.auto), "shadow": shadow,
@@ -687,279 +671,315 @@ def _docs_url():
# The words. STYLE.md: plain, short, present tense. A plan line is at most
# two sentences of at most 12 words each (UX-13 design, section 4); a title at
# most 5 words; everything else one sentence of at most 25 words. In shadow
# mode a text says what the add-on WOULD do. test_messages.py renders every
# function below through `text_samples()` and checks it.
# mode a text says what the add-on WOULD do. The sentences themselves are in
# app/ui_text/<lang>.json under `home_words` (UX-13 P5: English and Dutch).
# test_messages.py and test_ui_text.py render every function below through
# `text_samples()` in each language and check it.
# =========================================================================== #
MODE_HOUSE = "Follows the house"
ATTN_SHADOW = "Shadow mode is on"
_LANG = ["en"] # the language of the build in progress (build())
_WORDS: dict = {}
def _hw() -> dict:
lang = _LANG[0]
if lang not in _WORDS:
_WORDS[lang] = uitext.words(lang)
return _WORDS[lang]
def _t(path, **values) -> str:
node = _hw()["home_words"]
for k in path.split("."):
node = node[k]
return uitext.say(node, **values)
def _dec(text) -> str:
"""A decimal comma in Dutch."""
return text.replace(".", ",") if _LANG[0] == "nl" else text
def _kw(w) -> str:
return _dec(f"{abs(float(w)) / 1000:.1f}") + " kW"
def _eur(v) -> str:
return "€" + _dec(f"{float(v):.2f}")
def _kwh(v) -> str:
return _dec(f"{float(v):.1f}") + " kWh"
# Row id -> the title on Home. The row's own sentence is the body, and it stays
# in English (app/messages.py). Five titles are the notification's own title,
# which test_ui_text.py checks.
_EN = uitext.words("en")["home_words"]
ATTN_TITLES = {(None if k == "none" else k): v for k, v in _EN["attn"].items()}
ATTN_FROM_MESSAGES = {
"meter_frozen": messages.T_METER_FROZEN,
"power_too_high": messages.T_OUTPUT_CLAMP,
"not_keeping_up": messages.T_LOOP_BOUND,
"circulating": messages.T_CIRCULATING,
"delivery": messages.S_NOT_DELIVERING,
}
VERDICT = dict(_EN["verdict"])
MODE_HOUSE, ATTN_SHADOW = _EN["mode_house"], _EN["attn_shadow"]
ZERO_HELD, ZERO_GOING = _EN["zero_held"], _EN["zero_going"]
def mode_house() -> str:
return _t("mode_house")
def mode_word(key) -> str:
"""OPT-11: a battery's mode, or Off, in the page's language."""
return _t("modes." + key)
def attn_shadow() -> str:
return _t("attn_shadow")
def attn_shadow_body() -> str:
return "The add-on decides everything, but every inverter stays at 0 W."
ZERO_HELD, ZERO_GOING = "Held at 0 W", "Going to 0 W"
VERDICT = {
"held": ZERO_HELD,
"stopped": "Stopped",
"cycle": "Monthly cycle today",
"trade": "Trading on price",
"house": "Follows the house",
}
return _t("attn_shadow_body")
def _w(shadow, live, would):
return would if shadow else live
def verdict_words(key, settled=True) -> str:
return _t("verdict." + key) if settled or key != "held" \
else _t("zero_going")
def attn_title(rid) -> str:
table = _hw()["home_words"]["attn"]
return table.get(rid or "none", table["none"])
def month_name(local) -> str:
return _hw()["home_words"]["months"][local.month - 1]
def _w(shadow, key, **values) -> str:
"""`key`, or its `key_would` form in shadow mode."""
return _t(key + "_would" if shadow else key, **values)
def pill(kind) -> str:
return {"running": "Running", "shadow": "Shadow mode",
"stopped": "Stopped", "alarm": "Needs you now",
"warning": "Running, with a warning"}[kind]
return _t("pill." + kind)
def banner_sub(kind) -> str:
return {
"running": "The batteries follow the plan below.",
"shadow": "The plans below say what the add-on would do.",
"stopped": "Start control below when you are ready.",
"alarm": "See Needs attention below for what to do.",
"warning": "See Needs attention below.",
}[kind]
return _t("banner_sub." + kind)
def tile_solar(pv_w, has_solar) -> str:
if not has_solar:
return "Not set up"
return _t("tile.not_set_up")
if pv_w is None:
return "No reading"
return "Producing" if pv_w > IDLE_W else "Not producing"
return _t("tile.no_reading")
return _t("tile.producing") if pv_w > IDLE_W else _t("tile.not_producing")
def tile_house(house_w, has_solar, disagree=False) -> str:
if not has_solar:
return "Needs a solar reading"
return _t("tile.needs_solar")
if disagree:
return "Readings do not add up"
return "No reading" if house_w is None else "Using"
return _t("tile.not_adding_up")
return _t("tile.no_reading") if house_w is None else _t("tile.using")
def tile_grid(grid_w) -> str:
if grid_w is None:
return "No reading"
return _t("tile.no_reading")
if grid_w > IDLE_W:
return "Taking from the grid"
return _t("tile.taking")
if grid_w < -IDLE_W:
return "Feeding in"
return "Balanced"
return _t("tile.feeding")
return _t("tile.balanced")
def _zero(settled) -> str:
return ZERO_HELD if settled else ZERO_GOING
return _t("zero_held") if settled else _t("zero_going")
def tile_batt(batt_w, shadow, settled=True) -> str:
if shadow:
return _zero(settled)
return {"giving": "Giving power", "charging": "Charging",
"resting": "Resting", None: "No reading"}[flow(batt_w)]
return _t("tile." + {"giving": "giving", "charging": "charging",
"resting": "resting",
None: "no_reading"}[flow(batt_w)])
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
would_w=None, settled=True) -> str:
if soc is None or batt_w is None:
return "No reading from this battery."
return _t("now.no_reading")
zero = _zero(settled)
if shadow:
if would_w is None or abs(would_w) <= IDLE_W:
return f"{zero}. Would rest now."
verb = "give" if would_w > 0 else "charge"
return f"{zero}. Would {verb} {_kw(would_w)} now."
return _t("now.would_rest", zero=zero)
verb = "would_give" if would_w > 0 else "would_charge"
return _t("now." + verb, zero=zero, kw=_kw(would_w))
if held:
return f"{zero} until the alarm clears."
return _t("now.held", zero=zero)
if not running:
return f"{zero} while control is stopped."
return _t("now.stopped", zero=zero)
if phase != M_IDLE:
return {M_DRAIN: "Monthly cycle: emptying.",
M_CHARGE: "Monthly cycle: refilling.",
M_HOLD: "Monthly cycle: holding full."}.get(
phase, "Monthly cycle under way.")
return _t("now." + {M_DRAIN: "cycle_drain", M_CHARGE: "cycle_charge",
M_HOLD: "cycle_hold"}.get(phase, "cycle"))
st = flow(batt_w)
if st == "giving":
return f"Giving {_kw(batt_w)} now."
return _t("now.giving", kw=_kw(batt_w))
if st == "charging":
return f"Charging {_kw(batt_w)} now."
return "Resting now."
return _t("now.charging", kw=_kw(batt_w))
return _t("now.resting")
def plan_head(key, shadow, settled=True) -> str:
if key == "held":
return ("Every inverter is at 0 W until the alarm clears." if settled
else "Every inverter goes to 0 W until the alarm clears.")
return _t("head.held" if settled else "head.held_going")
if key == "stopped":
return "Control is stopped, so nothing is planned."
return _t("head.stopped")
if not shadow:
return {
"cycle": "A battery runs its monthly cycle today.",
"trade": "Charges in the cheapest hour, gives power in the dearest.",
"house": "Charges from spare solar and covers the house.",
}[key]
return "Shadow mode: this is what the add-on would do. " + {
"cycle": "A battery would run its monthly cycle today.",
"trade": "Would charge when cheapest and give power when dearest.",
"house": "Would charge from spare solar and cover the house.",
}[key]
return _t("head." + key)
return _t("head.shadow") + " " + _t("head." + key + "_would")
def run_grid_what(kwh, shadow) -> str:
return _w(shadow, "Charges", "Would charge") + \
f" up to {_kwh(kwh)} from the grid."
return _w(shadow, "run.grid_what", kwh=_kwh(kwh))
# The trade legs and the price line use the feed's own points (a 15-minute
# period on a quarter-hourly feed), so both name the same price and time.
def run_grid_why(price) -> str:
return f"Lowest published price, {_eur(price)} per kWh."
return _t("run.grid_why", eur=_eur(price))
def run_grid_amount(eur) -> str:
return f"Costs up to {_eur(eur)}."
return _t("run.grid_amount", eur=_eur(eur))
def run_give_what(kwh, shadow) -> str:
return _w(shadow, "Gives", "Would give") + \
f" up to {_kwh(kwh)}, to the house first."
return _w(shadow, "run.give_what", kwh=_kwh(kwh))
def run_give_why(price) -> str:
return f"Highest published price, {_eur(price)} per kWh."
return _t("run.give_why", eur=_eur(price))
def run_give_amount(eur) -> str:
return f"Worth up to {_eur(eur)} at that price."
return _t("run.give_amount", eur=_eur(eur))
def run_cycle_what(phase, shadow) -> str:
if phase == M_IDLE:
return _w(shadow, "Starts", "Would start") + \
" the monthly battery cycle."
return _w(shadow, "Runs", "Would run") + " the monthly battery cycle now."
return _w(shadow, "run.cycle_start" if phase == M_IDLE else "run.cycle_now")
def run_cycle_why(shadow=False) -> str:
return _w(shadow, "Empties, refills and holds full",
"Would empty, refill and hold full") + \
", so the level reads right."
return _w(shadow, "run.cycle_why")
def run_cycle_amount(hours) -> str:
if hours is None:
return "How long depends on the battery."
return f"Takes about {max(1, round(hours))} hours."
return _t("run.cycle_amount_unknown")
return _t("run.cycle_amount", hours=max(1, round(hours)))
def run_peak_what(limit_w, shadow) -> str:
return _w(shadow, "Gives", "Would give") + \
f" power to stay under {_kw(limit_w)}."
return _w(shadow, "run.peak_what", kw=_kw(limit_w))
def run_peak_why() -> str:
return "The peak limit comes before everything else."
return _t("run.peak_why")
def run_house_what(shadow) -> str:
return _w(shadow, "Follows", "Would follow") + " the house."
return _w(shadow, "run.house_what")
def run_house_why(shadow=False) -> str:
return _w(shadow, "Charges from spare solar, gives power",
"Would charge from spare solar, give power") + \
" when the house needs it."
return _w(shadow, "run.house_why")
def run_house_amount() -> str:
return "Amounts depend on the sun and the house."
return _t("run.house_amount")
def line_no_trade(key) -> str:
return {
"off": "",
"not_covered": "No trading on price: nothing measures the 15-minute "
"peak.",
"no_prices": "No trading on price yet: no prices have arrived.",
"peak": "No trading on price now: the peak limit comes first.",
"not_firm": "No trading on price yet: the prices are not published.",
"not_worth": "No trading on price today. The price gap is too small.",
"no_level": "No trading on price now: a battery level is unknown.",
}[key]
return "" if key == "off" else _t("no_trade." + key)
def line_trade_tomorrow(kind, at, price, shadow) -> str:
verb = {"grid": _w(shadow, "Charges", "Would charge"),
"give": _w(shadow, "Gives power", "Would give power")}[kind]
return f"Tomorrow: {verb.lower()} at {at}, {_eur(price)} per kWh."
return _w(shadow, "tomorrow_trade." + kind, at=at, eur=_eur(price))
def line_cycle_waits(name) -> str:
return f"{name} waits its turn for the monthly cycle."
return _t("cycle_waits", name=name)
def line_peak_limit(limit_w, shadow) -> str:
return _w(shadow, "The batteries step in", "The batteries would step in") \
+ f" above {_kw(limit_w)}."
return _w(shadow, "step_in", kw=_kw(limit_w))
def line_prices(now_eur, lo, hi) -> str:
parts = []
if now_eur is not None:
parts.append(f"Price now {_eur(now_eur)} per kWh, all in.")
parts.append(_t("prices.now", eur=_eur(now_eur)))
if lo is not None and hi is not None:
parts.append(f"Today: lowest {_eur(lo['eur_kwh'])} at "
f"{lo['label']}, highest {_eur(hi['eur_kwh'])} at "
f"{hi['label']}.")
return " ".join(parts) or "No prices for today."
parts.append(_t("prices.today", lo=_eur(lo["eur_kwh"]),
lo_at=lo["label"], hi=_eur(hi["eur_kwh"]),
hi_at=hi["label"]))
return " ".join(parts) or _t("prices.none")
def line_tomorrow(known) -> str:
return ("Tomorrow's prices are in." if known else
"Tomorrow's prices usually arrive after 13:00.")
return _t("tomorrow.known" if known else "tomorrow.unknown")
def line_region(rid, year) -> str:
if rid in REGION_PRESETS and year:
return f"Levies and VAT: {REGION_WORDS.get(rid, rid)}, {year} figures."
return "Levies and VAT: your own figure."
REGION_WORDS = {"flanders": "Flanders, Fluvius Antwerpen"}
name = _hw()["choices"]["region"].get(rid, rid)
return _t("region.preset", region=name, year=year)
return _t("region.own")
def line_peak_not_covered() -> str:
return "No peak limit: this meter does not measure 15-minute periods."
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow) -> str:
step = _w(shadow, "The batteries step in", "The batteries would step in")
step = _w(shadow, "step_in", kw=_kw(limit_w))
if period_w is None:
return f"{step} above {_kw(limit_w)}."
return (f"This 15-minute period is heading for {_kw(period_w)}. "
f"{step} above {_kw(limit_w)}.")
return step
return _t("period", kw=_kw(period_w)) + " " + step
def control_text(running, shadow) -> str:
if shadow:
return "Shadow mode: every inverter stays at 0 W."
return _t("control.shadow")
if running:
return "Control is running."
return "Control is stopped. Every inverter is at 0 W."
return _t("control.running")
return _t("control.stopped")
def text_samples() -> list:
"""`(kind, text)` for every sentence above, rendered with real values, so
test_messages.py can hold each to its STYLE limit. Kinds: title, plan
(2 sentences of 12 words), line (1 sentence of 25 words)."""
out = [("title", t) for t in VERDICT.values()] + [("title", ZERO_GOING)]
out += [("title", t) for t in ATTN_TITLES.values()]
out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW),
def text_samples(lang="en") -> list:
"""`(kind, text)` for every sentence above in `lang`, rendered with real
values, so test_messages.py can hold each to its STYLE limit. Kinds: title,
plan (2 sentences of 12 words), line (1 sentence of 25 words)."""
_LANG[0] = uitext.lang_of(lang)
try:
return _samples()
finally:
_LANG[0] = "en"
def _samples() -> list:
words = _hw()["home_words"]
out = [("title", t) for t in words["verdict"].values()]
out += [("title", _t("zero_going")), ("title", _t("zero_held"))]
out += [("title", t) for t in words["attn"].values()]
out += [("title", mode_word(k)) for k in words["modes"]]
out += [("title", mode_house()), ("title", attn_shadow()),
("line", attn_shadow_body())]
for k in ("running", "shadow", "stopped", "alarm", "warning"):
out += [("title", pill(k)), ("line", banner_sub(k))]
+196 -29
View File
@@ -37,9 +37,11 @@ import aiohttp
from . import arbitrage
from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
Claim, Resolution, actuator_for, resolve,
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_MODE, P_PEAK, P_SAFETY,
Claim, Resolution, actuator_for, pre_resolve, resolve,
)
from . import allocator
from . import packmode
from . import control as _ctl
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .delivery import (
@@ -67,7 +69,8 @@ from .hass import HomeAssistant
from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, arbitrate as maint_arbitrate,
IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate,
)
from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs,
unit_state_key)
@@ -87,9 +90,11 @@ from .watchdog import (
AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS,
SignWatchdog, SignWatchdogConfig, loop_is_following,
)
from . import setuponly
from . import web
OPTIONS_PATH = "/data/options.json"
INGRESS_PORT = 8099
_LOG = logging.getLogger("goodwe")
# ⚠️ `UNIT_A` NOW LIVES IN app/inverter.py AND IS RE-EXPORTED HERE, because the
@@ -375,6 +380,11 @@ class Controller:
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
peak_db_path: str = "/data/tariff.db",
price_history_path: str = "/data/price_history.json"):
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
# builds no controller. Refused here so no controller can hold one.
if getattr(hass, "read_only", False) is True:
raise ValueError("a controller cannot run on a read-only "
"HomeAssistant (setup-only mode)")
self.o = opts
self.hass = hass
self.store = store
@@ -887,6 +897,13 @@ class Controller:
self._peak_saturated = False
self._peak_floor_limited = False
self._maint_shape_suspended = False
# OPT-11. Battery modes, off by default so an install that has not
# turned them on behaves exactly as before (SAFETY-10 section 1). Each
# battery's mode and on/off switch live in the store, not in options,
# so a change applies at once (`pack_use`, `pack_mode` actions).
self.modes_on = opts.get("battery_modes_enabled") is True
# The units whose mode fault is logged already (edge-logged).
self._mode_faults = set()
# [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = []
@@ -1946,7 +1963,103 @@ class Controller:
that have nothing to do with the allocator, and a weighting held from
construction prefers a pack that has since emptied.
"""
return packshare.weights(self.pack_energy, target_w)
base = packshare.weights(self.pack_energy, target_w)
if not self.modes_on:
return base
# OPT-11: the solar order. While the site charges, Leftover solar
# batteries weigh 0, so Solar first batteries fill first. A comfort
# choice like the rest of this method: permission is the mode claim.
return packmode.charge_weights(base, self.pack_modes(), target_w)
def pack_mode(self, inv) -> "packmode.PackMode":
"""OPT-11. This battery's on/off switch and mode, read from the store."""
return packmode.read(getattr(self.store, "data", None) or {}, inv.name)
def pack_modes(self) -> dict:
return {inv.name: self.pack_mode(inv) for inv in self.inverters}
def loop_bound(self, claims):
"""OPT-11. `(lo, hi)`: what the batteries may place if the loop decides.
The sum over live units of each unit's hard limits with its mode
applied (`allocator.loop_bound()`), from THIS cycle's unit claims. At
one inverter every claim is on the site, so it is the mode range
itself, or None when no mode is filed (maintenance) or the range
leaves out 0 W (a fault `resolve()` reports).
"""
if len(self.inverters) <= 1:
modes = [c for c in claims if c.source == "mode"]
if not modes:
return None
lo = max(c.lo for c in modes)
hi = min(c.hi for c in modes)
return (lo, hi) if lo <= 0.0 <= hi else None
bounds = {u: pre_resolve([c for c in claims if c.actuator == u])
for u in self.units}
return allocator.loop_bound(bounds, live=self.live_units())
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
A mode range without 0 W would force a direction, which no mode may do,
so it is never applied (allocator.unit_interval()). main.py never
builds one; this is the line that says so if it ever does.
"""
alloc = resolution.allocation
now = {u for u, _ in getattr(alloc, "mode_faults", ())}
if resolution.mode_fault is not None and self.inverters:
now.add(self.inverters[0].name)
for inv in self.inverters:
if inv.name in now and inv.name not in self._mode_faults:
_LOG.error("Battery mode ignored for %s. Software fault "
"MODE_RANGE.", inv.name)
self.log_event(f"battery mode ignored on {inv.name}, a "
f"software fault", hint=messages.hint(
"mode_fault", unit=inv.name))
self._mode_faults = now
def _pack_action(self, what, unit, value, now) -> dict:
"""OPT-11: switch one battery on or off, or set its mode.
Store state, not an option, so it applies on the next cycle with no
restart. Written to the action log. Switching a battery off also stops
its maintenance cycle (the owner's 2026-09-27 answer). Refused, with a
plain reason, while battery modes are off or for an unknown battery or
value.
"""
if not self.modes_on:
return {"error": messages.pack_refused("off")}
inv = next((i for i in self.inverters if i.name == unit), None)
if inv is None:
return {"error": messages.pack_refused("unit")}
if what == "pack_use":
if not isinstance(value, bool):
return {"error": messages.pack_refused("value")}
inv.store_set(packmode.USE_KEY, value)
self.log_event(f"{inv.name} switched {'on' if value else 'off'} "
f"by operator",
hint=messages.hint("pack_on" if value
else "pack_off", unit=inv.name))
if not value:
self._maint_stop_off(self.maints[self.inverters.index(inv)],
now)
else:
if value not in packmode.MODES:
return {"error": messages.pack_refused("value")}
inv.store_set(packmode.MODE_KEY, value)
self.log_event(f"{inv.name} mode set to {value.replace('_', ' ')} "
f"by operator",
hint=messages.hint("pack_mode", unit=inv.name))
pm = self.pack_mode(inv)
return {"unit": inv.name, "use": pm.use, "mode": pm.mode}
def _maint_stop_off(self, machine, now) -> None:
"""OPT-11: stop this battery's maintenance cycle because it is off."""
if machine.phase == IDLE:
return
for msg in machine.abort(now, "battery switched off"):
self.log_event(msg, hint=messages.hint("maint_off",
unit=machine.name))
@property
def why_words(self) -> str:
@@ -2689,9 +2802,27 @@ class Controller:
# Calling `tick()` without the answer this produced is the
# two-packs-draining-at-once defect with the interlock present but not
# wired, which is why the plan is built here and not inside the loop.
maint_plan = maint_arbitrate(self.maints, now)
# OPT-11: a battery switched off starts no cycle and is left out of the
# slot, so it cannot hold the slot from a battery that is on. One that
# is mid-cycle is stopped, with a log line (the owner's 2026-09-27
# answer). The switch itself stops it at once (`pack_use`); this is
# the same rule for a store that says Off at start-up.
_off = ({inv.name for inv in self.inverters
if not self.pack_mode(inv).use} if self.modes_on else set())
for inv, machine in zip(self.inverters, self.maints):
if inv.name in _off:
self._maint_stop_off(machine, now)
_on = [m for inv, m in zip(self.inverters, self.maints)
if inv.name not in _off]
_plan_on = iter(maint_arbitrate(_on, now) if _off
else maint_arbitrate(self.maints, now))
maint_plan = [None if inv.name in _off else next(_plan_on)
for inv, _m in zip(self.inverters, self.maints)]
maint_results = []
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
if blocked is None:
maint_results.append(MaintResult(IDLE, False, None, False, []))
continue
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg, hint=messages.hint("maintenance",
@@ -3557,6 +3688,23 @@ class Controller:
actuator=self.actuator_of("maintenance",
inv)))
# --- OPT-11: each battery's mode, a soft unit limit at P_MODE ---------
# Filed only with battery modes on, and never on a battery in any
# maintenance phase: maintenance at 60/40 outranks the mode. The range
# always holds 0 W (packmode.mode_range()). pre_resolve() keeps it
# apart from the hard limits, and it gives way to the peak guard's
# site floor (P_PEAK) only as far as the floor needs, so the two never
# meet in a `lo > hi` (arbiter._unit_modes()).
if self.modes_on:
for inv, result in zip(self.inverters, maint_results):
if result.phase != IDLE:
continue
pm = self.pack_mode(inv)
lo, hi = packmode.mode_range(pm, inv.max_w)
claims.append(Claim.limit("mode", P_MODE, lo, hi, pm.key,
actuator=self.actuator_of("mode",
inv)))
# --- the grid-following loop -----------------------------------------
# ⚠️ SAFETY-10: `or self.shadow`. The loop is the leg a shadow run most
# needs and the one that is switched off by `auto_start: false`, so
@@ -3581,6 +3729,28 @@ class Controller:
self._unit_refusals()
_probing = {inv.name: self._unit_probe(inv)
for inv in self.inverters if inv.refused}
# --- SAFETY-73: each refusing unit's own P_SAFETY claim ---------------
# A hard unit limit that always intersects (OPT-11's P_MODE layer sits
# inside it): closed in the refused direction, so the allocator hands
# the share to the others within their own limits. During its probe it
# is a pin at PROBE_W and the others carry the rest. Filed here, before
# the loop, so OPT-11's loop bound sees it. Unit claims only, and still
# the last unit claims filed, so every unit's claim order is as before.
for inv in self.inverters:
if not inv.refused:
continue
if _probing.get(inv.name):
claims.append(Claim.set("refused", P_SAFETY,
inv.refused * _ctl.PROBE_W,
_ctl.PROBING,
actuator=self.actuator_of("refused",
inv)))
else:
claims.append(Claim.limit("refused", P_SAFETY,
*_refused_interval(inv.refused),
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
@@ -3642,6 +3812,9 @@ class Controller:
refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(),
probe=probe,
# OPT-11: None unless battery modes are on, which is the law
# exactly as it was.
bound=self.loop_bound(claims) if self.modes_on else None,
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
@@ -3750,26 +3923,6 @@ class Controller:
# ability to DELIVER rests on this argument - a weight cannot stop a
# unit being commanded and cannot move a failing unit's load to its
# sibling. The `pack` claim filed above is the half that constrains.
# --- SAFETY-73: each refusing unit's own P_SAFETY claim ---------------
# A hard unit limit that always intersects (OPT-11's P_MODE layer sits
# inside it): closed in the refused direction, so the allocator hands
# the share to the others within their own limits. During its probe it
# is a pin at PROBE_W and the others carry the rest.
for inv in self.inverters:
if not inv.refused:
continue
if _probing.get(inv.name):
claims.append(Claim.set("refused", P_SAFETY,
inv.refused * _ctl.PROBE_W,
_ctl.PROBING,
actuator=self.actuator_of("refused",
inv)))
else:
claims.append(Claim.limit("refused", P_SAFETY,
*_refused_interval(inv.refused),
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
_live = self.live_units()
while True:
resolution = resolve(claims, units=self.units, live=_live,
@@ -3789,6 +3942,8 @@ class Controller:
*_refused_interval(1 if claims[k].value > 0 else -1),
_ctl.REFUSED, actuator=claims[k].actuator)
self.allocation = resolution.allocation
if self.modes_on:
self._mode_fault_log(resolution)
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
# which is then written like any other cycle at
@@ -5537,7 +5692,7 @@ class Controller:
_inv_list.index(ent) + 1)})
return out
def status_payload(self) -> dict:
def status_payload(self, lang: str = "en") -> dict:
checks = self.checks()
bad = [c for c in checks if not c["ok"] and not c["warn"]]
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
@@ -5723,7 +5878,8 @@ class Controller:
# never cost the page its rows: a fault here is logged and the old
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks))
out.update(homeview.build(self, level=level, checks=checks,
lang=lang))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
@@ -5735,8 +5891,10 @@ class Controller:
out["home_error"] = True
return out
async def handle_action(self, what: str):
async def handle_action(self, what: str, *, unit=None, value=None):
now = datetime.now(timezone.utc)
if what in ("pack_use", "pack_mode"):
return self._pack_action(what, unit, value, now)
if what == "auto_toggle":
self.auto = not self.auto
self.store.set("auto", self.auto)
@@ -6011,6 +6169,15 @@ async def amain() -> None:
_dev_min, _dev_max, _ = await hass.limits(_sp)
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
_problems = validate_startup_config(opts, _dev_min, _dev_max)
# UX-15: a new install names no inverter. When that is the ONLY
# refusal it serves the setup wizard in setup-only mode, which builds
# nothing that commands and returns when stopped. Any other refusal
# still exits 78 (DECISIONS/UX-15-setup-only-mode.md).
if _problems and setuponly.applies(opts, _problems):
await setuponly.serve(
opts, HomeAssistant(session, read_only=True), store,
port=INGRESS_PORT)
return
if _problems:
refuse(_problems)
raise ConfigRefused(_problems)
@@ -6054,7 +6221,7 @@ async def amain() -> None:
stop = asyncio.Event()
controller = Controller(opts, hass, store, pub, stop=stop)
runner = await web.start(controller, port=8099)
runner = await web.start(controller, port=INGRESS_PORT)
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
+30
View File
@@ -382,6 +382,16 @@ def banner_stopped() -> str:
return "Stopped — the inverters are set to 0 W"
def banner_setup_only() -> str:
"""UX-15: setup-only mode, where the add-on serves the page and commands
nothing (app/setuponly.py)."""
return "Setup: nothing is controlled yet"
def row_setup_only() -> str:
return "No inverter is named yet, so finish Setup and restart the add-on."
def banner_shadow(latched: bool) -> str:
"""SAFETY-10: first in the cascade, never displaced by another banner.
@@ -861,6 +871,7 @@ HINTS = {
"control_stopped": "Control stopped",
"settings_saved": "Settings saved, they apply after a restart",
"restart_requested": "Restart requested from the settings page",
"setup_only": "In setup, nothing is sent to any inverter",
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
@@ -908,9 +919,26 @@ HINTS = {
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
"maintenance": "Battery maintenance: {phase}",
"maint_paused": "Maintenance charging paused to stay under the peak limit",
"pack_on": "{unit}: battery switched on",
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
"pack_mode": "{unit}: battery mode changed",
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
"mode_fault": "{unit}: battery mode ignored because of a software fault",
}
# OPT-11: why a battery switch or mode change was refused (the page shows it).
PACK_REFUSED = {
"off": "Battery modes are off. Turn on “Battery modes” first.",
"unit": "There is no battery with that name.",
"value": "That choice is not possible for this battery.",
}
def pack_refused(why: str) -> str:
return PACK_REFUSED[why]
def hint(key: str, **kw) -> str:
"""The plain words for one event. `w` is formatted as watts, `sw` as
watts with their sign (positive gives power, negative charges)."""
@@ -952,6 +980,8 @@ def status_samples() -> list:
("banner_peak_reserve", None, banner_peak_reserve()),
("banner_power_too_high", None, banner_power_too_high()),
("banner_shadow", None, banner_shadow(False)),
("banner_setup_only", None, banner_setup_only()),
("row_setup_only", AMBER, row_setup_only()),
("banner_shadow", None, banner_shadow(True)),
("row_controller_starting", GREEN, row_controller_starting()),
("row_controller_ran", GREEN, row_controller_ran(2)),
+103
View File
@@ -0,0 +1,103 @@
"""OPT-11 P1: each battery's mode and on/off switch, as limits and weights.
Pure: no I/O, no clock. main.py reads the store, calls these, and files the
result as a `mode` claim (arbiter.P_MODE) and as the charge weights.
DECISIONS/OPT-11-battery-modes.md has the record; OPT-11-design section 1 has
the modes.
Two rules hold for every mode, and they are what keeps modes safe:
1. A mode range always holds 0 W. A mode may forbid a direction, never force
one, so batteries never move power in opposite directions because of a mode
(allocator.unit_interval() drops a range without 0 as a software fault).
2. A mode is a limit, not a weight. A weight of 0 does not keep a battery out:
allocator._fill()'s last-resort pass still uses it. So "may not" is a range,
and weights only set the order among batteries that may.
P1 has no price runs and no forecast (P3, P4). So both modes give power to the
house at any hour, and the only mode that closes a direction is Off. What P1
changes is the solar order: while the site charges, Leftover solar batteries
weigh 0, so they get solar only once the Solar first batteries are full or at
their power limit. Selling (the owner's 2026-09-27 answer) is a per-battery
choice that arrives with the price runs in P3; `PackMode.sell` and the
`pack_sell` store key are reserved for it and read by nothing yet.
"""
from dataclasses import dataclass
from .store import unit_key
SOLAR_FIRST = "solar_first"
LEFTOVER_SOLAR = "leftover_solar"
CHEAP_HOURS = "cheap_hours" # reserved for P3; not accepted in P1
# The modes a battery may be set to in this release, in the order the page
# lists them. The first is the default when the store has no value.
MODES = (SOLAR_FIRST, LEFTOVER_SOLAR)
DEFAULT_MODE = SOLAR_FIRST
# Store keys, per battery: `unit/<name>/<key>` (store.unit_key()).
USE_KEY = "pack_use"
MODE_KEY = "pack_mode"
SELL_KEY = "pack_sell" # reserved for P3
@dataclass(frozen=True)
class PackMode:
"""One battery's choice: on or off, its mode, and (P3) selling."""
use: bool = True
mode: str = DEFAULT_MODE
sell: bool = False # reserved for P3, never read in P1
@property
def key(self) -> str:
"""The page's word for this state: a mode, or `off`."""
return self.mode if self.use else "off"
def read(data: dict, name: str) -> PackMode:
"""Battery `name`'s choice from the store's data. A missing or unknown
value falls back to the default (on, Solar first), never to Off: a store
written by a later version must not switch a battery off here."""
use = data.get(unit_key(name, USE_KEY), True)
mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE)
return PackMode(use=use is not False,
mode=mode if mode in MODES else DEFAULT_MODE,
sell=data.get(unit_key(name, SELL_KEY)) is True)
def mode_range(pm: PackMode, rating_w: float) -> tuple:
"""`(lo, hi)` in W for this battery's mode claim. Always holds 0 W.
Off asks for 0 W both ways. In P1 both modes may charge and give power up
to the battery's rating: Leftover solar holds back its power only in the
planned expensive hours, and there are none before P3.
"""
if not pm.use:
return 0.0, 0.0
r = abs(float(rating_w))
return -r, r
def charge_weights(base, modes: dict, target_w: float):
"""The allocator's weights with the solar order applied (design 3.5).
`base` is packshare.weights()' answer (None for "all 1.0"). While the site
charges (`target_w < 0`), a Leftover solar battery weighs 0 and a Solar
first battery keeps its weight, so the fill gives the Solar first batteries
everything they can take before the last-resort pass reaches the others.
Giving power keeps `base` (P3 adds the expensive-run order).
When no battery that is on would keep a positive weight (all Leftover
solar, or every Solar first one at 0), `base` is returned unchanged, so the
order among equals stays OPT-09's rather than an equal split.
"""
if float(target_w) >= 0.0 or not modes:
return base
names = sorted(modes)
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
out = {n: (0.0 if modes[n].mode == LEFTOVER_SOLAR else w[n])
for n in names}
if not any(out[n] > 0.0 for n in names if modes[n].use):
return base
return out
+90
View File
@@ -0,0 +1,90 @@
"""UX-15: setup-only mode. A new install serves the setup wizard and commands
nothing. The rule and the argument are in DECISIONS/UX-15-setup-only-mode.md.
`applies()` says when: the startup gate's only refusal is that no inverter is
named. `serve()` then runs the ingress page on a `SetupOnly` stand-in for the
controller. Nothing that can command is built: no Controller, no inverter, no
MQTT, no meter task. Its HomeAssistant is read-only, so a service call refuses
before anything is sent (hass.py), and `/action` refuses too (web.py).
"""
import asyncio
import contextlib
import logging
import signal
from datetime import datetime, timezone
from . import messages, web
from .inverter import roster_entries
from .validate import actuator_writers, no_power_request_problem
_LOG = logging.getLogger("goodwe.setup")
def applies(opts: dict, problems) -> bool:
"""True when the options name no inverter at all and that is the gate's
only refusal. Anything else refused keeps the exit 78 (CFG-01)."""
return (not actuator_writers(opts) and not roster_entries(opts)
and list(problems) == [no_power_request_problem()])
class SetupOnly:
"""What web.build_app() needs from a controller, and nothing that writes.
It holds no inverter and no publisher. `hass` must be read-only."""
setup_only = True
auto = False # control is never started here
grid = None # no meter is read here
inverters = ()
def __init__(self, opts: dict, hass, store):
if getattr(hass, "read_only", False) is not True:
raise ValueError("setup-only mode needs a read-only HomeAssistant")
self.o, self.hass, self.store = opts, hass, store
self.events: list[str] = []
self.hint = ""
def log_event(self, msg: str, hint: str | None = None) -> None:
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
if hint is not None:
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
def status_payload(self, lang: str = "en") -> dict:
return {"setup_only": True, "mode": "setup_only",
"banner": messages.banner_setup_only(), "level": "warn",
"rows": [["Control", "not started, in setup"]],
"checks": [{"ok": False, "warn": True,
"text": messages.row_setup_only()}],
"hint": self.hint, "events": list(self.events)}
async def handle_action(self, what):
# web.py answers 409 before it gets here. Nothing is done either way.
_LOG.warning("The page asked for an action in setup, so nothing "
"was done.")
return None
async def shutdown(self) -> None:
"""Nothing to wind down: nothing was ever commanded."""
async def serve(opts: dict, hass, store, *, port: int) -> None:
"""Serve the page in setup-only mode until the Supervisor stops us."""
controller = SetupOnly(opts, hass, store)
_LOG.warning("No inverter is named yet, so the add-on is in setup and "
"controls nothing. Open its page to finish setup.")
controller.log_event("setup-only mode: no inverter named, nothing is "
"commanded", hint=messages.hint("setup_only"))
runner = await web.start(controller, port=port)
stop = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(NotImplementedError):
loop.add_signal_handler(sig, stop.set)
try:
await stop.wait()
finally:
await runner.cleanup()
_LOG.info("stopped")
+4
View File
@@ -206,6 +206,10 @@ input:disabled{opacity:.6}
.ent-all{display:flex;gap:6px;padding:6px 10px;align-items:center;min-height:44px}
.elist .erow{padding:6px 0;border-bottom:1px dashed var(--line)}
.elist>.btn{margin-top:8px}
.plist .prow{display:grid;gap:6px}
.plist .pf{display:flex;flex-direction:column;gap:2px}
.plist .pf>select.sel{width:100%}
input.txt.time{width:5.5em;text-align:center;font-variant-numeric:tabular-nums}
.inv{border:1px solid var(--line);border-radius:10px;padding:0 12px;margin:10px 0}
.inv>.hd{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-weight:600;flex-wrap:wrap}
.inv>.f:nth-child(2){border-top:0}
+65 -14
View File
@@ -5,7 +5,19 @@
labels) or from /status (sentences built by app/homeview.py), so STYLE.md
is checked in Python. Data is always set as text, never as HTML. */
(function () {
const T = JSON.parse(document.getElementById("ui-text").textContent).home;
// P5: English or Dutch, by the browser's language. A word missing in Dutch
// falls back to English, key by key (app/uitext.py does the same).
const ALL = JSON.parse(document.getElementById("ui-text").textContent);
const LANG = /^nl\b/i.test(navigator.language || "") ? "nl" : "en";
const merge = (a, b) => {
if (!b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a)) return b ?? a;
const out = Object.assign({}, a);
for (const k in b) out[k] = k in a ? merge(a[k], b[k]) : b[k];
return out;
};
const WORDS = LANG === "en" ? ALL.en : merge(ALL.en, ALL[LANG]);
document.documentElement.lang = LANG;
const T = WORDS.home;
const $ = (s) => document.querySelector(s);
const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T);
function h(tag, attrs, ...kids) {
@@ -24,8 +36,9 @@
}
return e;
}
const kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1);
const eur = (v) => "€" + Number(v).toFixed(2);
const dec = (x) => (LANG === "nl" ? x.replace(".", ",") : x);
const kw = (w) => dec((Math.abs(Number(w)) / 1000).toFixed(1));
const eur = (v) => "€" + dec(Number(v).toFixed(2));
// Fixed labels from ui_text.
document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; });
@@ -55,7 +68,7 @@
/* ---------------- data ---------------- */
async function getStatus() {
const r = await fetch("status", { cache: "no-store" });
const r = await fetch("status?lang=" + LANG, { cache: "no-store" });
if (!r.ok) throw new Error("status " + r.status);
return r.json();
}
@@ -87,18 +100,20 @@
const st = S.state || null;
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner;
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
}
function render() {
renderPill();
const structured = S.now && S.plan && !S.home_error;
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", S.level === "bad" ? "alert" : null);
// UX-15: setup-only mode commands nothing and has no control to offer.
const setup = S.setup_only === true;
banner($("#home-banner"), S.level, setup ? t("setup_only.name") : S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
renderControl(structured);
renderDetails(!structured);
renderControl(structured, setup);
renderDetails(!structured && !setup);
renderBatteries();
}
@@ -270,12 +285,15 @@
badge.textContent = n; badge.hidden = !n;
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
}
function renderControl(structured) {
function renderControl(structured, setup) {
const running = structured ? S.control.running : null;
const btn = $("#btn-ctl");
btn.textContent = running === false ? t("control.start") : t("control.stop");
btn.className = "btn" + (running === false ? " primary" : "");
$("#ctl-text").textContent = structured ? S.control.text : "";
btn.hidden = !!setup;
$("#btn-setup").hidden = !setup;
$("#c-ctl .more").hidden = !!setup;
$("#ctl-text").textContent = structured ? S.control.text : setup ? t("setup_only.control") : "";
$("#hint").textContent = S.hint || "";
const docs = $("#docs-link");
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
@@ -290,17 +308,50 @@
if (open) $("#c-details").open = true;
}
/* ---------------- Batteries (placeholder until P3) ---------------- */
/* ---------------- Batteries (placeholder until UX-13 P3) ---------------- */
// OPT-11 P1: with battery modes on, each card gets an On/Off switch and a
// mode choice (`mode_key` in /status). UX-13 P3 replaces these cards.
const MODES = ["solar_first", "leftover_solar"];
let packBusy = false;
async function packAct(what, unit, value) {
if (packBusy) return;
packBusy = true;
try {
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: what, unit: unit, value: value }) });
const j = await r.json().catch(() => ({}));
toast(r.ok ? t("toast.done") : (j.error || t("toast.failed")));
} catch (e) { toast(t("toast.failed")); }
finally { packBusy = false; }
refresh();
}
function modeControls(b) {
const sw = h("button", { class: "btn" + (b.use ? " primary" : ""), type: "button", role: "switch",
"aria-checked": String(b.use), "aria-label": b.name + ": " + t("batteries.use"),
onclick: () => packAct("pack_use", b.name, !b.use) },
b.use ? t("batteries.on") : t("batteries.off"));
const sel = h("select", { class: "sel", "aria-label": b.name + ": " + t("batteries.mode"), disabled: !b.use,
onchange: (e) => packAct("pack_mode", b.name, e.target.value) },
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))));
return [h("div", { class: "row spread", style: "margin-top:8px" }, h("span", { class: "small" }, t("batteries.use")), sw),
h("div", { class: "row spread", style: "margin-top:8px" }, h("span", { class: "small" }, t("batteries.mode")), sel),
h("p", { class: "tiny muted", style: "margin:6px 0 0" }, t("batteries.choice." + b.mode_key + ".description"))];
}
function renderBatteries() {
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
// Do not rebuild a card under an open mode list or a request in flight.
if (packBusy || ($("#bcards").contains(document.activeElement) && document.activeElement.tagName === "SELECT")) return;
const modes = S.batteries.some((b) => b.mode_key != null);
$("#batt-desc").textContent = t(modes ? "batteries.with_modes.description" : "batteries.description");
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")),
levelBar(b.level_pct, b.name),
h("dl", { class: "kv" },
h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, b.stored_kwh.toFixed(1) + " kWh")] : null,
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, dec(b.stored_kwh.toFixed(1)) + " kWh")] : null,
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
b.mode_key == null ? [h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)] : null),
b.mode_key != null ? modeControls(b) : null)));
}
/* ---------------- control buttons ---------------- */
@@ -334,7 +385,7 @@
// Shared with settings.js, which loads after this file.
// `first`: the page opened with no route, so settings.js may open the setup
// wizard on a new install (UX-13 P4).
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
window.UX13 = { h, ask, toast, t, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
/* ---------------- tabs: hash routes, arrow keys ---------------- */
const TABS = [...document.querySelectorAll(".tab")];
+2 -1
View File
@@ -72,6 +72,7 @@
<div class="small" id="ctl-text"></div>
<div class="row" style="margin-top:10px">
<button class="btn" id="btn-ctl" type="button"></button>
<a class="btn primary" id="btn-setup" href="#setup" hidden data-t="setup_only.open"></a>
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
</div>
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
@@ -95,7 +96,7 @@
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
<div id="bcards"></div>
<div class="card">
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<p class="small muted" style="margin:0 0 10px" id="batt-desc" data-t="batteries.description"></p>
<a class="btn" href="#settings" data-t="batteries.settings"></a>
</div>
</section>
+54 -11
View File
@@ -9,9 +9,9 @@
P4: the Setup tab's wizard is the same page model, drawn one step at a
time from each field's `wizard` step, and saved through the same routes. */
(function () {
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
const ROOT = window.UX13.words; // English or Dutch (app.js)
const W = ROOT.settings;
const { h, ask, toast, maint } = window.UX13;
const { h, ask, toast, maint, lang } = window.UX13;
const $ = (s) => document.querySelector(s);
const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W);
const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)));
@@ -288,8 +288,45 @@
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
return box;
}
// P5: time windows, each a start, an end, its days and a price. The add-on
// builds the tariff from the rows and names the first overlap or gap.
function periodList(f, card) {
const P = W.periods;
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
const box = h("div", { class: "elist plist" });
if (!Array.isArray(get(f, card))) return h("div", { class: "msg info" }, W.derive.custom_profile.description);
arr().forEach((row, i) => {
const set = (k, v) => { const a = clone(arr()); a[i][k] = v; put(f, card, a); };
const say = (what) => P.window + " " + (i + 1) + ", " + what;
const time = (k) => {
const e = h("input", { class: "txt time", type: "text", inputmode: "numeric", maxlength: "5", placeholder: "00:00",
value: row[k] == null ? "" : row[k], autocomplete: "off", spellcheck: "false", "aria-label": say(P[k]) });
e.addEventListener("input", () => set(k, e.value.trim()));
return e;
};
const days = h("select", { class: "sel", "aria-label": say(P.days) });
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
days.addEventListener("change", () => set("days", days.value));
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
box.append(h("div", { class: "erow prow" },
h("div", { class: "row spread" }, h("strong", { class: "small" }, P.window + " " + (i + 1)),
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove)),
h("div", { class: "row" }, cell(P.from, time("from")), cell(P.to, time("to")),
cell(P.price, h("span", { class: "row" }, price, f.unit ? h("span", { class: "unit" }, f.unit) : null))),
cell(P.days, days)));
});
box.append(h("button", { type: "button", class: "btn", onclick: () => {
const a = arr(), last = a[a.length - 1];
put(f, card, clone(a).concat([{ from: last ? last.to : "00:00", to: "24:00", days: last ? last.days : "mon-sun", price: null }]), true);
} }, "+ " + P.add));
return box;
}
const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true),
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList };
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList,
period_list: periodList };
/* ---------------- rows, cards and groups ---------------- */
function keyText(f, card) {
@@ -304,7 +341,7 @@
const msg = h("div", { class: "msgs" });
const make = CONTROLS[f.control] || text;
const row = h("div", { class: "f" + (f.control === "toggle" ? " inline" : ""), "data-key": f.key, "data-card": card },
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && f.control !== "slider" && f.control !== "number" && f.control !== "locked" ? " (" + f.unit + ")" : ""),
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && !["slider", "number", "locked", "period_list"].includes(f.control) ? " (" + f.unit + ")" : ""),
keys ? h("div", { class: "key" }, keys) : null),
h("div", { class: "ctl" }, make(f, card, lid)),
f.help ? h("div", { class: "help" }, f.help) : null, msg);
@@ -469,7 +506,7 @@
const seq = ++vseq;
try {
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V }, extra || {})) });
body: JSON.stringify(Object.assign({ model: V, lang }, extra || {})) });
const b = await r.json();
if (seq !== vseq) return null;
MSG = b.messages || [];
@@ -498,7 +535,7 @@
async function load(after) {
let m;
try {
const r = await fetch("config/model?lang=en", { cache: "no-store" });
const r = await fetch("config/model?lang=" + lang, { cache: "no-store" });
if (!r.ok) throw new Error("model " + r.status);
m = await r.json();
if (!m.layout || !m.ui) throw new Error("no layout");
@@ -533,10 +570,12 @@
if (input) input.focus({ preventScroll: true });
}
// The one save path, for the Installer view and the wizard. After a stale
// refusal the page holds the new token, so a second press saves.
// refusal the page holds the new token, so a second press saves. `base` is
// the model the page opened: only what changed since then is written, onto
// what is stored now (UX-15 F2).
async function postSave(extra) {
const r = await fetch("config/save", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ model: V, fingerprint: FP }, extra || {})) });
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP, lang }, extra || {})) });
const b = await r.json().catch(() => ({}));
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
return { r, b };
@@ -624,8 +663,10 @@
if (WZ.kettle && WZ.kettle.sig !== sig) WZ.kettle = null; // another meter: test again
const k = WZ.kettle || { sig };
box.append(h("div", { class: "help" }, WS.kettle.description));
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn" }[k.say] || "bad";
const words = k.say ? WS.kettle[k.say] : k.before != null ? WS.kettle.before : null;
// A skip after the reading went down still says it went down (UX-15).
const said = k.say === "skipped" && k.wrong ? "skipped_down" : k.say;
const tone = { ok: "ok", flip: "info", none: "warn", unread: "bad", skipped: "warn", skipped_down: "warn" }[said] || "bad";
const words = said ? WS.kettle[said] : k.before != null ? WS.kettle.before : null;
if (words) box.append(h("div", { class: "msg " + (k.say ? tone : "info"), role: "status" },
say(words.description, { w: k.before != null ? watts(k.before, true) : "", d: k.d != null ? watts(k.d) : "" })));
const busy = (b) => { b.disabled = true; };
@@ -715,7 +756,9 @@
if (!b) { toast(WS.check.failed.description); return; }
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
const shadow = shadowValue() === M.setup.shadow.on;
ask(shadow ? WS.dialog : WS.dialog_live, async () => {
// In setup-only mode nothing runs yet, so no inverter is set to 0 W first.
const dlg = M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live) : (shadow ? WS.dialog : WS.dialog_live);
ask(dlg, async () => {
wzBanner();
let res;
try { res = await postSave({ setup: "done", model: posted() }); } catch (e) { toast(W.toast.failed); return; }
+5 -2
View File
@@ -581,9 +581,12 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
return rep
STATE_PATH = "/data/state.json"
class Store:
def __init__(self, path: str = "/data/state.json"):
self.path = path
def __init__(self, path: str | None = None):
self.path = path or STATE_PATH
self.data = dict(DEFAULTS)
# UX-13 P4: True when this boot found no state file, i.e. a new /data.
# Only the setup wizard's first-run rule reads it (uipage.setup_needed).
+181 -3
View File
@@ -10,6 +10,7 @@
"parts": {
"meter.timing": {"name": "Timing"},
"inverters.solar": {"name": "Solar"},
"inverters.modes": {"name": "Battery modes"},
"tariff.selling": {"name": "Charging and selling on price"},
"tariff.feed": {"name": "Price feed details"},
"safety.peak": {"name": "Peak limit"},
@@ -24,7 +25,8 @@
"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."}
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, 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"},
@@ -34,7 +36,8 @@
"meter_phases": {"1": "1 phase", "3": "3 phases"},
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"}
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
},
"applies": {
"restart": {"name": "Applies after a restart"},
@@ -79,7 +82,15 @@
},
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
"batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
"setup_only": {"name": "Setup: nothing is controlled yet", "description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
"with_modes": {"description": "Each battery follows its mode, after the peak limit and safety stops."},
"use": "Use this battery", "on": "On", "off": "Off",
"choice": {
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
"leftover_solar": {"name": "Leftover solar", "description": "Charges from solar the other batteries cannot take, and covers the house."},
"off": {"name": "Off", "description": "The add-on asks this battery for 0 W, unless the peak limit needs it."}
}},
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
},
"choices_later": {
@@ -126,6 +137,7 @@
"remove": "Remove",
"add_row": "Add a row",
"add_solar": "Add a solar inverter",
"periods": {"window": "Window", "from": "From", "to": "To", "days": "Days", "price": "Price", "add": "Add a time window"},
"reversed": "Reads negative",
"phase": "Phase",
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
@@ -155,6 +167,13 @@
"no_source": {"description": "Choose where the dynamic prices come from."},
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
"no_price": {"description": "Enter a price per kWh."},
"no_periods": {"description": "Add at least one time window."},
"period_time": {"description": "Window {row}: write each time as hours and minutes, like 07:00 or 24:00."},
"period_zero": {"description": "Window {row} starts and ends at the same time, so change one of them."},
"period_price": {"description": "Window {row}: enter a price per kWh."},
"period_price_range": {"description": "Window {row}: enter the price in euro per kWh, like 0.34."},
"period_overlap": {"description": "Windows {a} and {b} both cover {day} {from} to {to}, so change one of them."},
"period_gap": {"description": "No window covers {day} {from} to {to}, so add one or widen one."},
"unknown": {"description": "The page cannot store this choice, so reload the page."}
},
"setup": {
@@ -192,6 +211,7 @@
"self": {"description": "The add-on reads this meter itself, so Home shows its direction after the restart."},
"skipped": {"description": "The meter direction is not tested, so check it on Home after the restart."},
"skipped_wrong": {"description": "The kettle test saw the meter reading go down, so check its direction in step 1 before you start control."},
"skipped_down": {"description": "The reading went down when the kettle came on, so check the meter's direction before you start control."},
"tested": {"description": "The meter direction was tested with a kettle."},
"needed": {"description": "Do the kettle test first, or skip it."}
},
@@ -206,10 +226,168 @@
"shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."},
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
"dialog_setup_live": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and then they apply.", "yes": "Save and restart"},
"done": {"name": "Setup is done", "description": "The add-on restarts in shadow mode now, so open Home in a minute to see what it would do."},
"done_live": {"name": "Setup is done", "description": "The add-on restarts now, so open Home in a minute to start control."},
"restart_failed": {"name": "Saved, not running yet", "description": "The restart did not go through, so press Restart now to apply the settings."},
"home": "Open Home"
}
},
"home_words": {
"mode_house": "Follows the house",
"modes": {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "off": "Off"},
"attn_shadow": "Shadow mode is on",
"attn_shadow_body": "The add-on decides everything, but every inverter stays at 0 W.",
"zero_held": "Held at 0 W",
"zero_going": "Going to 0 W",
"verdict": {
"held": "Held at 0 W",
"stopped": "Stopped",
"cycle": "Monthly cycle today",
"trade": "Trading on price",
"house": "Follows the house"
},
"pill": {
"running": "Running",
"shadow": "Shadow mode",
"stopped": "Stopped",
"alarm": "Needs you now",
"warning": "Running, with a warning"
},
"banner_sub": {
"running": "The batteries follow the plan below.",
"shadow": "The plans below say what the add-on would do.",
"stopped": "Start control below when you are ready.",
"alarm": "See Needs attention below for what to do.",
"warning": "See Needs attention below."
},
"tile": {
"not_set_up": "Not set up",
"no_reading": "No reading",
"producing": "Producing",
"not_producing": "Not producing",
"needs_solar": "Needs a solar reading",
"not_adding_up": "Readings do not add up",
"using": "Using",
"taking": "Taking from the grid",
"feeding": "Feeding in",
"balanced": "Balanced",
"giving": "Giving power",
"charging": "Charging",
"resting": "Resting"
},
"now": {
"no_reading": "No reading from this battery.",
"would_rest": "{zero}. Would rest now.",
"would_give": "{zero}. Would give {kw} now.",
"would_charge": "{zero}. Would charge {kw} now.",
"held": "{zero} until the alarm clears.",
"stopped": "{zero} while control is stopped.",
"cycle_drain": "Monthly cycle: emptying.",
"cycle_charge": "Monthly cycle: refilling.",
"cycle_hold": "Monthly cycle: holding full.",
"cycle": "Monthly cycle under way.",
"giving": "Giving {kw} now.",
"charging": "Charging {kw} now.",
"resting": "Resting now."
},
"head": {
"held": "Every inverter is at 0 W until the alarm clears.",
"held_going": "Every inverter goes to 0 W until the alarm clears.",
"stopped": "Control is stopped, so nothing is planned.",
"cycle": "A battery runs its monthly cycle today.",
"trade": "Charges in the cheapest hour, gives power in the dearest.",
"house": "Charges from spare solar and covers the house.",
"shadow": "Shadow mode: this is what the add-on would do.",
"cycle_would": "A battery would run its monthly cycle today.",
"trade_would": "Would charge when cheapest and give power when dearest.",
"house_would": "Would charge from spare solar and cover the house."
},
"run": {
"grid_what": "Charges up to {kwh} from the grid.",
"grid_what_would": "Would charge up to {kwh} from the grid.",
"grid_why": "Lowest published price, {eur} per kWh.",
"grid_amount": "Costs up to {eur}.",
"give_what": "Gives up to {kwh}, to the house first.",
"give_what_would": "Would give up to {kwh}, to the house first.",
"give_why": "Highest published price, {eur} per kWh.",
"give_amount": "Worth up to {eur} at that price.",
"cycle_start": "Starts the monthly battery cycle.",
"cycle_start_would": "Would start the monthly battery cycle.",
"cycle_now": "Runs the monthly battery cycle now.",
"cycle_now_would": "Would run the monthly battery cycle now.",
"cycle_why": "Empties, refills and holds full, so the level reads right.",
"cycle_why_would": "Would empty, refill and hold full, so the level reads right.",
"cycle_amount_unknown": "How long depends on the battery.",
"cycle_amount": "Takes about {hours} hours.",
"peak_what": "Gives power to stay under {kw}.",
"peak_what_would": "Would give power to stay under {kw}.",
"peak_why": "The peak limit comes before everything else.",
"house_what": "Follows the house.",
"house_what_would": "Would follow the house.",
"house_why": "Charges from spare solar, gives power when the house needs it.",
"house_why_would": "Would charge from spare solar, give power when the house needs it.",
"house_amount": "Amounts depend on the sun and the house."
},
"no_trade": {
"not_covered": "No trading on price: nothing measures the 15-minute peak.",
"no_prices": "No trading on price yet: no prices have arrived.",
"peak": "No trading on price now: the peak limit comes first.",
"not_firm": "No trading on price yet: the prices are not published.",
"not_worth": "No trading on price today. The price gap is too small.",
"no_level": "No trading on price now: a battery level is unknown."
},
"tomorrow_trade": {
"grid": "Tomorrow: charges at {at}, {eur} per kWh.",
"grid_would": "Tomorrow: would charge at {at}, {eur} per kWh.",
"give": "Tomorrow: gives power at {at}, {eur} per kWh.",
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
},
"cycle_waits": "{name} waits its turn for the monthly cycle.",
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
"none": "No prices for today."
},
"tomorrow": {
"known": "Tomorrow's prices are in.",
"unknown": "Tomorrow's prices usually arrive after 13:00."
},
"region": {
"preset": "Levies and VAT: {region}, {year} figures.",
"own": "Levies and VAT: your own figure."
},
"peak_not_covered": "No peak limit: this meter does not measure 15-minute periods.",
"control": {
"shadow": "Shadow mode: every inverter stays at 0 W.",
"running": "Control is running.",
"stopped": "Control is stopped. Every inverter is at 0 W."
},
"attn": {
"controller": "Control is not running",
"meter": "Meter reading",
"meter_frozen": "Meter reading may be stuck",
"freeze_check": "Stuck meter check",
"month_peak": "This month's peak",
"peak_guard": "Peak limit",
"peak_forecast": "Peak limit forecast",
"sensor": "Battery readings",
"control": "Inverter control",
"power_too_high": "Power request too high",
"not_keeping_up": "Battery is not keeping up",
"write": "Power request not accepted",
"direction": "Power went the wrong way",
"site_direction": "Power went the wrong way",
"circulating": "Inverters working against each other",
"delivery": "An inverter is not delivering",
"estop": "Emergency stop",
"pv": "Solar reading",
"none": "Inverter connection"
},
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
}
}
+316
View File
@@ -0,0 +1,316 @@
{
"groups": {
"meter": {"name": "Meter", "description": "Waar de meting van het net vandaan komt."},
"inverters": {"name": "Omvormers en batterijen", "description": "De omvormers die de add-on regelt, en uw zonnepanelen."},
"tariff": {"name": "Tarief en prijzen", "description": "Wat u per kWh betaalt en terugkrijgt."},
"safety": {"name": "Veiligheidsgrenzen", "description": "De piekgrens, starten en stoppen, en de veiligheidsstops."},
"maintenance": {"name": "Onderhoud", "description": "De maandelijkse batterijcyclus en het logboek."},
"tuning": {"name": "Afstelling van de regeling", "description": "Alleen voor support, want deze zijn gemeten op echte hardware."}
},
"parts": {
"meter.timing": {"name": "Timing"},
"inverters.solar": {"name": "Zonnepanelen"},
"inverters.modes": {"name": "Batterijmodi"},
"tariff.selling": {"name": "Laden en verkopen op prijs"},
"tariff.feed": {"name": "Details van de prijsfeed"},
"safety.peak": {"name": "Piekgrens"},
"safety.start": {"name": "Starten en stoppen"},
"safety.stops": {"name": "Veiligheidsstops", "description": "Gemeten op echte hardware, dus laat ze zoals ze zijn."},
"maintenance.support": {"name": "Support"}
},
"fields": {
"solar": {"name": "Omvormers van de zonnepanelen", "description": "De vermogenssensor van elke zonne-omvormer, die de add-on bijhoudt maar niet gebruikt om te regelen."},
"contract": {"name": "Uw contract", "description": "Hoe uw leverancier elke kWh prijst die u van het net afneemt."},
"region": {"name": "Regio", "description": "Vult nettarieven, heffingen en btw in voor het getoonde jaar, nog niet voor Wallonië of Brussel, dus kijk uw factuur na."},
"fallback": {"name": "Prijs als de feed wegvalt", "description": "Gebruikt als de prijsbron niet antwoordt."},
"fixed_price": {"name": "Prijs per kWh, alles inbegrepen", "description": "Energie, kosten van de leverancier, nettarieven, heffingen en btw samen."},
"day_price": {"name": "Dagprijs, weekdagen 07:00 tot 22:00", "description": "Alles inbegrepen, zoals op uw factuur."},
"night_price": {"name": "Nacht- en weekendprijs", "description": "Alles inbegrepen, zoals op uw factuur."},
"periods": {"name": "Tijdvakken en prijzen", "description": "Eén prijs per kWh voor elk tijdvak, alles inbegrepen, zoals op uw factuur."}
},
"choices": {
"contract": {"none": "Geen prijzen", "fixed": "Vaste prijs", "daynight": "Dag en nacht", "dynamic": "Dynamisch, per uur", "custom": "Andere tijdvakken"},
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand"},
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
},
"applies": {"restart": {"name": "Geldt na een herstart"}, "now": {"name": "Geldt meteen"}},
"home": {
"app": {"name": "Batterij-add-on"},
"tabs": {"home": "Start", "batteries": "Batterijen", "settings": "Instellingen", "setup": "Installatie", "label": "Schermen"},
"now": {"name": "Nu", "description": "Elke 3 seconden bijgewerkt.", "solar": "Zonnepanelen", "house": "Huis", "grid": "Net", "batteries": "Batterijen"},
"plan": {
"name": "Plan voor vandaag",
"description": "Wijs een staaf of blok aan voor details, of tik erop op een telefoon.",
"cheap": "Lage prijs",
"dear": "Hoge prijs",
"estimate": "Schatting",
"grid": "Laadt van het net",
"give": "Levert stroom",
"cycle": "Maandelijkse batterijcyclus",
"peak": "Piekgrens",
"house": "Volgt het huis",
"now": "nu",
"time_to": "tot",
"per_kwh": "per kWh",
"cheap_tip": "laag voor vandaag",
"dear_tip": "hoog voor vandaag",
"estimate_tip": "schatting op basis van gisteren",
"none_tip": "Geen prijs bekend",
"no_prices": {"description": "Geen prijzen voor vandaag, dus de grafiek toont alleen het plan."},
"list": "Het plan in woorden",
"average_tip": "gemiddelde van het uur"
},
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
"details": {"name": "Details voor installateurs", "rows": "Statusregels", "checks": "Controles", "event": "Laatste gebeurtenis"},
"dialog": {
"cancel": "Annuleren",
"stop": {"name": "Regeling stoppen?", "description": "Elke omvormer gaat naar 0 W tot u de regeling opnieuw start.", "yes": "Regeling stoppen"},
"start": {"name": "Regeling starten?", "description": "De add-on regelt de batterijen weer en heft een veiligheidsstop op.", "yes": "Regeling starten"},
"maint_start": {"name": "Batterijcyclus starten?", "description": "De batterij loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"},
"maint_abort": {"name": "Batterijcyclus stoppen?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}
},
"toast": {"started": "Regeling gestart.", "stopped": "Regeling gestopt.", "done": "Klaar.", "failed": "Dat lukte niet. Probeer opnieuw."},
"offline": {"name": "Add-on niet bereikbaar", "description": "De pagina probeert het elke 3 seconden opnieuw."},
"setup_only": {"name": "Installatie: nog niets wordt geregeld", "description": "Er gaat niets naar een omvormer tot de installatie bewaard is en de add-on herstart.", "control": "De regeling start na de installatie.", "open": "Installatie openen"},
"batteries": {"name": "Batterijen", "description": "Zet batterijmodi aan in de instellingen om per batterij een modus te kiezen.", "level": "Laadniveau", "now": "Nu", "stored": "Opgeslagen", "mode": "Modus", "settings": "Instellingen van omvormers wijzigen",
"with_modes": {"description": "Elke batterij volgt haar modus, na de piekgrens en de veiligheidsstops."},
"use": "Deze batterij gebruiken", "on": "Aan", "off": "Uit",
"choice": {
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Laadt met zonne-energie die de andere batterijen niet opnemen, en dekt het huis."},
"off": {"name": "Uit", "description": "De add-on vraagt deze batterij 0 W, behalve als de piekgrens haar nodig heeft."}
}},
"settings": {"name": "Instellingen", "description": "De instellingen staan hieronder per taak.", "open": "Instellingen openen"}
},
"choices_later": {"region": {"wallonia": "Wallonië, nog geen standaardwaarde", "brussels": "Brussel, nog geen standaardwaarde"}},
"choice_help": {
"meter_source": {
"off": {"description": "Een Home Assistant-entiteit met het netvermogen in W, positief als u van het net afneemt."},
"ha_dsmr": {"description": "Twee Home Assistant-entiteiten van de DSMR-integratie, een voor afname en een voor injectie."},
"mqtt_p1": {"description": "De meetwaarden komen via MQTT binnen op een topic dat u opgeeft."},
"ha_signed": {"description": "Eén Home Assistant-entiteit met het nettovermogen, positief bij afname."},
"homewizard_local": {"description": "De add-on leest een HomeWizard-meter op uw netwerk en merkt een vastgelopen meter op."}
}
},
"settings": {
"name": "Instellingen",
"description": "Elke instelling, per taak, en de eenvoudige weergave volgt in een latere update.",
"classic": "Klassieke instellingen",
"search": {"name": "Instelling zoeken", "description": "Zoek een instelling, bijvoorbeeld piek of meter."},
"no_match": {"description": "Geen instelling past bij uw zoekopdracht."},
"loading": "Instellingen laden",
"load_failed": {"name": "Instellingen niet leesbaar", "description": "De add-on kon zijn bewaarde instellingen niet lezen, dus gebruik Klassieke instellingen of het tabblad Configuratie."},
"footer": {"description": "Elke wijziging gaat door de startcontroles van de add-on voor ze bewaard wordt."},
"to_fix": "op te lossen",
"to_check": "na te kijken",
"changed": "gewijzigd",
"on": "Aan",
"off": "Uit",
"not_set": "Niet ingesteld",
"locked": "Vergrendeld",
"change": "Wijzigen",
"support": {"description": "Deze zijn gemeten op echte hardware, dus wijzig ze alleen als support erom vraagt."},
"picker": {"choose": "Kies een entiteit", "search": {"description": "Zoek op naam of entiteit-id."}, "all": "Alle entiteiten tonen", "use": "Deze entiteit-id gebruiken", "clear": "Wissen", "missing": "Niet in Home Assistant", "none": {"description": "Niets gevonden, dus kijk de entiteit na in Ontwikkelhulpmiddelen."}},
"add_inverter": "Omvormer toevoegen",
"inverter": "Omvormer",
"remove": "Verwijderen",
"add_row": "Regel toevoegen",
"add_solar": "Zonne-omvormer toevoegen",
"periods": {"window": "Tijdvak", "from": "Van", "to": "Tot", "days": "Dagen", "price": "Prijs", "add": "Tijdvak toevoegen"},
"reversed": "Meet negatief",
"phase": "Fase",
"raw_roster": {"description": "Deze lijst met omvormers kan alleen op het tabblad Configuratie gewijzigd worden."},
"region_year": "cijfers van {year}",
"hour": "{hour}:00 UTC",
"preset": {"description": "Ingevuld door de regio hierboven, dus kies een eigen bedrag om het te wijzigen."},
"cycle": {"name": "Een cyclus met de hand starten", "description": "Dezelfde acties als op Start, die elk eerst om bevestiging vragen."},
"savebar": {"label": "Niet bewaarde wijzigingen", "one": "wijziging niet bewaard", "many": "wijzigingen niet bewaard", "discard": "Verwerpen", "save": "Bewaren", "saving": "Bezig met bewaren"},
"saved": {"name": "Bewaard, nog niet actief", "description": "De add-on werkt met de instellingen waarmee hij startte tot u hem herstart."},
"differs": {"name": "Bewaarde instellingen zijn niet actief", "description": "Sommige bewaarde instellingen verschillen van wat nu draait, dus herstart de add-on om ze toe te passen."},
"stale": {"name": "Elders gewijzigd", "description": "Deze instellingen zijn elders gewijzigd terwijl u bewerkte, dus bewaar opnieuw om de uwe te houden of verwerp om de andere te laden."},
"other": "Andere meldingen",
"restart": "Nu herstarten",
"dialog": {"name": "Add-on herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Nu herstarten"},
"toast": {"saved": "Bewaard. Herstart om toe te passen.", "discarded": "Wijzigingen verworpen.", "failed": "Dat lukte niet. Probeer opnieuw.", "blocked": {"description": "Los de rood gemarkeerde instellingen op en bewaar opnieuw."}, "restarting": {"description": "Herstart gevraagd, dus deze pagina antwoordt even niet."}},
"tier": {"bad": "Oplossen", "warn": "Nakijken", "info": "Opmerking", "ok": "In orde"},
"derive": {
"raw_roster": {"description": "Deze lijst met omvormers kan alleen op het tabblad Configuratie gewijzigd worden."},
"no_inverter": {"description": "Voeg minstens één omvormer toe."},
"flat_named": {"description": "Voeg een tweede omvormer toe voor u deze een naam geeft."},
"no_source": {"description": "Kies waar de dynamische prijzen vandaan komen."},
"custom_profile": {"description": "Deze tijdvakken kunnen alleen op het tabblad Configuratie gewijzigd worden."},
"no_price": {"description": "Vul een prijs per kWh in."},
"no_periods": {"description": "Voeg minstens één tijdvak toe."},
"period_time": {"description": "Tijdvak {row}: schrijf elk tijdstip als uren en minuten, zoals 07:00 of 24:00."},
"period_zero": {"description": "Tijdvak {row} begint en eindigt op hetzelfde uur, dus wijzig een van beide."},
"period_price": {"description": "Tijdvak {row}: vul een prijs per kWh in."},
"period_price_range": {"description": "Tijdvak {row}: vul de prijs in euro per kWh in, zoals 0,34."},
"period_overlap": {"description": "Tijdvakken {a} en {b} vallen allebei op {day} {from} tot {to}, dus wijzig er een."},
"period_gap": {"description": "Geen tijdvak dekt {day} {from} tot {to}, dus voeg er een toe of maak er een breder."},
"unknown": {"description": "De pagina kan deze keuze niet bewaren, dus laad de pagina opnieuw."}
},
"setup": {
"name": "Installatie",
"description": "Voor installateurs: zes stappen naar een werkende installatie, en elke stap kan later gewijzigd worden onder Instellingen.",
"done_before": {"description": "Deze installatie is al ingesteld, dus een wijziging hier vervangt de bewaarde instellingen."},
"step": "Stap {n} van {total}",
"back": "Terug",
"next": "Volgende",
"skip": "Volgende, of overslaan",
"finish": "Bewaren en herstarten",
"not_now": "Niet nu",
"steps": {
"1": {"name": "Uw meter", "description": "De add-on stuurt de netmeting naar nul, dus eerst is uw meter nodig."},
"2": {"name": "Omvormers en batterijen", "description": "Eén kaart per omvormer met een batterij, met zijn entiteiten in Home Assistant."},
"3": {"name": "Zonnepanelen", "description": "Niet verplicht, dus sla deze stap over als de add-on de zonneproductie niet moet bijhouden."},
"4": {"name": "Prijzen", "description": "Wat u per kWh betaalt, zodat de batterijen laden als stroom goedkoop is."},
"5": {"name": "Piekgrens", "description": "Het net blijft onder deze grens, gemiddeld over elk kwartier."},
"6": {"name": "Nakijken en starten", "description": "De add-on kijkt alles na met zijn eigen startcontroles en herstart dan."}
},
"modes_later": {"description": "Een modus kiezen per batterij komt in een latere update."},
"kettle": {
"name": "Richting nakijken",
"description": "Lees de meter af, zet een waterkoker aan en lees de meter opnieuw af.",
"read": "Meter aflezen",
"again": "Opnieuw aflezen",
"skip": "Deze test overslaan",
"before": {"description": "De meter geeft nu {w} aan, dus zet een waterkoker aan en druk op Opnieuw aflezen."},
"unread": {"description": "De meter is nu niet leesbaar, dus kijk de entiteit na en probeer opnieuw."},
"none": {"description": "De meting veranderde maar {d}, dus zet de waterkoker aan en lees opnieuw af."},
"ok": {"description": "Goed, de meting steeg met {d} toen de waterkoker aanging."},
"flip": {"description": "De meting daalde met {d}, dus de add-on keert het teken voor u om."},
"wrong_signed": {"description": "De meting daalde met {d}, dus kies hierboven “Eén entiteit voor netvermogen”, die het kan omkeren."},
"wrong_dsmr": {"description": "De meting daalde met {d}, dus kijk na of afname en injectie niet omgewisseld zijn."},
"self": {"description": "De add-on leest deze meter zelf, dus Start toont de richting na de herstart."},
"skipped": {"description": "De richting van de meter is niet getest, dus kijk ze na op Start na de herstart."},
"skipped_wrong": {"description": "Bij de waterkokertest daalde de meting, dus kijk de richting na in stap 1 voor u de regeling start."},
"skipped_down": {"description": "De meting daalde toen de waterkoker aanging, dus kijk de richting van de meter na voor u de regeling start."},
"tested": {"description": "De richting van de meter is getest met een waterkoker."},
"needed": {"description": "Doe eerst de waterkokertest, of sla hem over."}
},
"check": {"name": "Wat de add-on nakeek", "running": {"description": "Bezig met de startcontroles van de add-on."}, "pass": {"description": "De startcontroles van de add-on zijn in orde."}, "failed": {"description": "De controle liep niet, dus probeer opnieuw."}, "again": "Opnieuw nakijken", "blocked": {"description": "Los de rood gemarkeerde punten op en kijk dan opnieuw na."}},
"shadow": {"name": "Starten in schaduwmodus", "description": "Aanbevolen, want dan blijft elke omvormer op 0 W tot u de schaduwmodus uitzet onder Instellingen."},
"dialog": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, en de schaduwmodus houdt ze daarna op 0 W.", "yes": "Bewaren en herstarten"},
"dialog_live": {"name": "Bewaren en herstarten?", "description": "Elke omvormer gaat naar 0 W terwijl de add-on herstart, daarna gelden de nieuwe instellingen.", "yes": "Bewaren en herstarten"},
"dialog_setup": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en de schaduwmodus houdt elke omvormer op 0 W.", "yes": "Bewaren en herstarten"},
"dialog_setup_live": {"name": "Bewaren en herstarten?", "description": "De add-on herstart met deze instellingen, en dan gelden ze.", "yes": "Bewaren en herstarten"},
"done": {"name": "Installatie is klaar", "description": "De add-on herstart nu in schaduwmodus, dus open Start over een minuut om te zien wat hij zou doen."},
"done_live": {"name": "Installatie is klaar", "description": "De add-on herstart nu, dus open Start over een minuut om de regeling te starten."},
"restart_failed": {"name": "Bewaard, nog niet actief", "description": "De herstart lukte niet, dus druk op Nu herstarten om de instellingen toe te passen."},
"home": "Start openen"
}
},
"home_words": {
"mode_house": "Volgt het huis",
"modes": {"solar_first": "Eerst zonne-energie", "leftover_solar": "Overschot zonne-energie", "off": "Uit"},
"attn_shadow": "Schaduwmodus staat aan",
"attn_shadow_body": "De add-on beslist alles, maar elke omvormer blijft op 0 W.",
"zero_held": "Op 0 W gehouden",
"zero_going": "Gaat naar 0 W",
"verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"},
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "warning": "Actief, met een waarschuwing"},
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "warning": "Zie Vraagt aandacht hieronder."},
"tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"},
"now": {
"no_reading": "Geen meting van deze batterij.",
"would_rest": "{zero}. Zou nu rusten.",
"would_give": "{zero}. Zou nu {kw} leveren.",
"would_charge": "{zero}. Zou nu {kw} laden.",
"held": "{zero} tot het alarm weg is.",
"stopped": "{zero} zolang de regeling gestopt is.",
"cycle_drain": "Maandelijkse cyclus: leegmaken.",
"cycle_charge": "Maandelijkse cyclus: opladen.",
"cycle_hold": "Maandelijkse cyclus: vol houden.",
"cycle": "Maandelijkse cyclus bezig.",
"giving": "Levert nu {kw}.",
"charging": "Laadt nu {kw}.",
"resting": "Rust nu."
},
"head": {
"held": "Elke omvormer staat op 0 W tot het alarm weg is.",
"held_going": "Elke omvormer gaat naar 0 W tot het alarm weg is.",
"stopped": "De regeling is gestopt, dus er is niets gepland.",
"cycle": "Een batterij doet vandaag zijn maandelijkse cyclus.",
"trade": "Laadt in het goedkoopste uur, levert stroom in het duurste.",
"house": "Laadt met zonne-overschot en dekt het huis.",
"shadow": "Schaduwmodus: dit is wat de add-on zou doen.",
"cycle_would": "Een batterij zou vandaag zijn maandelijkse cyclus doen.",
"trade_would": "Zou laden als het goedkoopst is en leveren als het duurst is.",
"house_would": "Zou laden met zonne-overschot en het huis dekken."
},
"run": {
"grid_what": "Laadt tot {kwh} van het net.",
"grid_what_would": "Zou tot {kwh} van het net laden.",
"grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.",
"grid_amount": "Kost tot {eur}.",
"give_what": "Levert tot {kwh}, eerst aan het huis.",
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
"give_amount": "Tot {eur} waard aan die prijs.",
"cycle_start": "Start de maandelijkse batterijcyclus.",
"cycle_start_would": "Zou de maandelijkse batterijcyclus starten.",
"cycle_now": "Doet nu de maandelijkse batterijcyclus.",
"cycle_now_would": "Zou nu de maandelijkse batterijcyclus doen.",
"cycle_why": "Maakt leeg, laadt op en houdt vol, zodat het laadniveau klopt.",
"cycle_why_would": "Zou leegmaken, opladen en vol houden, zodat het laadniveau klopt.",
"cycle_amount_unknown": "Hoe lang hangt af van de batterij.",
"cycle_amount": "Duurt ongeveer {hours} uur.",
"peak_what": "Levert stroom om onder {kw} te blijven.",
"peak_what_would": "Zou stroom leveren om onder {kw} te blijven.",
"peak_why": "De piekgrens gaat voor alles.",
"house_what": "Volgt het huis.",
"house_what_would": "Zou het huis volgen.",
"house_why": "Laadt met zonne-overschot, levert als het huis vraagt.",
"house_why_would": "Zou laden met zonne-overschot, leveren als het huis vraagt.",
"house_amount": "Hoeveel hangt af van de zon en het huis."
},
"no_trade": {
"not_covered": "Geen handel op prijs: niets meet de kwartierpiek.",
"no_prices": "Nog geen handel op prijs: er zijn geen prijzen binnen.",
"peak": "Nu geen handel op prijs: de piekgrens gaat voor.",
"not_firm": "Nog geen handel op prijs: de prijzen zijn niet gepubliceerd.",
"not_worth": "Vandaag geen handel op prijs. Het prijsverschil is te klein.",
"no_level": "Nu geen handel op prijs: een laadniveau is onbekend."
},
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
"attn": {
"controller": "Regeling is niet actief",
"meter": "Meting van de meter",
"meter_frozen": "Meter staat mogelijk vast",
"freeze_check": "Controle op vastgelopen meter",
"month_peak": "Piek van deze maand",
"peak_guard": "Piekgrens",
"peak_forecast": "Prognose voor de piekgrens",
"sensor": "Metingen van de batterij",
"control": "Regeling van de omvormer",
"power_too_high": "Vermogensvraag te hoog",
"not_keeping_up": "Batterij kan niet volgen",
"write": "Vermogensvraag niet aanvaard",
"direction": "Vermogen de verkeerde kant op",
"site_direction": "Vermogen de verkeerde kant op",
"circulating": "Omvormers werken elkaar tegen",
"delivery": "Een omvormer levert niet",
"estop": "Noodstop",
"pv": "Meting van de zonnepanelen",
"none": "Verbinding met de omvormer"
},
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
}
}
+7 -1
View File
@@ -23,8 +23,9 @@ 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")
PERIOD_LIST = "period_list" # P5: time windows, each with its own price
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST)
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST, PERIOD_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")
@@ -125,6 +126,9 @@ GROUPS = (
# 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),)),
# OPT-11: the switch that turns battery modes on. Each battery's mode
# and on/off switch are runtime state on the Batteries screen.
Part("modes", (Field("battery_modes_enabled", TOGGLE),)),
)),
Group("tariff", (
Part("", (
@@ -154,6 +158,8 @@ GROUPS = (
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "custom"),), wizard=4),
)),
Part("selling", (
# OPT-11: derived from the batteries' selling switches once battery
+150 -12
View File
@@ -2,8 +2,8 @@
`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
simple prices or time windows (P5) 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
@@ -16,11 +16,14 @@ See DECISIONS/UX-13-one-layout-table.md.
import copy
import json
import re
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
from .static_tariff import ProfileInvalid, parse_static_profile
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH, MAX_PLAUSIBLE_EUR_KWH
from .static_tariff import (DAY_NAMES, MINUTES_PER_DAY, Period, ProfileInvalid,
parse_static_profile)
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
__all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial",
"REGION_PRESETS", "PERIOD_DAYS",
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
@@ -29,9 +32,11 @@ class DeriveError(ValueError):
the sentence the page shows (app/ui_text `derive.<code>`), and `field` the
page field it is shown beside."""
def __init__(self, text, code="", field=""):
def __init__(self, text, code="", field="", **values):
super().__init__(text)
self.code, self.field = code, field
self.values = values # the sentence's {placeholders} (P5)
self.keys = () # the option keys the failed derivation writes
# The page model's inverter count, which show conditions read, and what adding
@@ -211,14 +216,125 @@ def _profile_decode(raw):
return "custom", None, None, None
# --- 3b. the time-window editor (UX-13 P5) ---------------------------------- #
# Each row is {"from", "to", "days", "price"} and, when read from a stored
# profile, its "name". `days` is one of these; a profile with any other day set
# is edited in the Configuration tab only (`ui.periods` is None).
PERIOD_DAYS = ("mon-sun", "mon-fri", "sat,sun") + DAY_NAMES
_DAYSETS = {d: frozenset(range(7)) if d == "mon-sun" else
frozenset(range(5)) if d == "mon-fri" else
frozenset((5, 6)) if d == "sat,sun" else
frozenset((DAY_NAMES.index(d),)) for d in PERIOD_DAYS}
_TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*$")
def _hhmm(minute) -> str:
return f"{minute // 60:02d}:{minute % 60:02d}"
def _periods_decode(raw):
"""The rows for a stored profile: [] when unset, None when it cannot be
shown as rows (unparsable, or days the editor has no choice for)."""
try:
prof = parse_static_profile(raw) if isinstance(raw, str) else None
except ProfileInvalid:
return None
if prof is None:
return [] if isinstance(raw, str) else None
rows = []
for p in prof.periods:
days = next((d for d, s in _DAYSETS.items() if s == p.days), None)
if days is None:
return None
rows.append({"from": _hhmm(p.start_min), "to": _hhmm(p.end_min),
"days": days, "price": p.eur_per_kwh, "name": p.name})
return rows
def _minute(text, *, end):
""""07:00" -> 420, "24:00" -> 1440 as an end, else None."""
m = _TIME.match(str(text or ""))
if not m:
return None
h, mi = int(m.group(1)), int(m.group(2))
if mi > 59 or h > 24 or (h == 24 and (mi or not end)):
return None
return h * 60 + mi
def _periods_json(rows) -> str:
"""The static_profile for the editor's rows, or DeriveError naming the
first problem: a bad time, an empty price, an overlap or a gap."""
if not rows:
raise DeriveError("add at least one time window", "no_periods",
"ui.periods")
periods = []
for i, row in enumerate(rows, start=1):
row = row if isinstance(row, dict) else {}
start = _minute(row.get("from"), end=False)
end = _minute(row.get("to"), end=True)
if start is None or end is None:
raise DeriveError(f"window {i} has a bad time", "period_time",
"ui.periods", row=i)
end = end or MINUTES_PER_DAY # "00:00" as an end
if start == end:
raise DeriveError(f"window {i} is empty", "period_zero",
"ui.periods", row=i)
price = _num(row.get("price"))
if price is None:
raise DeriveError(f"window {i} needs a price", "period_price",
"ui.periods", row=i)
if abs(price) > MAX_PLAUSIBLE_EUR_KWH:
raise DeriveError(f"window {i}'s price is out of range",
"period_price_range", "ui.periods", row=i)
if row.get("days") not in _DAYSETS:
raise DeriveError(f"window {i} has no days", "unknown",
"ui.periods")
name = str(row.get("name") or "").strip() or f"window{i}"
periods.append(Period(name=name, start_min=start, end_min=end,
days=_DAYSETS[row["days"]],
eur_per_kwh=price))
_tiling(periods)
return json.dumps({"periods": [
{"name": p.name, "from": _hhmm(p.start_min),
"to": _hhmm(p.end_min), "days": row["days"],
"eur_per_kwh": p.eur_per_kwh}
for p, row in zip(periods, rows)]})
def _tiling(periods):
"""Each minute of the week in exactly one window, as static_tariff.py
requires. The first overlap or gap, by day and time, is the error."""
for day in range(7):
start, kind, rows = None, None, None
for minute in range(MINUTES_PER_DAY + 1):
here = [i for i, p in enumerate(periods, start=1)
if minute < MINUTES_PER_DAY and p.covers(day, minute)]
now = ("gap" if not here else "overlap" if len(here) > 1
else None) if minute < MINUTES_PER_DAY else "end"
if kind is not None and (now != kind or (kind == "overlap"
and here != rows)):
values = {"day": DAY_NAMES[day], "from": _hhmm(start),
"to": _hhmm(minute)}
if kind == "gap":
raise DeriveError("a time has no window", "period_gap",
"ui.periods", **values)
raise DeriveError("two windows cover one time",
"period_overlap", "ui.periods",
a=rows[0], b=rows[1], **values)
if now in ("gap", "overlap") and kind is None:
start, kind, rows = minute, now, here
def _tariff_decode(o):
kind, fixed, day, night = _profile_decode(o.get("static_profile", ""))
raw = o.get("static_profile", "")
kind, fixed, day, night = _profile_decode(raw)
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}
"ui.night_price": night, "ui.periods": _periods_decode(raw)}
def _tariff_encode(m, keep):
@@ -241,11 +357,16 @@ def _tariff_encode(m, keep):
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:
rows = m.get("ui.periods")
if rows is not None:
out["static_profile"] = _periods_json(rows)
elif "static_profile" not in keep:
raise DeriveError("odd tariff windows are edited in the "
"Configuration tab", "custom_profile",
"ui.contract")
out["static_profile"] = keep["static_profile"]
else:
# Days the editor cannot show: kept as stored.
out["static_profile"] = keep["static_profile"]
else:
raise DeriveError(f"unknown contract {kind!r}", "unknown",
"ui.contract")
@@ -337,14 +458,31 @@ def options_to_ui(options: dict) -> dict:
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."""
out, errors = ui_to_options_partial(model)
if errors:
raise errors[0]
return out
def ui_to_options_partial(model: dict):
"""(blob, [DeriveError]): every derivation that can be made, and the
stored values (`keep`) for each one that cannot. For the checks only, so a
step can be blocked by its own field and the rest still checked (UX-15
F1). Never saved: `ui_to_options()` raises instead."""
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.")}
errors = []
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:
continue
try:
out.update(encode(model, keep))
return out
except DeriveError as err:
err.keys = keys
errors.append(err)
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
return out, errors
+60 -14
View File
@@ -9,26 +9,23 @@ are (DECISIONS/UX-13-one-layout-table.md).
"""
import copy
import io
import json
import os
import re
from . import uilayout as L
from . import uitext
from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
from .validate import power_request_entities
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
"en.json")
# A choice the page cannot make, only keep: odd tariff windows (P5 editor).
ONLY_IF_HELD = ("custom",)
# A choice the page cannot make, only keep. None since P5's period editor.
ONLY_IF_HELD = ()
_TIERS = ("bad", "warn", "info")
def ui_text() -> dict:
with io.open(UI_TEXT, encoding="utf-8") as f:
return json.load(f)
def ui_text(lang="en") -> dict:
"""The page words in `lang`, English where Dutch has no key (P5)."""
return copy.deepcopy(uitext.words(lang))
def _roster():
@@ -122,6 +119,10 @@ def _field(fld, schema_ui, translations, lang, T):
if hints:
out["hints"] = {v: e.get("description", "")
for v, e in hints.items()}
if fld.control == L.PERIOD_LIST:
# P5: the day choices of one time window, in the editor's order.
names = T["choices"]["period_days"]
out["days"] = [[d, names.get(d, d)] for d in U.PERIOD_DAYS]
if fld.mirror:
out["mirror"] = fld.mirror
if fld.fills:
@@ -145,7 +146,7 @@ def _field(fld, schema_ui, translations, lang, T):
def layout(schema_ui, translations, lang="en") -> dict:
"""Every group, part and field of the Installer view, as the page draws
it. Built from the table only: a field the table places is on the page."""
T = ui_text()
T = ui_text(lang)
groups = []
for g in L.GROUPS:
parts = []
@@ -186,7 +187,32 @@ def to_options(model: dict, stored: dict | None = None) -> dict:
"""The options blob for a posted page model. A value equal to the stored
one is written as stored, in the stored key order, so a save that changed
nothing writes an identical blob (the browser reads 3.0 as 3)."""
out = U.ui_to_options(model)
return _settle(U.ui_to_options(model), stored)
def to_options_partial(model: dict, stored: dict | None = None):
"""(blob, [DeriveError]) for the checks: `uimodel.ui_to_options_partial`,
settled like `to_options`. Never saved (UX-15 F1)."""
out, errors = U.ui_to_options_partial(model)
return _settle(out, stored), errors
def only_edits(mine: dict, base: dict, stored: dict) -> dict:
"""The blob to save: `stored`, with the keys whose value in `mine` (this
page's model) differs from `base` (the model it opened) set to `mine`'s.
A key this page never edited keeps what is stored now, even when another
surface changed it meanwhile (UX-13 P4 test, F2)."""
edits = {k: v for k, v in mine.items()
if k not in base or not _same(v, base[k])}
gone = {k for k in base if k not in mine}
out = {k: copy.deepcopy(edits.get(k, v)) for k, v in stored.items()
if k not in gone}
out.update({k: copy.deepcopy(v) for k, v in edits.items()
if k not in out})
return out
def _settle(out: dict, stored: dict | None) -> dict:
stored = stored or {}
for k, v in out.items():
if k in stored and _same(v, stored[k]):
@@ -195,11 +221,31 @@ def to_options(model: dict, stored: dict | None = None) -> dict:
| {k: v for k, v in out.items() if k not in stored})
def derive_message(err: U.DeriveError) -> dict:
words = ui_text()["settings"]["derive"]
def derive_message(err: U.DeriveError, lang="en") -> dict:
T = uitext.words(lang)
words = T["settings"]["derive"]
text = words.get(err.code, words["unknown"])["description"]
args = dict(getattr(err, "values", None) or {})
# A time window's day is shown by name (P5 period editor).
if "day" in args:
args["day"] = T["choices"]["period_days"].get(args["day"], args["day"])
return {"field": err.field or None, "card": None, "tier": "bad",
"text": text}
"text": uitext.say(text, **args)}
def derive_messages(errors, problems, notes, model, keys, lang="en"):
"""The page's messages when part of the model could not become options:
each conversion's own sentence beside its field, then the checks of the
rest. A refusal or note about a key the failed conversion writes is left
out, because it judges the stored value, not what the page shows."""
failed = {k for err in errors for k in err.keys}
by_key, unanchored = problems_by_field(problems, keys)
kept = [t for k, texts in by_key.items() if k not in failed
for t in texts] + list(unanchored)
kept = [p for p in problems if p in kept]
notes = [n for n in notes if not set(n.keys or ()) & failed]
return ([derive_message(err, lang) for err in errors]
+ anchors(kept, notes, model, keys))
# --------------------------------------------------------------------------- #
+51
View File
@@ -0,0 +1,51 @@
"""UX-13 P5: the page's words, app/ui_text/<lang>.json, in English or Dutch.
One file per language with the same keys. A key missing from a language falls
back to English, key by key, the way the Supervisor's translations do.
test_ui_text.py holds both files to STYLE.md and to each other.
"""
import copy
import io
import json
import os
DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text")
LANGS = ("en", "nl")
_CACHE: dict = {}
def lang_of(value) -> str:
""""nl", "nl-BE" or "nl_NL" -> "nl"; anything else -> "en"."""
head = str(value or "").strip().lower().replace("_", "-").split("-")[0]
return head if head in LANGS else "en"
def raw(lang: str) -> dict:
"""One language file as written, without the English fallback."""
if lang not in _CACHE:
with io.open(os.path.join(DIR, f"{lang}.json"), encoding="utf-8") as f:
_CACHE[lang] = json.load(f)
return _CACHE[lang]
def _merge(base, over):
if not (isinstance(base, dict) and isinstance(over, dict)):
return copy.deepcopy(over)
out = copy.deepcopy(base)
for k, v in over.items():
out[k] = _merge(base.get(k), v) if k in base else copy.deepcopy(v)
return out
def words(lang="en") -> dict:
"""Every word in `lang`, English where a key is missing."""
lang = lang_of(lang)
return raw("en") if lang == "en" else _merge(raw("en"), raw(lang))
def say(template: str, **values) -> str:
"""Fill `{name}` placeholders, as settings.js `say()` does."""
for k, v in values.items():
template = template.replace("{" + k + "}", str(v))
return template
+24 -11
View File
@@ -808,6 +808,14 @@ class ConfigRefused(Exception):
super().__init__(problems[0] if problems else "configuration refused")
def no_power_request_problem() -> str:
"""Rule 1's refusal: no inverter to control. UX-15's setup-only mode
starts when this is the only refusal (app/setuponly.py)."""
return (f"{S('setpoint_entity')} is empty, so the add-on cannot control "
"the inverter. Set it to the ESPHome number entity for the "
"setpoint.")
def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
notes: list | None = None) -> list[str]:
"""Return the list of reasons this configuration must not be started.
@@ -828,9 +836,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
# --- 1. an actuator to write to, and AT MOST ONE WRITER PER ACTUATOR -----
writers = actuator_writers(opts)
if not writers:
problems.append(
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
"inverter. Set it to the ESPHome number entity for the setpoint.")
problems.append(no_power_request_problem())
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
# count of option keys. Read `actuator_writers()` for why the old check went
# green on the very config this one is here to refuse.
@@ -1704,6 +1710,14 @@ WATT_ENTITY_KEYS = (
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
# The meter entities each `meter_source` reads through Home Assistant.
METER_ENTITY_KEYS = {
"off": ("meter_entity",),
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
"p1_phase_import_entities", "p1_phase_export_entities")}
def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
"""(key, inverter name or "", entity id) for each entity the add-on reads
with these options. Keys the chosen meter source or roster shape ignores
@@ -1716,12 +1730,7 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
out.append((key, where, e.strip()))
source = str(opts.get("meter_source") or "off").strip()
by_source = {"off": ("meter_entity",),
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
"p1_phase_import_entities",
"p1_phase_export_entities")}
for key in by_source.get(source, ()):
for key in METER_ENTITY_KEYS.get(source, ()):
add(key, opts.get(key))
entries = roster_entries(opts)
if not entries:
@@ -1844,7 +1853,7 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
entity exists in Home Assistant (a write to a missing one is answered 200,
SAFETY-77, so only the entity list can tell), the battery levels read, and
prices are known. Tier "ok", "info", "warn", or "bad" for a missing power
request entity, which the wizard will not save. Reads nothing itself:
request or meter entity, which the wizard will not save. Reads nothing itself:
`entities` is Home Assistant's list by id (None when it did not answer),
`running` the options this process started with, `meter_now_w` its meter
reading and `prices_known` how many of today's hours have a price.
@@ -1855,7 +1864,11 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
"answering, so press Check again once it is back.")
else:
_entity_notes(notes, opts, entities, bad=("setpoint_entity",))
# A missing meter entity is red too (UX-15): with the kettle test
# skipped, nothing else on the way to the save would catch it.
meter_keys = {k for keys in METER_ENTITY_KEYS.values() for k in keys}
_entity_notes(notes, opts, entities,
bad=("setpoint_entity",) + tuple(sorted(meter_keys)))
meter = _meter_check(notes, opts, entities, running, meter_now_w)
batts = [_card_checks(notes, who, entry, entities)
for who, entry in _cards(opts)]
+74 -19
View File
@@ -31,6 +31,12 @@ of Home Assistant's entity list (app/haentities.py) and writes nothing.
UX-13 P4's setup wizard uses the same routes. The one thing it adds that is
not options is a Store key, `setup_done`, written when the wizard's save
succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
commands nothing; there `/action` answers 409.
OPT-11 P1 adds two `/action` values, `pack_use` and `pack_mode`, which set one
battery's on/off switch or mode in the store (Controller._pack_action). They
are the only actions that name a battery; the configuration routes still reach
none of them.
"""
import io
@@ -41,7 +47,7 @@ from datetime import datetime, timezone
from aiohttp import web
from . import messages, selfconfig, uipage
from . import messages, selfconfig, uipage, uitext
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes, setup_checks
@@ -51,14 +57,16 @@ from .uimodel import DeriveError, options_to_ui
_LOG = logging.getLogger("goodwe.web")
# OPT-11: the per-battery actions, which name a battery and a value.
PACK_ACTIONS = ("pack_use", "pack_mode")
# UX-13 P1: the page lives in app/static/ as three files - index.html,
# app.css, app.js - plus the page words from app/ui_text/en.json. They are
# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English
# and, since P5, Dutch; the page picks by the browser's language). They are
# joined into ONE response here, once, so the page needs no second request
# and no route of its own: the route list below is unchanged, and every URL
# the page uses stays relative (ingress). No build step, no external file.
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
"en.json")
def _read(path):
@@ -69,7 +77,8 @@ def _read(path):
def page() -> str:
"""The assembled index page. `</` is escaped inside the two inline
scripts, so no text in them can close the tag early."""
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
ui_text = json.dumps({lang: uitext.raw(lang) for lang in uitext.LANGS},
ensure_ascii=False)
scripts = {}
for name in ("app.js", "settings.js"):
scripts[name] = _read(os.path.join(STATIC, name))
@@ -127,8 +136,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
async def index(_request):
return web.Response(text=index_page, content_type="text/html")
async def status(_request):
return web.json_response(controller.status_payload())
async def status(request):
# UX-13 P5: Home's sentences in the page's language, English or Dutch.
lang = uitext.lang_of(request.query.get("lang"))
if lang == "en":
return web.json_response(controller.status_payload())
return web.json_response(controller.status_payload(lang=lang))
async def action(request):
try:
@@ -136,7 +149,21 @@ def build_app(controller, selfcfg=None) -> web.Application:
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
what = body.get("action")
result = await controller.handle_action(what)
if getattr(controller, "setup_only", False):
# UX-15: nothing to start or stop until setup is saved.
return web.json_response(
{"ok": False, "error": messages.row_setup_only()}, status=409)
if what in PACK_ACTIONS:
# OPT-11: one battery's switch or mode. Store state for the named
# battery, applied on the next cycle; nothing is written here.
result = await controller.handle_action(
what, unit=body.get("unit"), value=body.get("value"))
if isinstance(result, dict) and "error" in result:
return web.json_response({"ok": False,
"error": result["error"]},
status=400)
else:
result = await controller.handle_action(what)
if result is None:
raise web.HTTPBadRequest(text=f"unknown action {what!r}")
return web.json_response({"ok": True, "result": result})
@@ -149,14 +176,23 @@ def build_app(controller, selfcfg=None) -> web.Application:
"""(body, options, model). UX-13's settings view posts its page
`model`, which app/uimodel.py turns into the options blob here, so the
validator and the Supervisor only ever see a real blob. The classic
page posts `options`. Raises DeriveError for a model no blob can say."""
page posts `options`. Raises DeriveError for a model no blob can say.
With `stored` (a save) and the `base` model the page opened, only the
keys this page changed are written onto what is stored now, so a save
after a stale refusal keeps the other surface's changes (UX-15 F2)."""
try:
body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = body.get("model")
if isinstance(model, dict):
return body, uipage.to_options(model, stored), model
mine = uipage.to_options(model, stored)
base = body.get("base")
if stored is not None and isinstance(base, dict):
was, _errors = uipage.to_options_partial(base, stored)
mine = uipage.only_edits(mine, was, stored)
return body, mine, model
options = body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
@@ -202,6 +238,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
options, store_data(), bool(getattr(controller, "auto", False)),
fresh=getattr(getattr(controller, "store", None), "fresh",
False) is True),
# UX-15: this process serves setup only and commands nothing.
"setup_only": getattr(controller, "setup_only", False) is True,
})
return web.json_response(model)
@@ -219,11 +257,19 @@ def build_app(controller, selfcfg=None) -> web.Application:
be a second opinion about a rule it does not own.
"""
try:
_body, options, model = await _proposed(request)
except DeriveError as err:
return web.json_response({"problems": [], "by_key": {},
"unanchored": [], "notes": [],
"messages": [uipage.derive_message(err)]})
_body = await request.json()
except ValueError:
raise web.HTTPBadRequest(text="expected JSON")
model = _body.get("model")
errors = []
if isinstance(model, dict):
# UX-15 F1: a part the page cannot turn into options blocks its
# own step, and everything else is still checked.
options, errors = uipage.to_options_partial(model)
else:
model, options = None, _body.get("options")
if not isinstance(options, dict):
raise web.HTTPBadRequest(text="expected an options object")
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass, notes=notes)
@@ -237,8 +283,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
if model is not None:
payload["messages"] = uipage.anchors(problems, notes, model,
_keys_of(options))
payload["messages"] = (
uipage.derive_messages(errors, problems, notes, model,
_keys_of(options),
uitext.lang_of(_body.get("lang")))
if errors
else uipage.anchors(problems, notes, model, _keys_of(options)))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
@@ -286,10 +336,15 @@ def build_app(controller, selfcfg=None) -> web.Application:
return web.json_response({"error": str(err)}, status=502)
try:
body, options, model = await _proposed(request, stored_now)
except DeriveError as err:
except DeriveError:
model = posted["model"]
_opts, errors = uipage.to_options_partial(model, stored_now)
lang = uitext.lang_of(posted.get("lang"))
messages_ = [uipage.derive_message(e, lang) for e in errors]
return web.json_response(
{"kind": "invalid", "problems": [], "by_key": {},
"unanchored": [], "messages": [uipage.derive_message(err)]},
"unanchored": [], "messages": messages_,
"steps": uipage.step_problems(messages_, model)},
status=422)
expect = str(body.get("fingerprint") or "")
try:
+6
View File
@@ -146,6 +146,11 @@ options:
# options blob, and the upgrade leaves an add-on that cannot start.
inverters: []
estop_fitted: false
# OPT-11: battery modes. Off by default, so an install that has not turned
# them on behaves exactly as before. When on, each battery gets an on/off
# switch and a mode on the Batteries screen, kept in the add-on's store.
# DECISIONS/OPT-11-battery-modes.md.
battery_modes_enabled: false
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
# DECIDES ANYTHING. Optional, empty on every install that does not set it, and
@@ -943,6 +948,7 @@ schema:
capacity_kwh: float(0.1,200)?
estop_fitted: bool
battery_modes_enabled: bool
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
# `price_now_entity`/`price_avg_entity`, which are legal only while
# `price_provider` is `off` and make the add-on REFUSE TO START otherwise.
+127
View File
@@ -1041,6 +1041,133 @@ check("...and the three that are NOT caught are reported as such, with the "
all(b is False for _n, b, _w in MUT[-3:]))
print()
print("13. OPT-11: the mode layer - applied at or below 35, released above it")
# A mode is a SOFT unit limit beside the hard ones (UnitBounds.mode_*). The
# allocator applies it only when the site target was decided at or below the
# mode's own priority (P_MODE, 35), drops it when its range leaves out 0 W,
# and never lets it narrow a hard limit into nothing.
from app.allocator import (MODE_FAULT, MODE_OUTRANKED, MODE_RELEASED, # noqa: E402
loop_bound, unit_interval)
from app.arbiter import P_MODE # noqa: E402
def M(lo=-RATING, hi=RATING, mlo=-inf, mhi=inf, prio=P_MODE, **kw):
"""A unit's bounds with a mode range beside its hard limits."""
b = B(lo, hi, **kw)
return UnitBounds(lo=b.lo, hi=b.hi, gate=b.gate, pinned_w=b.pinned_w,
pinned_by=b.pinned_by, contradiction=b.contradiction,
mode_lo=mlo, mode_hi=mhi, mode_priority=prio)
_off = {A: M(), Bee: M(mlo=0.0, mhi=0.0)}
_at35 = plan(3000, _off, deciding_priority=P_MODE)
_at50 = plan(3000, _off, deciding_priority=P_PEAK)
_at10 = plan(3000, _off, deciding_priority=P_LOOP)
print(f" B off, 3000 W: decided at 10 {_at10.explain()} | at 35 "
f"{_at35.explain()} | at 50 {_at50.explain()}")
check("⭐ RELEASE AT 35: a target decided at P_MODE itself keeps the mode - "
"B (Off) is held at 0 W and A carries all 3000 W",
_at35.shares == {A: 3000, Bee: 0} and _at35.unmet_w == 0)
check("...and below it too (the loop, 10): the same split",
_at10.shares == _at35.shares)
check("⭐ RELEASE AT 50: a target decided by the peak floor releases the mode "
"and B takes its weighted half",
_at50.shares == {A: 1500, Bee: 1500} and _at50.unmet_w == 0)
check("...and the boundary is exactly 35: at 36 the mode is released",
plan(3000, _off, deciding_priority=P_MODE + 1).shares
== {A: 1500, Bee: 1500})
check("...and no deciding priority named means the mode applies (the safe "
"reading for a direct caller)",
plan(3000, _off).shares == {A: 3000, Bee: 0})
check("unit_interval() reports why: None when applied, MODE_RELEASED above "
"35, and the hard limits always",
unit_interval(_off[Bee], P_MODE) == (0.0, 0.0, None)
and unit_interval(_off[Bee], P_PEAK) == (-RATING, RATING, MODE_RELEASED))
# CONTAINS 0: a mode that would FORCE a direction is a software fault.
_force = {A: M(mlo=500.0, mhi=RATING), Bee: M()}
_f = plan(-1000, _force, deciding_priority=P_LOOP)
print(f" A's mode is [500, 5000] (leaves out 0 W), site charges 1000 W: "
f"{_f.explain()}, faults {_f.mode_faults}")
check("⭐ CONTAINS 0: a mode range that leaves out 0 W is dropped and named "
"as a fault, so REFUSE_SIGN stays unreachable: both units charge and "
"the split is allowed",
_f.ok and _f.mode_faults == ((A, MODE_FAULT),)
and all(v <= 0 for v in _f.shares.values())
and sum(_f.shares.values()) == -1000)
_f_obeyed = plan(-1000, {A: B(500.0, RATING), Bee: B(-RATING, RATING)})
check("...and that is not a hypothetical: the same range obeyed as a hard "
"limit forces A to give 500 W while B charges 1500 W, which the "
"allocator REFUSES as circulating power",
not _f_obeyed.ok and REFUSE_SIGN in _f_obeyed.refused)
check("...and a mode whose own lo is above its own hi is the same fault",
unit_interval(M(mlo=300.0, mhi=-300.0))[2] == MODE_FAULT)
_out = unit_interval(M(lo=-2000.0, hi=-500.0, mlo=0.0, mhi=0.0))
check("a hard limit that leaves the mode no room wins outright "
"(MODE_OUTRANKED): the unit keeps its hard interval",
_out == (-2000.0, -500.0, MODE_OUTRANKED))
check("a unit with no mode (every unit with battery modes off) gets exactly "
"its hard limits, and plan() is the same record as before the mode "
"fields existed",
unit_interval(B(-700.0, 900.0)) == (-700.0, 900.0, None)
and plan(1234, TWO, deciding_priority=P_PEAK) == plan(1234, TWO))
# The sweep: sum and sign kept whatever the modes, the target and the
# deciding priority; every share inside its effective interval.
_rng = random.Random(1135)
_bad, _nm = [], 0
for _ in range(20000):
_units = {}
for u in (A, Bee, "inverter-c")[:_rng.choice((2, 3))]:
r = _rng.choice((2000.0, 3600.0, 5000.0))
mlo = _rng.choice((-r, -700.0, 0.0))
mhi = _rng.choice((0.0, 450.0, r))
_units[u] = M(-r, r, mlo, mhi)
_d = _rng.choice((P_LOOP, P_ARBITRAGE, P_MODE, P_MODE + 1, P_PEAK, None))
_t = _rng.randint(-15000, 15000)
_p = plan(_t, _units, deciding_priority=_d)
_nm += 1
_eff = {u: unit_interval(b, _d)[:2] for u, b in _units.items()}
_sh = _p.shares
if (not _p.ok or sum(_sh.values()) + _p.unmet_w != _p.sum_w
or (any(v > 0 for v in _sh.values())
and any(v < 0 for v in _sh.values()))
or any(not (_eff[u][0] <= _sh[u] <= _eff[u][1]) for u in _sh)
or (_d is not None and _d > P_MODE
and any(_eff[u] != (b.lo, b.hi) for u, b in _units.items()))):
_bad.append((_t, _d, _p.explain()))
for x in _bad[:5]:
print(f" BAD {x}")
check(f"over {_nm} random ticks (2-3 units, mode ranges holding 0 W, targets "
f"+-15 kW, deciding at 10/30/35/36/50/None) the shares sum to S with "
f"`unmet`, never split the sign, and each stays inside its interval - "
f"the mode's above 35 released, applied at or below",
not _bad and _nm == 20000)
# loop_bound(): what the loop may ask when it decides.
_lb = loop_bound({A: M(mlo=-3000.0, mhi=0.0), Bee: M(mlo=0.0, mhi=0.0)})
check("loop_bound() sums each unit's interval with its mode applied: A may "
"only charge 3000 W, B is off, so the loop may ask [-3000, 0]",
_lb == (-3000.0, 0.0))
check("...a unit that is not live adds nothing",
loop_bound({A: M(), Bee: M()}, live=(A,)) == (-RATING, RATING))
check("...a PINNED unit adds the span from 0 to its pin, so a 200 W probe "
"(SAFETY-73: the closed limit becomes a pin) stays reachable",
loop_bound({A: M(gate=P_SAFETY, pin=200.0),
Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 200.0))
check("...and the pin is clamped into the hard limits first, as plan() "
"clamps it",
loop_bound({A: M(lo=-RATING, hi=0.0, gate=P_SAFETY, pin=200.0),
Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 0.0))
check("...a mode that leaves out 0 W adds the hard interval (dropped, as in "
"plan()), so every term holds 0 and so does the sum",
loop_bound({A: M(mlo=500.0, mhi=900.0), Bee: M(mlo=0.0, mhi=0.0)})
== (-RATING, RATING))
check("...and a unit contradicting itself adds nothing",
loop_bound({A: M(contra=True), Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 0.0))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+254 -4
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: twelve sources, each classified where it is BUILT")
print("ARB-03 2. the split: thirteen sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -193,16 +193,20 @@ UNIT_SOURCES = {
"refused": "THIS battery's own reading gave nothing against THIS unit's "
"own standing command, so that direction is closed on THIS "
"unit and its share goes to the others. Filed at N>1 only",
# OPT-11, added on purpose (test_peakguard.py part 10f says why).
"mode": "THIS battery's mode, chosen by its owner. A soft unit limit "
"that stage 1 keeps apart from the hard ones and stage 3 "
"releases when a claim above P_MODE decides the site target",
}
for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all TWELVE claim sources are classified, and only those twelve",
check("all THIRTEEN claim sources are classified, and only those thirteen",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the six that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the six that are about a PACK, a DEVICE or one write are per-inverter",
check("the seven that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
check("a source nobody classified cannot be routed at all - actuator_for() "
"raises rather than defaulting to the site, because defaulting to the "
@@ -220,7 +224,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the six "
check("with TWO the identity breaks and the split becomes real: the seven "
"per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
@@ -540,6 +544,252 @@ check("...and a correctly routed cycle is unaffected, which is what says this "
resolve(SITE_SIDE, units=TWO).allocated is True)
print()
print("OPT-11 9. the mode layer: a unit `hi` never meets the site `lo`")
# A battery's mode is a SOFT unit limit at P_MODE (35). Stage 1 keeps it apart
# from the hard limits; stage 3 applies it only while the site target was
# decided at or below P_MODE. So the peak guard's site floor (P_PEAK) releases
# every mode and SAFETY-25 section 0a's cross-inverter contradiction - 0 W in a
# billed period - still cannot be written down.
from app.arbiter import MODE_SOURCE, P_MODE # noqa: E402
from app.allocator import MODE_FAULT, MODE_RELEASED # noqa: E402
def _two(loop_w, *, peak_lo=None, modes=None, rating=2000.0, arb=None):
"""A two-inverter cycle's claims: ratings, each unit's mode, the loop,
optionally the peak floor and an arbitrage set."""
cl = []
for u in TWO:
cl.append(Claim.limit("supervised", P_SAFETY, -rating, rating,
"max_w", actuator=u))
for u, (lo, hi) in (modes or {}).items():
cl.append(Claim.limit(MODE_SOURCE, P_MODE, lo, hi, "mode", actuator=u))
cl.append(Claim.set("loop", P_LOOP, loop_w))
if arb is not None:
cl.append(Claim.set("arbitrage", P_ARBITRAGE, arb))
if peak_lo is not None:
cl.append(Claim.limit("peak", P_PEAK, lo=peak_lo))
return cl
check("P_MODE sits between arbitrage and the peak guard, so a trade stays "
"inside each mode and the peak floor releases them all",
P_ARBITRAGE < P_MODE < P_PEAK and P_MODE < P_MAINT_SHAPE)
_off_b = {"inverter-b": (0.0, 0.0)}
r = resolve(_two(-500.0, peak_lo=3000.0, modes=_off_b), units=TWO)
print(f" peak floor 3000 W, loop asks -500 W, B off: {r.explain()}, "
f"deciding {r.deciding_priority}, shares {r.shares}")
check("⭐ at N=2 a mode `hi` of 0 W on B and the peak guard's site `lo` of "
"3000 W do NOT contradict: no 0 W stop, the floor decides S (priority "
"50), the mode is released, and the shares still sum to S",
not r.contradiction and r.allocated and r.target_w == 3000.0
and r.deciding_priority == P_PEAK
and sum(r.shares.values()) == 3000 and r.allocation.unmet_w == 0)
_forbidden = [c if c.source != MODE_SOURCE
else Claim.limit(MODE_SOURCE, P_MODE, c.lo, c.hi,
actuator=ACTUATOR_SITE)
for c in _two(-500.0, peak_lo=3000.0, modes=_off_b)]
_rf = site_resolve([c for c in _forbidden if c.actuator == ACTUATOR_SITE])
check("...and the SAME mode filed as a SITE limit is exactly the fault the "
"layer exists to keep out: stage 2 then reads `lo` 3000 > `hi` 0 and "
"stops the site at 0 W (so the check above sees the difference)",
_rf.contradiction and _rf.target_w == 0.0)
check("...and resolve() refuses that mis-filed mode at N=2 (a UNIT claim on "
"the site), rather than dividing a number that may be wrong",
resolve(_forbidden, units=TWO).allocated is False)
# The sweep: every mode range that holds 0 W, every loop ask, every floor.
_bad = []
_n = 0
_spans = [(0.0, 0.0), (-2000.0, 0.0), (0.0, 2000.0), (-2000.0, 2000.0),
(-700.0, 300.0)]
for _ma in _spans:
for _mb in _spans:
for _loop in (-5000.0, -1200.0, 0.0, 800.0, 3900.0):
for _peak in (None, 0.0, 1500.0, 3000.0, 4000.0):
_n += 1
_r = resolve(_two(_loop, peak_lo=_peak,
modes={"inverter-a": _ma,
"inverter-b": _mb}), units=TWO)
_al = _r.allocation
_sh = list(_r.shares.values())
if (_r.contradiction or not _r.allocated
or sum(_sh) + _al.unmet_w != _al.sum_w
or (any(x > 0 for x in _sh) and any(x < 0 for x in _sh))
or (_peak is not None and _r.target_w < _peak)):
_bad.append((_ma, _mb, _loop, _peak, _r.explain(),
_al.explain()))
for x in _bad[:5]:
print(f" BAD {x}")
check(f"over {_n} two-inverter cycles (5x5 mode ranges holding 0 W, 5 loop "
f"asks, 5 peak floors) no cycle contradicts, refuses, loses a watt of "
f"the sum, splits the sign, or goes under the peak floor",
not _bad and _n == 625)
check("deciding_priority: the loop alone decides at 10",
resolve(_two(800.0), units=TWO).deciding_priority == P_LOOP)
check("...a trade decides at 30, so the modes still apply to it",
resolve(_two(800.0, arb=-1500.0), units=TWO).deciding_priority
== P_ARBITRAGE)
check("...a peak floor that does not bind leaves the loop deciding at 10",
resolve(_two(3500.0, peak_lo=1000.0), units=TWO).deciding_priority
== P_LOOP)
check("...and a peak floor that binds decides at 50",
resolve(_two(500.0, peak_lo=1000.0), units=TWO).deciding_priority
== P_PEAK)
_r35 = resolve(_two(3000.0, modes=_off_b), units=TWO)
check("with the loop deciding, B's Off mode holds: B gets 0 W and A carries "
"what it can, the rest reported as unmet",
_r35.shares == {"inverter-a": 2000, "inverter-b": 0}
and _r35.allocation.unmet_w == 1000)
_ub = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0)])
check("stage 1 keeps the mode APART from the hard limits: the unit's own "
"interval is still its rating and the mode travels beside it",
(_ub.lo, _ub.hi, _ub.mode_lo, _ub.mode_hi, _ub.mode_priority)
== (-2000, 2000, 0.0, 0.0, P_MODE) and not _ub.contradiction)
_ub2 = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
Claim.limit(MODE_SOURCE, P_MODE, 500.0, 2000.0)])
check("...so a mode that does not hold 0 W cannot make the unit contradict "
"itself at stage 1; stage 3 drops it as a software fault instead",
not _ub2.contradiction
and allocate(1000.0, {"inverter-a": _ub2, "inverter-b": UnitBounds()})[1]
and resolve(_two(1000.0, modes={"inverter-a": (500.0, 2000.0)}),
units=TWO).allocation.mode_faults
== (("inverter-a", MODE_FAULT),))
_ubm = pre_resolve([Claim.limit("supervised", P_SAFETY, -2000, 2000),
Claim.set("maintenance", P_MAINTENANCE, 2500.0, "drain"),
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0)])
check("...and a unit's own maintenance SET (60) gates the mode out, as it "
"gates every lower limit: maintenance outranks the mode",
_ubm.mode_priority is None and _ubm.pinned_by == "maintenance")
print()
print("OPT-11 10. at N=1 the mode bounds the site target, by the same rule")
_one = [Claim.limit("supervised", P_SAFETY, -5000, 5000, "max_w"),
Claim.limit(MODE_SOURCE, P_MODE, 0.0, 0.0, "off")]
_r1 = resolve(_one + [Claim.set("loop", P_LOOP, 1200.0)], units=ONE)
check("the loop deciding: an Off battery's site target is 0 W, named as the "
"mode's doing, with no contradiction",
_r1.target_w == 0.0 and not _r1.contradiction
and _r1.bound_by == "mode(off)" and _r1.allocated)
_r1p = resolve(_one + [Claim.set("loop", P_LOOP, 1200.0),
Claim.limit("peak", P_PEAK, lo=2000.0)], units=ONE)
check("...a binding peak floor releases it (2000 W, no contradiction), "
"which at N=1 would otherwise be `lo` 2000 > `hi` 0 and a 0 W stop",
_r1p.target_w == 2000.0 and not _r1p.contradiction
and _r1p.deciding_priority == P_PEAK)
_r1a = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, -3000.0)],
units=ONE)
check("...a trade (30) stays inside it", _r1a.target_w == 0.0)
_r1f = resolve([Claim.limit(MODE_SOURCE, P_MODE, 100.0, 900.0),
Claim.set("loop", P_LOOP, -1200.0)], units=ONE)
check("...and a mode range without 0 W is dropped and reported, never "
"obeyed: the target stays the loop's -1200 W",
_r1f.target_w == -1200.0 and _r1f.mode_fault is not None)
check("...and with no mode claim at all, stage 2's answer is untouched "
"(the N=1 path with battery modes off)",
resolve([Claim.set("loop", P_LOOP, 1200.0)], units=ONE).target_w
== 1200.0)
print()
print("OPT-11 11. a mode gives way only as far as the peak limit needs")
# Review of PR #149. The first cut released the modes only when a limit above
# P_MODE bound S. A trade (30) that won above the peak floor then kept the
# modes, and the units placed less than the floor: the mode beat the peak
# guard (a billed peak). And a peak limit that merely capped a charge released
# the modes, so an Off battery was charged from the grid. The rule now: clamp
# into what the modes can place, then back into the limits above the mode.
_offb = {"inverter-b": (0.0, 0.0)}
def _placed(r):
return sum(r.shares.values())
_ra = resolve(_two(0.0, arb=3000.0, peak_lo=2000.0, rating=1500.0,
modes=_offb)[:-1] + [
Claim.set("arbitrage", P_ARBITRAGE, 3000.0),
Claim.limit("peak", P_PEAK, lo=2000.0)], units=TWO)
print(f" trade 3000 W over a 2000 W floor, A 1500 W, B off: {_ra.shares}")
check("⭐ a trade above the peak floor with B Off: the units still place the "
"floor (2000 W), A to its rating and B only the 500 W A cannot give",
_placed(_ra) == 2000 and _ra.shares == {"inverter-a": 1500,
"inverter-b": 500})
_r1t = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, 3000.0),
Claim.limit("peak", P_PEAK, lo=2000.0)], units=ONE)
check("⭐ ...and at N=1 an Off battery under a trade gives the peak floor "
"(2000 W), not 0 W", _r1t.target_w == 2000.0
and _r1t.bound_by == "peak")
_rc = resolve(_two(0.0, arb=-3000.0, peak_lo=-1000.0, modes=_offb),
units=TWO)
check("⭐ a grid charge the peak limit caps at 1000 W does not release B's "
"Off: A takes it all and B stays at 0 W",
_rc.shares == {"inverter-a": -1000, "inverter-b": 0})
_r1c = resolve(_one + [Claim.set("arbitrage", P_ARBITRAGE, -3000.0),
Claim.limit("peak", P_PEAK, lo=-1000.0)], units=ONE)
check("...and at N=1 an Off battery is not charged from the grid under a "
"capped trade", _r1c.target_w == 0.0)
_rp = resolve(_two(500.0, peak_lo=1500.0, modes=_offb), units=TWO)
check("a peak floor A can carry alone leaves B Off at 0 W",
_rp.shares == {"inverter-a": 1500, "inverter-b": 0}
and _rp.target_w == 1500.0)
_rall = resolve(_two(0.0, peak_lo=3000.0,
modes={"inverter-a": (0.0, 0.0),
"inverter-b": (0.0, 0.0)}), units=TWO)
check("every battery Off and a 3000 W peak floor: the floor is placed, "
"split between them, and nothing contradicts",
_placed(_rall) == 3000 and not _rall.contradiction
and _rall.allocation.refused is None)
import random as _rnd # noqa: E402
_g = _rnd.Random(149)
_worse, _needless, _sign, _nn = [], [], [], 0
for _ in range(20000):
_units = ("inverter-a", "inverter-b", "inverter-c")[:_g.choice((2, 3))]
_cl = []
for _u in _units:
_rt = _g.choice((1000.0, 2500.0))
_cl.append(Claim.limit("supervised", P_SAFETY, -_rt, _rt,
actuator=_u))
if _g.random() < 0.2:
_cl.append(Claim.limit("pack", P_SAFETY,
*_g.choice(((0.0, inf), (-inf, 0.0))),
actuator=_u))
_cl.append(Claim.limit(MODE_SOURCE, P_MODE,
_g.choice((0.0, -_rt)), _g.choice((0.0, _rt)),
actuator=_u))
_src = _g.choice(("loop", "arbitrage"))
_cl.append(Claim.set(_src, P_LOOP if _src == "loop" else P_ARBITRAGE,
_g.uniform(-7000.0, 7000.0)))
_pk = _g.uniform(-4000.0, 6000.0)
_cl.append(Claim.limit("peak", P_PEAK, lo=_pk))
_r = resolve(_cl, units=_units)
_r0 = resolve([c for c in _cl if c.source != MODE_SOURCE], units=_units)
_nn += 1
if _r.allocation.refused is not None:
_sign.append(_cl)
continue
if _placed(_r) < _pk - 1 and _placed(_r0) >= _pk - 1:
_worse.append(_cl)
# An Off unit gives power only when the peak floor is short without it.
_off = [c.actuator for c in _cl if c.source == MODE_SOURCE
and c.lo == 0.0 and c.hi == 0.0]
# What the units whose mode lets them give power can give, within it.
_on_cap = sum(max(0.0, min(_r.unit_bounds[u].hi, _r.unit_bounds[u].mode_hi))
for u in _units if _r.unit_bounds[u].mode_hi > 0.0
and _r.unit_bounds[u].lo <= 0.0)
if any(_r.shares.get(u, 0) > 0 for u in _off) and _pk <= _on_cap - 1:
_needless.append(_cl)
check(f"over {_nn} random N=2/3 cycles (modes, packs, a trade or the loop, a "
f"peak floor): no split is refused, the placed power never falls under "
f"the floor where the same cycle without modes met it, and an Off "
f"battery gives power only when the others cannot carry the floor "
f"({len(_sign)}, {len(_worse)}, {len(_needless)})",
not _sign and not _worse and not _needless)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+136
View File
@@ -1156,6 +1156,142 @@ check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at
_R["gain03"]["over"] < _R["rail"]["over"]
and _R["gain02"]["over"] < _R["gain03"]["over"])
print()
print("OPT-11: the loop is told what the battery modes allow")
# compute(bound=(lo, hi)) is the site output bound: the sum over live units of
# (hard limits with the mode applied), allocator.loop_bound(). Without it the
# loop asks for watts no battery may give, the allocator reports them unmet,
# and the saturation freeze parks the target on them (SAFETY-73 section 1).
import random as _random # noqa: E402
from app.control import MODE_BOUND # noqa: E402
from named_absence import present # noqa: E402
TB = Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0,
step_w=10, target_grid_w=-10.0)
def closed_loop(cycles, load, *, bound=None, deliver=None, change=None):
"""A plant that follows 94 % of the command each cycle, capped at
`deliver` (what the allocator can place). `change(i)` may return a new
bound. Rows: (command, actual, sat_count, frozen, reason, i_w)."""
prev, actual, sat, i_w, rows = 0.0, 0.0, 0, None, []
for i in range(cycles):
if change is not None:
bound = change(i, bound)
d = compute(prev, load - actual, actual, TB, sat, i_w, bound=bound)
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
cmd = prev if deliver is None else max(deliver[0],
min(deliver[1], prev))
actual = actual + 0.94 * (cmd - actual)
rows.append((prev, actual, sat, d.frozen, d.reason, d.i_w))
return rows
# (a) One battery may give 800 W (the other is Off), the house takes 3000 W.
_with = closed_loop(40, 3000.0, bound=(-800.0, 800.0))
_without = closed_loop(40, 3000.0, deliver=(-800.0, 800.0))
print(f" house 3000 W, modes allow 800 W: with the bound the command ends "
f"at {nth(_with, -1)[0]:.0f} W (sat {max(r[2] for r in _with)}, reason "
f"{nth(_with, -1)[4]}); without it {nth(_without, -1)[0]:.0f} W with "
f"{sum(r[3] for r in _without)} frozen cycles")
check("⭐ THE LOOP BOUND HOLDS: every command and every integrator value "
"stays inside [-800, 800], and the command settles at the bound",
all(-800.0 <= r[0] <= 800.0 and -800.0 <= r[5] <= 800.0 for r in _with)
and present(nth(_with, -1)) and nth(_with, -1)[0] == 800.0
and nth(_with, -1)[4] == MODE_BOUND)
_big = []
_pv, _iw, _sat = 0.0, None, 0
for _i in range(30):
_d = compute(_pv, 12000.0, _pv, TB, _sat, _iw, bound=(-800.0, 800.0))
_pv, _iw, _sat = _d.target_w, _d.i_w, _d.sat_count
_big.append(_d)
check("...and a house far beyond what the modes allow (12 kW against 800 W) "
"does not read as the loop winding into its rail: SAFETY-03's flags "
"stay false on every cycle",
_big and not any(d.i_clamped or d.clamped for d in _big)
and all(d.target_w <= 800.0 for d in _big))
check("⭐ NO SATURATION FREEZE from unmet watts: sat_count never reaches the "
"freeze and no cycle is frozen",
not any(r[3] for r in _with)
and max(r[2] for r in _with) < TB.saturation_cycles)
check("...and the same site WITHOUT the bound is the failure it removes: the "
"loop asks past 800 W, the plant cannot follow and the freeze engages",
any(r[3] for r in _without) and max(r[0] for r in _without) > 800.0)
# (b) Every battery switched off: the bound is [0, 0].
_off = closed_loop(20, 2500.0, bound=(0.0, 0.0))
check("all batteries off: the loop asks 0 W on every cycle, with the "
"integrator at 0, and never freezes",
all(r[0] == 0.0 and r[5] == 0.0 and not r[3] for r in _off))
# (c) A mode closes while the battery gives 2000 W: the ask drops at once.
_close = closed_loop(30, 2000.0, bound=(-5000.0, 5000.0),
change=lambda i, b: (0.0, 0.0) if i >= 20 else b)
check("a mode that closes mid-run drops the ask to 0 W on the same cycle "
"(no slew steps of unmet watts) and carries no integrator past it",
present(nth(_close, 20)) and nth(_close, 19)[0] > 1500.0
and nth(_close, 20)[0] == 0.0
and nth(_close, 20)[5] == 0.0 and not any(r[3] for r in _close[20:]))
# (d) Rounding never steps outside the bound.
_q = compute(1000.0, 3000.0, 1000.0, TB, 0, 1236.0, bound=(-5000.0, 1236.0))
check("quantising to 10 W does not step outside the bound: 1236 W becomes "
"1230 W, not 1240 W", _q.target_w == 1230.0)
_qn = compute(-1000.0, -3000.0, -1000.0, TB, 0, -1236.0,
bound=(-1236.0, 5000.0))
check("...on the charge side too: -1236 W becomes -1230 W",
_qn.target_w == -1230.0)
# (e) A bound that leaves out 0 W is a fault and is not obeyed.
_rngc = _random.Random(1131)
_same, _nb = True, 0
for _ in range(4000):
args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-4000, 4000),
_rngc.uniform(-5000, 5000), TB, _rngc.randint(0, 5),
_rngc.choice((None, _rngc.uniform(-6000, 6000))))
_nb += 1
if (compute(*args, bound=(200.0, 900.0)) != compute(*args)
or compute(*args, bound=None) != compute(*args)):
_same = False
check(f"a bound that leaves out 0 W is ignored, and bound=None is the law as "
f"it was: {_nb} random cycles give the identical Decision",
_same and _nb == 4000)
# (f) Inside the bound nothing changes: a bound wider than every command.
_wide = True
for _ in range(4000):
args = (_rngc.uniform(-4000, 4000), _rngc.uniform(-3000, 3000),
_rngc.uniform(-4000, 4000), TB, _rngc.randint(0, 5),
_rngc.choice((None, _rngc.uniform(-4000, 4000))))
if compute(*args, bound=(-9000.0, 9000.0)) != compute(*args):
_wide = False
check("a bound wider than the rail changes no Decision (4000 random cycles)",
_wide)
# (g) Review of PR #149: a bound EQUAL to the rail - every battery on, the
# default once modes are on - must leave SAFETY-03 exactly as it was. Before
# the fix the mode clamp ran first and took `i_clamped` away from the rail.
_rail_same, _rail_hits = True, 0
for _tb in (TB, Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0,
step_w=10, target_grid_w=-10.0,
integrator_max_w=7000.0)):
_lim = _tb.max_w
for _ in range(4000):
args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-12000, 12000),
_rngc.uniform(-5000, 5000), _tb, _rngc.randint(0, 5),
_rngc.choice((None, _rngc.uniform(-8000, 8000))))
_d0 = compute(*args)
_rail_hits += _d0.i_clamped
if compute(*args, bound=(-_lim, _lim)) != _d0:
_rail_same = False
check(f"a bound equal to the rail changes no Decision, SAFETY-03's i_clamped "
f"included ({_rail_hits} of 8000 random cycles wind into the rail), and "
f"with integrator_max_w above max_w too", _rail_same and _rail_hits > 100)
_one_side = compute(4000.0, 12000.0, 4000.0, TB, 0, 4990.0,
bound=(0.0, 5000.0))
check("...and a bound that closes only the other side still lets the rail "
"flag a loop winding into it", _one_side.i_clamped)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
+16 -5
View File
@@ -546,12 +546,13 @@ def resolve(path):
return node
check("the page is assembled: no placeholder is left and the words parse",
_WORDS = json.loads(PAGE.split('id="ui-text">', 1)[1]
.split("</script>", 1)[0].replace("<\\/", "</"))
check("the page is assembled: no placeholder is left and the words parse, "
"English and Dutch (UX-13 P5)",
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
and "/*UI_TEXT*/" not in PAGE
and json.loads(PAGE.split('id="ui-text">', 1)[1]
.split("</script>", 1)[0].replace("<\\/", "</"))["home"]
== UI)
and _WORDS["en"]["home"] == UI and set(_WORDS) == {"en", "nl"})
check("four tabs with the four hash routes, in a keyboard tablist",
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
"settings", "setup"]
@@ -563,7 +564,7 @@ check(f"no external file and no web font: no URL leaves the add-on "
and "<link" not in PAGE and "src=" not in PAGE)
_abs = re.findall(r"""(?:fetch\(|href=)["'](/[^"']*)["']""", PAGE)
check(f"every fetch and link is relative, for ingress ({_abs})",
not _abs and 'fetch("status"' in JS and 'fetch("action"' in JS)
not _abs and 'fetch("status?lang=' in JS and 'fetch("action"' in JS)
_keys = set(re.findall(r'data-t(?:-label)?="([\w.]+)"', PAGE)) | set(
re.findall(r'''\bt\("([\w.]+)"\)''', JS))
_missing = sorted(k for k in _keys if not isinstance(resolve(k), str))
@@ -610,6 +611,8 @@ async def _serve():
out["page"] = (r.status, r.content_type, await r.text())
async with s.get(f"http://127.0.0.1:{port}/status") as r:
out["status"] = await r.json()
async with s.get(f"http://127.0.0.1:{port}/status?lang=nl-BE") as r:
out["status_nl"] = await r.json()
async with s.get(f"http://127.0.0.1:{port}/config/model") as r:
out["model"] = await r.json()
finally:
@@ -618,6 +621,14 @@ async def _serve():
E = asyncio.run(_serve())
_nl_words = json.load(io.open(os.path.join(HERE, "app", "ui_text", "nl.json"),
encoding="utf-8"))["home_words"]
check("UX-13 P5: /status?lang=nl-BE builds Home in Dutch, and plain /status "
"stays English",
E["status_nl"]["state"]["pill"] in _nl_words["pill"].values()
and E["status_nl"]["control"]["text"] in _nl_words["control"].values()
and E["status"]["state"]["pill"] not in _nl_words["pill"].values()
and set(E["status_nl"]) == set(E["status"]))
check("GET / serves the assembled page as HTML", E["page"][0] == 200
and E["page"][1] == "text/html" and E["page"][2] == PAGE)
check("GET status over HTTP carries both halves",
+17 -58
View File
@@ -47,57 +47,10 @@ def check(name, cond):
fails.append(name)
# --- the rule (STYLE.md sections 1 and 4) --------------------------------------
BANNED = re.compile(
r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|"
r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|"
r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|"
r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|"
r"capaciteitstarief|journalctl|ssh|grep|"
r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I)
ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)"
r"\.[a-z0-9_*]+")
SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b")
SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b")
PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]")
def words(text):
return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text))
if re.search(r"[A-Za-z0-9]", w)]
def sentences(text):
"""Sentence count: a stop followed by a space and a capital, or the end."""
t = PLACEHOLDER.sub("X", text.strip())
t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t)
return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()])
def title_problems(title):
out = []
if len(words(title)) > 5:
out.append(f"title over 5 words ({len(words(title))})")
if title.lower().startswith("goodwe"):
out.append("title carries a GoodWe prefix")
if title.rstrip().endswith("."):
out.append("title ends in a full stop")
return out + text_problems(title, keys=False, entities=False)
def text_problems(text, *, keys, entities):
t = PLACEHOLDER.sub("X", text)
out = []
hit = sorted({m.group(0) for m in BANNED.finditer(t)})
if hit:
out.append("banned: " + ", ".join(hit[:4]))
if not entities and ENTITY.search(t):
out.append("entity id: " + ENTITY.search(t).group(0))
if not keys and SNAKE.search(ENTITY.sub("", t)):
out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0))
if SHOUT.search(t):
out.append("capitals: " + SHOUT.search(t).group(0))
return out
# --- the rule (STYLE.md sections 1 and 4): tools/style_rules.py, shared with
# test_ui_text.py since UX-13 P5.
from style_rules import (BANNED, ENTITY, PLACEHOLDER, SHOUT, SNAKE, # noqa: E402,F401
sentences, text_problems, title_problems, words)
def body_problems(body, severity):
@@ -483,10 +436,13 @@ def collect():
if isinstance(v.get("fields"), dict):
walk(v["fields"], f"{path}{key}.")
walk(tr.get("configuration"), "")
# UX-13: the new page's own words, held to the option texts' rule.
# UX-13: the new page's own words, held to the option texts' rule. Home's
# sentences (`home_words`) are templates, checked rendered in part 8.
for f in sorted((APP / "ui_text").glob("*.json")):
def walk_ui(node, path):
for key, v in node.items():
if not path and key == "home_words":
continue
if isinstance(v, dict):
walk_ui(v, f"{path}{key}.")
elif isinstance(v, str):
@@ -1852,12 +1808,15 @@ def home_problems(kind, text):
return out
_home = homeview.text_samples()
_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)] if p]
for k, t, p in _home_bad:
print(f" {k}: {t!r}: {p}")
check(f"every Home sentence obeys its STYLE limit ({len(_home)} rendered, "
f"{len(_home_bad)} over)", len(_home) > 80 and not _home_bad)
for _lang in ("en", "nl"): # UX-13 P5: Home in English and Dutch
_home = homeview.text_samples(_lang)
_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)]
if p]
for k, t, p in _home_bad:
print(f" {_lang} {k}: {t!r}: {p}")
check(f"every Home sentence in {_lang} obeys its STYLE limit "
f"({len(_home)} rendered, {len(_home_bad)} over)",
len(_home) > 80 and not _home_bad)
_shadowed = [t for k, t in homeview.text_samples() if "ould" in t]
check("in shadow mode the Home plan says what the add-on WOULD do: every "
"shadow variant of a plan text carries 'would'",
+488
View File
@@ -0,0 +1,488 @@
"""Runnable check for OPT-11 P1, battery modes. `python3 test_modes.py`
Each battery gets an on/off switch and a mode (Solar first, Leftover solar),
kept in the store and filed as a SOFT unit limit at P_MODE (35). Drives the
REAL `Controller` (read_inputs + cycle) through tools/opt11_identity.py's
harness, in simulated time. DECISIONS/OPT-11-battery-modes.md has the record.
1. With battery modes off, every command is byte-identical to release/1.0
a5dddad, at one inverter and at two (digests measured on that tree).
2. Solar order: surplus solar fills the Solar first battery, then spills to the
Leftover solar one; a full Solar first battery hands all of it on.
3. Off: the battery is asked for 0 W, the others carry the site, the loop is
bounded and never freezes on watts no battery may give; all off at N=1 and
N=2 asks 0 W.
4. Off stops and skips maintenance, with a log line; a battery in maintenance
gets no mode claim.
5. The actions: `pack_use` and `pack_mode`, what they store, log and refuse.
6. A mode range without 0 W is dropped as a software fault, logged once.
7. The Batteries screen's data.
Mutations (MUTATION.md, purged copies, graded over this file and the named
tests) are listed in the pull request and DECISIONS/OPT-11-battery-modes.md.
"""
import asyncio
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11_identity as rig # noqa: E402
from named_absence import nth, present # noqa: E402
import app.main as app_main # noqa: E402
from app import homeview, messages, packmode # noqa: E402
from app.control import MODE_BOUND # noqa: E402
from app.maintenance import DRAIN, IDLE # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
ON = {"battery_modes_enabled": True}
A, B = "inverter-a", "inverter-b"
def store_with(**modes):
"""A store where each named battery has (use, mode)."""
s = rig._Store()
for name, (use, mode) in modes.items():
s.data[unit_key(name, packmode.USE_KEY)] = use
s.data[unit_key(name, packmode.MODE_KEY)] = mode
return s
def spy_claims():
"""Record every claim list main.py's own `resolve` is handed."""
seen, real = [], app_main.resolve
def _spy(claims, **kw):
seen.append(list(claims))
return real(claims, **kw)
app_main.resolve = _spy
return seen, lambda: setattr(app_main, "resolve", real)
def spy_bounds():
"""Record the `bound` main.py hands `compute()` on every cycle."""
seen, real = [], app_main.compute
def _spy(*a, **kw):
seen.append(kw.get("bound", "absent"))
return real(*a, **kw)
app_main.compute = _spy
return seen, lambda: setattr(app_main, "compute", real)
def flat(t):
return 0.0
# ============================================================================ #
print("1. battery modes off: byte-identical to release/1.0 a5dddad")
# The digests below were printed by `python3 tools/opt11_identity.py` run on
# the a5dddad tree (the same file copied in, nothing else changed), in the CI
# image, on 2026-09-28. Each hashes 540 cycles: site target, every inverter's
# written value, the loop's reason, integrator and saturation count, the
# arbiter's sentence and the allocation record.
A5DDDAD = {
"n1": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262",
"n2": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
"n1_off": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262",
"n2_off": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
# Added in review (PR #149), printed on a5dddad the same way: both
# batteries due for maintenance, and the arbiter alone on 6000 seeded
# claim lists with the peak floor, trades, maintenance and refusals.
"n1_maint": "5a21d43ec7cbf95a224bbb9bca13a8e62a00161101b586ae9fa68ab565992977",
"n2_maint": "48a1ad96121592e207a4664fb2da9f5208a49b88e5802a358873a5d30cb8c18e",
"resolve": "43a874216711936e14b9185bcc8d26c58fe4f3cfc3c1c8eaf89bffd95d6e81ef",
}
_claims, _undo_c = spy_claims()
_bounds, _undo_b = spy_bounds()
try:
_now = rig.digests()
finally:
_undo_c()
_undo_b()
for k in A5DDDAD:
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
f"{_now[k][:16]}")
check("⭐ at N=1 and N=2, with the option absent and with it false, every "
"command, reason and allocation record over 540 cycles each is "
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
"at its cut-out level, at N=2 a refusal with probes, both batteries "
"due for maintenance), and so is the arbiter on 6000 claim lists",
_now == A5DDDAD)
check("...and no cycle in those runs filed a `mode` claim or handed "
"`compute()` a bound",
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
and _bounds and set(_bounds) == {None})
# ============================================================================ #
print("\n2. the solar order: Solar first fills, the rest spills to Leftover")
_surplus = (lambda t: -3200.0) # 3200 W more solar than the house uses
_full_a = (lambda u, t: 100.0 if (u == "a" and t >= 300) else 50.0)
_rows, _c = rig.run(2, extra=ON, dur=600.0, load=_surplus, soc=_full_a,
cut=lambda u, t: False,
store=store_with(**{A: (True, packmode.SOLAR_FIRST),
B: (True, packmode.LEFTOVER_SOLAR)}))
_room = [r for r in _rows if 200 <= r[0] < 300]
_full = [r for r in _rows if r[0] >= 500]
def _cmd(r):
"""One row's written values, by battery; ABSENTs if the row is missing."""
return (dict(r[2]) if present(r)
else {"a": nth((), 0, "a"), "b": nth((), 0, "b")})
print(f" A has room: A {_cmd(nth(_room, -1))['a']:+.0f} W, B "
f"{_cmd(nth(_room, -1))['b']:+.0f} W | A full: A {_cmd(nth(_full, -1))['a']:+.0f} "
f"W, B {_cmd(nth(_full, -1))['b']:+.0f} W")
check("⭐ while A (Solar first) has room it takes all it can, 2500 W, and B "
"(Leftover solar) gets only the spill, about 700 W",
present(nth(_room, -1))
and all(_cmd(r)["a"] == -2500.0 and -800.0 <= _cmd(r)["b"] <= -600.0
for r in _room))
check("⭐ once A is full, the surplus spills to B, up to B's rating",
present(nth(_full, -1))
and all(_cmd(r)["a"] == 0.0 and _cmd(r)["b"] == -2500.0 for r in _full))
_rows_off, _ = rig.run(2, dur=300.0, load=_surplus,
soc=lambda u, t: 50.0, cut=lambda u, t: False)
check("...and the same site with battery modes off splits 50/50, which is "
"what says the order above comes from the modes",
_cmd(_rows_off[-1])["a"] == _cmd(_rows_off[-1])["b"] < -1500.0)
_give = rig.run(2, extra=ON, dur=300.0, load=lambda t: 3000.0,
soc=lambda u, t: 50.0, cut=lambda u, t: False,
store=store_with(**{A: (True, packmode.SOLAR_FIRST),
B: (True, packmode.LEFTOVER_SOLAR)}))[0]
check("while giving power the modes keep OPT-09's weights: A Solar first and "
"B Leftover solar, both half full, share a 3000 W house equally",
_cmd(_give[-1])["a"] == _cmd(_give[-1])["b"] > 1400.0)
# ============================================================================ #
print("\n3. Off: 0 W on that battery, the loop bounded, no freeze")
_rows, _c = rig.run(2, extra=ON, dur=600.0, load=lambda t: 4000.0,
soc=lambda u, t: 50.0, cut=lambda u, t: False,
store=store_with(**{B: (False, packmode.SOLAR_FIRST)}))
_tail = _rows[40:]
print(f" house 4000 W, B off: A {_cmd(_tail[-1])['a']:+.0f} W, B "
f"{_cmd(_tail[-1])['b']:+.0f} W, site {_tail[-1][1]:+.0f} W, reason "
f"{_tail[-1][3]}, {_tail[-1][7]}")
check("⭐ B (Off) is asked for 0 W on every cycle and A carries the house up "
"to its rating",
all(_cmd(r)["b"] == 0.0 for r in _rows)
and all(_cmd(r)["a"] == 2500.0 for r in _tail))
check("⭐ the loop stays inside what the modes allow (2500 W) and never "
"freezes: no saturated-freeze reason, and the saturation count stays "
"below the freeze",
all(r[1] <= 2500.0 for r in _rows)
and not any(r[3] == "saturated-freeze" for r in _rows)
and max(r[5] for r in _rows) < _c.tuning.saturation_cycles
and _tail[-1][3] == MODE_BOUND)
check("...and nothing is left unmet: the allocation places the whole target",
all("UNMET" not in (r[7] or "") for r in _tail))
_bounds, _undo_b = spy_bounds()
try:
rig.run(2, extra=ON, dur=100.0, load=lambda t: 4000.0,
soc=lambda u, t: 50.0, cut=lambda u, t: False,
store=store_with(**{B: (False, packmode.SOLAR_FIRST)}))
finally:
_undo_b()
check("...because main.py hands compute() the loop bound, B's 0 W included: "
"[-2500, 2500]", _bounds and set(_bounds) == {(-2500.0, 2500.0)})
# Review (PR #149): a unit the allocator drops (stale link) must not count in
# the loop bound either, or the loop asks for watts nobody live can place.
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
cut=lambda u, t: False)
_both = _c.loop_bound([app_main.Claim.limit(
"supervised", app_main.P_SAFETY, -2500.0, 2500.0,
actuator=_c.actuator_of("supervised", i)) for i in _c.inverters])
_c.live_units = lambda: (A,)
_one_live = _c.loop_bound([app_main.Claim.limit(
"supervised", app_main.P_SAFETY, -2500.0, 2500.0,
actuator=_c.actuator_of("supervised", i)) for i in _c.inverters])
check("the loop bound counts only live units: both live [-5000, 5000], B's "
"link stale [-2500, 2500]",
_both == (-5000.0, 5000.0) and _one_live == (-2500.0, 2500.0))
for _n in (1, 2):
_st = store_with(**{A: (False, packmode.SOLAR_FIRST),
B: (False, packmode.LEFTOVER_SOLAR)})
if _n == 1:
_st = rig._Store()
_st.data[unit_key(app_main.UNIT_A, packmode.USE_KEY)] = False
_rows, _c = rig.run(_n, extra=ON, dur=300.0, load=lambda t: 3000.0,
soc=lambda u, t: 50.0, cut=lambda u, t: False,
store=_st)
check(f"every battery off at N={_n}: every command is 0 W, the site "
f"target is 0 W, the integrator stays at 0 and nothing freezes",
all(all(v == 0.0 for _u, v in r[2]) and r[1] == 0.0 and r[4] == 0.0
and r[3] != "saturated-freeze" for r in _rows))
_rows1, _ = rig.run(1, extra=ON, dur=300.0, load=lambda t: 1800.0,
soc=lambda u, t: 50.0)
_rows1o, _ = rig.run(1, dur=300.0, load=lambda t: 1800.0,
soc=lambda u, t: 50.0)
check("at N=1 a battery that is on (Solar first by default) follows the "
"house exactly as with battery modes off",
[r[:6] for r in _rows1] == [r[:6] for r in _rows1o]
and _cmd(_rows1[-1])["a"] > 1700.0)
# ============================================================================ #
print("\n4. Off stops and skips maintenance; maintenance outranks the mode")
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11}
_st = store_with(**{A: (False, packmode.SOLAR_FIRST)})
_rows, _c = rig.run(2, extra={**ON, **MAINT}, dur=60.0,
load=lambda t: 500.0, soc=lambda u, t: 60.0,
cut=lambda u, t: False, store=_st)
print(f" both due at 11:00, A off: A {_c.maints[0].phase}, "
f"B {_c.maints[1].phase}")
check("⭐ a battery that is off starts no cycle, and does not hold the slot: "
"both are due, A (first in the roster) is off, so B starts",
_c.maints[0].phase == IDLE and _c.maints[1].phase == DRAIN)
_st2 = store_with()
_seen, _undo_c = spy_claims()
_msgs = []
async def _switch_b_off(c, t):
if t == 30.0:
_msgs.append(await c.handle_action("pack_use", unit=B, value=False))
try:
_rows, _c = rig.run(2, extra={**ON, **MAINT}, dur=60.0,
load=lambda t: 500.0, soc=lambda u, t: 60.0,
cut=lambda u, t: False, store=_st2,
before=_switch_b_off)
finally:
_undo_c()
_during = [cl for cl in _seen[:6]]
print(f" B off at 30 s while A drains: A {_c.maints[0].phase}, "
f"B {_c.maints[1].phase}; events: "
f"{[e for e in _c.events if 'switched' in str(e) or 'ABORT' in str(e)]}")
check("while A runs its cycle it gets no mode claim (maintenance at 60/40 "
"outranks the mode) and B, idle, gets one",
all({c.actuator for c in cl if c.source == "mode"} == {B}
for cl in _during))
_st3 = store_with()
_st3.data[unit_key(B, "phase")] = DRAIN
_st3.data["maint_schema_version"] = 1
async def _b_off_now(c, t):
if t == 10.0:
_msgs.append(await c.handle_action("pack_use", unit=B, value=False))
_rows, _c = rig.run(2, extra=ON, dur=30.0, load=lambda t: 500.0,
soc=lambda u, t: 60.0, cut=lambda u, t: False,
store=_st3, before=_b_off_now)
_ev = [str(e) for e in _c.events]
print(f" B mid-drain, switched off at 10 s: B {_c.maints[1].phase}; "
f"{[e for e in _ev if 'switched off' in e or 'ABORT' in e]}")
check("⭐ switching off a battery that is mid-cycle stops its cycle at once, "
"with a log line saying why",
_c.maints[1].phase == IDLE
and any("battery switched off" in e for e in _ev)
and _cmd(_rows[-1])["b"] == 0.0)
_st4 = store_with(**{B: (False, packmode.SOLAR_FIRST)})
_st4.data[unit_key(B, "phase")] = DRAIN
_st4.data["maint_schema_version"] = 1
_rows, _c = rig.run(2, extra=ON, dur=20.0, load=lambda t: 500.0,
soc=lambda u, t: 60.0, cut=lambda u, t: False, store=_st4)
check("...and a store that says Off for a battery mid-cycle (a restart) stops "
"it on the first cycle, and it is never asked to drain",
_c.maints[1].phase == IDLE
and all(_cmd(r)["b"] == 0.0 for r in _rows))
# ============================================================================ #
print("\n5. the actions: pack_use and pack_mode")
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat,
soc=lambda u, t: 50.0, cut=lambda u, t: False)
_r1 = asyncio.run(_c.handle_action("pack_mode", unit=B,
value=packmode.LEFTOVER_SOLAR))
_r2 = asyncio.run(_c.handle_action("pack_use", unit=A, value=False))
print(f" {_r1} {_r2}")
check("pack_mode stores the mode under the battery's own key and says what "
"it now is",
_r1 == {"unit": B, "use": True, "mode": packmode.LEFTOVER_SOLAR}
and _c.store.data[unit_key(B, packmode.MODE_KEY)]
== packmode.LEFTOVER_SOLAR)
check("pack_use stores the switch, and both are written to the action log",
_r2 == {"unit": A, "use": False, "mode": packmode.SOLAR_FIRST}
and _c.store.data[unit_key(A, packmode.USE_KEY)] is False
and any("inverter-a switched off by operator" in str(e)
for e in _c.events)
and any("inverter-b mode set to leftover solar" in str(e)
for e in _c.events))
_bad = [asyncio.run(_c.handle_action("pack_use", unit="inverter-z",
value=True)),
asyncio.run(_c.handle_action("pack_use", unit=A, value="yes")),
# 0 == False in Python: a stored 0 would read back as ON.
asyncio.run(_c.handle_action("pack_use", unit=A, value=0)),
asyncio.run(_c.handle_action("pack_use", unit=A, value=1)),
asyncio.run(_c.handle_action("pack_mode", unit=A,
value=packmode.CHEAP_HOURS)),
asyncio.run(_c.handle_action("pack_mode", unit=A, value=None))]
check("an unknown battery, a switch that is not true or false (0 and 1 "
"included), and a mode "
"this release does not offer (Cheap hours arrives in P3) are refused, "
"with a plain reason, and store nothing",
all(isinstance(x, dict) and set(x) == {"error"} for x in _bad)
and unit_key(A, packmode.MODE_KEY) not in _c.store.data
and _c.store.data[unit_key(A, packmode.USE_KEY)] is False)
_rows, _c0 = rig.run(2, dur=10.0, load=flat, soc=lambda u, t: 50.0,
cut=lambda u, t: False)
_r0 = asyncio.run(_c0.handle_action("pack_use", unit=A, value=False))
check("with battery modes off the actions are refused and store nothing",
set(_r0) == {"error"}
and unit_key(A, packmode.USE_KEY) not in _c0.store.data)
check("the old actions still take no battery and answer as before",
asyncio.run(_c0.handle_action("nonsense")) is None)
check("a store value from a later version (an unknown mode) reads as the "
"default, Solar first and on, never as Off",
packmode.read({unit_key(A, packmode.MODE_KEY): "grid_only",
unit_key(A, packmode.USE_KEY): "maybe"}, A)
== packmode.PackMode(True, packmode.SOLAR_FIRST))
# ============================================================================ #
print("\n6. a mode range without 0 W is a software fault, never obeyed")
_real_range = packmode.mode_range
packmode.mode_range = lambda pm, r: (100.0, 900.0)
_log = []
class _H(logging.Handler):
def emit(self, rec):
_log.append((rec.levelno, rec.getMessage()))
_h = _H()
logging.getLogger("goodwe").addHandler(_h)
try:
_rows, _c = rig.run(2, extra=ON, dur=60.0, load=lambda t: -2000.0,
soc=lambda u, t: 50.0, cut=lambda u, t: False)
finally:
packmode.mode_range = _real_range
logging.getLogger("goodwe").removeHandler(_h)
_errs = [msg for lv, msg in _log if lv >= logging.ERROR and "MODE_RANGE" in msg]
print(f" {_errs}")
check("⭐ both batteries still charge from the surplus (the forced range is "
"dropped, so no battery is made to give power) and no split is refused",
all(v <= 0.0 for _u, v in _rows[-1][2])
and _cmd(_rows[-1])["a"] < 0.0
and not any("REFUSED" in (r[7] or "") for r in _rows))
check("...and it is logged once per battery, as an ERROR naming the fault "
"code, not once per cycle",
len(_errs) == 2 and any(A in e for e in _errs) and any(B in e for e in _errs))
# ============================================================================ #
print("\n7. the Batteries screen's data")
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
cut=lambda u, t: False,
store=store_with(**{B: (False, packmode.LEFTOVER_SOLAR)}))
_bat = homeview.battery_rows(_c, False, True)
_bat_nl = homeview.build(_c, level="ok", checks=[], lang="nl")["batteries"]
print(f" {[(b['name'], b['use'], b['mode_key'], b['mode']) for b in _bat]}")
check("with battery modes on each row carries its switch, its state and its "
"stored mode, in the page's words",
[(b["use"], b["mode_key"], b["mode_choice"], b["mode"]) for b in _bat]
== [(True, "solar_first", "solar_first", "Solar first"),
(False, "off", "leftover_solar", "Off")]
and _bat_nl[1]["mode"] == "Uit")
_rows, _c0 = rig.run(2, dur=10.0, load=flat, soc=lambda u, t: 50.0,
cut=lambda u, t: False)
check("...and with them off the rows are what they were: no new keys, the "
"mode reads as before",
all("mode_key" not in b and b["mode"] == homeview.mode_house()
for b in homeview.battery_rows(_c0, False, True)))
# ============================================================================ #
print("\n8. the page's route: /action and /status over the real web app")
import socket # noqa: E402
import aiohttp # noqa: E402
from aiohttp import web as aioweb # noqa: E402
from app import web as app_web # noqa: E402
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
cut=lambda u, t: False)
async def _page():
# The configuration routes are not used here; a stand-in keeps the app
# from building a Supervisor client.
app = app_web.build_app(_c, selfcfg=object())
runner = aioweb.AppRunner(app)
await runner.setup()
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
out = {}
try:
async with aiohttp.ClientSession() as s:
root = f"http://127.0.0.1:{port}/"
for key, body in (
("mode", {"action": "pack_mode", "unit": B,
"value": packmode.LEFTOVER_SOLAR}),
("off", {"action": "pack_use", "unit": A,
"value": False}),
("bad", {"action": "pack_mode", "unit": A,
"value": "grid_only"})):
async with s.post(root + "action", json=body) as r:
out[key] = (r.status, await r.json(content_type=None))
async with s.get(root + "status") as r:
out["status"] = (r.status, await r.json(content_type=None))
async with s.get(root) as r:
out["page"] = (r.status, await r.text())
finally:
await runner.cleanup()
return out
_web = asyncio.run(_page())
print(f" {_web['mode']} {_web['off']} {_web['bad']}")
check("POST /action with pack_mode and pack_use names a battery and a value, "
"and answers what it stored",
_web["mode"] == (200, {"ok": True, "result": {
"unit": B, "use": True, "mode": packmode.LEFTOVER_SOLAR}})
and _web["off"][0] == 200 and _web["off"][1]["result"]["use"] is False)
check("...a value this release does not offer is a 400 with the plain reason "
"the page shows",
_web["bad"][0] == 400 and _web["bad"][1]["ok"] is False
and _web["bad"][1]["error"] == messages.pack_refused("value"))
_sb = {b["name"]: b for b in _web["status"][1].get("batteries", [])}
check("...and /status then shows both batteries' switch and mode for the "
"Batteries screen",
_web["status"][0] == 200
and (_sb.get(A, {}).get("mode_key"), _sb.get(B, {}).get("mode_key"))
== ("off", packmode.LEFTOVER_SOLAR))
check("the page carries the mode names from app/ui_text for its cards",
_web["page"][0] == 200 and "Leftover solar" in _web["page"][1]
and "Overschot zonne-energie" in _web["page"][1])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
+30 -11
View File
@@ -2653,9 +2653,26 @@ check("...and so does the persistent notification, which outlives the page and "
#
# If you are here because you added a TWELFTH, that argument is what this line
# wants from you, not an edit.
# ⚠️ SAFETY-73 ADDED `refused` (a P_SAFETY unit limit on a measured refusal).
# ⚠️ OPT-11 ADDED `mode`, ON PURPOSE, AND IT IS NOT THE DEFERRED RESERVE EITHER.
# `mode` is `Claim.limit("mode", P_MODE, lo, hi)` on ONE inverter's actuator:
# the owner's per-battery choice (on/off, Solar first, Leftover solar), filed
# only with `battery_modes_enabled` on (default off). It is not conditioned on
# a state of charge, and it cannot bound the site against the peak floor:
# 1. stage 1 keeps it APART from the hard limits (UnitBounds.mode_*), so it
# never enters a `lo > hi` test, not even the unit's own;
# 2. stage 3 applies it only while the site target was decided at or below
# P_MODE (35), so the guard's floor (P_PEAK, 50) RELEASES it, and at N=1
# `resolve()` applies it to the site by the same rule, after stage 2;
# 3. its range always holds 0 W, so it can forbid a direction and never
# force one (a range without 0 is dropped as a software fault).
# test_arbiter.py part 9 grades the no-contradiction claim at N=2, over 625
# cycles with the floor, and part 10 at N=1. The sweeps below never reach it:
# their fixtures leave battery modes off, so they still see {device,
# supervised, loop}. DECISIONS/OPT-11-battery-modes.md.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack", "refused"}
"sign_site", "pack", "refused", "mode"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2697,7 +2714,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these TWELVE. THIS "
check("...and the sources that cycle can file are EXACTLY these THIRTEEN. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it "
@@ -2849,7 +2866,7 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack",
"refused"})
"refused", "mode"})
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
# real cycle files is a site claim. Observed on the claims main.py's own resolve
@@ -2947,6 +2964,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"pack": "inverter-a", "refused": "inverter-a",
"mode": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
@@ -3028,13 +3046,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all THIRTEEN of `Controller.cycle()`'s DIRECT appends route through "
check("all FOURTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 13
len(_direct) == 14
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
@@ -3051,15 +3069,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Eight of the thirteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and "
"SAFETY-73's two `refused` - and all eight pass one. "
"share. Nine of the fourteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, "
"SAFETY-73's two `refused` and OPT-11's `mode` - and all nine pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is "
"the site target",
_unit_sources_unrouted == []
and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 8)
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 9)
check("...and the only appends this pin does NOT reach are the two that file a "
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
"`actuator=` at the call site to mis-type. Their routing is pinned where "
@@ -3068,7 +3086,8 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the fifteen appends account for exactly the twelve "
check("...and between them the sixteen appends account for exactly the "
"thirteen "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3129,7 +3148,7 @@ check("...and naming those three is not an accident of this fixture but the "
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site",
"refused"})
"refused", "mode"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects
+141 -11
View File
@@ -9,7 +9,8 @@ Part 6 the save through the P2 path, and every blob the wizard can produce
(tools/ux13_wizard_blobs.py; the Supervisor's own validator runs over the same
set in the PR, not in CI, which has no Supervisor image). Part 7 the stale
save names only what changed elsewhere (P2 test, finding a). Part 8 the
words. The headless-browser click-through is in the PR.
words. Part 9 UX-15's fixes: F1, F2 with two tabs, a missing meter entity and
step 1 after a skip. The headless-browser click-through is in the PR.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
@@ -27,7 +28,7 @@ HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING) # the validator's own log lines
from aiohttp import ClientSession, web as aioweb # noqa: E402
from aiohttp import ClientSession, ContentTypeError, web as aioweb # noqa: E402
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
@@ -51,6 +52,7 @@ DEFAULTS = D.defaults(D.CONFIG)
NEW = dict(DEFAULTS, setpoint_entity="number.inv1_power")
UI_TEXT = uipage.ui_text()
WS = UI_TEXT["settings"]["setup"]
WS_D = UI_TEXT["settings"]["derive"]
JS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
encoding="utf-8").read()
@@ -262,7 +264,10 @@ async def _get(s, url):
async def _post(s, url, body):
async with s.post(url, json=body) as r:
return r.status, await r.json()
try:
return r.status, await r.json()
except (ValueError, ContentTypeError): # e.g. a plain-text 500
return r.status, {}
STATES = [state("sensor.grid", "402"), state("number.inv1_power", "0"),
@@ -626,11 +631,21 @@ print(f" {len(_all)} blobs from {len(B.starts())} starts; "
check(f"every plan from a new install is accepted by the shipped validator "
f"({len(_new)} plans)", len(_new) >= 25 and not [n for n, r in _new
if r])
_windows = [(n, b) for n, b, r in _all if "periods" in n and not r]
check(f"UX-13 P5: the time-window plans are among them and write the "
f"windows as typed ({len(_windows)} blobs)",
len(_windows) >= 12 and all(
len(json.loads(b["static_profile"])["periods"]) in (1, 2, 5)
for _n, b in _windows))
_empty = [(n, r) for n, _b, r in _all if n.startswith("empty-")]
check(f"every plan from a new install as shipped (no power request entity, "
f"setup-only mode) is accepted ({len(_empty)} plans)",
len(_empty) == len(_new) and not [n for n, r in _empty if r])
check("every plan from the TEST-23 sites is accepted, except where the site "
"trades on price with a plan that has no meter or no prices, which the "
"last step then lists",
all(not r or ("options.arb" in n and "arbitrage_enabled" in r[0])
for n, _b, r in _all) and len(_ok) >= 80)
for n, _b, r in _all) and len(_ok) >= 100)
check("every accepted blob keeps every option key (the Supervisor count, 78)",
all(set(b) == set(DEFAULTS) for _n, b, r in _all if not r))
@@ -645,9 +660,11 @@ async def _stale(s, root, sup):
model = copy.deepcopy(m["ui"])
model["price_timeout_s"] = 12 # the operator's own edit
first = await _post(s, root + "config/save",
{"model": model, "fingerprint": m["fingerprint"]})
{"model": model, "base": m["ui"],
"fingerprint": m["fingerprint"]})
again = await _post(s, root + "config/save",
{"model": model, "fingerprint": first[1]["fingerprint"]})
{"model": model, "base": m["ui"],
"fingerprint": first[1]["fingerprint"]})
return first, again
@@ -659,12 +676,13 @@ check("the banner's list is the other surface's key only, not the operator's "
"own edit", _first_r[0] == 409
and _first_r[1]["changed_fields"] == ["peak_cap_w"]
and _first_r[1]["moved"] == ["peak_cap_w"])
check("`changed` (stored against mine, which the classic page shows as "
"theirs -> mine) is unchanged",
_first_r[1]["changed"] == ["peak_cap_w", "price_timeout_s"])
check("`changed` (stored against what this page would write) is only the "
"page's own edit, since a save writes only that (UX-15 F2)",
_first_r[1]["changed"] == ["price_timeout_s"])
check("saving again with the new token is the deliberate second press: it "
"stores the page as shown (Discard loads theirs instead)",
_again[0] == 200 and _sup.options["peak_cap_w"] == 3500
"stores this page's own edit and keeps the other surface's change "
"(UX-15 F2)",
_again[0] == 200 and _sup.options["peak_cap_w"] == 5000
and _sup.options["price_timeout_s"] == 12)
@@ -757,6 +775,118 @@ _leak = sorted(k for k in TL.config_surface(D.CONFIG)[0] if re.search(
check(f"settings.js, wizard included, names no option key ({_leak})",
not _leak)
# --------------------------------------------------------------------------- #
print("\n9. UX-15: F1, F2, a missing meter entity, and step 1 after a skip")
# --------------------------------------------------------------------------- #
async def _f1(s, root, sup):
m = await _get(s, root + "config/model?lang=en")
price = copy.deepcopy(m["ui"])
price.update({"ui.contract": "daynight", "ui.fallback": "daynight",
"ui.day_price": None, "ui.night_price": 0.25})
none = copy.deepcopy(m["ui"])
none["inverters"] = []
none[U.COUNT] = 0
return {"price": (await _post(s, root + "config/validate",
{"model": price, "setup": True}))[1],
"none": (await _post(s, root + "config/validate",
{"model": none}))[1],
"save": await _post(s, root + "config/save", {
"model": price, "base": m["ui"], "setup": "done",
"fingerprint": m["fingerprint"]})}
_r, _sup = asyncio.run(session(Controller(SITE, STATES, Store(), grid=402.0),
SITE, _f1))
print(f" empty day price: steps {_r['price'].get('steps')}")
check("F1: a price the page cannot turn into options blocks step 4, beside "
"its own field",
_r["price"].get("steps", {}).get("4", 0) >= 1
and {"field": "ui.day_price", "card": None, "tier": "bad",
"text": WS_D["no_price"]["description"]} in _r["price"].get("messages", []))
check("F1: ...and the last step's live checks still run on the rest",
[x["tier"] for x in _r["price"].get("setup", [])
if "number.inv1_power" in x["text"]] == ["ok"]
and any("402 W" in x["text"] for x in _r["price"]["setup"]))
check("F1: no inverter card blocks step 2, not the last step alone",
_r["none"].get("steps", {}).get("2", 0) >= 1)
check("F1: the save refuses it (422) and says which step to go back to",
_r["save"][0] == 422 and _r["save"][1]["steps"]["4"] >= 1
and _sup.writes == [])
async def _tabs(s, root, sup):
"""Two tabs open one blob. A saves a peak limit; B edited only the
timeout, gets the 409, and presses Save again."""
a = await _get(s, root + "config/model?lang=en")
b = await _get(s, root + "config/model?lang=en")
va = copy.deepcopy(a["ui"])
va["peak_cap_w"] = 4000
ra = await _post(s, root + "config/save", {
"model": va, "base": a["ui"], "fingerprint": a["fingerprint"]})
after_a = copy.deepcopy(sup.options)
vb = copy.deepcopy(b["ui"])
vb["price_timeout_s"] = 12
first = await _post(s, root + "config/save", {
"model": vb, "base": b["ui"], "fingerprint": b["fingerprint"]})
again = await _post(s, root + "config/save", {
"model": vb, "base": b["ui"], "fingerprint": first[1]["fingerprint"]})
return ra, after_a, first, again
(_ra, _after_a, _first_b, _again_b), _sup = asyncio.run(
session(Controller(SITE, STATES), dict(SITE, peak_cap_w=3600), _tabs))
_moved = sorted(k for k in _sup.options
if _sup.options[k] != _after_a.get(k))
print(f" A: {_ra[0]}, B: {_first_b[0]} then {_again_b[0]}; B's re-save "
f"moved {_moved}")
check("F2: two tabs. A saves peak 4.0; B, which edited only the timeout, "
"gets the 409 naming the peak limit",
_ra[0] == 200 and _first_b[0] == 409
and _first_b[1]["changed_fields"] == ["peak_cap_w"])
check("F2: B's second press keeps A's peak 4.0 and writes only B's own key",
_again_b[0] == 200 and _sup.options["peak_cap_w"] == 4000
and _sup.options["price_timeout_s"] == 12
and _moved == ["price_timeout_s"])
_both = uipage.only_edits({"a": 2, "b": 1, "c": 5}, {"a": 1, "b": 1, "c": 3},
{"a": 1, "b": 9, "c": 4, "d": 0})
check("F2: only_edits() keeps every stored key the page did not edit, and a "
"key both edited takes this page's value (the deliberate press)",
_both == {"a": 2, "b": 9, "c": 5, "d": 0}
and list(_both) == ["a", "b", "c", "d"])
_gone = tiers(setup_checks(dict(SITE, meter_entity="sensor.gone"),
entities=ROWS))
check("a meter entity Home Assistant does not have is red on the last step, "
"so the wizard does not save it with the kettle test skipped",
("bad", "Home Assistant has no entity sensor.gone, which “Grid power "
"entity” (meter_entity) names.") in _gone)
_net_gone = tiers(setup_checks(dict(SITE, meter_source="ha_signed",
p1_net_entity="sensor.gone"),
entities=ROWS))
check("...for a net or import and export meter too",
[t for t, x in _net_gone if "sensor.gone" in x] == ["bad"])
from app.validate import config_notes # noqa: E402
check("...while the Installer view keeps it a warning, which never blocks "
"a save there",
[n.tier for n in config_notes(dict(SITE, meter_entity="sensor.gone"),
entities=ROWS)
if "sensor.gone" in n.text] == ["warn"])
check("step 1 after a skip that followed a fall says plainly that the "
"reading went down",
'k.say === "skipped" && k.wrong ? "skipped_down" : k.say' in JS
and "went down" in WS["kettle"]["skipped_down"]["description"]
and "not tested" not in WS["kettle"]["skipped_down"]["description"])
check("in setup-only mode the wizard's restart dialog does not claim a 0 W "
"wind-down that nothing runs, and the shadow one says 0 W",
"M.setup_only ? (shadow ? WS.dialog_setup : WS.dialog_setup_live)" in JS
and "0 W" in WS["dialog_setup"]["description"]
and "0 W" not in WS["dialog_setup_live"]["description"])
check("the page posts the model it opened with every save (F2)",
"model: V, base: ORIG, fingerprint: FP" in JS)
print()
if fails:
print(f"{len(fails)} FAILED")
+494
View File
@@ -0,0 +1,494 @@
"""UX-15: setup-only mode. `python3 test_setup_only.py`
A new install names no inverter, and instead of exiting 78 it serves the setup
wizard and commands nothing (DECISIONS/UX-15-setup-only-mode.md).
Part 1 checks when it applies. Part 2 the write path refuses in that mode.
Part 3 boots the REAL `app.main.main()` in a child process (this file with
`--driver`) against a fake Supervisor and Home Assistant over HTTP, and logs
every request they get: the shipped defaults serve the wizard with no service
call, also with `auto_start` on, a Store that says control was started and a
Start pressed on the page; the wizard saves; the next boot passes the normal
gate and runs in shadow. Part 4: a refused but complete configuration still
exits 78 and serves nothing.
Takes ~25 s. Mutating a module this file grades? Read MUTATION.md first.
"""
import asyncio
import copy
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
DEADLINE_S = 60
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def defaults():
import ux13_demo as D
return D.defaults(D.CONFIG)
def state(eid, value, unit="W", cls=None, **attrs):
return {"entity_id": eid, "state": value,
"attributes": dict({"friendly_name": eid,
"unit_of_measurement": unit,
"device_class": cls}, **attrs)}
STATES = [state("sensor.grid", "402"),
state("number.inv1_power", "0", min=-2500, max=2500, step=1),
state("sensor.inv1_level", "62", "%", "battery"),
state("sensor.inv1_battery", "0")]
# --------------------------------------------------------------------------- #
# The child: the real main(), with only the outside world faked over HTTP.
# --------------------------------------------------------------------------- #
def driver(scenario, work):
"""`work` holds options.json, state.json and the files the parent reads.
Every request the fakes get is streamed as `EV [...]` lines."""
import aiohttp
from aiohttp import web as aioweb
from aiohttp.abc import AbstractResolver
import app.main as M
import app.store
import app.web
import ux13_demo as D
import ux13_wizard_blobs as B
import translations_lint as TL
def event(*e):
print("EV " + json.dumps(list(e)), flush=True)
opts = json.load(open(os.path.join(work, "options.json")))
stored = {"options": copy.deepcopy(opts)}
schema = D.schema_ui(D.CONFIG)
translations = {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}
# --- the fake Supervisor and Home Assistant ---------------------------
async def h_probe(_r):
event("GET", "/core/api/")
return aioweb.Response(text="API running.")
async def h_states(_r):
event("GET", "/core/api/states")
return aioweb.json_response(STATES)
async def h_state(r):
eid = r.match_info["eid"]
event("GET", "/core/api/states/" + eid)
found = next((s for s in STATES if s["entity_id"] == eid), None)
return (aioweb.json_response(found) if found
else aioweb.json_response({"message": "not found"}, status=404))
async def h_service(r):
body = await r.json()
event("SERVICE", r.match_info["domain"], r.match_info["service"], body)
return aioweb.json_response([])
async def h_mqtt(_r):
event("GET", "/services/mqtt")
return aioweb.json_response({"result": "error"}, status=400)
async def h_info(_r):
return aioweb.json_response({"data": {"slug": "local_goodwe",
"homeassistant": "2026.9.1"}})
async def h_self_info(_r):
return aioweb.json_response({"data": {
"slug": "local_goodwe", "options": stored["options"],
"schema": schema, "translations": translations}})
async def h_options(r):
body = await r.json()
stored["options"] = body["options"]
event("OPTIONS")
with open(os.path.join(work, "saved.json"), "w") as f:
json.dump(body["options"], f)
return aioweb.json_response({"result": "ok", "data": {}})
async def h_restart(_r):
event("RESTART")
return aioweb.json_response({"result": "ok", "data": {}})
async def h_other(r):
event("OTHER", r.method, r.path)
return aioweb.json_response({}, status=404)
fake = aioweb.Application()
fake.add_routes([
aioweb.get("/core/api/", h_probe),
aioweb.get("/core/api/states", h_states),
aioweb.get("/core/api/states/{eid}", h_state),
aioweb.post("/core/api/services/{domain}/{service}", h_service),
aioweb.get("/services/mqtt", h_mqtt),
aioweb.get("/info", h_info),
aioweb.get("/addons/self/info", h_self_info),
aioweb.post("/addons/self/options", h_options),
aioweb.post("/addons/self/restart", h_restart),
aioweb.route("*", "/{tail:.*}", h_other)])
fake_port = free_port()
class ToFake(AbstractResolver):
"""`http://supervisor` is the fake, on its port."""
async def resolve(self, host, port=0, family=socket.AF_INET):
return [{"hostname": host, "host": "127.0.0.1", "port": fake_port,
"family": socket.AF_INET, "proto": 0, "flags": 0}]
async def close(self):
pass
Real = aiohttp.ClientSession
def session(*a, **k):
k.setdefault("connector", aiohttp.TCPConnector(resolver=ToFake()))
return Real(*a, **k)
aiohttp.ClientSession = session
os.environ["SUPERVISOR_TOKEN"] = "test-token"
M.OPTIONS_PATH = os.path.join(work, "options.json")
app.store.STATE_PATH = os.path.join(work, "state.json")
M.INGRESS_PORT = free_port()
kw = M.Controller.__init__.__kwdefaults__
kw["peak_db_path"] = os.path.join(work, "tariff.db")
kw["price_history_path"] = os.path.join(work, "price_history.json")
served = []
real_start = app.web.start
async def start(controller, port=8099, selfcfg=None):
runner = await real_start(controller, port, selfcfg)
served.append(type(controller).__name__) # listening now
event("SERVED", type(controller).__name__,
getattr(controller.hass, "read_only", None))
return runner
app.web.start = start
real_amain = M.amain
root = f"http://127.0.0.1:{M.INGRESS_PORT}/"
async def ask(s, method, path, body=None):
async with s.request(method, root + path, json=body) as r:
try:
out = await r.json(content_type=None)
except ValueError:
out = None
event("PAGE", method, path, r.status, out)
return r.status, out
async def scenario_steps():
async with Real() as s:
await ask(s, "GET", "status")
_st, model = await ask(s, "GET", "config/model?lang=en")
event("STATE_FILE", os.path.exists(app.store.STATE_PATH))
await ask(s, "POST", "action", {"action": "auto_toggle"})
if scenario.startswith("setup"):
await ask(s, "POST", "action", {"action": "maint_start"})
wiz = B.apply(model["ui"], B.BASE)
wiz = B.browser(json.loads(json.dumps(wiz)))
await ask(s, "POST", "config/validate",
{"model": wiz, "setup": True})
event("STATE_FILE", os.path.exists(app.store.STATE_PATH))
await ask(s, "POST", "config/save",
{"model": wiz, "base": model["ui"],
"fingerprint": model["fingerprint"],
"setup": "done"})
await ask(s, "POST", "config/restart", {"confirm": "restart"})
await asyncio.sleep(3.0) # room for any stray task to write
await ask(s, "GET", "status")
async def wrapped():
runner = aioweb.AppRunner(fake)
await runner.setup()
await aioweb.TCPSite(runner, "127.0.0.1", fake_port).start()
task = asyncio.create_task(real_amain())
try:
t0 = time.monotonic()
while not served and not task.done() \
and time.monotonic() - t0 < 20:
await asyncio.sleep(0.05)
if task.done():
event("ENDED_BEFORE_SERVING")
await task
return
await scenario_steps()
os.kill(os.getpid(), signal.SIGTERM) # the Supervisor's stop
await asyncio.wait_for(task, 20)
finally:
await runner.cleanup()
M.amain = wrapped
M.main()
def free_port():
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def run_child(scenario, options, state=None):
work = tempfile.mkdtemp(prefix="ux15-")
with open(os.path.join(work, "options.json"), "w") as f:
json.dump(options, f)
if state is not None:
with open(os.path.join(work, "state.json"), "w") as f:
json.dump(state, f)
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
try:
p = subprocess.run([sys.executable, "-B", __file__, "--driver",
scenario, work], cwd=HERE, env=env,
capture_output=True, text=True, timeout=DEADLINE_S)
rc, out, err = p.returncode, p.stdout, p.stderr
except subprocess.TimeoutExpired as e:
rc, out, err = None, e.stdout or "", e.stderr or ""
out = out.decode() if isinstance(out, bytes) else out
err = err.decode() if isinstance(err, bytes) else err
ev = [json.loads(ln[3:]) for ln in out.splitlines() if ln.startswith("EV ")]
files = {}
for name in ("saved.json", "state.json"):
path = os.path.join(work, name)
if os.path.exists(path):
files[name] = json.load(open(path))
shutil.rmtree(work, ignore_errors=True)
return rc, ev, err, files
def page(ev, method, path):
"""(status, body) of each page request, in order."""
return [(e[3], e[4]) for e in ev
if e[0] == "PAGE" and e[1] == method and e[2].startswith(path)]
def tail(text, n=12):
return "\n".join(" | " + ln for ln in text.splitlines()[-n:])
if len(sys.argv) == 4 and sys.argv[1] == "--driver":
driver(sys.argv[2], sys.argv[3])
sys.exit(0)
# --------------------------------------------------------------------------- #
print("1. when setup-only mode applies")
# --------------------------------------------------------------------------- #
from app import setuponly # noqa: E402
from app.validate import validate_startup_config # noqa: E402
SHIPPED = defaults()
V = validate_startup_config
check("the shipped defaults are refused only for the missing power request "
"entity, and that is setup-only mode",
setuponly.applies(SHIPPED, V(SHIPPED)) and len(V(SHIPPED)) == 1)
_blank_bad = dict(SHIPPED, maintenance_charge_w=2500)
check("no power request entity AND another refusal: not setup-only, exit 78",
len(V(_blank_bad)) >= 2 and not setuponly.applies(_blank_bad,
V(_blank_bad)))
_complete_bad = dict(SHIPPED, setpoint_entity="number.inv1_power",
maintenance_charge_w=2500)
check("a complete configuration that is refused: not setup-only, exit 78",
V(_complete_bad) and not setuponly.applies(_complete_bad,
V(_complete_bad)))
_list_blank = dict(SHIPPED, inverters=[{
"name": "inverter-a", "setpoint_entity": "", "soc_entity": "sensor.s",
"batt_entity": "sensor.b", "max_w": 2000, "bms_soc_floor": 10}])
check("an inverter list whose entry has no power request entity: not "
"setup-only (someone edited it), exit 78",
V(_list_blank) and not setuponly.applies(_list_blank, V(_list_blank)))
_ok = dict(SHIPPED, setpoint_entity="number.inv1_power")
check("a startable configuration: nothing refused, the normal start",
V(_ok) == [] and not setuponly.applies(_ok, V(_ok)))
# --------------------------------------------------------------------------- #
print("\n2. in setup-only mode the write path refuses")
# --------------------------------------------------------------------------- #
from app import hass as H # noqa: E402
import app.main as M # noqa: E402
class Session:
"""Records every request a HomeAssistant would send."""
def __init__(self):
self.sent = []
def post(self, url, **k):
self.sent.append(("POST", url))
raise AssertionError("a request was sent")
def get(self, url, **k):
self.sent.append(("GET", url))
raise AssertionError("a request was sent")
def _refused(coro):
"""True only for the read-only refusal; a request that reached the
session (AssertionError there) or no refusal at all is False."""
try:
asyncio.run(coro)
except H.ReadOnly:
return True
except Exception: # noqa: BLE001
return False
return False
_s = Session()
_ro = H.HomeAssistant(_s, "t", read_only=True)
check("a read-only HomeAssistant refuses number.set_value before it sends "
"anything", _refused(_ro.set_number("number.inv1_power", 0.0))
and _s.sent == [])
check("...and any other service call, e.g. a notification",
_refused(_ro.call("persistent_notification", "create",
{"message": "x"})) and _s.sent == [])
try:
_ro.read_only = False
_flipped = True
except AttributeError:
_flipped = False
check("...and read-only cannot be switched off afterwards",
not _flipped and _ro.read_only is True)
_rw = H.HomeAssistant(Session(), "t")
try:
asyncio.run(_rw.set_number("number.inv1_power", 0.0))
_sent = False
except AssertionError:
_sent = True
check("control: a normal HomeAssistant does send the write", _sent
and _rw.read_only is False)
try:
M.Controller(dict(_ok), _ro, None, None, stop=asyncio.Event())
_built = True
except ValueError as err:
_built = "read-only" not in str(err)
except Exception: # noqa: BLE001
_built = True # it got past the check and failed on something else
check("no controller can be built on a read-only HomeAssistant", not _built)
try:
setuponly.SetupOnly(SHIPPED, _rw, None)
_took = True
except ValueError:
_took = False
check("setup-only mode refuses a HomeAssistant that can write", not _took)
_so = setuponly.SetupOnly(SHIPPED, _ro, None)
check("setup-only mode holds no inverter and never says control is started",
list(_so.inverters) == [] and _so.auto is False
and _so.status_payload()["setup_only"] is True)
# --------------------------------------------------------------------------- #
print("\n3. the real main(): the shipped defaults serve the wizard, 0 writes")
# --------------------------------------------------------------------------- #
_runs = {
"setup": (dict(SHIPPED), None),
# A started-control attempt three ways: auto_start on, a Store that says
# control was started, and the page's own Start (the driver presses it).
"setup_started": (dict(SHIPPED, auto_start=True), {"auto": True}),
}
SAVED = None
for name, (options, st) in _runs.items():
rc, ev, err, files = run_child(name, options, st)
services = [e for e in ev if e[0] == "SERVICE"]
print(f" {name}: exit {rc}, {len(ev)} requests logged, "
f"{len(services)} service calls")
check(f"{name}: the process served the page in setup-only mode on a "
"read-only HomeAssistant, and exited 0 when stopped",
rc == 0 and ["SERVED", "SetupOnly", True] in ev)
if rc != 0:
print(tail(err))
check(f"{name}: 0 service calls to Home Assistant (no set_value, no "
"notification), and MQTT was never asked for",
services == [] and ["GET", "/services/mqtt"] not in ev)
_status = page(ev, "GET", "status")
check(f"{name}: /status says setup_only, with the setup banner",
_status and all(b and b.get("setup_only") is True
and b.get("mode") == "setup_only"
and b.get("banner", "").startswith("Setup")
for _c, b in _status))
_model = page(ev, "GET", "config/model")
check(f"{name}: the page model opens the wizard (no power request) and "
"says setup-only",
_model and _model[0][1]["setup_needed"] == "no_power_request"
and _model[0][1]["setup_only"] is True)
_acts = page(ev, "POST", "action")
check(f"{name}: Start and the battery cycle are refused with 409",
len(_acts) == 2 and all(c == 409 for c, _b in _acts))
check(f"{name}: setup-only mode writes no Store file before the wizard "
"saves, so the first-run rule still sees a new install",
[e for e in ev if e[0] == "STATE_FILE"][:2]
== [["STATE_FILE", st is not None]] * 2)
_val = page(ev, "POST", "config/validate")
check(f"{name}: the wizard's live checks run and find nothing red",
_val and _val[0][0] == 200
and not [m for m in _val[0][1]["messages"] if m["tier"] == "bad"]
and not [m for m in _val[0][1]["setup"] if m["tier"] == "bad"])
_save = page(ev, "POST", "config/save")
check(f"{name}: the wizard saves (200) and asks for the restart",
_save and _save[0][0] == 200 and ["RESTART"] in ev
and "saved.json" in files)
check(f"{name}: the Store gets setup_done from the save",
bool(files.get("state.json", {}).get("setup_done")))
if name == "setup":
SAVED, SAVED_STATE = files.get("saved.json"), files.get("state.json")
check("the saved blob passes the normal gate: no refusal, not setup-only",
SAVED is not None and V(SAVED) == []
and not setuponly.applies(SAVED, V(SAVED))
and SAVED.get("shadow_mode") == "on")
print("\n after the restart, on the saved settings:")
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
services = [e for e in ev if e[0] == "SERVICE"]
writes = [e[3].get("value") for e in services
if e[1:3] == ["number", "set_value"]]
print(f" after: exit {rc}, {len(services)} service calls, set_value "
f"values {sorted(set(writes))}")
check("the next boot passes the normal gate: a Controller serves the page, "
"and it exits 0 when stopped", rc == 0 and ["SERVED", "Controller",
False] in ev)
if rc != 0:
print(tail(err))
check("...where Start is accepted (200), unlike in setup-only mode",
[c for c, _b in page(ev, "POST", "action")] == [200])
_status = page(ev, "GET", "status")
check("...its /status is not setup-only", _status and all(
b and "setup_only" not in b for _c, b in _status))
_model = page(ev, "GET", "config/model")
check("...and the wizard does not open again: setup_done is in the Store",
_model and _model[0][1]["setup_needed"] is None
and _model[0][1]["setup_only"] is False)
check("...and it runs in shadow, even after Start: every service call is "
"number.set_value 0 W", writes and len(writes) == len(services)
and all(w == 0.0 for w in writes))
# --------------------------------------------------------------------------- #
print("\n4. a refused configuration that is not only missing setup: exit 78")
# --------------------------------------------------------------------------- #
for name, options in (("refused", _complete_bad), ("refused_blank", _blank_bad)):
rc, ev, err, _files = run_child(name, options)
print(f" {name}: exit {rc}")
check(f"{name}: exit 78 and nothing served, no service call",
rc == 78 and not [e for e in ev if e[0] in ("SERVED", "SERVICE")]
and ["ENDED_BEFORE_SERVING"] in ev)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+293
View File
@@ -0,0 +1,293 @@
"""UX-13 P5: the page's words, English and Dutch, follow STYLE.md. `python3 test_ui_text.py`
app/ui_text/en.json and nl.json hold every word the Home, Batteries, Settings
and Setup screens show that is not an option label (those are
translations/*.yaml, test_translations.py) or a status row (app/messages.py,
test_messages.py). This check fails CI and names the offending string when:
1. the two files differ in keys, or a string's {placeholders} differ;
2. a string breaks STYLE.md: a name or label over 6 words, a description over
1 sentence of 25 words, any other line over 2 sentences, a semicolon, a
banned or jargon word (English and Dutch lists), an option key or entity id;
3. a string carries its own details (a link, a section number, a file name)
instead of leaving them to the page's DOCS link;
4. Home's sentences, rendered by app/homeview.py in each language, break
the Home rules of test_messages.py part 8 (checked there too);
5. a Dutch string is left in English, outside the short list of words that
are the same in both;
6. the English words that mirror app/messages.py drift from it.
Each rule is shown to catch a spoiled input (habit 1 of MUTATION.md).
"""
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"))
from style_rules import (PLACEHOLDER, sentences, text_problems, # noqa: E402
title_problems, words)
from app import homeview, messages, uitext # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def load(lang):
with open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
encoding="utf-8") as f:
return json.load(f)
EN, NL = load("en"), load("nl")
def leaves(node, path=()):
"""(path, text) for every string, lists included (the month names)."""
if isinstance(node, dict):
for k, v in node.items():
yield from leaves(v, path + (k,))
elif isinstance(node, list):
for i, v in enumerate(node):
yield from leaves(v, path + (i,))
else:
yield path, node
def where(lang, path):
return f"{lang}.json:" + ".".join(str(p) for p in path)
# ============================================================================ #
print("1. English and Dutch have the same keys and placeholders")
_en, _nl = dict(leaves(EN)), dict(leaves(NL))
def missing(en, nl):
"""The keys only one language has."""
return (sorted(where("en", p) for p in en if p not in nl),
sorted(where("nl", p) for p in nl if p not in en))
def holes(en, nl):
"""The Dutch strings whose {placeholders} differ from the English."""
return sorted(
f"{where('nl', p)}: {sorted(PLACEHOLDER.findall(str(en[p])))} vs "
f"{sorted(PLACEHOLDER.findall(str(t)))}"
for p, t in nl.items() if p in en
and sorted(PLACEHOLDER.findall(str(t)))
!= sorted(PLACEHOLDER.findall(str(en[p]))))
_only_en, _only_nl = missing(_en, _nl)
for x in _only_en + _only_nl:
print(f" MISSING in the other language: {x}")
check(f"every one of the {len(_en)} English strings has a Dutch one, and no "
"Dutch string is extra", not _only_en and not _only_nl and len(_en) > 300)
_holes = holes(_en, _nl)
for x in _holes:
print(f" PLACEHOLDERS differ: {x}")
check("every Dutch string fills the same {placeholders} as its English one",
not _holes)
# ============================================================================ #
print("\n2. every string follows STYLE.md, in both languages")
# STYLE.md section 4, the "Not" column, plus the Dutch words for the same
# things (translations/nl.yaml keeps "setpoint" in two option names, which the
# page never shows). Matched as whole words, case-insensitive.
JARGON = {
"en": r"controllers?|control loop|setpoints?|SoC|state of charge|BMS|PV|"
r"failsafe|trip(?:ped)?|parked|discharg\w*|P1 feed|quarter-hour|"
r"payload|endpoint|JSON|API|timeout|boolean|config|commands?|"
r"charge level|ceiling|billed quarter|quarters?|banked|soft limit|"
r"open block|actuator|ESP32|master|meter source|circulating|"
r"sign disagreement|rail",
"nl": r"setpoints?|setpunt\w*|SoC|laadtoestand|BMS|PV|failsafe|regelaar|"
r"regellus|controller|latch|ontlad\w*|payload|endpoint|JSON|API|"
r"time-?out|boolean|config|kwartuur\w*|commando\w*|geparkeerd|"
r"ESP32|master",
}
JARGON = {k: re.compile(r"\b(?:" + v + r")\b", re.I) for k, v in JARGON.items()}
# Details belong behind the page's DOCS link, never in the string itself.
DETAILS = re.compile(r"https?:|www\.|DOCS|documentation, section|§|"
r"\bsee section\b|\bzie sectie\b", re.I)
NAME_WORDS, DESC_WORDS, LINE_SENTENCES = 6, 25, 2
def kind_of(path):
if path[0] == "home_words":
return "template" # part 4 renders and checks these
return "description" if path[-1] == "description" else "name"
def problems(lang, path, text):
kind = kind_of(path)
out = []
if not isinstance(text, str) or not text.strip():
return ["empty"]
if kind == "name" and len(words(text)) > NAME_WORDS:
out.append(f"name over {NAME_WORDS} words ({len(words(text))})")
if kind == "name" and sentences(text) > LINE_SENTENCES:
out.append(f"over {LINE_SENTENCES} sentences ({sentences(text)})")
if kind == "description":
if sentences(text) > 1:
out.append(f"description over 1 sentence ({sentences(text)})")
if len(words(text)) > DESC_WORDS:
out.append(f"description over {DESC_WORDS} words "
f"({len(words(text))})")
if ";" in text:
out.append("semicolon")
hit = JARGON[lang].search(PLACEHOLDER.sub("X", text))
if hit:
out.append(f"jargon: {hit.group(0)!r}")
if DETAILS.search(text):
out.append(f"details belong behind the DOCS link: "
f"{DETAILS.search(text).group(0)!r}")
# The picker's own help names "entity id": the one place it is the word.
ok_entity = path[:2] == ("settings", "picker")
return out + [p for p in text_problems(text, keys=False, entities=False)
if not (ok_entity and p.startswith("entity id"))]
_bad = []
for lang, data in (("en", EN), ("nl", NL)):
for path, text in leaves(data):
for p in problems(lang, path, text):
_bad.append(f"{where(lang, path)}: {p} :: {text!r}")
for x in _bad:
print(f" STYLE {x}")
check(f"every string in en.json and nl.json follows STYLE.md "
f"({len(_en) + len(_nl)} strings, {len(_bad)} breaking it)", not _bad)
# ============================================================================ #
print("\n3. the rule catches a spoiled input")
check("a 7-word name is caught",
problems("en", ("fields", "x", "name"),
"One two three four five six seven"))
check("a 2-sentence description is caught",
problems("nl", ("fields", "x", "description"), "Eén zin. Twee zinnen."))
check("jargon is caught in English and in Dutch",
problems("en", ("x",), "Setpoint sent") and
problems("nl", ("x",), "De SoC is laag"))
check("a link or section in a string is caught",
problems("en", ("x", "description"),
"See https://example.org for the rest."))
check("a snake_case option key is caught",
problems("nl", ("x",), "Zet peak_cap_w hoger"))
check("a semicolon is caught",
problems("en", ("x",), "Saved; restart to apply"))
check("a 26-word description is caught, and 25 words pass",
problems("en", ("x", "description"), " ".join(["word"] * 26) + ".")
and not problems("en", ("x", "description"),
" ".join(["word"] * 25) + "."))
check("a 3-sentence line is caught",
problems("en", ("x",), "One. Two. Three."))
check("a key only one language has is caught, both ways",
missing({("a",): "x", ("b",): "y"}, {("a",): "x"}) == (["en.json:b"], [])
and missing({("a",): "x"}, {("a",): "x", ("c",): "z"})
== ([], ["nl.json:c"]))
check("a placeholder dropped or renamed in Dutch is caught",
holes({("a",): "Window {row}"}, {("a",): "Tijdvak"})
and holes({("a",): "{w} now"}, {("a",): "nu {d}"})
and not holes({("a",): "{a} {b}"}, {("a",): "{b} {a}"}))
# ============================================================================ #
print("\n4. Home's sentences, rendered in each language (as test_messages 8)")
PLAN_WORDS, PLAN_SENTENCES = 12, 2
def home_problems(kind, text):
if "{" in text or "}" in text:
return ["a placeholder was not filled"]
if kind == "title":
return title_problems(text)
out = text_problems(text, keys=False, entities=False)
if kind == "line":
if sentences(text) > 1 or len(words(text)) > 25:
out.append("line over 1 sentence of 25 words")
return out
if sentences(text) > PLAN_SENTENCES:
out.append(f"plan line over {PLAN_SENTENCES} sentences")
out += [f"plan sentence over {PLAN_WORDS} words: {x!r}"
for x in re.split(r"(?<=[.!?])\s+", text)
if len(words(x)) > PLAN_WORDS]
return out
for lang in ("en", "nl"):
_samples = homeview.text_samples(lang)
_hb = [f"{lang} {k}: {p} :: {t!r}" for k, t in _samples
for p in home_problems(k, t)]
_hb += [f"{lang}: jargon {JARGON[lang].search(t).group(0)!r} :: {t!r}"
for _k, t in _samples if JARGON[lang].search(t)]
for x in _hb:
print(f" HOME {x}")
check(f"{lang}: every Home sentence follows its rule "
f"({len(_samples)} rendered, {len(_hb)} breaking it)",
len(_samples) > 80 and not _hb)
_en_s = [t for _k, t in homeview.text_samples("en")]
_nl_s = [t for _k, t in homeview.text_samples("nl")]
check("Home in Dutch is not Home in English: most sentences differ, and "
"numbers use a decimal comma",
sum(a != b for a, b in zip(_en_s, _nl_s)) > 0.9 * len(_en_s)
and "2,5 kW" in homeview.text_samples("nl").__repr__()
and "2.5 kW" in _en_s.__repr__())
check("the language resets after a Dutch build, so English callers stay "
"English", homeview.pill("running") == EN["home_words"]["pill"]["running"])
# ============================================================================ #
print("\n5. no Dutch string is left in English")
# Words that are the same in both, or names. Anything else equal is untranslated.
SAME = {"Meter", "Timing", "Support", "Details", "Alarm", "Info", "per kWh",
"{hour}:00 UTC", "Flanders, Fluvius Antwerpen", "Modus", "Status"}
def english(en, nl):
return sorted(f"{where('nl', p)} = {t!r}" for p, t in nl.items()
if p in en and t == en[p] and t not in SAME
and PLACEHOLDER.sub("", t).strip(" .:,"))
_same = english(_en, _nl)
for x in _same:
print(f" ENGLISH {x}")
check(f"every Dutch string is Dutch ({len(_same)} still English)", not _same)
_spoiled = dict(_nl)
_spoiled[("settings", "savebar", "save")] = _en[("settings", "savebar",
"save")]
check("mutation: an English string left in nl.json is caught",
english(_en, _spoiled) == ["nl.json:settings.savebar.save = 'Save'"])
# ============================================================================ #
print("\n6. the words that mirror app/messages.py")
check("the setup-only banner on Home is messages.banner_setup_only()",
EN["home"]["setup_only"]["name"] == messages.banner_setup_only())
_mirror = {k: (EN["home_words"]["attn"][k], v)
for k, v in homeview.ATTN_FROM_MESSAGES.items()}
_drift = {k: v for k, v in _mirror.items() if v[0] != v[1]}
check(f"five Home titles are the notification's own title ({_drift})",
len(_mirror) == 5 and not _drift)
check("app/uitext.py falls back to English per key and reads nl-BE as nl",
uitext.lang_of("nl-BE") == "nl" and uitext.lang_of("fr") == "en"
and uitext.words("nl")["home"]["tabs"]["home"]
== NL["home"]["tabs"]["home"]
and uitext._merge({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}})
== {"a": {"b": 3, "c": 2}})
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+110
View File
@@ -217,6 +217,116 @@ check("the hook answers None while battery modes do not exist",
check("so the toggle is stored as the page has it",
edit(DEFAULTS, arbitrage_enabled=True)["arbitrage_enabled"] is True)
print("\n7. the time-window editor (UX-13 P5)")
def rows(*spec):
return [{"from": a, "to": b, "days": d, "price": p} for a, b, d, p in spec]
def periods(r, base=SITE):
return edit(base, ui__contract="custom", ui__fallback="custom",
ui__periods=r)
def refused(r):
"""(code, values) of the DeriveError the rows give, or None."""
try:
periods(r)
except U.DeriveError as err:
return err.code, err.values
return None
# Five windows the day/night preset cannot say: a weekday peak, a weekday
# night, Saturday split at noon, and Sunday.
_five = rows(("06:30", "21:15", "mon-fri", 0.31), ("21:15", "06:30", "mon-fri", 0.19),
("00:00", "12:00", "sat", 0.2), ("12:00", "00:00", "sat", 0.25),
("00:00", "24:00", "sun", 0.18))
_p5 = periods(_five)
_prof5 = parse_static_profile(_p5["static_profile"])
check("five odd windows write a profile the tariff parser reads, with every "
"price in place", [p.eur_per_kwh for p in _prof5.periods]
== [0.31, 0.19, 0.2, 0.25, 0.18] and _prof5.periods[3].end_min == 1440)
check("...that the shipped start checks accept, and that reads back as the "
"same rows", not validate_startup_config(_p5)
and U.options_to_ui(_p5)["ui.contract"] == "custom"
and [(r["from"], r["to"], r["days"], r["price"])
for r in U.options_to_ui(_p5)["ui.periods"]]
== [("06:30", "21:15", "mon-fri", 0.31), ("21:15", "06:30", "mon-fri", 0.19),
("00:00", "12:00", "sat", 0.2), ("12:00", "24:00", "sat", 0.25),
("00:00", "24:00", "sun", 0.18)])
_named = dict(SITE, static_profile=json.dumps({"periods": [
{"name": "peak", "from": "08:00", "to": "20:00", "days": "mon-sun",
"eur_per_kwh": 0.33},
{"name": "offpeak", "from": "20:00", "to": "08:00", "days": "mon-sun",
"eur_per_kwh": 0.21}]}))
_rows = U.options_to_ui(_named)["ui.periods"]
_rows[1]["price"] = 0.2
check("a stored profile's window names are kept when a price changes",
[p.name for p in parse_static_profile(periods(_rows, base=_named)
["static_profile"]).periods]
== ["peak", "offpeak"])
check("the day and night preset shows as three rows, so 'Other time windows' "
"starts from what is stored",
[r["days"] for r in U.options_to_ui(
edit(SITE, ui__contract="daynight", ui__day_price=0.3,
ui__night_price=0.2))["ui.periods"]]
== ["mon-fri", "mon-fri", "sat,sun"])
check("an overlap is named: the two windows, the day and the times",
refused(rows(("07:00", "23:00", "mon-fri", 0.3),
("22:00", "07:00", "mon-fri", 0.2),
("00:00", "24:00", "sat,sun", 0.2)))
== ("period_overlap", {"day": "mon", "from": "22:00", "to": "23:00",
"a": 1, "b": 2}))
check("a gap is named: the day and the times",
refused(rows(("07:00", "22:00", "mon-sun", 0.3),
("22:00", "24:00", "mon-sun", 0.2)))
== ("period_gap", {"day": "mon", "from": "00:00", "to": "07:00"}))
check("an overlap that changes partners is named piece by piece: windows 1 "
"and 2 first, over their own times only",
refused(rows(("00:00", "24:00", "mon-sun", 0.3),
("06:00", "08:00", "mon-sun", 0.2),
("08:00", "10:00", "mon-sun", 0.2)))
== ("period_overlap", {"day": "mon", "from": "06:00", "to": "08:00",
"a": 1, "b": 2}))
check("a gap on one day only is found on that day",
refused(rows(("00:00", "24:00", "mon-fri", 0.3),
("00:00", "24:00", "sat", 0.2)))
== ("period_gap", {"day": "sun", "from": "00:00", "to": "24:00"}))
check("bad times are named by window: '7h', '25:00', '07:60', '24:00' as a "
"start", all(refused(rows(("00:00", "24:00", "mon-sun", 0.2), (a, b, "mon", 0.1)))
== ("period_time", {"row": 2}) for a, b in
(("7h", "08:00"), ("25:00", "08:00"), ("07:60", "08:00"),
("24:00", "08:00"), ("07:00", "")))
and refused(rows(("7:00", "07:00", "mon-sun", 0.2)))
== ("period_zero", {"row": 1}))
check("an empty price, and a price in cents, are named by window",
refused(rows(("00:00", "24:00", "mon-sun", None)))
== ("period_price", {"row": 1})
and refused(rows(("00:00", "24:00", "mon-sun", 34)))
== ("period_price_range", {"row": 1}))
check("no windows at all is refused", refused([]) == ("no_periods", {}))
from app import uipage # noqa: E402
_msg = uipage.derive_message(U.DeriveError("x", "period_overlap", "ui.periods",
day="sat", a=1, b=3, **{
"from": "06:00", "to": "07:00"}))
_nl = uipage.derive_message(U.DeriveError("x", "period_gap", "ui.periods",
day="sun", **{"from": "00:00",
"to": "07:00"}), "nl")
check(f"the page shows the error beside the editor, with the day by name "
f"({_msg['text']!r}, {_nl['text']!r})",
_msg["field"] == "ui.periods" and "Windows 1 and 3" in _msg["text"]
and "Saturday 06:00 to 07:00" in _msg["text"]
and "zondag 00:00 tot 07:00" in _nl["text"])
_raw = json.dumps({"periods": [
{"from": "00:00", "to": "24:00", "days": "mon,wed", "eur_per_kwh": 0.2},
{"from": "00:00", "to": "24:00", "days": "tue,thu-sun", "eur_per_kwh": 0.3}]})
check("days the editor has no choice for stay in the Configuration tab, and "
"survive a save", U.options_to_ui(dict(SITE, static_profile=_raw))
["ui.periods"] is None and edit(dict(SITE, static_profile=_raw), gain=0.5)
["static_profile"] == _raw)
print()
if fails:
print(f"{len(fails)} FAILED")
+93 -1
View File
@@ -5,6 +5,7 @@
python3 tools/alloc_sim.py --splits # AC 2: split-invariance at the meter
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
python3 tools/alloc_sim.py --all
python3 tools/alloc_sim.py --spill # OPT-11: surplus solar, by mode
Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig.
@@ -245,12 +246,98 @@ def sweep(sitesim, control, watchdog, cycles, load_w):
return worst
def spill(sitesim, control, arbiter, allocator, packmode, gain=0.6,
cycles=30, load_w=1000.0, pv_w=9000.0):
"""OPT-11 P1: surplus solar spills from Solar first to Leftover solar.
The rig's two plants and the add-on's own `compute()`, `resolve()`,
`loop_bound()` and `packmode.charge_weights()`, with the claims main.py
files for this state: each unit's rating, each unit's mode, the loop, and
OPT-09's `pack` limit (`lo = 0`) once battery A is full. A is Solar first
and B Leftover solar. Three phases of `cycles` each: A has room; A is full;
then B is switched off. The loop is fed the site total (DEV-02).
Returns (rows, verdicts).
"""
from plantsim import INVERTER_A, INVERTER_B, Inverter, Site
names = ("inverter-a", "inverter-b")
specs = [("A", INVERTER_A), ("B", INVERTER_B)]
invs = [Inverter.from_spec(n, dict(sp, **sitesim.INSTANT))
for n, sp in specs]
site = Site(invs, load=load_w, pv=pv_w)
tun = control.Tuning(gain=gain, max_w=sum(sp["rating_w"] for _n, sp in specs),
slew_w=sitesim.EXPERIMENT_SLEW_W)
C = arbiter.Claim
prev, i_w, sat = 0.0, None, 0
rows = []
phases = (("A has room", False, True), ("A full", True, True),
("A full, B off", True, False))
for label, a_full, b_on in phases:
modes = {names[0]: packmode.PackMode(True, packmode.SOLAR_FIRST),
names[1]: packmode.PackMode(b_on, packmode.LEFTOVER_SOLAR)}
for _c in range(cycles):
claims = []
for u, (_n, sp) in zip(names, specs):
r = sp["rating_w"]
claims.append(C.limit("supervised", arbiter.P_SAFETY, -r, r,
actuator=u))
lo, hi = packmode.mode_range(modes[u], r)
claims.append(C.limit("mode", arbiter.P_MODE, lo, hi,
actuator=u))
if a_full:
claims.append(C.limit("pack", arbiter.P_SAFETY, lo=0.0,
actuator=names[0]))
bound = allocator.loop_bound(
{u: arbiter.pre_resolve([c for c in claims if c.actuator == u])
for u in names})
d = control.compute(prev, site.grid, site.battery, tun, sat, i_w,
bound=bound)
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
claims.append(C.set("loop", arbiter.P_LOOP, d.target_w))
res = arbiter.resolve(
claims, units=names,
weights=lambda t: packmode.charge_weights(None, modes, t))
for inv, u in zip(invs, names):
inv.command(float(res.shares[u]))
site.settle(sitesim.CYCLE_S, dt=0.05)
rows.append(dict(phase=label, target=d.target_w, grid=site.grid,
a=res.shares[names[0]], b=res.shares[names[1]],
unmet=res.allocation.unmet_w, frozen=d.frozen,
reason=d.reason, bound=bound))
return rows
def spill_report(rows, cycles):
print("=== OPT-11 P1: surplus solar, A Solar first, B Leftover solar ===")
ok = True
for label in dict.fromkeys(r["phase"] for r in rows):
ph = [r for r in rows if r["phase"] == label]
last = ph[-1]
frozen = sum(r["frozen"] for r in ph)
print(f" {label:15} A {last['a']:+6.0f} W B {last['b']:+6.0f} W "
f"grid {last['grid']:+7.0f} W loop bound "
f"[{last['bound'][0]:.0f}, {last['bound'][1]:.0f}] "
f"unmet {last['unmet']} W frozen cycles {frozen} "
f"({last['reason']})")
ok &= frozen == 0 and all(r["a"] <= 0 and r["b"] <= 0 for r in ph)
if label == "A has room":
ok &= last["a"] == -5000 and last["b"] < 0
elif label == "A full":
ok &= last["a"] == 0 and last["b"] == -5000
else:
ok &= last["a"] == 0 and last["b"] == 0 and last["unmet"] == 0
print(f" -> spill {'HOLDS' if ok else 'FAILS'}: A fills first, the rest "
f"spills to B, a full A and an Off B send the surplus to the grid, "
f"and the loop never freezes on watts no battery may take")
return ok
def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--table", action="store_true")
ap.add_argument("--splits", action="store_true")
ap.add_argument("--sweep", action="store_true")
ap.add_argument("--all", action="store_true")
ap.add_argument("--spill", action="store_true")
ap.add_argument("--cycles", type=int, default=40)
ap.add_argument("--load", type=float, default=2000.0)
ap.add_argument("--gain", type=float, default=1.0)
@@ -261,7 +348,7 @@ def main():
"or one inverter's power (the PRE-DEV-02 sensing, "
"kept because the comparison is the evidence)")
a = ap.parse_args()
if not (a.table or a.splits or a.sweep):
if not (a.table or a.splits or a.sweep or a.spill):
a.all = True
sim = find_sim()
@@ -288,6 +375,11 @@ def main():
print()
if a.sweep or a.all:
sweep(sitesim, control, watchdog, a.cycles, a.load)
print()
if a.spill or a.all:
packmode = sitesim.load_addon("app.packmode", "alloc_sim")
held &= spill_report(spill(sitesim, control, arbiter, allocator,
packmode), 30)
return 0 if held else 1
+289
View File
@@ -0,0 +1,289 @@
#!/usr/bin/env python3
"""OPT-11: with battery modes off, is every command byte-identical to before?
python3 tools/opt11_identity.py # prints one digest per scenario
Drives the REAL `Controller` (read_inputs + cycle: the freshness gate, the
arbiter, the allocator, OPT-09's pack claim, SAFETY-73's refusal claims) in
simulated time, and hashes, per cycle, everything a command depends on: the
site target, each inverter's written value, the loop's reason, integrator and
saturation count, and the allocation record. It uses only the Controller API
that release/1.0 a5dddad already had, so the SAME file runs on the old tree and
the new one. test_modes.py part 1 holds the new tree to the digests this file
printed on a5dddad (they are written there, with that provenance).
Scenarios: N=1 and N=2, a house that steps, a surplus of solar, a battery
reaching its cut-out level and, at N=2, a battery that refuses (SAFETY-73) and
is probed; the same with both batteries due for maintenance; and the arbiter
alone on 6000 seeded claim lists (peak floor, trades, maintenance, refusals).
"""
import asyncio
import hashlib
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
TICK = 5.0
BASE = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, k):
v = self.data.get(k)
return datetime.fromisoformat(v) if v else None
def set_time(self, k, when):
self.data[k] = when.isoformat() if when else None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
def house(t):
"""A house that steps, then a solar surplus, then an evening load."""
if t < 300:
return 800.0
if t < 900:
return 3200.0
if t < 1500:
return -2600.0 # more solar than the house uses
if t < 2100:
return 1400.0
return 4200.0
class _Hass:
"""One meter, `units` batteries that follow their command unless `cut`."""
def __init__(self, units, soc, cut, load=house):
self.units, self.soc, self.cut, self.load = units, soc, cut, load
self.cmd = {u: 0.0 for u in units}
self.t = 0.0
def power(self, u):
c = self.cmd[u]
if self.cut(u, self.t) and c >= -4.0:
return -4.0
return float(round(c))
def grid(self):
return self.load(self.t) - sum(self.power(u) for u in self.units)
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.grid()
u = e[-1]
if e.startswith("sensor.soc"):
return self.soc(u, self.t)
if e.startswith("sensor.batt"):
return self.power(u)
return None
async def set_number(self, e, v):
self.cmd[e[-1]] = float(v)
return True
def _options(n, extra):
common = {"meter_entity": "sensor.grid", "meter_source": "off",
"gain": 0.6, "slew_w": 400, "target_grid_w": -10,
"deadband_w": 15, "step_w": 10, "heartbeat_s": 10,
"stale_input_s": 15, "auto_start": True}
if n == 1:
common.update({"setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a",
"batt_entity": "sensor.batt_a", "max_w": 5000,
"bms_soc_floor": 10})
else:
common["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]]
common.update(extra or {})
return common
def _soc(u, t):
# A empties toward its cut-out level over the run; B stays half full.
return max(8.0, 60.0 - t / 40.0) if u == "a" else 50.0
def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
soc=_soc, cut=None, before=None):
"""Cycle a real Controller; returns (rows, controller). `before(c, t)`
runs before each cycle, `on_cycle(c, t)` after it."""
units = tuple("ab"[:n])
if cut is None:
def cut(u, t):
return n > 1 and u == "a" and 1600.0 <= t < 2300.0
async def go():
hass = _Hass(units, soc, cut, load)
c = Controller(_options(n, extra), hass, store or _Store(), _Pub(),
stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
rows = []
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
if before is not None:
r = before(c, t)
if asyncio.iscoroutine(r):
await r
await c.read_inputs(now_mono=t)
await c.cycle()
alloc = getattr(c, "allocation", None)
rows.append((t, c.target, tuple(sorted(hass.cmd.items())),
c.loop_reason, round(float(c.i_w), 6),
c.sat_count, c.reason,
alloc.explain() if alloc is not None else None))
if on_cycle is not None:
on_cycle(c, t)
t += TICK
finally:
m.datetime = real_dt
return rows, c
return asyncio.run(go())
def digest(rows) -> str:
return hashlib.sha256(repr(rows).encode()).hexdigest()
# Both batteries due for their monthly cycle at the run's first cycle, so the
# maintenance slot, its pins and its shaping are in every row (PR #149 review:
# the scenarios above never reach maintenance).
_MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11}
SCENARIOS = {
"n1": dict(n=1),
"n2": dict(n=2),
# The option present and false: what an install that saved the new
# option without turning it on runs. The old tree ignores the key.
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
"n1_maint": dict(n=1, extra=_MAINT),
"n2_maint": dict(n=2, extra=_MAINT),
}
def resolve_rows(cases=6000, seed=1149):
"""The arbiter alone, on seeded random claim lists with no mode claim:
the peak floor, a trade, the loop, maintenance pins and shaping, SAFETY-73
refusal limits and probe pins, packs, ratings and safety stops, at one to
three inverters. The peak guard needs a P1 meter the cycle runs above do
not have, so its claim is graded here. Uses only a5dddad's API."""
import random
from math import inf
from app import arbiter as a
rnd = random.Random(seed)
rows = []
for _ in range(cases):
n = rnd.choice((1, 2, 3))
units = ("inverter-a", "inverter-b", "inverter-c")[:n]
def act(src, u):
return a.actuator_for(src, u, units)
cl = []
for u in units:
r = rnd.choice((1000.0, 2500.0, 5000.0))
cl.append(a.Claim.limit("supervised", a.P_SAFETY, -r, r, "max_w",
actuator=act("supervised", u)))
x = rnd.random()
if x < 0.15:
cl.append(a.Claim.limit("pack", a.P_SAFETY, *rnd.choice(
((0.0, inf), (-inf, 0.0))), actuator=act("pack", u)))
elif x < 0.25:
cl.append(a.Claim.limit("refused", a.P_SAFETY, *rnd.choice(
((-inf, 0.0), (0.0, inf))), "refused",
actuator=act("refused", u)))
elif x < 0.32:
cl.append(a.Claim.set("refused", a.P_SAFETY,
rnd.choice((200.0, -200.0)), "probing",
actuator=act("refused", u)))
elif x < 0.42:
cl.append(a.Claim.set("maintenance", a.P_MAINTENANCE,
rnd.choice((2000.0, -2000.0)), "drain",
actuator=act("maintenance", u)))
elif x < 0.5:
cl.append(a.Claim.limit("maintenance", a.P_MAINT_SHAPE,
hi=-rnd.choice((500.0, 2000.0)),
actuator=act("maintenance", u)))
y = rnd.random()
if y < 0.8:
cl.append(a.Claim.set("loop", a.P_LOOP, rnd.uniform(-8000, 8000)))
if 0.5 < y < 0.9:
cl.append(a.Claim.set("arbitrage", a.P_ARBITRAGE,
rnd.uniform(-6000, 6000)))
if rnd.random() < 0.6:
cl.append(a.Claim.limit("peak", a.P_PEAK,
lo=rnd.uniform(-4000, 6000)))
if rnd.random() < 0.05:
cl.append(a.Claim.set("safety", a.P_SAFETY, 0.0, "stopped"))
rnd.shuffle(cl)
r = a.resolve(cl, units=units if n > 1 else None,
weights=(None if rnd.random() < 0.5 else
{u: rnd.choice((0.0, 0.5, 1.0)) for u in units}))
rows.append((r.target_w, r.winner, r.bound_by, r.contradiction,
tuple(sorted((r.shares or {}).items())), r.allocated,
r.allocation.explain() if r.allocation else None))
return rows
def digests() -> dict:
out = {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
out["resolve"] = digest(resolve_rows())
return out
if __name__ == "__main__":
if "--summary" in sys.argv:
for k, v in SCENARIOS.items():
rows = run(**v)[0]
print(f"{k}: {len(rows)} cycles, loop reasons "
f"{sorted({r[3] for r in rows})}, arbiter "
f"{sorted({r[6].split(' ->')[0] for r in rows})}")
rr = resolve_rows()
print(f"resolve: {len(rr)} claim lists, winners "
f"{sorted({r[1] for r in rr})}, bound by "
f"{sorted({str(r[2]).split('(')[0] for r in rr})}")
for k, v in digests().items():
print(f"{k} {v}")
+58
View File
@@ -0,0 +1,58 @@
"""STYLE.md sections 1 and 4 as code: the banned words and the counts.
Shared by test_messages.py (UX-08) and test_ui_text.py (UX-13 P5), so the two
checks cannot drift apart. Moved here unchanged from test_messages.py.
"""
import re
BANNED = re.compile(
r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|"
r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|"
r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|"
r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|"
r"capaciteitstarief|journalctl|ssh|grep|"
r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I)
ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)"
r"\.[a-z0-9_*]+")
SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b")
SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b")
PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]")
def words(text):
return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text))
if re.search(r"[A-Za-z0-9]", w)]
def sentences(text):
"""Sentence count: a stop followed by a space and a capital, or the end."""
t = PLACEHOLDER.sub("X", text.strip())
t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t)
return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()])
def title_problems(title):
out = []
if len(words(title)) > 5:
out.append(f"title over 5 words ({len(words(title))})")
if title.lower().startswith("goodwe"):
out.append("title carries a GoodWe prefix")
if title.rstrip().endswith("."):
out.append("title ends in a full stop")
return out + text_problems(title, keys=False, entities=False)
def text_problems(text, *, keys, entities):
t = PLACEHOLDER.sub("X", text)
out = []
hit = sorted({m.group(0) for m in BANNED.finditer(t)})
if hit:
out.append("banned: " + ", ".join(hit[:4]))
if not entities and ENTITY.search(t):
out.append("entity id: " + ENTITY.search(t).group(0))
if not keys and SNAKE.search(ENTITY.sub("", t)):
out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0))
if SHOUT.search(t):
out.append("capitals: " + SHOUT.search(t).group(0))
return out
+19 -3
View File
@@ -1,7 +1,8 @@
"""UX-13: serve the real page and routes over a fixture controller, for a
headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]`
STATE is running, shadow or alarm. The controller is the shipped `Controller`
STATE is running, shadow, alarm, new (P4) or setup (UX-15's setup-only
mode, P5). The controller is the shipped `Controller`
with its readings set by hand (two batteries, solar, today's prices in the
mockup's shape, trading on). Nothing here talks to Home Assistant or to an
inverter: `/action` changes only this fixture. Used for the P1 render check
@@ -35,6 +36,7 @@ from aiohttp import web as aioweb # noqa: E402
from app import homeview, web # noqa: E402
from app.main import Controller # noqa: E402
from app.setuponly import SetupOnly # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
from app.static_tariff import TariffPlan # noqa: E402
@@ -203,8 +205,9 @@ class Supervisor:
async def info(self):
return {"options": json.loads(json.dumps(self.options)),
"schema": schema_ui(CONFIG),
"translations": {"en": TL.parse_translation_file(
os.path.join(HERE, "translations", "en.yaml"))}}
"translations": {lang: TL.parse_translation_file(
os.path.join(HERE, "translations", f"{lang}.yaml"))
for lang in ("en", "nl")}}
async def write_options(self, options):
self.options = json.loads(json.dumps(options))
@@ -255,7 +258,20 @@ def controller(state):
return c
class ReadOnlyHass(Hass):
read_only = True # app/setuponly.py serves only on a read-only HA
async def main(state, port, host="127.0.0.1"):
if state == "setup":
# UX-15's setup-only mode: shipped defaults, nothing controlled.
stored = defaults(CONFIG)
c = SetupOnly(dict(stored), ReadOnlyHass(), Store())
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
await runner.setup()
await aioweb.TCPSite(runner, host, port).start()
print(f"serving setup on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
if state == "new":
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
store = Store()
@@ -0,0 +1,96 @@
"""UX-13 P5: the DOCS screenshots of the new screens, taken from the real page.
Run by tools/ux13_screenshots.sh, which serves tools/ux13_demo.py fixtures and
runs this inside the Playwright image. Each shot fails the run on a page
error, so a broken screen is never pictured.
python3 ux13_screenshots.py OUT_DIR # DEMO_<STATE> = the fixture's URL
The battery-mode screens (P3) are not built, so there is no shot of them:
DOCS.md says so instead of showing a mock-up.
"""
import os
import sys
from playwright.sync_api import sync_playwright
OUT = sys.argv[1]
DESKTOP = {"width": 1000, "height": 900}
PHONE = {"width": 390, "height": 844}
def url(state):
return os.environ[f"DEMO_{state.upper()}"]
def shot(browser, name, state, route, *, lang="en-GB", size=DESKTOP,
full=True, act=None, only=None):
ctx = browser.new_context(locale=lang, viewport=size,
color_scheme="light", reduced_motion="reduce")
page = ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(url(state) + route)
page.wait_for_load_state("networkidle")
page.wait_for_timeout(1500) # the first /status and checks
if act:
act(page)
page.wait_for_timeout(1500) # the debounced start checks
path = os.path.join(OUT, name + ".png")
if only:
page.locator(only).screenshot(path=path)
else:
page.screenshot(path=path, full_page=full)
ctx.close()
if errors:
raise SystemExit(f"{name}: page errors {errors}")
print(f" {name}.png")
def open_tariff(page):
page.locator("details.grp[data-g=tariff] > summary").click()
page.locator("details.grp[data-g=meter] > summary").click() # close it
# The fixture site is on a dynamic contract: its fallback gets windows.
page.get_by_role("radiogroup", name="Price when the feed is down") \
.get_by_role("radio", name="Other time windows").click()
def windows(page, overlap=False):
open_tariff(page)
add = page.get_by_role("button", name="+ Add a time window")
rows = (("07:00", "23:00" if overlap else "22:00", "mon-fri", "0.34"),
("22:00", "07:00", "mon-fri", "0.26"),
("00:00", "24:00", "sat,sun", "0.24"))
for i, (a, b, days, price) in enumerate(rows, start=1):
add.click()
page.get_by_label(f"Window {i}, From").fill(a)
page.get_by_label(f"Window {i}, To").fill(b)
page.get_by_label(f"Window {i}, Days").select_option(days)
page.get_by_label(f"Window {i}, Price").fill(price)
page.locator("[data-key='ui.periods']").scroll_into_view_if_needed()
def main():
os.makedirs(OUT, exist_ok=True)
with sync_playwright() as p:
b = p.chromium.launch()
shot(b, "home-running", "running", "#home")
shot(b, "home-running-nl", "running", "#home", lang="nl-BE")
shot(b, "home-shadow", "shadow", "#home")
shot(b, "home-phone", "running", "#home", size=PHONE)
shot(b, "home-setup-only", "setup", "#home", full=False)
shot(b, "settings-installer", "running", "#settings")
shot(b, "settings-installer-nl", "running", "#settings", lang="nl-BE")
tariff = "details.grp[data-g=tariff]"
shot(b, "settings-time-windows", "running", "#settings",
act=windows, only=tariff)
shot(b, "settings-time-windows-overlap", "running", "#settings",
act=lambda page: windows(page, overlap=True), only=tariff)
shot(b, "setup-step-1", "new", "#setup")
shot(b, "setup-step-1-nl", "new", "#setup", lang="nl-BE")
b.close()
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
#!/bin/sh
# UX-13 P5: regenerate the DOCS screenshots in docs/img/ux13/ from the real page.
#
# sh goodwe_controller/tools/ux13_screenshots.sh [OUT_DIR]
#
# Serves tools/ux13_demo.py's fixtures (running, shadow, new, setup) in the CI
# test image, then runs tools/ux13_screenshots.py in the Playwright image on a
# private Docker network (the image has the browsers; the matching Python
# package is installed into the container on each run). Nothing talks to Home Assistant or an inverter. The
# prices and the "now" line follow the clock, so the pictures change a little
# from run to run. Needs Docker and PyPI access; pulls the Playwright image once.
set -eu
HERE=$(cd "$(dirname "$0")/.." && pwd)
OUT=${1:-$HERE/../docs/img/ux13}
PW=mcr.microsoft.com/playwright/python:v1.55.0-noble
NET=ux13-shots-$$
STATES="running shadow new setup"
mkdir -p "$OUT"
OUT=$(cd "$OUT" && pwd)
cleanup() {
for s in $STATES; do docker rm -f "$NET-$s" >/dev/null 2>&1 || true; done
docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
docker build -q -t goodwe-addon-checks:1 "$HERE/../.gitea/ci" >/dev/null
docker network create "$NET" >/dev/null
ENVS=""
for s in $STATES; do
docker run -d --name "$NET-$s" --network "$NET" -e PYTHONDONTWRITEBYTECODE=1 \
-v "$HERE:/w:ro" goodwe-addon-checks:1 \
python3 /w/tools/ux13_demo.py "$s" 8099 0.0.0.0 >/dev/null
ENVS="$ENVS -e DEMO_$(echo "$s" | tr a-z A-Z)=http://$NET-$s:8099/"
done
sleep 3
# shellcheck disable=SC2086
docker run --rm --network "$NET" --user "$(id -u):$(id -g)" -e HOME=/tmp \
$ENVS -v "$HERE/tools:/t:ro" -v "$OUT:/out" "$PW" \
sh -euc 'pip install -q --disable-pip-version-check --target /tmp/pw \
playwright==1.55.0
PYTHONPATH=/tmp/pw python3 /t/ux13_screenshots.py /out'
echo "screenshots in $OUT"
+34 -6
View File
@@ -10,8 +10,9 @@ own validator run over them.
The wizard edits the page model one step at a time and saves it through
uipage.to_options(), like the Installer view. `blobs()` walks each step's
choices (meter source and sign, one to three inverters, solar rows, every
contract and price source, region, peak limit, shadow mode) from a new
install and from the TEST-23 sites, and keeps what the shipped validator
contract and price source, time windows (P5), region, peak limit, shadow
mode) from a new
install as shipped, one with a power request entity, and the TEST-23 sites, and keeps what the shipped validator
accepts, because the wizard saves nothing it refuses. test_setup.py checks
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
offline, as the P2 test did. It touches nothing: no network, no Supervisor.
@@ -103,6 +104,29 @@ PRICES = {
"csv": {"ui.contract": "dynamic", "price_provider": "manual_csv",
"price_csv_path": "/share/prices.csv", "ui.fallback": "fixed",
"ui.fixed_price": 0.3},
# P5: the time-window editor, odd windows the day/night preset cannot say.
"periods": {"ui.contract": "custom", "ui.fallback": "custom",
"ui.periods": [
{"from": "06:30", "to": "21:15", "days": "mon-fri",
"price": 0.31},
{"from": "21:15", "to": "06:30", "days": "mon-fri",
"price": 0.19},
{"from": "00:00", "to": "12:00", "days": "sat",
"price": 0.2},
{"from": "12:00", "to": "24:00", "days": "sat",
"price": 0.25},
{"from": "00:00", "to": "24:00", "days": "sun",
"price": 0.18}]},
"periods_one": {"ui.contract": "custom", "ui.fallback": "custom",
"ui.periods": [{"from": "00:00", "to": "00:00",
"days": "mon-sun", "price": 0.29}]},
"dynamic_periods": {"ui.contract": "dynamic",
"price_provider": "generic_dayahead",
"ui.fallback": "custom", "ui.periods": [
{"from": "07:00", "to": "23:00",
"days": "mon-sun", "price": 0.32},
{"from": "23:00", "to": "07:00",
"days": "mon-sun", "price": 0.21}]},
}
REGIONS = {"flanders": {"ui.region": "flanders"},
"own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}}
@@ -139,6 +163,9 @@ def plans():
"shadow": "on"},
{"meter": "mqtt", "roster": "two", "solar": "none",
"prices": "formula", "region": "flanders", "peak": "low",
"shadow": "on"},
{"meter": "dsmr", "roster": "two", "solar": "one",
"prices": "periods", "region": "flanders", "peak": "forecast",
"shadow": "on"}):
seen.append(plan)
return seen
@@ -177,13 +204,14 @@ def apply(model, plan):
def starts():
"""(name, stored options) the wizard opens on: a new install (config.yaml's
defaults and one power request entity, typed in the Configuration tab so
the add-on starts) and the TEST-23 sites."""
"""(name, stored options) the wizard opens on: a new install as shipped
(config.yaml's defaults, served in setup-only mode since UX-15), one with a
power request entity typed in the Configuration tab, and the TEST-23
sites."""
sys.path.insert(0, os.path.join(HERE, "tools"))
import ux13_demo as D
new = dict(D.defaults(D.CONFIG), setpoint_entity="number.inv1_power")
out = [("new", new)]
out = [("empty", D.defaults(D.CONFIG)), ("new", new)]
for f in sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
"*.json")))[:3]:
out.append((os.path.basename(f)[:-5], dict(D.defaults(D.CONFIG),
+3
View File
@@ -82,6 +82,9 @@ configuration:
estop_fitted:
name: "Hardware · RS485 emergency stop is fitted"
description: "Switch this on if the emergency stop is installed. It only changes a warning on the status page."
battery_modes_enabled:
name: "Hardware · Battery modes"
description: "Give each battery an on and off switch and a mode, set on the Batteries screen."
pv_entity:
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."
+3
View File
@@ -63,6 +63,9 @@ configuration:
estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst"
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
battery_modes_enabled:
name: "Hardware · Batterijmodi"
description: "Geef elke batterij een aan- en uitschakelaar en een modus, in te stellen op het scherm Batterijen."
pv_entity:
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."