Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffc9a4d721 | ||
|
|
d69fd8fbfd | ||
|
|
a5dddadf45 | ||
|
|
ede01b8a04 | ||
|
|
667b8e9145 | ||
|
|
8c34647d05 | ||
|
|
39e6c260fe | ||
|
|
30cba1e58d | ||
|
|
63e7e8a133 | ||
|
|
159d7461d8 | ||
|
|
4de96752ff |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -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).
|
||||
@@ -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. |
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||