Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
516bc8b4ec | ||
|
|
4f25b24aa6 | ||
|
|
044fc0d5bd | ||
|
|
f967073cfe | ||
|
|
af39c10893 | ||
|
|
6eb67c0f9c | ||
|
|
5d27f8496f | ||
|
|
5a4cdc273e | ||
|
|
7104515229 | ||
|
|
6dab4ec16b | ||
|
|
b014fd2e16 | ||
|
|
902524ae5e | ||
|
|
7a00f12c76 | ||
|
|
ce0aea30b6 | ||
|
|
e6096d3e17 | ||
|
|
7337b791e5 | ||
|
|
308adc9277 | ||
|
|
c664e38ef4 | ||
|
|
0951cb227f | ||
|
|
7995e4df0f | ||
|
|
e5a0ba23d7 | ||
|
|
a9f7d78527 | ||
|
|
b310282729 | ||
|
|
7c4c8f5c66 | ||
|
|
2d2b6afb07 | ||
|
|
ed739d4cdb | ||
|
|
ffc9a4d721 | ||
|
|
d69fd8fbfd | ||
|
|
a5dddadf45 | ||
|
|
ede01b8a04 | ||
|
|
667b8e9145 | ||
|
|
8c34647d05 | ||
|
|
39e6c260fe | ||
|
|
30cba1e58d | ||
|
|
63e7e8a133 | ||
|
|
159d7461d8 | ||
|
|
4de96752ff | ||
|
|
3198f346fd | ||
|
|
be57b87c34 | ||
|
|
515c11b380 | ||
|
|
7be09197d4 | ||
|
|
1752adf6e6 | ||
|
|
0a28f71872 | ||
|
|
c9ab48d6fc | ||
|
|
28a49ccf75 | ||
|
|
06aa09e28c | ||
|
|
1a9c7523c9 | ||
|
|
a36dca60dd | ||
|
|
fad73d4012 | ||
|
|
5a8d832eba | ||
|
|
f8d63cc614 | ||
|
|
016ed5a5a3 | ||
|
|
afd6af068d | ||
|
|
badea3cb2d | ||
|
|
15627c38a6 | ||
|
|
652097abde | ||
|
|
3c0f2b9669 | ||
|
|
1d33265aac | ||
|
|
1f97c7652f | ||
|
|
d1f7e3336e | ||
|
|
3c298af3d8 | ||
|
|
c628099e69 | ||
|
|
29837dfaa7 | ||
|
|
c4032ebc21 | ||
|
|
2f1627e287 | ||
|
|
2b14b88cb4 | ||
|
|
e0b578756f | ||
|
|
f1a660881c | ||
|
|
23a3a3f5b5 | ||
|
|
125b0702a2 | ||
|
|
b878663b2d | ||
|
|
32b9e56357 | ||
|
|
569f16aa91 | ||
|
|
6a95447248 | ||
|
|
a02cd4b713 |
@@ -1,2 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
7081df4276ad3cf788355b97a3b3f40ea28d72bd
|
||||
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
# STYLE.md - how user-facing text is written (UX-08)
|
||||
|
||||
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Kinds that have not been rewritten yet are on its allowlist, and that allowlist only shrinks.
|
||||
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Its allowlist is empty since phase 2d and its cap is 0: a new string that breaks the rule fails the test.
|
||||
|
||||
## 1. Shape
|
||||
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
|
||||
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
|
||||
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
|
||||
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
|
||||
- **Log line** (WARNING/ERROR): one line, at most 25 words. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated.
|
||||
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words in `app/ui_text/<lang>.json` under `home_words`, built by `app/homeview.py`, checked by `test_messages.py` part 8. The page's own words, English and Dutch, are checked by `test_ui_text.py`.
|
||||
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
|
||||
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
|
||||
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
|
||||
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
|
||||
|
||||
## 2. Numbers
|
||||
@@ -48,8 +51,14 @@ A setting is named by its label in `translations/en.yaml`, in quotes when it sit
|
||||
| safety stop (a stop that only starting control clears) | failsafe latch |
|
||||
| software fault | BUG SIGNAL |
|
||||
| control board | master, ESP32 |
|
||||
| power request, what the add-on asks | command, setpoint (on the page) |
|
||||
| the power went the wrong way | sign disagreement, command and meter point opposite ways |
|
||||
| check for inverters working against each other | circulating power check |
|
||||
| month peak, 15-minute periods measured | billed quarter, banked, soft limit, open block |
|
||||
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. In shadow mode a text says what the add-on **would** send, never that it sent it. |
|
||||
|
||||
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
|
||||
|
||||
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
|
||||
|
||||
**Glossary.** DOCS.md "Words used on this page" explains, once, the words a home owner meets without context: control board, isolator, Developer Tools, shadow mode, safety stop, peak limit, peak guard, peak reserve and cut-out level. A home-owner text may use them without explaining them again. Card ids stay out of DOCS.md headings: they go on a *For installers* line under the heading.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# UX-13 screenshots
|
||||
|
||||
Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not
|
||||
edit them by hand: run the script again.
|
||||
|
||||
- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup.
|
||||
- `settings-*.png`: Settings, Simple and Installer, and the time-window editor with an overlap.
|
||||
- `batteries*.png`: the Batteries screen with modes on, in Dutch, an Off battery helping the peak limit, a refusal at phone width, and modes off.
|
||||
- `setup-step-1*.png`: Setup, step 1.
|
||||
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -285,32 +285,11 @@ cross-inverter `lo > hi` contradiction at `arbiter.py:114`. The only line this
|
||||
card changed there is **which** roster entry the claim is filed on: the one whose
|
||||
state machine produced the result, rather than `inverters[0]`.
|
||||
|
||||
## 6. FOUND AND NOT FIXED: the site loop still contradicts a drain at N>=2
|
||||
## 6. FIXED BY SAFETY-78: the site loop contradicted a drain at N>=2
|
||||
|
||||
Measured while wiring this card, and **pre-existing on `release/1.0` at
|
||||
`2d025ee`** — the same drain filed on `inverters[0]` produces the identical
|
||||
refusal with the two unit names swapped.
|
||||
|
||||
The maintenance SET **pins** its pack. The loop's target is a **site** claim.
|
||||
When the site target is *below* the pin — a 2000 W drain while the house imports
|
||||
less than that, which is most of a night — the only split that sums to the site
|
||||
target asks the sibling to **charge** while the maintained pack **discharges**,
|
||||
and `allocator.plan()` refuses it by name as circulating power. The refusal is
|
||||
correct and fails safe: the whole site goes to 0 W with an `ARBITER: REFUSED`
|
||||
line in the action log.
|
||||
|
||||
**The consequence an operator would see is a misdiagnosis.** The pack is held at
|
||||
0 W while its state machine believes it is draining, so after 12 h the DRAIN
|
||||
timeout fires and reports *"drain stuck"* — §12's words for a battery that is not
|
||||
following commands. The battery is fine; the site refused to allocate.
|
||||
|
||||
**Why it is not fixed here.** The fix is to change what the site loop's target
|
||||
*means* while a unit is pinned — regulate the meter using only the unpinned
|
||||
packs — which is loop and allocator territory and changes the integrator's
|
||||
subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
|
||||
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
|
||||
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
|
||||
its own.
|
||||
Found here and pre-existing: below the pin the other pack had to charge, the
|
||||
allocator refused it and the site sat at 0 W. Fixed by a site `drain` floor;
|
||||
see [[SAFETY-78]] (`DECISIONS/SAFETY-78-drain-at-two.md`).
|
||||
|
||||
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# OPT-11 - battery modes, per battery
|
||||
|
||||
A battery's mode (on/off, Solar first, Leftover solar) is a soft unit limit at
|
||||
`P_MODE` = 35 that always holds 0 W. A SET above 35 (a safety stop) overrides
|
||||
it; a limit above 35 (the peak guard) makes it give way only as far as that
|
||||
limit needs, and a battery its mode holds back then carries only what the
|
||||
others cannot (`arbiter._unit_modes()`, `_site_mode()`). Design and owner answers: `goodwe-session/OPT-11-design.md` (2026-09-27);
|
||||
P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
|
||||
- Off by default (`battery_modes_enabled`); off, commands are byte-identical to
|
||||
a5dddad (`test_modes.py` part 1).
|
||||
- Off is a mode range, so the peak guard can still use an Off battery when the
|
||||
others cannot cover the floor (design sections 2-3). Whether Off should be
|
||||
absolute is an open owner question.
|
||||
- Review (PR #149): releasing only when the peak floor bound S let a trade won
|
||||
above the floor keep the modes and place less than the floor, and let a
|
||||
capped grid charge release an Off battery. Both are graded in
|
||||
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the
|
||||
rail to the rail, so SAFETY-03 still flags a loop winding into it.
|
||||
- P2 (HA entities): a select and a switch per battery, only while modes are
|
||||
on; commands go through `handle_action()`, the page's path. A retained
|
||||
command is dropped, not applied: the store already holds the last choice,
|
||||
and a replay after a restart could otherwise undo a newer one. The config
|
||||
topics announced are kept in the store (`mqtt_mode_configs`), so turning
|
||||
modes off removes exactly those, and an install that never turned them on
|
||||
publishes what ed739d4 did (`test_mqtt.py` part 10). A removed topic stays
|
||||
in that list and its empty config is re-sent on each start, so a start with
|
||||
no broker cannot lose it; re-sending is harmless. The HA options are the
|
||||
page's English names, pinned by a test, and are not renamed with the page:
|
||||
automations send them.
|
||||
- In P1 only Off closes a direction; Leftover solar differs by charging last.
|
||||
- P3 (price runs, `priceruns.py`, `modetrade.py`): each battery's range is
|
||||
narrowed by its role this tick (hold, give, charge, rest, sell); main.py
|
||||
files the intersection with the switch, so it still holds 0 W. With modes on
|
||||
the site trade is filed by the modes, sized from the loop bound (so no
|
||||
battery a mode or a refusal holds is asked for watts, P1's unmet share) and
|
||||
the guard's headroom; `arbitrage.decide()` runs only with modes off, which
|
||||
stays byte-identical to 7a00f12 (`test_modes_p3.py` part 1).
|
||||
- SAFETY-46 item 2 is decided as the owner answered (2026-09-25): a trade is
|
||||
a run of consecutive firm slots. It applies with battery modes on; the
|
||||
modes-off trade keeps its one-period shape, because that path must not move.
|
||||
- Choices that were measured, not assumed (tools/opt11p3_replay.py):
|
||||
- a sale needs the guard to have measured the quarter, not to be clear: a
|
||||
5 kW discharge alone reads "engaged" on export and flipped the sale every
|
||||
5 s;
|
||||
- a grid charge makes the others rest, so it starts only at 500 W and, once
|
||||
the guard or the headroom stops it, stays off for that 15-minute period
|
||||
(without: 13 flips in 4 minutes under a 3.5 kW house);
|
||||
- stored energy is valued at the last cheap run for 12 h, and a charge
|
||||
needs its own run to clear the spread (without: a night charge was spent
|
||||
on flat morning prices, then charging went on at them);
|
||||
- a run edge reseeds the loop at the house's level (a 3 kW import spike
|
||||
removed; about 0.1 x rating still overshoots as export).
|
||||
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
|
||||
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
|
||||
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
|
||||
trade 18.14. Review of #153 (same model, with a per-action split):
|
||||
- the cheapest hours were at midday on 15 of 16 days, so Leftover solar (acting
|
||||
as Cheap hours until P4) bought 12.2 kWh there that the sun would have
|
||||
given: 7 kWh more exported, 12 kWh less solar stored. Holding for the
|
||||
expensive hours saved 4 kWh of import, not enough. With no grid charge on
|
||||
Leftover solar: 19.44;
|
||||
- the old trade charged one hour, so the afternoon sun still fit;
|
||||
- a sale earned the wholesale price (2.27 EUR for 11.4 kWh) and the house
|
||||
then bought the same energy back (2.29 EUR): a wash before wear. It pays
|
||||
only when the sale hour's wholesale price beats the later import price,
|
||||
levies included (about 0.13 EUR/kWh above that hour's wholesale here);
|
||||
- valuing the charge left at the end, wear and real Flemish levies make
|
||||
selling worse and do not change the order.
|
||||
- Owner decision 2026-09-29 (option A): selling is off until it can be judged
|
||||
on the price the grid pays (`packmode.SELLING_ENABLED`, code kept), and
|
||||
Leftover solar takes solar only until P4's forecast
|
||||
(`packmode.GRID_CHARGE_MODES`); Cheap hours only still charges from the grid. Replayed
|
||||
(16 days, as above): Solar first + Leftover solar 19.44 EUR net against
|
||||
P2's 19.33; Solar first + Cheap hours only 19.57.
|
||||
- Not done in P3: the forecast (P4), and a headroom too short for the run
|
||||
moving the rest to the next cheap periods.
|
||||
@@ -0,0 +1,110 @@
|
||||
# SAFETY-52 - a battery that refuses is asked for 0 W
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
|
||||
questions answered yes.**
|
||||
|
||||
## The defect
|
||||
|
||||
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
|
||||
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
|
||||
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
|
||||
inside the interval a limit can produce. The request stood at +5000 W for
|
||||
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
|
||||
moved again only when the grid error changed sign. SAFETY-48's lead cap only
|
||||
changes where `i_w` parks (one slew step ahead instead of at the rail). When
|
||||
the request moved after the reading went flat, the stale-reading gate won
|
||||
instead and ran SAFETY-59's ~70 s trip cycle.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
|
||||
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
|
||||
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
|
||||
`saturation_w`. The drop to 0 skips
|
||||
the slew limit (it only removes an ask nobody meets). The direction is
|
||||
carried like `sat_count` (`refused=`, `Decision.refused`).
|
||||
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
|
||||
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
|
||||
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
|
||||
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
|
||||
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
|
||||
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
|
||||
cycle. The log keeps SAFETY-56's two-explanation wording.
|
||||
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
|
||||
|
||||
Other exits: the loop wants the other direction (charge is accepted at the
|
||||
floor), control stopped or started, another writer on the wire (the arbiter
|
||||
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
|
||||
what seeds it.
|
||||
|
||||
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
|
||||
battery's discharge end and moves its share. There, a one-unit gate trip must
|
||||
not refuse the site. `compute()` is passed `refused=None` there, and that path
|
||||
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
|
||||
`test_control.py` still holds).
|
||||
|
||||
## Evidence
|
||||
|
||||
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
|
||||
shapes, recovery with no sign change, the probe cadence, every exit).
|
||||
`tools/replay_refusal.py` replays the 16-day capture, same model as
|
||||
`replay_recorded_loop.py`, and should be read for deltas:
|
||||
|
||||
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
|
||||
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
|
||||
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
|
||||
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
|
||||
|
||||
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
|
||||
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
|
||||
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
|
||||
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
|
||||
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
|
||||
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
|
||||
just above its floor, the probe is answered, control ramps, the pack empties
|
||||
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
|
||||
follow an other-direction exit the same way. On a site this is one
|
||||
`STALE_READING` ERROR line per answered probe at most, and only on a battery
|
||||
that answers a probe and then cuts out again. Catching the re-cut before the
|
||||
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
|
||||
battery, so it is left to SAFETY-58/59. After the review changes below the
|
||||
fitted-plant row reads 133 trips (the delivery exit now also catches answers
|
||||
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
|
||||
the phase A plan: bounding the integrator by demand made the worst quarter
|
||||
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
|
||||
configured floor to be right and needs SoC.
|
||||
|
||||
## Review changes (#141)
|
||||
|
||||
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
|
||||
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
|
||||
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
|
||||
old law. The capture has 0.11 h in that band, so the replay does not move.
|
||||
- **Any delivery ends the hold, not only an answered probe.** With
|
||||
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
|
||||
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
|
||||
held it for 15 min. Now the late reading ends it within seconds (2 s held in
|
||||
`test_refusal.py` part 5b).
|
||||
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
|
||||
battery only when its cut-out level is configured right.
|
||||
|
||||
## Not covered
|
||||
|
||||
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
|
||||
genuine limit (SAFETY-13 case a) and still holds its request.
|
||||
|
||||
## What SAFETY-58 and SAFETY-59 must take into account
|
||||
|
||||
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
|
||||
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
|
||||
the first verdict, the seed arrives later. The 09-19 shape then needs the
|
||||
compute-side engagement (12 cycles) to win, which it does only while the
|
||||
request stands still. Keep `test_refusal.py` part 4 green.
|
||||
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
|
||||
change a refusing battery no longer produces the pattern at one inverter. The
|
||||
DOCS text should say so, and that the `battery refuses` reason is the
|
||||
discriminator the add-on now has. At N>1 the old pattern can still occur.
|
||||
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
|
||||
`test_refusal.py` part 2 checks the inequality.
|
||||
@@ -0,0 +1,104 @@
|
||||
# SAFETY-73 - a battery that refuses, with two or more inverters
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option R). All four
|
||||
new questions answered yes. SAFETY-52's four decisions carry over.**
|
||||
|
||||
## The defect
|
||||
|
||||
SAFETY-52 held a refusing battery at 0 W at one inverter only. At N>1
|
||||
(`main.py` passed `refused=None`) nothing closed a unit that gives ~0 W while
|
||||
its state of charge reads above its configured cut-out, so the allocator kept
|
||||
handing it its weighted half. Measured on the real `Controller`, two units,
|
||||
house 3000 W, A cut out:
|
||||
|
||||
| register | law | B gives | grid | A's gate trips / h |
|
||||
|---|---|---|---|---|
|
||||
| flat -4 W | old | 533 W | +2471 W | 46 (each stops the whole site) |
|
||||
| flat -4 W | new | 2000 W (its rating) | +1004 W | 1 |
|
||||
| jitter | old | 1910 W | +1094 W | 0 |
|
||||
| jitter | new | 2000 W | +1004 W | 0 |
|
||||
|
||||
House 1200 W, which B alone carries: old +671 W (flat) and +284 W (jitter)
|
||||
standing import, new -16 W. Both cut out: old keeps asking two empty batteries
|
||||
(+1910 W each on the jitter shape), new holds the site at 0 W.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
|
||||
claim, source `refused`. It engages when the unit gives under
|
||||
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least
|
||||
`REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W`
|
||||
(200 W), or when its stale-reading gate
|
||||
trips on a held reading under 100 W. The allocator then hands its share to
|
||||
the others within their own limits (owner decision 1). It is a hard unit
|
||||
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
|
||||
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
|
||||
arbitrage and maintenance are placed around it like the loop.
|
||||
3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending
|
||||
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
|
||||
unit: its share is 0 W, and `account()` treats it as parked.
|
||||
4. **All units refusing the same way** pass that sign to `compute()`, so the
|
||||
site holds 0 W with SAFETY-52's law (decision 4). Probes go one unit at a
|
||||
time.
|
||||
5. **Probe:** every 15 min for 34 s the claim becomes a pin at +-200 W and the
|
||||
others carry the rest. The pin stands only while the resolved site target
|
||||
asks at least 200 W its way; otherwise it would make a sibling take the
|
||||
opposite direction, so it falls back to the closed limit for that cycle.
|
||||
6. **Exits:** a reading of 100 W or more in the held direction (probe or not);
|
||||
100 W or more taken the other way for 12 cycles (charge at the floor; one
|
||||
cycle of the loop overshooting through zero is not enough); stop or start.
|
||||
On every exit the site target and integrator restart from the watts on the
|
||||
wire, or the reopened unit is handed a parked remainder at once.
|
||||
|
||||
**Why 200 W and not `saturation_w`.** A unit's share is often small: a 400 W
|
||||
night load split two ways asks 200 W of each. With 500 W the empty battery
|
||||
kept a phantom share that SAFETY-43's relative arm then reported as not
|
||||
delivering, the ERROR decision 3 replaces. Shares of 200 W or less that give
|
||||
under 100 W are "too small to call" in `account()`: the sibling covers the
|
||||
meter and nothing is asked of the empty battery that matters.
|
||||
|
||||
**Why one direction throughout.** On TEST-19's flapping family a command that
|
||||
swaps sign each cycle reads ~0 through the plant's lag: 42 engagements in 21
|
||||
no-fault runs without the rule, 0 with it.
|
||||
|
||||
**N=1 is unchanged.** Every SAFETY-73 path is gated on `len(inverters) > 1`.
|
||||
`test_refusal_units.py` part 6 runs SAFETY-52's whole suite with every
|
||||
SAFETY-73 method made to raise.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `test_refusal_units.py`: cases a, b and c on both register shapes against
|
||||
the old behaviour, probes and their spacing, one-at-a-time probing, the
|
||||
recovery reseed, each exit, the small-ask and small-site-target cases, the
|
||||
WARNING, the status row and shadow mode. Mutations U1-U13 in its docstring.
|
||||
- **No false engagements.** TEST-19's no-fault campaign (admin/goodwe
|
||||
`sim/delivery_window.py` families, 2640 runs at N=2 and 2640 at N=1, every
|
||||
unit executing its share) graded with the engage clause: 0 engagements, the
|
||||
longest qualifying streak 5 cycles against 12. The grader is
|
||||
`goodwe-session/scratch_s73/s73_nofault.py`.
|
||||
- **Recovery.** Case a, A recovering into a house that fell below the parked
|
||||
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
|
||||
reseed and the site hold.
|
||||
|
||||
## Review changes
|
||||
|
||||
- **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12
|
||||
cycles were 11 s, shorter than a healthy reading's lag (dead time plus a
|
||||
10-17 s publish). The reviewer's harness (real `Controller`, both units with
|
||||
6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then
|
||||
refused both healthy units 10-16 times an hour, and each time the site held
|
||||
0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without
|
||||
the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is
|
||||
`(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged.
|
||||
- **The cut-out WARNING is for a refusal to give power only.** A full battery
|
||||
that stops taking charge is always above its cut-out level, and the WARNING
|
||||
told the installer to check a setting that has nothing to do with it.
|
||||
|
||||
## Not covered
|
||||
|
||||
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
|
||||
N=1. SAFETY-42/43 still report it.
|
||||
- `pack_permissions` stays SoC-based, so the peak guard may size a shave with a
|
||||
refusing battery counted. The shortfall shows as `unmet_w`.
|
||||
- A wedged unit (HW-01) reading ~0 W is now shown as a refusal, not reported as
|
||||
not delivering. Its share still moves, which is the gain SAFETY-42 measured.
|
||||
@@ -0,0 +1,44 @@
|
||||
# SAFETY-78 - a maintenance drain at two inverters
|
||||
|
||||
**Status: DECIDED 2026-09-29.** Fixes MAINT-05 §6 (OPT-11 P1 F1, UX-13 P3
|
||||
check 5, OPT-11 P3 F4). Tests: `test_safety78.py`.
|
||||
|
||||
**The defect.** At N>=2 the drain pins its pack (a unit SET at
|
||||
`P_MAINTENANCE`) and the site target is split around the pin. Whenever the
|
||||
site target was below the pin, the other pack had to charge. The allocator
|
||||
refuses opposite directions (SAFETY-25 §0a, SAFETY-31), so the site went to
|
||||
0 W and logged `ARBITER: REFUSED` every cycle. That covers a house below the
|
||||
drain rate, a grid-charge trade, and a house above it too: after a refusal the
|
||||
loop restarts from 0 W and its 400 W slew step is below the pin again.
|
||||
|
||||
**The fix.** While a pack drains at N>=2, main.py files a site limit
|
||||
`drain`, `lo` = what the pin really places (`allocator.pin_w()`: the drain
|
||||
rate inside that pack's own hard limits), at `P_MAINTENANCE`. So the site
|
||||
target is never below the pin and the other pack only adds power in the same
|
||||
direction. Opposite splits stay forbidden.
|
||||
|
||||
- The order is the arbiter's own: a safety SET (100) outranks the limit and
|
||||
the pin, so everything parks; the peak floor (50) and the drain floor are
|
||||
both applied, so the higher wins; a trade or the loop below the drain is
|
||||
lifted to it.
|
||||
- Below the drain rate the drained pack exports the rest, as the drain does at
|
||||
N=1 (`maintenance.py`: "forced discharge, exports the surplus"). Holding it
|
||||
to the house load would make N=2 drain differently from N=1; that is an
|
||||
owner question, not this fix.
|
||||
- A draining pack that can place nothing (a refusal, a stale link) adds no
|
||||
floor, and the wait is logged once per episode.
|
||||
- With battery modes on, no pack is a grid charger while another drains
|
||||
(`modetrade.decide(draining=)`, why `cycle`), and the other battery's card
|
||||
says why (`other_cycle`). Not while the drain waits: no floor is filed
|
||||
then, so the other battery may charge from solar.
|
||||
- Not filed at N=1, where the drain is the site SET itself: byte-identical
|
||||
(`tools/safety78_identity.py`, which hashes the claims too).
|
||||
|
||||
**Not changed (measured in the PR #154 review, follow-ups).**
|
||||
- At N=1 a drain (60) outranks the peak floor (50), so the guard does not add
|
||||
to it: a 5000 W house imports 3000 W against a 2500 W limit, a 6000 W house
|
||||
4000 W. Pre-existing, kept by the N=1 identity.
|
||||
- At N=2 a maintenance CHARGE in a cheap window has the same stall: the
|
||||
charging pack's `hi = -floor` against the loop asking the other to give is
|
||||
refused on 141 of 261 cycles. The mirror fix is a site `hi`, which can meet
|
||||
the peak floor's `lo` (0 W), so it is not this card.
|
||||
@@ -0,0 +1,41 @@
|
||||
# SAFETY-79 — the peak guard keeps a margin below the peak limit
|
||||
|
||||
**Status: DECIDED 2026-09-28 (owner decision).**
|
||||
|
||||
## Decision
|
||||
|
||||
The peak guard aims `peak_margin_w` under the limit it holds: under
|
||||
`peak_cap_w`, or under a month peak already banked above it
|
||||
(`quarter_target_w()`). `peak_margin_w` is a setting: default 50 W, range
|
||||
0-500 W, whole watts. An install without the key gets 50.
|
||||
|
||||
**Why (owner):** the guard aimed exactly at the cap, and in the OPT-11 P1 test
|
||||
a quarter banked 3501.9 W against a 3500 W limit (OPT-11-P1-test-report.md,
|
||||
check 4). A peak even 1 W over still sets the billed peak. It is a setting and
|
||||
not a constant because the add-on is sold to several sites.
|
||||
|
||||
**Banked peak (review, 2026-09-29):** the PR first aimed at a banked month
|
||||
peak as it stood. That can bank a few W over it, which raises the bill the
|
||||
same way; and a peak banked between cap - margin and the cap (common in the
|
||||
month an install upgrades, since the old guard held quarters at the cap) was
|
||||
aimed at exactly, so one overshoot went over the cap. The margin now applies
|
||||
under it too. Cost: up to `peak_margin_w` of a peak already paid for goes
|
||||
unused (more discharge, less charge headroom); no bill cost. Undo is one line
|
||||
in `quarter_target_w()`.
|
||||
|
||||
## What uses which number
|
||||
|
||||
- **the aim:** `peakguard.decide()` (the claim's floor, and so arbitrage's
|
||||
`charge_headroom_w()` and OPT-11's give-way, which read it), and, as cap -
|
||||
margin, `shaping_suspended()` and `maintenance_charge_floor()` (the forecast
|
||||
checks, via `Controller.peak_aim_w()`).
|
||||
- **the real cap (reporting and billing):** `PeakTracker` (month peak,
|
||||
headroom, 80 % warning), the decision's `ceiling_w` and every notice and
|
||||
status row built from it, Home's limit, and `validate.config_notes()`.
|
||||
|
||||
## Checks
|
||||
|
||||
`peak_margin_w: 0` is byte-identical to ed739d4 (test_peak_margin.py part 1,
|
||||
digests from tools/safety79_identity.py, which also runs a maintenance charge
|
||||
with a forecast). validate.py rule 12 refuses a value outside 0-500, not
|
||||
whole, or not below `peak_cap_w`.
|
||||
@@ -0,0 +1,63 @@
|
||||
# SAFETY-81 — no false "Load too high for battery" alarms
|
||||
|
||||
**Status: 2026-09-29, owner go for the card. Review kept the 10 W tolerance
|
||||
as a constant and added the 10 s hold.**
|
||||
|
||||
## Defect
|
||||
|
||||
The SAFETY-79 test (SAFETY-79-test-report.md, F1/F2) saw 6 false alarms in 7.
|
||||
The alarm fired whenever `decide()` clamped its floor to the rating
|
||||
(`saturated`). Three things set that while the quarter landed on target:
|
||||
|
||||
- **Double count.** `decide()` sizes from the held meter reading plus the
|
||||
battery reading. When the meter sample predates the battery's last increase,
|
||||
the increase is counted twice. It happens under noise, and at each quarter
|
||||
start, where the battery is back while the held sample is still the spike.
|
||||
- **The aim, not the cap.** `saturated` is graded against cap − margin, so at
|
||||
margin 50 noise needs 50 W less to trip it (F2).
|
||||
- **Last seconds.** The gain 900 / remaining turns a few W over the aim into
|
||||
thousands of W wanted, with the battery far from its rating.
|
||||
|
||||
## Decision
|
||||
|
||||
The alarm needs `saturated` and `peakguard.load_too_high()`: with the battery
|
||||
at full power from now on, the quarter still ends more than `ALARM_MISS_W`
|
||||
(10 W) over the real limit (`ceiling_w`). That is the miss itself, without the
|
||||
gain. The battery reading it uses is the lower of this cycle's and the last
|
||||
one's. It must hold for `ALARM_HOLD_S` (10 s). At most one notification per
|
||||
quarter, because near the threshold the test flickers with the meter; the
|
||||
ERROR log line is written for every episode.
|
||||
|
||||
**Why 10 W:** at margin 0 the guard itself lands up to +5.7 W over (SAFETY-79
|
||||
test, check 5), and those misses are real but tiny; the alarm would fire in
|
||||
the last second of most guarded quarters. The margin (default 50) is the fix
|
||||
for them. Cost: a real miss of 10 W or less is not alarmed; at a capacity
|
||||
tariff of the order of EUR 50 per kW a year, 10 W in one month is cents.
|
||||
Review measured +13 W on a 10 kW two-unit site at margin 0, not saturated, so
|
||||
no alarm: the tolerance only matters when the battery is at its rating. A
|
||||
constant, not an option: `peak_margin_w` is the owner's knob.
|
||||
|
||||
**Why the hold (review):** the test assumes the load now lasts the rest of
|
||||
the quarter. A load over cap + battery for ~5 s every 30 s (`sine4500_fast`,
|
||||
a cycling hob or heater) alarmed at each quarter start while the quarter
|
||||
landed under the aim. The hold stops that.
|
||||
|
||||
**Cost:** a real alarm comes 10-12 s later than on 5a4cdc2 (`step6500` at
|
||||
1211 s, not 1201 s); a slow ramp 20 s later (`ramp6500`), as it waits for the
|
||||
real cap. While the battery refuses or its reading is lost, the alarm waits
|
||||
until the quarter is lost even at full battery (27-60 s later in review runs);
|
||||
those faults have their own alarms. A load over cap + battery for more than
|
||||
10 s still alarms even if the quarter later lands under. `ALARM_HOLD_S = 0`
|
||||
removes the hold.
|
||||
|
||||
## Not changed: the 1 s spike itself
|
||||
|
||||
The cycle after a quarter boundary and before the new quarter's first meter
|
||||
sample still sees 0 s left, files no claim, and an Off battery drops to 0 W for
|
||||
one cycle. That is the same edge tick as OPT-15 F1, so it is left to OPT-15.
|
||||
Control is unchanged here: every command in `tools/safety81_sim.py` is
|
||||
byte-identical to 5a4cdc2 (test_safety81.py part 1).
|
||||
|
||||
The double count also moves control, not only the alarm: on a late sample the
|
||||
guard asks for the full rating for one cycle (edge4600 at 902 s: 2000 W where
|
||||
1150 W holds the aim). Left to a follow-up card.
|
||||
@@ -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.
|
||||
|
||||
@@ -44,3 +44,49 @@ Validator refusals, the parse errors they carry, and every option name and descr
|
||||
2. **Citations moved to DOCS.** Option descriptions say "Measured value, leave it unchanged" or similar, and the record behind the value is under "For installers: where the numbers come from". The UX-01 citation check in `test_translations.py` now checks the tab for that instruction and DOCS for the record.
|
||||
3. **What left the refusals:** the shipped default in the peak-reserve reason, the superseded-default note in the levy-table reason (DOCS already covers it), the endpoints of the withdrawn providers (DOCS "Energy price providers"), the plant measurement behind `slew_w` (DOCS, beside the setting), and the leg-specific paragraphs of the tariff-formula errors. Each reason keeps the rejected value, the limit and what to change.
|
||||
4. **Not rewritten:** errors that only a code defect can raise, such as plugin-contract and argument-type checks in `price.py`, `price_forecast.py`, `static_tariff.py`, `arbitrage.py` and `main.py`, which a home owner never reads. INFO lines such as the tariff-formula preview were also left.
|
||||
|
||||
# UX-08 phase 2d: log lines, the rest of the status page, and DOCS
|
||||
|
||||
The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons (the semicolon rule now covers log lines too). **Unchanged:** every log level, every trigger and cadence, every notification id, every row's colour and when it shows, the banner cascade, and every refusal count.
|
||||
|
||||
1. **No code prefixes on log lines.** Nothing outside the repositories greps them, except `/home/homelab/clampwatch.sh`, a one-off site watch from 2026-09-05 that greps `OUTPUT_CLAMP` and `Engagements so far`. `OUTPUT_CLAMP` is still in the reason the line quotes; "Engagements so far" is now "Cut back N times so far". The tests that found a line by its card id (`SAFETY-37`, `SAFETY-44`, `CONTROL LOOP DIED`, `UNGRADED`) now find it by its words.
|
||||
2. **Reason texts keep their codes.** A line quotes a watchdog's reason (`%s`), such as `STALE_READING: …` or `OUTPUT_CLAMP: …`. Those are the evidence an installer searches for and many tests pin them, so they were not rewritten. The line around them is plain.
|
||||
3. **Reasoning moved to DEBUG, not deleted,** for the 10 lines marked DROP-to-DEBUG (for example the 14 768 W history behind the over-rating alarm, why SAFETY-42 reports and does not act, and the Eneco card advice). The 25-word line keeps the action and the numbers. SAFETY-57's promise stays on the ERROR line, as an ordered pair: "until it changes, or 17 s after the command drops below 250 W".
|
||||
4. **The line under the buttons** showed the newest raw action-log entry (`OUTPUT_CLAMP …`, `FAILSAFE …: SIGN_DISAGREEMENT`, `ALLOCATOR …`). Each `log_event()` call now also names a plain hint from `messages.HINTS`, and the page shows that. The action log itself is unchanged, because tests and later analysis read it, and the page no longer shows it. It stays reachable as `events` in the `status` JSON (review of #136), because some entries are written nowhere else: a dropped inverter's reason, the unplaced watts, the peak guard's projection and the shadow decision.
|
||||
5. **The top table.** *Why* is `messages.why()`: the arbiter's winner and limit in words ("Keeping the grid near zero: 440 W, held back by the peak limit"). `self.reason` keeps `Resolution.explain()` for the log and the checks. *Tariff*, *Price history*, *Arbitrage* and *Shadow mode* keep every figure TARIFF-11 and SAFETY-47 put there, each in its own slot, in words. Labels: *Battery level*, *Solar production*, *Power request*.
|
||||
6. **The month-peak row became two rows**, because one sentence of 15 words cannot hold the peak, its time, its share of the limit and TARIFF-13's coverage figure. The store's state at start (`restore_state`) left the row: it is the INFO line at start.
|
||||
7. **The peak guard rows** name the projection first and the limit second, as before (SAFETY-38). The blind row says what it means for the owner: the battery does not charge until the period can be measured again.
|
||||
8. **Wording that changed meaning-carrying words**: "command and meter point opposite ways" is now "the power went the wrong way" (banner) and "…went against what the add-on asked" (row). The N=1 banner still names the meter, the N≥2 texts still say "own power". The inverter range reads "up to 5 000 W each way" instead of "-5 000 W to 5 000 W".
|
||||
9. **Refusals are counted as shown.** Setting placeholders are expanded to label and key, and the long cases the 2c test found are driven through the validator. Seven refusals were shortened. The card refusals no longer show `c_eur_per_kwh`, `vat_pct` or "its own card", and a text that does not parse says "not valid near character N" instead of the parser's words. A missing value shows as *empty*, not `None`.
|
||||
10. **The panel's key match** no longer files `inverters.max_w` under the flat `max_w`.
|
||||
11. **The Supervisor question at start** is one INFO line with a real reason (a timeout's own text is empty, so the error type is named).
|
||||
12. **DOCS**: a glossary ("Words used on this page") explains control board, isolator and Developer Tools once. Card ids left the headings and sit on a *For installers* line under each. The links to the renamed headings were updated.
|
||||
|
||||
# UX-10: the last plain-language leftovers (after the 2d test)
|
||||
|
||||
**Unchanged:** every safety behaviour, every alarm's log level (WARNING and ERROR stay), every notification id, trigger and cadence, the banner cascade, and every row colour except the meter row right after a start (item 6).
|
||||
|
||||
1. **Why the 2d scan missed the clamp lines.** Part 3 reads a log call's own literal and counts `%s` as one word. The clamp lines are `"…software fault: %s …"`, and the text in `%s` was built elsewhere, by `loop_clamp_note()` and `ClampResult.describe()`. So the scan passed a line whose logged text had a semicolon, "BUG SIGNAL", "clamped" and "1 times". The same was true of every quoted reason. Part 6 closes the gap. It lists every dynamic part of every WARNING+ line as data (a number, a name, a path, a library's own error text) or as a built text, and a new part in neither list fails. Each built text is produced by the real code (the watchdogs, `decide()`, `loop_clamp_note()`, `MigrationReport.explain()`) or, for a family of builders, by every template of it (`PriceRead.no_data`, `Gate.block`). The text and the line as logged are then checked. A mutation shows the 2d clamp text is caught on both clamp lines.
|
||||
2. **A quoted reason gets the red-alarm shape**, 3 sentences of at most 30 words, and not the 25-word line limit. It is the evidence behind an alarm, and it keeps its code and its limits, which the owner asked to keep. The alarm shape is STYLE.md's existing rule for the texts behind a safety stop, so no new limit was made up. Rewritten: `OUTPUT_CLAMP` (both kinds), `METER_FROZEN`, `CIRCULATING_POWER`, `STALE_READING` (SAFETY-56's two explanations and "cannot tell which" kept, in order), the peak guard's reasons, the state-migration report, the price feed and price history reasons, and the maintenance and trading gate reasons. "Software fault" left the clamp reason, because the ERROR line around it already says it.
|
||||
3. **Codes are one token.** `PEAK GUARD`, `PEAK GUARD FLOOR-LIMITED` and `PEAK GUARD BLIND` read as capitals, so they are now `PEAK_GUARD`, `PEAK_GUARD_FLOOR_LIMITED` and `PEAK_GUARD_BLIND`, like `OUTPUT_CLAMP`. Nothing in admin/goodwe or in the scripts under `/home/homelab` greps them (checked). `/home/homelab/clampwatch.sh` greps `OUTPUT_CLAMP`, which stays.
|
||||
4. **Top table.** *Price history* writes "96 prices over 24 hours for 26 Sep", joined by commas. *Tariff* writes the spacing as "one every 15 minutes or every hour". "Not final yet" was dropped, because a plan is provisional exactly when it holds an estimated price (`n_carried` counts the provisional points), so the estimated count already says it.
|
||||
5. **Levels and noise.** The shadow line of every cycle is DEBUG, and "Shadow mode is on/off" is INFO: it follows a setting or a button, and the banner, hint and event are unchanged. aiohttp's access log is on only at log level debug. A settings warning is logged once per start (a filter on `goodwe.config`, WARNING only, so refusals are untouched), because the validator runs twice at start. When Home Assistant restarts, `hass.Outage` says so in one WARNING line for the REST API and the meter websocket together, puts the library's text on DEBUG, and says at INFO when both answer again. INFO lines lost "§8.3", ISO timestamps, "banked" and "keys ARE", and error texts lost "NOT_OBSERVED", "did NOT land" and "AND".
|
||||
6. **Display bugs.**
|
||||
- "Why: Other" during a latched per-inverter stop: `main.py` turns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit", main.py's own refusal to split) had no entry in `WHY_WINNER`. The mapping is now `main.why_key()`, and part 7 finds every `Claim.set()` in the code and checks that every key it can produce has words.
|
||||
- Red "Meter: no reading for 0 seconds" after a restart: until the first reading arrives, `P1Ingest.stale` is true, and the age is the time since start. The row now reads amber "waiting for the first reading" while that age is inside the limit. **Colour change, justified:** red at 0 s contradicted the limit the same row prints, and once readings exist the row is red only past that limit. Amber, not green, because control waits for the reading. Past the limit it is red as before. `stale` itself, which gates control, is unchanged.
|
||||
- "A reading is missing for 0 seconds" stuck at 0: with the meter reading gone, the grid value stops changing, so the loop runs a cycle only every `cycle_floor_s`, and the line was computed once per cycle. The age is now counted when the page asks. The hold and its 15 s escalation are unchanged.
|
||||
7. **Review: the line as logged gets the shape too.** Part 6 first checked the quoted reason alone, so a title, a 2-sentence reason and two tails still logged as 4 sentences ("Power request too high …: OUTPUT_CLAMP: … +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far."). Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. The wrappers became one sentence each, the circulation and peak guard reasons two, and `STALE_READING`'s fixed limits moved from the reason to a DEBUG line beside the ERROR (its release clause stays, pinned by test_freshness). The clamp lines for one inverter are pinned at 30 words at most: `OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W`. Several clamped inverters read as one sentence of asked/sent pairs; which limit bound each is on its own action-log row and in the notification. Totals above 30 words remain on the direction, delivery, frozen-meter and stale lines (up to 74 words). Shortening them is a follow-up: the direction and delivery reasons are also persisted in state.json, and the direction one is pinned byte-for-byte in test_watchdog.
|
||||
8. **Home Assistant 400 while it loads** now reads "Home Assistant refused number.set_value for number.x (error 400, no details). The entity may still be loading, or the value is out of range." A body with more than "400: Bad Request" is still quoted. Still WARNING, still once a minute, still a failed write.
|
||||
9. **Witness** (admin/goodwe): the resolved push says "after no answers from … to …", as the resolved card does. The YAML with every title and message stripped is identical.
|
||||
|
||||
# UX-11: safety log lines give the code, the figures go to DEBUG
|
||||
|
||||
**Unchanged:** every trigger, latch, threshold and alarm log level (the WARNING and ERROR lines stay), every notification id and cadence, every row colour and the banner cascade. No notification is added.
|
||||
|
||||
1. **One plain sentence and the code.** A WARNING/ERROR line that reports a safety reason (direction, delivery, stuck meter, stale reading, peak guard, circulation, over-rating) now ends in the reason's code, taken by `messages.reason_code()`. The reason itself, figures and limits included, is on a DEBUG line logged right after it ("Evidence for inverter-a: …"), and the action log entry carries it as before. Part 6 checks that pairing in the code. Circulation and the clamp lines were not among the five named, but they break the same 25-word rule, so they follow the same pattern. The clamp numbers stay in the notification (phase 2a item 3).
|
||||
2. **The reasons and `state.json` are unchanged.** `reason` stays the evidence text, so what is saved has the same format as before. An old state file therefore loads and displays like a new one: every saved reason has opened with its code since it was first written (checked in the git history), and that code is all the ERROR line reads. The saved text goes on the DEBUG line. A stop saved without a reason shows `REASON_NOT_SAVED`. `test_messages.py` part 7 restarts from a state file holding the old long texts. `test_watchdog.py` pins the meaning (the code first, then command, reading, time and both limits, in that order) and, since the text did not change, still the bytes.
|
||||
3. **The 25-word rule is checked as logged.** Part 6 fails any WARNING+ line over 25 words with its built text filled in. Nine non-safety families still run long (tariff plan words, price feed reasons, the state migration report, write refusals and claim scope errors that only a software fault raises, maintenance, trading gate and price history reasons). They are listed with a reason in `LONG_LINE_OK`, capped at 9, and a listed family that gets shorter fails until it is removed. Shortening them is a follow-up.
|
||||
4. **The meter row's "the add-on log shows why" is now true.** When the meter goes quiet past its limit, the add-on logs one WARNING per episode, not one per cycle, and an INFO line when readings return. A meter that flaps (on `ha_signed` a healthy meter under a flat load can go 42–97 s between changes) logs at most one such pair per 10 minutes, and the episodes in between go to DEBUG. This is a new log line, not a new alarm: it has no notification and changes no decision.
|
||||
5. **The Why line past the 15 s hold** names the missing reading ("No usable meter reading for over 15 seconds: 0 W", or the battery power or level of an inverter), instead of "A reading is missing: 0 W".
|
||||
6. **Action log:** "25 engagement(s)" is "25 engagements", and "SHADOW MODE OFF - …" is "Shadow mode off: …". "SHADOW MODE ON" was not named in the card and is left as it is.
|
||||
7. **Review: after a restart the figures go on the action log too.** `log_level` defaults to `info`, so the DEBUG evidence line is not seen by default, and `events` starts empty. Each restored stop (direction per inverter and site, delivery, circulation) now also writes its saved reason to the action log ("FAILSAFE inverter-a, saved before the restart: …"), and the page's event line gets the same plain words the live stop gives. `LONG_LINE_OK` also refuses a key whose text is a safety reason, not only a `reason_code` key.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# UX-13 — one layout table, and why UI code may now name an option key
|
||||
|
||||
**Status: DECIDED 2026-09-27 (owner approved the UX-13 design and mockup).**
|
||||
Card 6ab979852f3eca02fb40f69e. Amends [[UX-02]] §3.1. Design note and
|
||||
mockup: `goodwe-session/UX-13-design.md`, `UX-13-mockup.html`.
|
||||
|
||||
## 1. What UX-02 said, and why it no longer fits
|
||||
|
||||
UX-02 built the configuration panel from the Supervisor's schema, so
|
||||
`app/configui.py` contains "no option key, no label and no rule", and
|
||||
test_selfconfig.py part 5 checks that the page script names no key. That kept
|
||||
a second copy of the option surface from drifting, and it is why the panel is
|
||||
a flat list of fields in schema order.
|
||||
|
||||
UX-13 needs things a schema cannot say: groups and sub-groups, a slider in kW
|
||||
for a value stored in W, a picker for an entity, a field shown only for one
|
||||
meter source, locked measured values, a Simple view and a setup wizard. It
|
||||
also needs page fields that are not options at all: one inverter card that is
|
||||
stored as six flat keys, solar rows that become `pv_entity` or `pv_entities`,
|
||||
a contract and two prices that become `static_profile` JSON, and a region
|
||||
that becomes the levies figure. None of that can be read off the schema, so
|
||||
some code has to name option keys.
|
||||
|
||||
## 2. The decision
|
||||
|
||||
Option keys may appear in UI code in exactly two files:
|
||||
|
||||
- **`app/uilayout.py`**, the layout table: screen → group → part → field, with
|
||||
control, unit, display scale, step, entity kind, when it is shown, whether it
|
||||
is on the Simple view or a wizard step, and whether it applies now or after a
|
||||
restart. Every option in `config.yaml` is a field, `DERIVED` or `HIDDEN`,
|
||||
exactly once.
|
||||
- **`app/uimodel.py`**, the derivations the table names: `options_to_ui()` and
|
||||
`ui_to_options()`. It may name only the keys it writes.
|
||||
|
||||
What UX-02 made authoritative stays authoritative:
|
||||
|
||||
- **Ranges** come from the schema (`uilayout.range_of()`); the table writes
|
||||
none. A unit holds no digit.
|
||||
- **Labels and help** come from `translations/*.yaml` by key. Page-only fields,
|
||||
group titles and choices are in `app/ui_text/*.json`, held to STYLE by
|
||||
test_messages.py.
|
||||
- **Rules** stay in `validate.py`. It gains a warning tier (`validate.Note`,
|
||||
"warn" or "info") for the page. A note never blocks a save, and passing the
|
||||
notes list does not change the refusals returned.
|
||||
- **The stored blob** is still the one truth. The page model carries the stored
|
||||
values of every derived key (`keep`). A derivation whose page fields are
|
||||
unchanged writes those values back untouched, so a blob the page cannot
|
||||
express survives a save of another field.
|
||||
|
||||
## 3. How it is held
|
||||
|
||||
- test_uilayout.py fails when `config.yaml` gains an option, or an inverter
|
||||
field, that the table does not place. It also checks that uimodel.py names
|
||||
only the keys it writes and that web.py and haentities.py name none.
|
||||
- test_uimodel.py round-trips options → page model → JSON → options for every
|
||||
validator call the suite made at 652097a (`scenarios/ux13/`), the TEST-23
|
||||
option files and config.yaml's defaults. All must be identical.
|
||||
- `tools/ux13_validator_calls.py compare` runs those calls through the old and
|
||||
new validator. At P0: 261 calls, 158 refused, no refusal changed.
|
||||
- test_selfconfig.py part 5 still checks the old page script; P1 moves the page
|
||||
into `app/static/` and the same check applies there.
|
||||
- P2: the Installer view (`app/static/settings.js`) draws from
|
||||
`app/uipage.py`, which serialises the table and names no key itself
|
||||
(test_uilayout part 5, test_settings part 2). The view posts its page model
|
||||
to the existing `/config/validate` and `/config/save`, which turn it into
|
||||
options with `ui_to_options()` before the shipped validator runs. No route
|
||||
was added.
|
||||
- P4: the setup wizard draws the same model by each field's `wizard` step.
|
||||
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
|
||||
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
|
||||
names no key. See [[UX-13-setup-wizard]].
|
||||
- P5: the page words are in `app/ui_text/en.json` and `nl.json`, one key set,
|
||||
loaded by `app/uitext.py` with an English fallback per key. The page picks
|
||||
Dutch when the browser's language is `nl`, as the classic page does. Home's
|
||||
sentences moved from `homeview.py` into `home_words`, so `/status?lang=nl`
|
||||
builds them in Dutch. Status rows, banners, hints and validator sentences
|
||||
(`messages.py`, `validate.py`) stay English: they are shared with
|
||||
notifications, the log and the start refusal. `test_ui_text.py` checks both
|
||||
files against STYLE.md and each other, using the rules in
|
||||
`tools/style_rules.py`, which it shares with test_messages.py.
|
||||
- P5: "Other time windows" is a list editor (`ui.periods`, control
|
||||
`period_list`). `uimodel._periods_json()` builds `static_profile` and names
|
||||
the first bad time, empty price, overlap or gap. Its tiling rule matches
|
||||
`static_tariff._check_tiling`. A stored profile whose days the editor has no
|
||||
choice for (for example "mon,wed") is still kept as stored and edited in the
|
||||
Configuration tab. Supervisor 2026.08.0's `AppOptions` accepted all 133
|
||||
wizard blobs, 17 of them with time windows
|
||||
(`tools/ux13_wizard_blobs.py --supervisor`, run offline on 2026-09-28).
|
||||
- P3: Settings, Simple draws the fields the table marks `simple`, with an
|
||||
optional `note` under each (words in `settings.simple.note`), plus the
|
||||
batteries' switches and modes. Those are store state set through the
|
||||
existing `/action` values, not options, so the table places no key for
|
||||
them. The Batteries screen's Now, Today and Why lines are built in
|
||||
`homeview.py` from what the add-on knows this cycle; the plan per mode is
|
||||
OPT-11 P3/P4, so until then Today says "Not planned yet" when no block
|
||||
is planned for the battery (`test_batteries_screen.py`).
|
||||
|
||||
## 4. What this costs
|
||||
|
||||
The table is a second place that knows the option surface, which UX-02 set
|
||||
out to avoid. The cost is bounded by the coverage test: an option added to
|
||||
config.yaml and not placed here fails CI, rather than silently missing from
|
||||
the page. Changing a key's type or range needs no change here.
|
||||
@@ -0,0 +1,101 @@
|
||||
# UX-13 P4 — the setup wizard, and when it opens by itself
|
||||
|
||||
**Status: DECIDED 2026-09-28 (P4 dev; the design and mockup were approved
|
||||
2026-09-27).** Card 6ab979852f3eca02fb40f69e. Builds on
|
||||
[[UX-13-one-layout-table]]. Design note: `goodwe-session/UX-13-design.md` §1, §3.6.
|
||||
|
||||
## 1. What the wizard is
|
||||
|
||||
The Setup tab draws the Installer view's page model one step at a time: a
|
||||
field is on step *n* when its `uilayout.Field` says `wizard=n`. It uses P2's
|
||||
controls, `/config/validate` and `/config/save`. No route was added.
|
||||
`uilayout.py` holds what a field cannot say: the kettle table (`KETTLE`), the
|
||||
optional step and the shadow switch (`SHADOW`). Step 5 has no battery modes
|
||||
yet (OPT-11); it says they come later.
|
||||
|
||||
A step moves on when no refusal is anchored to one of its shown fields
|
||||
(`uipage.step_problems`). A refusal on a hidden field, a field on no step or
|
||||
no field at all blocks the last step, which lists every refusal.
|
||||
|
||||
## 2. When it opens by itself (`uipage.setup_needed`)
|
||||
|
||||
The page opens `#setup` only when it is opened with no route, and only when
|
||||
`/config/model` gives a reason:
|
||||
|
||||
- **`no_power_request`**: the stored options name no inverter to control.
|
||||
- **`first_start`**: this boot found no `/data/state.json` (`Store.fresh`), the
|
||||
Store has no `setup_done` and no `auto` key, and control is not started.
|
||||
`auto` is the key Start/Stop writes. Neither the startup gate's exit 78 nor
|
||||
setup-only mode (UX-15) writes the Store, so a new install's first good boot
|
||||
is fresh.
|
||||
|
||||
"Never started" alone is not enough: both TEST-23 add-ons have `auto_start`
|
||||
off and no `auto` key, and one has run in shadow for weeks (review of PR #146,
|
||||
R1). So a Store read from a file is an old site, and it is never sent to the
|
||||
wizard. A new install that restarts before the page is opened is not sent
|
||||
either, and its Setup tab still works. A site that runs control, or ever
|
||||
started or stopped it, is never sent. Without a Store the answer is no. The wizard's save writes
|
||||
`setup_done` (an ISO time) to the Store. A save from the Installer view does
|
||||
not.
|
||||
|
||||
**Decided since, in [[UX-15-setup-only-mode]] (owner, 2026-09-28):** a
|
||||
brand-new install ships `setpoint_entity: ""`, and it no longer exits 78. When
|
||||
naming no inverter is the gate's only refusal, the add-on serves the page in
|
||||
setup-only mode, which commands nothing, and the wizard opens with
|
||||
`no_power_request`. Setup-only mode writes no Store file, so the first-run
|
||||
rule above still holds for an install set up on the Configuration tab.
|
||||
|
||||
## 3. The kettle test and the sign checks
|
||||
|
||||
Step 1 reads the meter's entities now (`GET ha/entities?entity=`, one
|
||||
`hass.state()` read, in W) before and after a kettle is switched on. The
|
||||
change, not the sign, decides: a rise of at least 500 W keeps `meter_invert`
|
||||
off, a fall turns it on. For a net entity or import and export, a fall cannot
|
||||
be fixed by an option, so the step is not passed and the page says what to
|
||||
check. A meter the add-on reads itself (HomeWizard, MQTT) cannot be read by
|
||||
the page, so the test is skipped and Home shows the direction after the
|
||||
restart. The installer may skip the test, because a cloud can fake a fall;
|
||||
the last step then warns, and after a fall it says the reading went down.
|
||||
|
||||
The battery and solar signs cannot be forced from the page, which never
|
||||
commands an inverter. The last step reports each battery's reading with its
|
||||
direction ("reads 800 W charging now, so check the inverter shows the same")
|
||||
and warns when a solar entity reads below zero without its "reads negative"
|
||||
switch. It also warns when meter + solar + battery, all known, add up to a
|
||||
house using less than -300 W: a battery sensor reversed while giving power.
|
||||
An idle or charging battery cannot be judged that way.
|
||||
|
||||
## 4. The last step
|
||||
|
||||
`validate.setup_checks()` sits beside the rules because it names option keys.
|
||||
It reads nothing itself: `web.py` passes a Home Assistant entity list at most
|
||||
5 s old, the running options, the running meter reading and today's price
|
||||
count. Tiers are ok, info, warn and bad. A power request entity missing from
|
||||
Home Assistant is **bad**, because a write to it is answered 200 (SAFETY-77)
|
||||
and only the entity list can tell. So is Home Assistant not answering, since
|
||||
the list is then unknown. The wizard does not save while anything is
|
||||
bad. It then saves through P2's path (`setup: "done"`) and asks for the
|
||||
restart as the second, confirmed request, with a dialog that names the 0 W.
|
||||
The shadow switch is on by default and writes `shadow_mode: on`, which holds
|
||||
every inverter at 0 W after a restart even if control is started. Off keeps the
|
||||
stored value, or `auto` if that was `on`. The switch goes only on the model the
|
||||
wizard posts, never into the page model the Installer view also saves.
|
||||
|
||||
## 5. The stale save lists only what moved (P2 test, finding a)
|
||||
|
||||
`/config/model` remembers the last 16 blobs it handed out, by fingerprint. On a
|
||||
stale save the add-on compares that blob with what is stored now, so the banner
|
||||
lists only the other surface's changes. `changed` (stored against the posted
|
||||
blob) is unchanged for the classic page's "theirs → mine" list. When the
|
||||
fingerprint was not handed out by this process, it falls back to `changed`.
|
||||
|
||||
## 6. How it is held
|
||||
|
||||
- test_setup.py: the steps and tables, the kettle logic (and the page's copy of
|
||||
it), step blocking, the first-run rule on new and TEST-23 sites, the live
|
||||
checks, the save through the P2 path, the stale fix, and the words.
|
||||
- tools/ux13_wizard_blobs.py produces every blob the wizard can save from a new
|
||||
install and three TEST-23 sites (88 accepted by the shipped validator).
|
||||
UX-15 added the shipped defaults as a start (setup-only mode). At P4
|
||||
all 88 were accepted by Supervisor 2026.08.0's `AppOptions`, offline, 78
|
||||
keys each.
|
||||
@@ -0,0 +1,114 @@
|
||||
# UX-15 — setup-only mode: a new install serves the wizard
|
||||
|
||||
**Status: DECIDED 2026-09-28 (owner decision; UX-15 dev).** Card
|
||||
6aba0053d27a32cb3f210dcc. Changes the CFG-01 startup gate and closes the open
|
||||
question in [[UX-13-setup-wizard]] §2. Amends [[UX-02-two-config-surfaces]] §4.
|
||||
|
||||
## 1. The rule
|
||||
|
||||
`config.yaml` ships `setpoint_entity: ""`, so a new install's options are
|
||||
refused by rule 1 of `validate_startup_config()`: no inverter to control.
|
||||
Until now that was exit 78 before any page was served, and the installer had
|
||||
to fill in the entity on the Configuration tab before the wizard could open.
|
||||
|
||||
The add-on now starts in **setup-only mode** when, and only when
|
||||
(`setuponly.applies()`):
|
||||
|
||||
- the options name no inverter at all: no flat `setpoint_entity` and no
|
||||
`inverters` entry, and
|
||||
- rule 1's refusal (`no_power_request_problem()`) is the gate's **only**
|
||||
refusal.
|
||||
|
||||
In setup-only mode it serves the page, says it is in setup (banner, `/status`
|
||||
`setup_only: true`, a WARNING log line) and opens the wizard when the page is
|
||||
opened with no route (`setup_needed` is `no_power_request`). After the
|
||||
wizard's save and restart the normal gate applies to the saved options.
|
||||
|
||||
## 2. What still exits 78, and why
|
||||
|
||||
Everything else the gate refuses, including:
|
||||
|
||||
- a complete configuration that is refused, e.g. `maintenance_charge_w` above
|
||||
`max_w`;
|
||||
- no power request entity **and** any other refusal;
|
||||
- an `inverters` list whose entry has a blank power request entity.
|
||||
|
||||
These are configurations somebody edited. The Configuration tab stored them,
|
||||
the log's refusal banner names each problem, and the tab is where they are
|
||||
fixed (UX-02 §4). Serving the wizard there would put a six-step setup in
|
||||
front of someone who needs to change one field. A narrow rule also keeps the
|
||||
setup-only decision to one comparison against one known sentence, so a new
|
||||
validator rule cannot widen it by accident: any other refusal fails the
|
||||
equality and exits 78.
|
||||
|
||||
The cost: an installer who first sets, say, a DSMR meter with no entities on
|
||||
the Configuration tab and leaves the power request entity empty gets exit 78,
|
||||
not the wizard. The log then names both fields.
|
||||
|
||||
## 3. Why it cannot command
|
||||
|
||||
Setup-only mode builds nothing that commands. `amain()` branches to
|
||||
`setuponly.serve()` before the MQTT broker, the controller, the meter task and
|
||||
the tariff task are built, and it returns from there when the Supervisor
|
||||
stops it. What serves the page is `SetupOnly`, which holds no inverter.
|
||||
|
||||
Three checks make it structural rather than a convention:
|
||||
|
||||
1. **The HomeAssistant is read-only** (`HomeAssistant(read_only=True)`).
|
||||
`call()` refuses before it sends anything, and every service call goes
|
||||
through it: `number.set_value` (`set_number()`), notifications, all of
|
||||
them. The flag is fixed at construction, and there is no setter.
|
||||
2. **`SetupOnly` refuses a HomeAssistant that can write, and `Controller`
|
||||
refuses one that cannot.** So neither can be built on the wrong one.
|
||||
3. **`/action` answers 409** in setup-only mode, before
|
||||
`handle_action()`. Start, Stop and the battery cycle do nothing.
|
||||
|
||||
No MQTT client is created, so nothing is published. The page's other routes
|
||||
already read only, or write the add-on's own options (UX-02 §6).
|
||||
|
||||
Setup-only mode writes no Store file. The wizard's save writes `setup_done`,
|
||||
as in any mode. So a new install that is set up on the Configuration tab
|
||||
instead still has a fresh Store on its first normal boot, and the first-run
|
||||
rule (UX-13 §2) sends it to the wizard as before.
|
||||
|
||||
## 4. How it is held
|
||||
|
||||
- `test_setup_only.py` part 1: when it applies, including the three exit-78
|
||||
cases above. Part 2: the read-only write path refuses with nothing sent,
|
||||
and the two constructors refuse. Part 3 boots the real `main()` on the
|
||||
shipped defaults against a fake Supervisor and Home Assistant over HTTP and
|
||||
logs every request: the page is served, `/status` says setup-only, Start
|
||||
and the cycle get 409, and there are 0 service calls and no MQTT request,
|
||||
also with `auto_start` on and a Store that says control was started. The
|
||||
wizard's save is then booted: it passes the normal gate, does not open the
|
||||
wizard again, and every write is 0 W (shadow). Part 4: the refused
|
||||
configurations exit 78 and serve nothing.
|
||||
- Mutations, each red: `applies()` always false or always true, the
|
||||
read-only refusal removed, the Controller's check removed, `amain()` giving
|
||||
setup-only mode a writable HomeAssistant, `/action`'s 409 removed, and the
|
||||
`return` after `serve()` removed.
|
||||
- TEST-23 (PR #147 review, 2026-09-28): the add-on image on the shipped
|
||||
defaults and an empty `/data` made 0 service calls and no MQTT request in
|
||||
3.5 minutes of setup-only mode, counted in both the stand-in and the rig
|
||||
shim logs. The wizard save then passed Supervisor 2026.08.0's `AppOptions`,
|
||||
and after the restart it ran in shadow: 11 of 11 writes were
|
||||
`set_value 0.0`.
|
||||
|
||||
## 5. The wizard fixes in the same card
|
||||
|
||||
- **F1.** `/config/validate` converts what it can of a page model
|
||||
(`uimodel.ui_to_options_partial()`), so a conversion error blocks its own
|
||||
step beside its field and the live checks still run on the rest. A refusal
|
||||
about a key the failed conversion writes is left out, because it would
|
||||
judge the stored value. The save still refuses such a model (422, with
|
||||
`steps`).
|
||||
- **F2.** A save from the page model sends `base`, the model it opened. The
|
||||
add-on writes only the keys whose value differs from `base`, onto the blob
|
||||
stored now (`uipage.only_edits()`). So "save again to keep yours" after a
|
||||
409 keeps the other surface's changes. A key both surfaces changed takes
|
||||
this page's value, which is what the second press after the banner means.
|
||||
- **Meter entity.** On the last step a meter entity Home Assistant does not
|
||||
have is red, like a missing power request entity, so a skipped kettle test
|
||||
cannot let it through. In the Installer view it stays a warning.
|
||||
- **Step 1 after a skip.** When the kettle test saw the reading fall and was
|
||||
then skipped, step 1 says the reading went down. The skip stays (UX-13 §3).
|
||||
@@ -7,45 +7,73 @@ balance cells and recalibrate its coulomb counter.
|
||||
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
|
||||
v0.1.0 and outdated; where it disagrees with this page, this page wins.
|
||||
|
||||
## Words used on this page
|
||||
|
||||
- **Control board**: the small computer on the inverter's meter cable. It
|
||||
passes the add-on's power request to the inverter. If the add-on goes quiet
|
||||
for about 30 seconds, it sets the inverter to 0 W by itself. (For
|
||||
installers: a T-CAN485 board with an ESP32 chip on the RS485 bus.)
|
||||
- **Isolator**: the switch that cuts an inverter off from the mains. Switching
|
||||
it off stops the inverter, whatever the add-on or the control board asks.
|
||||
- **Developer Tools**: the Home Assistant page that lists every entity and its
|
||||
current value, on its *States* tab. Use it to check that a sensor the add-on
|
||||
reads is really there and updating.
|
||||
- **Shadow mode**: the add-on makes every decision but sends nothing, so every
|
||||
inverter stays at 0 W. Pages and notifications say what it *would* have done.
|
||||
- **Safety stop**: the add-on has set one inverter, or all of them, to 0 W and
|
||||
keeps it there until you start control on the add-on page.
|
||||
- **Peak limit**: the 15-minute average grid use you want to stay under this
|
||||
month, because your capacity tariff bills the highest one.
|
||||
- **Peak guard**: the part of the add-on that uses the battery to keep each
|
||||
15-minute period under the peak limit.
|
||||
- **Peak reserve** and **battery cut-out level**: the charge the peak guard
|
||||
keeps back, and the level where the battery switches itself off.
|
||||
|
||||
## Before you start
|
||||
|
||||
You need:
|
||||
|
||||
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
|
||||
- The vendor's meter-emulating controller **disconnected** from the bus
|
||||
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml`
|
||||
- A control board: a T-CAN485 (ESP32) flashed with
|
||||
`firmware/goodwe-master.yaml`
|
||||
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
|
||||
|
||||
After installing, turn on **Watchdog** on the add-on's Info tab. A crashed
|
||||
control loop stops the add-on at 0 W, and the Supervisor restarts it only when
|
||||
Watchdog is on (it is off by default). See
|
||||
`DECISIONS/SAFETY-61-control-task-death.md`.
|
||||
After installing, turn on **Watchdog** on the add-on's Info tab. If control
|
||||
stops by accident, the add-on sets the inverters to 0 W and exits, and Home
|
||||
Assistant starts it again only when Watchdog is on. It is off by default.
|
||||
|
||||
## The safety model, in short
|
||||
|
||||
The inverter **holds its last command forever** — it has no meter-timeout. So:
|
||||
The inverter **keeps its last power request forever**. It has no timeout of its
|
||||
own. So:
|
||||
|
||||
- The ESP32 commands 0 W if this add-on stops refreshing for ~30 s, and keeps
|
||||
commanding it.
|
||||
- This add-on commands 0 W when its inputs go missing, when you stop control,
|
||||
and when it shuts down.
|
||||
- This add-on commands 0 W **and latches there** if what it is commanding and
|
||||
what the meter reads point opposite ways for three minutes — see "The
|
||||
- The control board sets the inverter to 0 W if this add-on stops sending for
|
||||
about 30 seconds, and keeps it there.
|
||||
- This add-on sets the inverters to 0 W when a reading goes missing, when you
|
||||
stop control, and when it shuts down.
|
||||
- This add-on sets the inverters to 0 W **and keeps them there** if the power
|
||||
goes the opposite way to what it asks for three minutes. See "The
|
||||
sign-disagreement failsafe" below.
|
||||
- The **optional RS485 e-stop** is the only thing that covers this machine
|
||||
dying. Without it, a failed host leaves the battery latched at its last
|
||||
command until someone intervenes.
|
||||
- This add-on **raises an alarm — and nothing more —** when the meter keeps
|
||||
answering with a reading that never changes while the command sits at the
|
||||
rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff
|
||||
exposure of the grid-following loop".
|
||||
- The **optional emergency stop** is the only thing that covers this computer
|
||||
failing. Without it, the battery keeps its last power request until someone
|
||||
steps in.
|
||||
- This add-on **raises an alarm, and does nothing more,** when the meter keeps
|
||||
answering with a value that never changes while the battery is asked for
|
||||
full power. See "The frozen-meter alarm" and, before you rely on it,
|
||||
"Capacity-tariff exposure of the grid-following loop".
|
||||
|
||||
**If anything looks wrong: stop the add-on.** That commands 0 W and the
|
||||
hardware holds it there.
|
||||
**If anything looks wrong: stop the add-on.** That sets the inverters to 0 W,
|
||||
and the control board keeps them there.
|
||||
|
||||
*For installers: a crashed control task is SAFETY-61, see
|
||||
`DECISIONS/SAFETY-61-control-task-death.md`.*
|
||||
|
||||
## Configuration
|
||||
|
||||
### ⚠️ There are TWO places to configure this add-on (UX-02)
|
||||
### ⚠️ There are TWO places to configure this add-on
|
||||
|
||||
*For installers: card UX-02.*
|
||||
|
||||
Since **UX-02** the ingress panel has a **Configuration** page of its own,
|
||||
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
|
||||
@@ -108,7 +136,9 @@ One column, big targets, sections collapsed until you open them, and the
|
||||
refusals pinned where you cannot scroll past them. It is the screen for
|
||||
standing in front of the inverter.
|
||||
|
||||
### What the Configuration tab actually shows you (UX-01)
|
||||
### What the Configuration tab actually shows you
|
||||
|
||||
*For installers: card UX-01.*
|
||||
|
||||
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
|
||||
so Home Assistant's *Configuration* tab renders human labels and a description
|
||||
@@ -153,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. |
|
||||
@@ -173,16 +204,17 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Limits | Inverter rating (W) | `max_w` | Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning. |
|
||||
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
|
||||
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
|
||||
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
|
||||
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
|
||||
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
|
||||
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
|
||||
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
|
||||
| Tariff | Distribution, levies and VAT (EUR/kWh) | `price_taxes_eur_kwh` | Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates. |
|
||||
| Tariff | Injection price (EUR/kWh) | `export_price_eur_kwh` | What you are paid per kWh fed into the grid, all in. It may be negative. |
|
||||
| Tariff | Fixed or day/night tariff (JSON) | `static_profile` | Your fixed or day/night contract as one line of JSON, prices all in. Fill it in even on a dynamic contract, as the fallback when the price feed fails. |
|
||||
| Tariff | Day/night levies and VAT (JSON) | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. |
|
||||
| Tariff | Your own tariff formula (JSON) | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card as one line of JSON, and the add-on checks them against the card. |
|
||||
| Tariff | Your own injection formula (JSON) | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
|
||||
| Tariff | Fixed or day/night tariff | `static_profile` | Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback. |
|
||||
| Tariff | Day/night levies and VAT | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. |
|
||||
| Tariff | Your own tariff formula | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card. |
|
||||
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
|
||||
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
|
||||
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
|
||||
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
|
||||
@@ -218,9 +250,9 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Advanced | Ignore errors below (W) | `deadband_w` | Errors smaller than this are ignored. Keep it small, since any resting point inside it holds. |
|
||||
| Advanced | Meter resting point (W) | `target_grid_w` | What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day. |
|
||||
| Advanced | Command rounding (W) | `step_w` | The command is rounded to a multiple of this, so noise does not change it every cycle. |
|
||||
| Advanced | Inverter at limit gap (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. |
|
||||
| Advanced | At-limit shortfall (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. |
|
||||
| Advanced | Cycles at limit before freezing | `saturation_cycles` | How many cycles in a row at the limit before control stops building up error. Do not set it to 1. |
|
||||
| Advanced | Error build-up limit (W) | `integrator_max_w` | Caps the error that control saves up from past cycles. 0 means “Inverter rating”. Do not raise it above the rating. |
|
||||
| Advanced | Catch-up limit (W) | `integrator_max_w` | Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating. |
|
||||
| Advanced | Command refresh interval (s) | `heartbeat_s` | How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit. |
|
||||
| Advanced | Missing inputs time limit (s) | `stale_input_s` | How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”. |
|
||||
| Advanced | Longest time between cycles (s) | `cycle_floor_s` | A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s. |
|
||||
@@ -243,10 +275,11 @@ When the add-on refuses to start, each reason names the setting by its label and
|
||||
- **Inverter rating** (`max_w`): your own number, not a derived one. It is not read when the inverter list is filled in, since each entry carries its own and the site limit is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not.
|
||||
- **Battery cut-out level** (`bms_soc_floor`, `inverters.bms_soc_floor`): measured, not chosen. Register 0x0560 reads 10 % on the reference GW5000S-BP. It exists so the add-on can refuse a “Peak reserve” at or below it, which could never take effect. Each inverter entry states its own, because one default for two different batteries protects the shallower one with the deeper one's number.
|
||||
- **Peak limit** (`peak_cap_w`): your own number, from your grid contract. In Belgium it is the capacity-tariff peak your distribution charge is billed on.
|
||||
- **Margin below the peak limit** (`peak_margin_w`): 50 W by default, 0 to 500 W (SAFETY-79). Peak limiting aims at the peak limit minus this margin, because a 15-minute period aimed exactly at the limit can end a watt or two over it (3501.9 W against 3500 W in a test), and even 1 W over sets the billed peak. Once this month's peak is already above the limit, it aims the same margin below that peak. The bill, this month's peak and the warnings still compare against the peak limit itself. 0 aims at the limit, as before.
|
||||
- **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
|
||||
- **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
|
||||
- **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
|
||||
- **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different-tariff-18).
|
||||
- **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different).
|
||||
- **Day/night levies and VAT** (`price_taxes_profile`): for Wallonia and Brussels, where distribution has a day and a night rate (ORES 13,27 against 7,39 c/kWh, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10). The figures are levies alone, added to the market price. The add-on refuses to start if it and the flat figure disagree. See [Levies and VAT by time of day](#levies-and-vat-by-time-of-day-price_taxes_profile).
|
||||
- **Injection price** (`export_price_eur_kwh`): one number because no supplier here publishes a dynamic injection curve. In Belgium it is often near zero, and at a sunny midday it can be negative.
|
||||
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
|
||||
@@ -259,19 +292,19 @@ When the add-on refuses to start, each reason names the setting by its label and
|
||||
- **Draw-down and recharge rate** (`maintenance_discharge_w`, `maintenance_charge_w`): the draw-down rate above the rating is cut back, not refused. A recharge rate above the rating would ask for more than the limit allows and resolve to 0 W, so maintenance would never charge. That is why the add-on refuses it.
|
||||
- **Draw down to** (`maintenance_soc_floor`): the monthly cycle goes this deep on purpose, while “Peak reserve” is a depth peak limiting never reaches. 11 % against 20 % is why they are two settings.
|
||||
- **Wrong-direction threshold and time** (`sign_disagree_w`, `sign_disagree_s`): measured, not chosen (SAFETY-02). On the captured healthy day (2026-08-23) signs agree on 93.4 % of gated samples, on the fault day (2026-08-20) 0.0-10.5 %, with opposed runs up to 1752 s. The longest legitimate opposed run is 30 s, and the fault day still trips 13 times at 300 s, so 180 s sits in a wide gap. The threshold must stay below “Inverter rating”.
|
||||
- **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit gap. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
|
||||
- **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit shortfall. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
|
||||
- **Site wrong-direction threshold** (`site_sign_disagree_w`): the single-inverter 300 W carried across deliberately (SAFETY-30), because near zero the sign is noise whatever is behind the one meter.
|
||||
- **Site wrong-direction time** (`site_sign_disagree_s`): derived from the same 1890 runs (SAFETY-30). The worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, so 180 s would false-trip. 325 s is 1.51 times that and inside the 900 s billing period.
|
||||
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
|
||||
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
|
||||
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
|
||||
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
|
||||
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode-safety-10).
|
||||
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported, and neither is one that gives under 100 W (SAFETY-73: it is a battery that refuses). Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
|
||||
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode).
|
||||
- **Control gain** (`gain`): measured on real hardware and already at the limit.
|
||||
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
|
||||
- **Ignore errors below, Meter resting point** (`deadband_w`, `target_grid_w`): the meter's import counter bills every positive watt, so a resting point at 0 W leaks about half the deadband as billed import, roughly 0.2-0.35 kWh a day at 15 W. -10 W moves that leak to export.
|
||||
- **Inverter at limit gap** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
|
||||
- **Error build-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
|
||||
- **At-limit shortfall** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
|
||||
- **Catch-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
|
||||
- **Command refresh interval** (`heartbeat_s`): the control board refuses a link that has been quiet for 30 s.
|
||||
- **Longest time between cycles** (`cycle_floor_s`): keep it under “Reading too old after”, since past that there is no reading left to act on.
|
||||
|
||||
@@ -286,9 +319,9 @@ When the add-on refuses to start, each reason names the setting by its label and
|
||||
| `batt_invert` | | Flip if needed |
|
||||
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
|
||||
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
|
||||
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
|
||||
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
|
||||
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities-tel-14) |
|
||||
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity) |
|
||||
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities) |
|
||||
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities) |
|
||||
|
||||
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
|
||||
those serve cached values, and a stale reading here ends the maintenance charge
|
||||
@@ -344,7 +377,7 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
|
||||
| `soc_entity` | | This pack's state of charge |
|
||||
| `batt_entity` | | This unit's battery power, `+` = discharging |
|
||||
| `batt_invert` | | Flip if needed |
|
||||
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs-opt-09) |
|
||||
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs) |
|
||||
|
||||
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
|
||||
sensor** — it does *not* fall back to the flat one. That is deliberate: every
|
||||
@@ -387,7 +420,9 @@ in the log rather than silent, but it is an outage until someone puts the
|
||||
single-inverter entity back in that field. **Before downgrading, write down the
|
||||
`setpoint_entity` of the unit you want to keep running.**
|
||||
|
||||
#### How the site target is divided between the packs (OPT-09)
|
||||
#### How the site target is divided between the packs
|
||||
|
||||
*For installers: card OPT-09.*
|
||||
|
||||
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
|
||||
version before it the split is a fixed 50/50 regardless of what either pack can
|
||||
@@ -480,6 +515,41 @@ 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 (and Settings, Simple) 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 and covers the house at any hour. It never charges from the grid and never sells.
|
||||
- *Leftover solar* charges from the solar the others cannot take, never from the grid (until the solar forecast arrives). It saves its power for the expensive hours and covers the house then.
|
||||
- *Cheap hours only* charges from the grid in the cheapest hours, saves its power for the expensive hours and covers the house then.
|
||||
- **Selling** is off until it can be judged on the price the grid pays (owner decision 2026-09-29, option A).
|
||||
|
||||
With battery modes on, these choices replace the *Charging and selling on price* switch (`arbitrage_enabled`); that switch is only read with modes off.
|
||||
|
||||
**Cheap and expensive hours.** They come from the published day-ahead prices, as runs of consecutive 15-minute periods, not a single period.
|
||||
- The cheap run is the cheapest stretch long enough to fill the batteries that may charge, at their power.
|
||||
- An expensive hour costs at least *Battery cycle cost* (`cycle_cost_threshold_eur`, widened as for trading) more than the cheap run. The batteries hold their power until then.
|
||||
- The sale uses the most expensive stretch inside those hours, and never a period below 0.
|
||||
- A carried (not yet published) price is never used. A run that has started runs to its end.
|
||||
- With no prices, or too small a gap, every battery follows the house.
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period.
|
||||
- While one battery charges from the grid, the others rest, and the house uses the grid.
|
||||
- While one battery empties for its monthly cycle, no battery charges from the grid.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction.
|
||||
|
||||
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
|
||||
|
||||
*For installers:* commands arrive on `goodwe_ctl/unit/<battery>/mode/set` (a *Mode* option, such as `Solar first`) and `.../use/set` (`ON` or `OFF`). They go through the same checks as the Batteries screen. A refusal is logged once, and later refusals are counted in the log. A retained command is never applied, so the broker cannot replay an old choice after a restart; the add-on keeps the last choice itself.
|
||||
|
||||
*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`.
|
||||
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
|
||||
### 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
|
||||
@@ -494,7 +564,7 @@ accumulator is doing:
|
||||
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
|
||||
| `Loop reason` | — | the law's own word for this cycle |
|
||||
|
||||
`Loop reason` takes one of eight values:
|
||||
`Loop reason` takes one of ten values:
|
||||
|
||||
| value | what happened |
|
||||
|---|---|
|
||||
@@ -506,10 +576,40 @@ accumulator is doing:
|
||||
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
||||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||||
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
|
||||
|
||||
**When the battery refuses.** At its cut-out level the battery gives nothing,
|
||||
whatever it is asked. The add-on then asks 0 W instead of repeating its last
|
||||
request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
|
||||
15 minutes it asks 200 W for about half a minute. Control resumes as soon as
|
||||
the battery gives 100 W or more, when the grid needs charging instead, and when
|
||||
control is stopped or started. 200 W is below the 250 W the stale-reading check
|
||||
needs, so a test never trips it. A battery that gives some power, even 100 W,
|
||||
is not refusing and keeps its request.
|
||||
|
||||
**With two or more inverters** the same rule works per inverter. An inverter
|
||||
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
|
||||
asked for more than 200 W is asked 0 W, and the other inverters take over its share, each up to
|
||||
its own limits. The status page names it: *inverter-a: the battery refuses to
|
||||
give power, the others take over*. It is tested at 200 W every 15 minutes, one
|
||||
inverter at a time, and only while the site asks at least 200 W that way.
|
||||
It still accepts charge, and 12 cycles of charging end the refusal. When every
|
||||
inverter refuses the same way, the whole site asks 0 W as with one inverter.
|
||||
If the battery refuses to give power while its level reads above its “Battery
|
||||
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
|
||||
A battery that refuses is not reported as *not delivering*.
|
||||
|
||||
*For installers:* SAFETY-52 and SAFETY-73. The event log reads
|
||||
`BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
|
||||
N>1), and the INFO line ends in `BATTERY_REFUSES`.
|
||||
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
|
||||
`DECISIONS/SAFETY-73-refusal-per-inverter.md` the two-inverter ones.
|
||||
|
||||
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
|
||||
often disagree.** `Why` is the *arbiter's* sentence — `loop -> -1200 W` — which
|
||||
names the strategy that won and the watts it won with. It has never carried the
|
||||
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
|
||||
near zero: -1 200 W* — which names the strategy that won and the watts it won
|
||||
with. The log keeps the arbiter's own `loop -> -1200 W`. It has never carried the
|
||||
strategy's own reason, so a loop that is slew-limited or frozen says so **only**
|
||||
here.
|
||||
|
||||
@@ -567,10 +667,12 @@ and `Inverter B` — are two names to the validator and one id once slugged. The
|
||||
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
|
||||
get then depend on roster order. Give your inverters plainly different names.
|
||||
|
||||
### PV production (`pv_entity`, TEL-11)
|
||||
### PV production (`pv_entity`)
|
||||
|
||||
*For installers: card TEL-11.*
|
||||
|
||||
> **More than one solar inverter?** Use `pv_entities` instead — see
|
||||
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The
|
||||
> [Several PV inverters](#several-pv-inverters-pv_entities) below. The
|
||||
> rest of this section applies to both.
|
||||
|
||||
|
||||
@@ -604,7 +706,9 @@ Only the third is a software defect, and **none of them can be told apart
|
||||
without a record of production.** This entity is that record. The attribution
|
||||
itself is OPT-10's, with a week of history behind it.
|
||||
|
||||
#### The sign, and `pv_invert_entities` (TEL-14)
|
||||
#### The sign, and `pv_invert_entities`
|
||||
|
||||
*For installers: card TEL-14.*
|
||||
|
||||
**Positive must mean producing.** Some sensors — typically a kWh meter or an
|
||||
ESPHome power clamp in the PV circuit — read negative while producing and a few
|
||||
@@ -642,7 +746,9 @@ pictures, which is exactly the distinction the whole card exists to preserve.
|
||||
A failed read also **clears** the previous good one: the value never outlives
|
||||
its own sensor.
|
||||
|
||||
### Several PV inverters (`pv_entities`, TEL-12)
|
||||
### Several PV inverters (`pv_entities`)
|
||||
|
||||
*For installers: card TEL-12.*
|
||||
|
||||
A multi-string or multi-orientation roof routinely carries **two or three PV
|
||||
inverters**, each publishing its own power sensor into Home Assistant. List them
|
||||
@@ -1025,7 +1131,9 @@ behind it would trip the firmware watchdog on a system that is working fine.
|
||||
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
|
||||
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
|
||||
|
||||
#### Shadow mode (`shadow_mode`, SAFETY-10)
|
||||
#### Shadow mode (`shadow_mode`)
|
||||
|
||||
*For installers: card SAFETY-10.*
|
||||
|
||||
Shadow mode runs the **whole** decision path — the price strategy, the capacity
|
||||
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
|
||||
@@ -1192,11 +1300,13 @@ If the setpoint this add-on is emitting and the grid figure it is regulating on
|
||||
point **opposite ways** — charging the battery while the house imports, or
|
||||
discharging it while the house exports — with both over `sign_disagree_w`, for
|
||||
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
|
||||
there. The banner reads *Command and meter point opposite ways*, the add-on
|
||||
there. The banner reads *The meter saw power go the wrong way*, the add-on
|
||||
log says which two numbers disagreed and for how long, and the `Controller
|
||||
status` entity reports `failsafe`.
|
||||
|
||||
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
|
||||
#### ⚠️ At two or more inverters this is TWO checks, not one
|
||||
|
||||
*For installers: card SAFETY-30.*
|
||||
|
||||
The paragraph above describes what a **one-inverter** install does, and it is
|
||||
unchanged: there is one meter, one actuator, and comparing the command against
|
||||
@@ -1285,7 +1395,9 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
|
||||
says so.
|
||||
|
||||
|
||||
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
|
||||
#### ⚠️ A unit that stops DELIVERING is a third fault again
|
||||
|
||||
*For installers: card SAFETY-42.*
|
||||
|
||||
The two checks above both key on **direction**. A unit sitting at **0 W against
|
||||
a non-zero share** has no direction: it is not pushing the wrong way, it is
|
||||
@@ -1393,7 +1505,9 @@ correct toward that wrong value instead of holding a stale command. It is bounde
|
||||
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||||
safety property.
|
||||
|
||||
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
|
||||
#### When the freeze lets go, and when it deliberately never does
|
||||
|
||||
*For installers: card SAFETY-13.*
|
||||
|
||||
While the loop is frozen the command's **magnitude may fall but never rise**. That is
|
||||
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
|
||||
@@ -1524,6 +1638,7 @@ help - see "Why the tuning is what it is".
|
||||
|---|---|
|
||||
| `peak_forecast_entity` | Quarter-hour demand forecast, for capacity-tariff markets. Empty = no cap |
|
||||
| `peak_cap_w` | The site's capacity-tariff target |
|
||||
| `peak_margin_w` (50) | Watts the peak guard aims under `peak_cap_w` (SAFETY-79). Reports and the bill use `peak_cap_w` itself |
|
||||
| `peak_soc_floor` (20) | **The percent below which the capacity-peak guard will not discharge the battery.** ⚠️ It bounds **the peak guard only** — it is **not a pack-wide reserve**, and ordinary self-consumption discharges straight past it (SAFETY-25, see below). Not optional and not the same thing as `maintenance_soc_floor` |
|
||||
| `bms_soc_floor` (10) | The pack's **own** cut-out percent, the number the BMS enforces whatever you set above. `peak_soc_floor` must be **strictly above** it or the add-on refuses to start. ⚠️ **At a declared `inverters:` roster this key moves INTO each entry and is REQUIRED there** (SAFETY-32): it describes a battery, and two packs of different models have different cut-outs, so one number for both protects the shallower pack with the deeper pack's BMS. Nothing is inherited — an entry that omits it is refused **by name** at startup, and the flat key below is then not read at all |
|
||||
| `price_now_entity`, `price_avg_entity` | Dynamic tariff. Empty = never force a paid grid top-up |
|
||||
@@ -1533,6 +1648,10 @@ under `peak_cap_w`, and if the forecast goes over the cap the charge-only clamp
|
||||
is dropped so the battery can shave the peak instead. Money outranks the
|
||||
maintenance schedule.
|
||||
|
||||
With two or more batteries, the one being emptied gives its drain rate and the
|
||||
others only add power when the house needs more; none charges while it drains
|
||||
(SAFETY-78). Below the drain rate the rest is exported, as with one battery.
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
|
||||
@@ -1592,7 +1711,7 @@ is the design rather than an accident: it is 1.0 at the top of the quarter and
|
||||
grows, so the guard is gentlest when its projection rests on the least evidence
|
||||
and becomes aggressive only once the quarter is nearly measured.
|
||||
|
||||
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that
|
||||
**The ceiling** is `peak_cap_w`, raised to this month's recorded peak when that
|
||||
already stands higher — once a month's bill is set at 5013 W, holding later
|
||||
quarters to 3500 W buys nothing and costs real charging. **That relaxation is
|
||||
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
|
||||
@@ -1625,7 +1744,8 @@ control action and goes to the action log and the status page. A *saturated*
|
||||
guard — asking for everything the inverter has while the quarter is still
|
||||
projected over — raises one notification (`goodwe_peak_saturated`), because that
|
||||
is the one state where the money will be spent and shedding load is the only
|
||||
thing left that changes the bill.
|
||||
thing left that changes the bill. SAFETY-81 keeps a 1 s meter spike, noise and
|
||||
a quarter's last seconds from raising it: see `peakguard.load_too_high()`.
|
||||
|
||||
**And it sizes a price-driven charge, not only a forced discharge (SAFETY-45).**
|
||||
The same arithmetic read the other way answers *"how hard may this site charge
|
||||
@@ -1668,8 +1788,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||||
**nothing more**. See below for what that costs on a real evening.
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
|
||||
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
|
||||
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
|
||||
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
|
||||
go over the ceiling, so shedding load is
|
||||
the only thing left that changes the bill. Failing to shave is never silent —
|
||||
the euro figure is the whole point of the guard.
|
||||
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A
|
||||
@@ -1685,7 +1806,9 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
|
||||
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
|
||||
smaller, or your quarters are worse, raise it.
|
||||
|
||||
#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25)
|
||||
#### ⚠️ What `peak_soc_floor` does **not** protect
|
||||
|
||||
*For installers: card SAFETY-25.*
|
||||
|
||||
**On a sustained-load evening this add-on will take your battery to the BMS
|
||||
cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed
|
||||
@@ -1708,7 +1831,7 @@ because the guard it bounds is not the thing doing the discharging.
|
||||
|
||||
**And the two can happen in the same cycle.** On the high-load block of the same
|
||||
day (15:00–17:00 UTC) with the pack below the floor, the guard reports
|
||||
`PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on 49 cycles,
|
||||
`PEAK_GUARD_FLOOR_LIMITED` — *"there is no forced discharge"* — on 49 cycles,
|
||||
and on **all 49 of them** the loop is simultaneously being granted a discharge of
|
||||
up to 2000 W by the arbiter. The status page is telling you the truth about the
|
||||
guard while the battery empties.
|
||||
@@ -1798,14 +1921,14 @@ Both are watched. A guard that watched only the emitted value would be silent on
|
||||
every standard configuration.
|
||||
|
||||
**The integrator bound is not a bug, and it is reported as what it is.** It
|
||||
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's
|
||||
command this cycle was +2000 W"*, titled *"the control loop is at its integrator
|
||||
bound"*, logged as a `WARNING`, with an amber (not red) status row and its own
|
||||
notification (`goodwe_integrator_bound`), so it never replaces a BUG SIGNAL
|
||||
notice still on screen. Brief
|
||||
engagements after a large load or solar step are normal. If it stays engaged,
|
||||
check that the battery is following its commands. Only a command over the rating
|
||||
carries the BUG SIGNAL text, and it has its own notification budget.
|
||||
reads *"the correction the add-on builds up reached its +5000 W limit,
|
||||
command now +2000 W"*, titled *"Battery is not keeping up"*, logged
|
||||
as a `WARNING`, with an amber (not red) status row and its own notification
|
||||
(`goodwe_integrator_bound`), so it never replaces a software-fault notice still
|
||||
on screen. Brief engagements after a large load or solar step are normal. If it
|
||||
stays engaged, check that the battery is following its commands. Only a command
|
||||
over the rating is logged as a software fault, at `ERROR`, and it has its own
|
||||
notification budget.
|
||||
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
|
||||
|
||||
### The frozen-meter alarm
|
||||
@@ -1878,7 +2001,9 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
|
||||
parked at 0 W and the two halves of the rule cannot both be true. At the schema
|
||||
floor of 60 s that figure is 60 W and only the margin is left.
|
||||
|
||||
### The circulating-power alarm (SAFETY-31)
|
||||
### The circulating-power alarm
|
||||
|
||||
*For installers: card SAFETY-31.*
|
||||
|
||||
> **Only at two or more inverters.** At the shipped roster of one this quantity
|
||||
> is identically zero for every reading, no claim is ever filed, and the status
|
||||
@@ -2345,7 +2470,9 @@ distinct state from "prices, all of them zero": the plan is marked provisional
|
||||
and arbitrage is disabled, per the degradation matrix. It never falls back to a
|
||||
price of zero, which would read as free electricity.
|
||||
|
||||
#### The hours the feed has not published yet (FCST-04)
|
||||
#### The hours the feed has not published yet
|
||||
|
||||
*For installers: card FCST-04.*
|
||||
|
||||
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
|
||||
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
|
||||
@@ -2388,29 +2515,29 @@ firm/provisional split whenever that split is what refused a trade. And
|
||||
since TARIFF-11 that row carries the provider plan itself:
|
||||
|
||||
```
|
||||
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s
|
||||
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead,
|
||||
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing,
|
||||
provisional
|
||||
Tariff: No fixed tariff set. Price plan from 412 s ago, for 48 hours ahead:
|
||||
day-ahead prices from the price feed, 60 prices over 33 hours,
|
||||
36 published and 24 estimated, one every 15 minutes or every hour
|
||||
```
|
||||
|
||||
⚠️ **The two halves of that row are not computed at the same moment, and the
|
||||
row says so.** Everything before the semicolon is the static-profile or Home
|
||||
row says so.** Everything before "Price plan" is the static-profile or Home
|
||||
Assistant price-entity sentence, regenerated on the **30 s tick** from options
|
||||
alone — no network. Everything after it is the **background refresh's**
|
||||
resolution, which runs every 1800 s and is served for up to 5400 s before it is
|
||||
retired; `fetched N s ago` is the age of the fetch that produced it and is the
|
||||
retired; `Price plan from N s ago` is the age of the fetch that produced it and is the
|
||||
only figure in the row that tells you whether the feed is still answering. On a
|
||||
site with a provider configured and no usable plan the row says that too,
|
||||
rather than falling silently back to the static half.
|
||||
|
||||
Read the plan half as five separate facts: which *product* the feed publishes
|
||||
and which *plugin* read it, how many **points** it carries and how many **whole
|
||||
hours** those points cover, how many of the points are **firm** (published for
|
||||
their own interval) against **carried** (filled from yesterday under §8.3), the
|
||||
**spacing** of the points in seconds — `900+3600` is the normal mixed steady
|
||||
state, quarter-hourly firm points beside hourly carried ones — and `provisional`
|
||||
whenever any point is carried.
|
||||
and whether it came from the feed or from your fixed tariff, how many
|
||||
**prices** it carries and how many **whole hours** they cover, how many are
|
||||
**published** (firm, for their own interval) against **estimated** (carried
|
||||
from yesterday under §8.3), and how far **apart** they are — "one every 15
|
||||
minutes or every hour" is the normal mixed steady state, quarter-hourly firm
|
||||
points beside hourly carried ones. The plan is provisional exactly when some
|
||||
prices are estimated, so the estimated count says it.
|
||||
|
||||
⚠️ **Points and hours are two different numbers and this row used to print one
|
||||
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
|
||||
@@ -2585,7 +2712,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
|
||||
— configure two of them and the add-on refuses to start, naming the conflict,
|
||||
because the add-on reads exactly one and the loser is silent. Use one.
|
||||
|
||||
### The three buckets — which price components can move a decision (TARIFF-17)
|
||||
### The three buckets — which price components can move a decision
|
||||
|
||||
*For installers: card TARIFF-17.*
|
||||
|
||||
**Read this before adding a price component of any kind.** The optimiser reads
|
||||
exactly two quantities off a price series, and nothing else:
|
||||
@@ -2641,7 +2770,9 @@ Three consequences worth having in mind:
|
||||
is the only ORDER component the add-on has. Anything added next belongs in one
|
||||
of the three, and saying which is part of adding it.
|
||||
|
||||
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18)
|
||||
### What `price_taxes_eur_kwh` is made of, and why yours is different
|
||||
|
||||
*For installers: card TARIFF-18.*
|
||||
|
||||
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
|
||||
Flemish residential bill — everything on your invoice that is *not* the energy
|
||||
@@ -2799,6 +2930,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
|
||||
@@ -3037,8 +3174,11 @@ stop control on the add-on page.
|
||||
|
||||
This part is for installers. The texts live in `app/messages.py` and follow
|
||||
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
|
||||
replaces the card. The full figures and reason codes are on the ERROR or
|
||||
WARNING line of the add-on log.
|
||||
replaces the card. The ERROR or WARNING line of the add-on log names the
|
||||
reason code. The full figures are in `events` in the `status` JSON, also for a
|
||||
stop restored after a restart, and on the DEBUG line logged right after it.
|
||||
That DEBUG line only shows with “Log level” (log_level) set to `debug`; the
|
||||
default `info` hides it.
|
||||
|
||||
| Title | Severity | id | Card |
|
||||
|---|---|---|---|
|
||||
@@ -3077,9 +3217,12 @@ reason code `OUTPUT_CLAMP`.
|
||||
**Load too high for battery.** The capacity-peak guard (SAFETY-07) asks for
|
||||
everything the inverter can discharge and the running 15-minute average is
|
||||
still projected over the peak limit, so the month's billed peak
|
||||
(capaciteitstarief) goes up. In shadow mode the guard's projection is made on a
|
||||
plant that did not respond (SAFETY-47), and the text says so. See *The
|
||||
capacity-peak guard*.
|
||||
(capaciteitstarief) goes up. It fires only when, for 10 s, the period would
|
||||
still end more than 10 W over the real limit (not the margin's aim) with the
|
||||
battery at full power. It notifies at most once per period; later overloads
|
||||
in the same period go to the log (SAFETY-81). In shadow mode the
|
||||
guard's projection is made on a plant that did not respond (SAFETY-47), and
|
||||
the text says so. See *The capacity-peak guard*.
|
||||
|
||||
**Battery stopped limiting the peak.** The peak guard may not discharge below
|
||||
`peak_soc_floor` (label “Peak reserve”, SAFETY-24). It also stops when the
|
||||
@@ -3095,8 +3238,8 @@ does not protect*.
|
||||
P1 meter, so every other guard, which reads that net figure, stays quiet. This
|
||||
has happened on this hardware once for 6 h 25 min. The alarm is latched,
|
||||
survives restarts, and is raised again every 5 min until control is started.
|
||||
The watts and the time window are in the log; the *Circulating power check*
|
||||
row shows the live watts. See *The circulating-power alarm*.
|
||||
The watts and the time window are in the log. The *Check for inverters working
|
||||
against each other* row shows the live watts. See *The circulating-power alarm*.
|
||||
|
||||
**An inverter is not delivering.** SAFETY-42. The unit stood well short of its
|
||||
share of the site target for longer than `delivery_hold_s`. A unit at 0 W is
|
||||
@@ -3114,16 +3257,91 @@ and command figures are in the log. See *The frozen-meter alarm*.
|
||||
|
||||
## The Web UI
|
||||
|
||||
The ingress panel shows live values, why the controller is commanding what it
|
||||
is, and a **Commissioning** checklist that names any problem in words. It also
|
||||
carries the three buttons: start/stop control, force a maintenance cycle, and
|
||||
abort one.
|
||||
The add-on page shows live values, why the battery does what it does, and a
|
||||
**Commissioning** checklist that names any problem in words. It also has three
|
||||
buttons: start or stop control, force a maintenance cycle, and abort one.
|
||||
Under the buttons is the last thing that happened, in a few words.
|
||||
|
||||
Since **UX-02** it has a second page, **Configuration** — see *There are TWO
|
||||
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
|
||||
inverter.** The configuration page reads and writes the add-on's own settings
|
||||
and can restart the add-on; the set of commands that reach the hardware is
|
||||
exactly the three buttons it always had.
|
||||
It has a second page, **Configuration**. See *There are TWO places to configure
|
||||
this add-on*, above. **It adds nothing you can do to the inverter.** It reads
|
||||
and writes the add-on's own settings and can restart the add-on. Only the three
|
||||
buttons reach the inverter.
|
||||
|
||||
*For installers: the Configuration page is UX-02. The line under the buttons is
|
||||
`messages.hint()`, the plain form of the newest entry of the add-on's own
|
||||
action log (`Controller.events`). The page no longer shows that log, but the
|
||||
`status` JSON still carries its last 40 entries as `events`: open the add-on
|
||||
page and add `status` to its address. The action-log tables in this document
|
||||
refer to it.*
|
||||
|
||||
### 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/`, including the Batteries screen and Settings, Simple.
|
||||
|
||||
*For installers: regenerate the screenshots with
|
||||
`tools/ux13_screenshots.sh`, which serves the page from test fixtures.*
|
||||
|
||||
### The Batteries screen and Settings, Simple
|
||||
|
||||
The **Batteries** tab has a card per battery: its level, and three lines.
|
||||
**Now** says what it does, **Today** lists the blocks planned for it (price
|
||||
trades, the monthly cycle, the peak limit), and **Why** says the reason. With
|
||||
battery modes on, each card also has the On/Off switch and the mode list; both
|
||||
apply at once. Today reads "Not planned yet" until the add-on has made the
|
||||
day's price runs.
|
||||
|
||||
An Off battery that the peak limit still needs says so, with the power it
|
||||
gives: "Off, but gives 0.5 kW for the peak limit."
|
||||
|
||||
**Settings** opens on the **Simple** view, or on the view you last picked:
|
||||
the peak limit, the batteries' switches and modes, and shadow mode, each
|
||||
marked "Applies right away" or "Applies after a restart". **Installer** shows
|
||||
every setting.
|
||||
|
||||
### Setup, for a new install
|
||||
|
||||
The **Setup** tab walks an installer through six steps: the meter, the
|
||||
inverters and batteries, solar (optional), prices, the peak limit, and a last
|
||||
check. Each step shows the same fields as Settings, and nothing is saved until
|
||||
the last step.
|
||||
|
||||
- **The kettle test** on the meter step reads the meter, then reads it again
|
||||
after you switch on a kettle. If the reading goes down, the add-on flips its
|
||||
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 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -3138,27 +3356,34 @@ 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. |
|
||||
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. |
|
||||
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. |
|
||||
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
|
||||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||||
| *month*: peak … | This month's peak, and how close it is to your peak limit. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. |
|
||||
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
|
||||
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
|
||||
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | Whether the inverter can be controlled, and the power range it accepts. |
|
||||
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
|
||||
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
|
||||
| *name*: … cannot be controlled, commands refused, “Setpoint entity” is not set | Whether each inverter still takes commands. |
|
||||
| Power request too high, Battery is not keeping up | See those titles under Notifications. Each shows how often it happened since the add-on started, and *fine now* once it has cleared. In shadow mode the row says what *would* have been sent. |
|
||||
| Direction check, Site direction check | Whether each inverter's power goes the way it is told. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
|
||||
| Circulating power check, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
||||
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
|
||||
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
||||
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
|
||||
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
|
||||
|
||||
### For installers: status page
|
||||
|
||||
The texts are in `app/messages.py`. The full figures of any alarm are on its
|
||||
ERROR or WARNING line in the add-on log.
|
||||
The texts are in `app/messages.py`. An alarm's ERROR or WARNING line names its
|
||||
reason code, and its full figures are on the DEBUG line right after it and in
|
||||
`events` in the `status` JSON.
|
||||
|
||||
- **Stuck-meter check: off** needs `meter_source: homewizard_local` (label
|
||||
“Where the meter reading comes from”), a `max_w` (“Inverter rating”) and a
|
||||
@@ -3176,7 +3401,22 @@ ERROR or WARNING line in the add-on log.
|
||||
***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can
|
||||
write, so the site refuses to run at part strength and holds 0 W.
|
||||
- **Direction check, Site direction check** are the sign checks (SAFETY-02/30).
|
||||
**Circulating power check** is SAFETY-31, **Delivery check** SAFETY-42.
|
||||
**Check for inverters working against each other** is SAFETY-31, **Delivery
|
||||
check** SAFETY-42.
|
||||
- **Month peak** and **15-minute periods measured** are TARIFF-01 and
|
||||
TARIFF-13. The store's state at start is the INFO line `Month peak store:
|
||||
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
|
||||
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
|
||||
ERROR line says when it clears and names `STALE_READING`. The full reason,
|
||||
with both possible causes, is on the DEBUG line after it.
|
||||
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
|
||||
meter goes quiet, "No meter reading for …", and an INFO line when readings
|
||||
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
|
||||
line names the missing reading.
|
||||
- The table at the top: **Why** is `messages.why()`, the arbiter's answer in
|
||||
words (`Resolution.explain()` stays on the log). **Tariff**, **Price
|
||||
history**, **Arbitrage** and **Shadow mode** keep every figure they had
|
||||
(TARIFF-11), in words.
|
||||
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
|
||||
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
|
||||
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
|
||||
@@ -3190,7 +3430,9 @@ battery power, circulating power, state of charge, maintenance phase and
|
||||
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||||
broker, and MQTT problems can never affect control.
|
||||
|
||||
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
|
||||
## Before you commission inverter B
|
||||
|
||||
*For installers: card ARCH-02. The tool is `tools/rehearse_roster.py`.*
|
||||
|
||||
The day the `inverters:` roster is declared, two things change that are
|
||||
**one-way on a live box and invisible until after the restart that applies
|
||||
@@ -3371,7 +3613,9 @@ maintenance bookkeeping — and if a cycle ran during it, correct
|
||||
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
|
||||
decision, not a discovery made at the inverter.
|
||||
|
||||
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
|
||||
## Before you deploy
|
||||
|
||||
*For installers: card PKG-09. The tool is `tools/predeploy_check.py`.*
|
||||
|
||||
Two ways a deploy has gone wrong on this system, both of which the Supervisor
|
||||
reports as success:
|
||||
|
||||
@@ -98,6 +98,12 @@ DROP_STALE = "stale link"
|
||||
DROP_CONTRADICTION = "contradicts itself"
|
||||
DROP_NO_INTEGER = "no integer setpoint in its interval"
|
||||
|
||||
# OPT-11: why a unit's MODE layer was not applied this tick. Only the first is
|
||||
# a fault; the other two are the rule working.
|
||||
MODE_FAULT = "mode range leaves out 0 W"
|
||||
MODE_RELEASED = "mode released by a higher claim"
|
||||
MODE_OUTRANKED = "hard limit leaves no room for the mode"
|
||||
|
||||
REFUSE_SCOPE = "mis-routed claim"
|
||||
REFUSE_EMPTY = "no unit available"
|
||||
REFUSE_SIGN = "opposite directions"
|
||||
@@ -213,6 +219,10 @@ class Allocation:
|
||||
# that calls `plan()` for its arithmetic gets None and does not care;
|
||||
# main.py stamps every record it puts on the write path.
|
||||
tick: int | None = None
|
||||
# OPT-11: ((unit, why), ...) for each unit whose mode range left out 0 W
|
||||
# this tick. That mode was dropped (see `unit_interval()`); main.py logs it
|
||||
# as a software fault. Empty on every tick without a mode.
|
||||
mode_faults: tuple = ()
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -246,11 +256,11 @@ class Allocation:
|
||||
block the failsafe is worse than the hole it closes.
|
||||
"""
|
||||
if tick is None or self.tick is None or self.tick != tick:
|
||||
return (f"the allocation record on hand was made on tick "
|
||||
f"{self.tick!r} and this is tick {tick!r} - a record whose "
|
||||
f"envelopes have moved cannot authorise a write")
|
||||
return (f"the power split on hand is from tick {self.tick!r}, "
|
||||
f"not this tick {tick!r}, and an old split cannot allow a "
|
||||
f"write")
|
||||
if not self.ok:
|
||||
return f"the allocator refused this tick ({self.explain()})"
|
||||
return f"no power split was possible this tick ({self.explain()})"
|
||||
if unit not in self.shares:
|
||||
return (f"{unit} has no share in this tick's allocation "
|
||||
f"({self.explain()})")
|
||||
@@ -290,6 +300,94 @@ class Allocation:
|
||||
return out
|
||||
|
||||
|
||||
def unit_interval(b, deciding_priority=None):
|
||||
"""OPT-11. `(lo, hi, note)`: the interval a unit is allocated into.
|
||||
|
||||
The hard limits (`b.lo`, `b.hi`) always apply. The unit's MODE range
|
||||
(`b.mode_lo`, `b.mode_hi`) narrows them unless `deciding_priority` is
|
||||
above the mode's own priority (arbiter._unit_modes() passes None when the
|
||||
modes hold, and the yielding limit's priority when they give way). `note` says why the
|
||||
mode did not apply, or is None when it did or there is none:
|
||||
|
||||
* MODE_FAULT: the mode range leaves out 0 W. A mode may forbid a direction
|
||||
and never force one, so this is a software fault and the mode is dropped.
|
||||
It is what keeps REFUSE_SIGN unreachable from a mode: the fill below
|
||||
moves every free unit away from 0 in ONE direction, and it starts from 0
|
||||
only while every interval holds 0.
|
||||
* MODE_RELEASED: a higher claim (the peak floor) decided the target.
|
||||
* MODE_OUTRANKED: the hard limits and the mode do not overlap. The hard
|
||||
limits win, as they always do.
|
||||
|
||||
A unit without mode fields (`mode_priority` absent or None) gets its hard
|
||||
limits, unchanged: that is every unit when battery modes are off.
|
||||
"""
|
||||
lo, hi = b.lo, b.hi
|
||||
prio = getattr(b, "mode_priority", None)
|
||||
if prio is None:
|
||||
return lo, hi, None
|
||||
mlo, mhi = b.mode_lo, b.mode_hi
|
||||
if not (mlo <= 0.0 <= mhi):
|
||||
return lo, hi, MODE_FAULT
|
||||
if deciding_priority is not None and deciding_priority > prio:
|
||||
return lo, hi, MODE_RELEASED
|
||||
nlo, nhi = max(lo, mlo), min(hi, mhi)
|
||||
if nlo > nhi:
|
||||
return lo, hi, MODE_OUTRANKED
|
||||
return nlo, nhi, None
|
||||
|
||||
|
||||
class _Narrowed:
|
||||
"""A unit's bounds with its interval replaced by `unit_interval()`'s."""
|
||||
__slots__ = ("lo", "hi")
|
||||
|
||||
def __init__(self, lo, hi):
|
||||
self.lo, self.hi = lo, hi
|
||||
|
||||
|
||||
def pin_w(b):
|
||||
"""The watts a unit's own SET places once held inside its hard limits,
|
||||
as `plan()` places a standing pin, or None when the unit has no SET."""
|
||||
pv = getattr(b, "pinned_w", None)
|
||||
if getattr(b, "gate", None) is None or pv is None:
|
||||
return None
|
||||
lo, hi = _int_envelope(b)
|
||||
return float(pv) if lo > hi else float(_clamp_int(round(float(pv)), lo, hi))
|
||||
|
||||
|
||||
def loop_bound(bounds, live=None):
|
||||
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
|
||||
|
||||
The sum, over live units that do not contradict themselves, of each unit's
|
||||
interval with its mode applied (`unit_interval()` with no higher claim). A
|
||||
pinned unit adds the span from 0 to its pin, so a probe or a maintenance
|
||||
pin stays reachable. Every term holds 0, so the sum does too.
|
||||
|
||||
`compute()` keeps the site target inside this, so the loop never asks for
|
||||
watts no battery may give: the allocator would report them as unmet and the
|
||||
saturation freeze would park the target on them (SAFETY-73 section 1).
|
||||
Pure, like `plan()`: it reads numbers and intervals and nothing else.
|
||||
"""
|
||||
lo_sum, hi_sum = 0.0, 0.0
|
||||
for u in sorted(bounds):
|
||||
b = bounds[u]
|
||||
if live is not None and u not in live:
|
||||
continue
|
||||
if getattr(b, "contradiction", False):
|
||||
continue
|
||||
p = pin_w(b)
|
||||
if p is not None:
|
||||
lo_sum += min(0.0, p)
|
||||
hi_sum += max(0.0, p)
|
||||
continue
|
||||
lo, hi, _ = unit_interval(b)
|
||||
lo, hi = _int_envelope(_Narrowed(lo, hi))
|
||||
if lo > hi:
|
||||
continue
|
||||
lo_sum += min(0.0, lo)
|
||||
hi_sum += max(0.0, hi)
|
||||
return lo_sum, hi_sum
|
||||
|
||||
|
||||
def _int_envelope(b):
|
||||
"""A unit's interval, narrowed to the integers a 1 W register can hold.
|
||||
|
||||
@@ -385,7 +483,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 +510,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 +599,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 +615,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 +634,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 +767,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"
|
||||
|
||||
@@ -175,6 +184,12 @@ CLAIM_SCOPE = {
|
||||
# the banner and the claim an install has had since SAFETY-02 shipped.
|
||||
# `Controller.cycle()` does not even arm the site detector there.
|
||||
"sign_site": SCOPE_SITE,
|
||||
# main.py: `Claim.limit("drain", P_MAINTENANCE, lo=...)`, SAFETY-78. At
|
||||
# N>=2, while a pack's maintenance drain is pinned, the site target may
|
||||
# not go below what that pin places: below it a sibling would have to
|
||||
# charge against the drain (circulation, refused, 0 W). A site `lo`,
|
||||
# never filed at N=1. DECISIONS/SAFETY-78-drain-at-two.md.
|
||||
"drain": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
@@ -226,12 +241,25 @@ CLAIM_SCOPE = {
|
||||
# reaches the allocator, so a unit-scoped claim is the shape that sentence
|
||||
# asks for rather than an evasion of it.
|
||||
"pack": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("refused", P_SAFETY, ...)` and, during a probe,
|
||||
# `Claim.set("refused", P_SAFETY, +-PROBE_W)`. SAFETY-73: THIS battery gives
|
||||
# nothing in one direction, measured on THIS unit's own reading. A hard unit
|
||||
# limit that always intersects, so the share goes to the siblings. Filed at
|
||||
# N>1 only; at N=1 SAFETY-52's law in compute() does the same job.
|
||||
"refused": SCOPE_UNIT,
|
||||
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
|
||||
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
|
||||
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
|
||||
# this pack to its floor, charge this pack to full, recalibrate this pack's
|
||||
# coulomb counter. Two packs recalibrate on their own schedules.
|
||||
"maintenance": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
|
||||
# battery's mode, chosen per battery by its owner. A unit claim that never
|
||||
# enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the
|
||||
# hard limits, and it gives way to a site limit above P_MODE only as far as
|
||||
# that limit needs (`_unit_modes()`). At N=1 it routes to the site, and
|
||||
# `resolve()` takes it out of stage 2 for the same reason.
|
||||
"mode": SCOPE_UNIT,
|
||||
}
|
||||
|
||||
|
||||
@@ -359,6 +387,13 @@ class UnitBounds:
|
||||
pinned_w: float | None = None
|
||||
pinned_by: str | None = None
|
||||
contradiction: bool = False
|
||||
# OPT-11: the mode layer, APART from `lo`/`hi` above, which stay the hard
|
||||
# limits. `mode_priority` is None when no mode claim applies. Whether the
|
||||
# mode binds is decided at stage 3 (`allocator.unit_interval()`), because it
|
||||
# depends on the priority that decided the SITE target.
|
||||
mode_lo: float = -inf
|
||||
mode_hi: float = inf
|
||||
mode_priority: int | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
if self.contradiction:
|
||||
@@ -404,6 +439,15 @@ class Resolution:
|
||||
# rules drifting apart: this is set by the same line that picks the winner
|
||||
# `explain()` names.
|
||||
winner_priority: int | None = None
|
||||
# OPT-11. The higher of the winning set's priority and the priority of the
|
||||
# limit that bound `target_w`. Whether the modes hold is decided from the
|
||||
# limits themselves (`_site_mode()`, `_unit_modes()`): a mode gives way
|
||||
# only as far as a limit above it (the peak floor) needs, so a mode `hi`
|
||||
# never meets a site `lo`.
|
||||
deciding_priority: int | None = None
|
||||
# OPT-11 at N=1: why the site's mode layer was dropped this tick, or None.
|
||||
# At N>1 the same fact is on `allocation.mode_faults`, per unit.
|
||||
mode_fault: str | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""One line an operator can act on - this is the point of the module."""
|
||||
@@ -427,7 +471,8 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
|
||||
of the fault this card removes, and the reason `resolve()` partitions rather
|
||||
than filters twice.
|
||||
"""
|
||||
sets = sorted((c for c in unit_claims if c.kind == SET),
|
||||
sets = sorted((c for c in unit_claims
|
||||
if c.kind == SET and c.source != MODE_SOURCE),
|
||||
key=lambda c: c.priority, reverse=True)
|
||||
gate = pinned_w = pinned_by = None
|
||||
if sets:
|
||||
@@ -435,19 +480,30 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
|
||||
|
||||
lo, hi = -inf, inf
|
||||
lo_src = hi_src = None
|
||||
# OPT-11: a mode claim never joins the hard limits, so a mode cannot make a
|
||||
# unit contradict itself or narrow a pin. A mode SET is not a thing: only
|
||||
# limits are read. The lowest mode priority is kept (the first to release).
|
||||
mode_lo, mode_hi, mode_priority = -inf, inf, None
|
||||
for c in unit_claims:
|
||||
if c.kind != LIMIT or (gate is not None and c.priority < gate):
|
||||
continue
|
||||
if c.source == MODE_SOURCE:
|
||||
mode_lo, mode_hi = max(mode_lo, c.lo), min(mode_hi, c.hi)
|
||||
mode_priority = (c.priority if mode_priority is None
|
||||
else min(mode_priority, c.priority))
|
||||
continue
|
||||
if c.lo > lo:
|
||||
lo, lo_src = c.lo, c.source
|
||||
if c.hi < hi:
|
||||
hi, hi_src = c.hi, c.source
|
||||
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
|
||||
contradiction=lo > hi)
|
||||
contradiction=lo > hi, mode_lo=mode_lo, mode_hi=mode_hi,
|
||||
mode_priority=mode_priority)
|
||||
|
||||
|
||||
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None) -> tuple:
|
||||
scope_errors=(), site_priority=None,
|
||||
deciding_priority=None) -> tuple:
|
||||
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
|
||||
|
||||
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
|
||||
@@ -485,11 +541,13 @@ def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
"""
|
||||
return allocate_record(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority)[:2]
|
||||
site_priority=site_priority,
|
||||
deciding_priority=deciding_priority)[:2]
|
||||
|
||||
|
||||
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
scope_errors=(), site_priority=None, tick=None) -> tuple:
|
||||
scope_errors=(), site_priority=None, tick=None,
|
||||
deciding_priority=None) -> tuple:
|
||||
"""`allocate()` plus the record - `(shares, allocated, allocation)`.
|
||||
|
||||
`tick` is SAFETY-28's freshness token: it identifies the tick the record is
|
||||
@@ -501,7 +559,7 @@ def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
|
||||
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
|
||||
scope_errors=scope_errors,
|
||||
site_priority=site_priority, site_key=ACTUATOR_SITE,
|
||||
tick=tick)
|
||||
tick=tick, deciding_priority=deciding_priority)
|
||||
return rec.shares, rec.ok, rec
|
||||
|
||||
|
||||
@@ -538,19 +596,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,
|
||||
@@ -600,6 +665,12 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
written down.
|
||||
"""
|
||||
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
|
||||
# OPT-11: a mode claim on the site (N=1, where every source routes there)
|
||||
# never enters stage 2's `lo > hi` test. It is applied after it, below, by
|
||||
# the same release rule stage 3 uses.
|
||||
site_modes = [c for c in site_claims if c.source == MODE_SOURCE]
|
||||
if site_modes:
|
||||
site_claims = [c for c in site_claims if c.source != MODE_SOURCE]
|
||||
if units is None:
|
||||
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
|
||||
units = tuple(sorted(units))
|
||||
@@ -611,6 +682,8 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
|
||||
# `claims`, so this is the shipped arbiter on the shipped list.
|
||||
out = site_resolve(site_claims)
|
||||
if site_modes:
|
||||
_site_mode(out, site_modes, site_claims)
|
||||
|
||||
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
|
||||
#
|
||||
@@ -643,10 +716,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
|
||||
|
||||
@@ -325,12 +325,13 @@ class CirculationWatchdog:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
|
||||
self.reason = (
|
||||
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
|
||||
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
|
||||
f"continuously - the meter cannot see it, because it nets to zero "
|
||||
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
|
||||
f"{self.cfg.hold_s:.0f} s)")
|
||||
f"the inverters for {held:.0f} s, peaking at {self.peak_w:.0f} W, "
|
||||
f"over the limits of {self.cfg.min_w:.0f} W and "
|
||||
f"{self.cfg.hold_s:.0f} s. The meter cannot see it, because it "
|
||||
f"nets to zero there")
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
|
||||
@@ -142,6 +142,8 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from . import messages
|
||||
|
||||
# The reason code, distinct from any saturation-freeze code by construction:
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
@@ -234,31 +236,46 @@ CLEAR_CYCLES = 3
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something "
|
||||
"upstream computed a setpoint the actuator cannot deliver.")
|
||||
# UX-10: the reasons below are quoted by a WARNING or ERROR line, so they
|
||||
# follow STYLE.md's rule for a quoted reason (test_messages.py part 6): no
|
||||
# final full stop, the line adds it. The line around them says "software
|
||||
# fault" for an over-rating, so the reason does not. Each is one sentence, so
|
||||
# the logged line stays within 3 sentences and about 30 words.
|
||||
|
||||
|
||||
def loop_clamp_note(d) -> str:
|
||||
"""The loop-side alarm text for one `control.Decision`, or "".
|
||||
|
||||
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
|
||||
only a command over the rating is a BUG SIGNAL. Every number is the
|
||||
only a command over the rating is a software fault. Every number is the
|
||||
decision's own, i.e. from the tick that clamped.
|
||||
"""
|
||||
if d.clamped:
|
||||
return (f"{REASON_CODE}: the loop computed {d.requested_w:+.0f} W, over "
|
||||
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to "
|
||||
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
|
||||
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
|
||||
return (f"{REASON_CODE}: computed {d.requested_w:+.0f} W, cut to the "
|
||||
f"{d.clamped_w:+.0f} W rating, command now {d.target_w:+.0f} W")
|
||||
if d.i_clamped:
|
||||
return (f"{REASON_CODE}: the loop's integrator reached its "
|
||||
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command "
|
||||
f"this cycle was {d.target_w:+.0f} W. Brief "
|
||||
f"engagements after a large load or solar step are normal. If it "
|
||||
f"persists, check that the battery is following its commands.")
|
||||
return (f"{REASON_CODE}: the correction the add-on builds up reached "
|
||||
f"its {d.i_bound_w:+.0f} W limit, command now "
|
||||
f"{d.target_w:+.0f} W")
|
||||
return ""
|
||||
|
||||
|
||||
def write_clamp_note(named) -> str:
|
||||
"""The write-side alarm text: `named` is `[(inverter name or None,
|
||||
ClampResult)]` for every inverter whose write was cut back this cycle.
|
||||
|
||||
One inverter reads as `describe()`. Several read as one sentence of
|
||||
asked/sent pairs, so the line stays one sentence however many inverters
|
||||
there are. Which limit cut each one back is on its own action-log row
|
||||
(`describe()`) and in the notification."""
|
||||
if len(named) == 1:
|
||||
name, r = named[0]
|
||||
return f"{name}: {r.describe()}" if name else r.describe()
|
||||
return f"{REASON_CODE}: " + ", ".join(
|
||||
f"{name} asked {r.requested_w:+.0f} W and sent {r.applied_w:+.0f} W"
|
||||
for name, r in named)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
"""What the guard did, with the RAW value preserved.
|
||||
@@ -275,10 +292,12 @@ class ClampResult:
|
||||
bound: str = "" # "" | "max_w" | "device" - which stage bit
|
||||
|
||||
def describe(self) -> str:
|
||||
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
|
||||
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} "
|
||||
f"bound (rating {self.limit_w:.0f} W) - something upstream is "
|
||||
f"winding")
|
||||
# The sent value IS the limit that bound, so the rating is not
|
||||
# repeated: on "max_w" it equals the rating, and on "device" the
|
||||
# rating was not what cut it back.
|
||||
return (f"{REASON_CODE}: asked {self.requested_w:+.0f} W and sent "
|
||||
f"{self.applied_w:+.0f} W, "
|
||||
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}")
|
||||
|
||||
|
||||
def is_commandable(value) -> bool:
|
||||
|
||||
@@ -413,10 +413,9 @@ function showProblems(payload){
|
||||
save.disabled = total > 0;
|
||||
if(total){
|
||||
note(banner, 'bad', 'This configuration would not start.',
|
||||
'The add-on refuses to run on it and would stop with exit 78. '
|
||||
+ 'Saving is blocked until these are fixed; nothing has been written. '
|
||||
+ 'Each message below is the add-on\\u2019s own words, from the same '
|
||||
+ 'check that runs at startup.');
|
||||
'The add-on would refuse to start on it. Saving is blocked until these '
|
||||
+ 'are fixed, and nothing has been written. Each message below comes '
|
||||
+ 'from the same check that runs at start.');
|
||||
banner.insertBefore(banner.lastChild, banner.firstChild);
|
||||
msg(total + (total === 1 ? ' problem' : ' problems')
|
||||
+ ' - saving is blocked', 'bad');
|
||||
|
||||
@@ -289,7 +289,10 @@ def problems_by_field(problems, keys):
|
||||
cross-field rule is about all of its fields, and hiding it from one of them
|
||||
would be this card's own defect in miniature.
|
||||
"""
|
||||
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
|
||||
# ⚠️ `.` BEFORE THE KEY DOES NOT COUNT (UX-08 2d): `inverters.max_w`
|
||||
# names a setting of one inverter in the list, which is changed under
|
||||
# `inverters`, and it must not also land beside the flat `max_w`.
|
||||
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_.])" + re.escape(key)
|
||||
+ r"(?![A-Za-z0-9_])")) for key in keys]
|
||||
by_key, unanchored = {}, []
|
||||
for problem in problems:
|
||||
|
||||
@@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure.
|
||||
"""
|
||||
|
||||
import math
|
||||
from math import inf
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -265,6 +266,38 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||
# `slew_limit_w()` and therefore with the rail.
|
||||
SLEW_LEAD_STEPS = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
|
||||
#
|
||||
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
|
||||
# saturation freeze below holds the command where it stood (the recorded
|
||||
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
|
||||
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
|
||||
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
|
||||
# carried between cycles like `sat_count`, and left again when the loop wants
|
||||
# the other direction, when a small probe is answered, or when the caller
|
||||
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
|
||||
#
|
||||
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
|
||||
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
|
||||
# test_refusal.py pins the inequality. The cadence and length are the owner's
|
||||
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
|
||||
# (17 s is how long a healthy reading needs to show a command).
|
||||
REFUSED = "battery refuses"
|
||||
PROBING = "battery probe"
|
||||
PROBE_W = 200.0
|
||||
PROBE_ANSWER_W = PROBE_W / 2
|
||||
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
|
||||
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
|
||||
# (review of #141). The capture's refusals read -4 W.
|
||||
REFUSED_BELOW_W = PROBE_ANSWER_W
|
||||
PROBE_EVERY_S = 900.0
|
||||
PROBE_S = 34.0
|
||||
|
||||
# OPT-11. The loop's reason when the battery modes, not the meter, set the
|
||||
# command: every battery that may move this way is at its mode's limit.
|
||||
MODE_BOUND = "mode-bound"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -346,6 +379,9 @@ class Decision:
|
||||
i_bound_w: float = 0.0
|
||||
requested_w: float = 0.0
|
||||
clamped_w: float = 0.0
|
||||
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
|
||||
# charge, 0 none), or None when the caller does not carry it.
|
||||
refused: int | None = None
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -442,6 +478,9 @@ def compute(
|
||||
tuning: Tuning,
|
||||
sat_count: int = 0,
|
||||
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).
|
||||
|
||||
@@ -449,8 +488,27 @@ def compute(
|
||||
`grid_w` net grid power, + = importing
|
||||
`actual_w` what the inverter reports it is doing, + = discharging
|
||||
`i_w` the carried integrator, or None to seed it from `prev_w`
|
||||
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
||||
(the `i_w=None` equivalence path does)
|
||||
`probe` the caller's clock says a refused direction may try PROBE_W now
|
||||
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
|
||||
their modes applied (`allocator.loop_bound()`), or None. See
|
||||
MODE_BOUND below. None is the law as it was.
|
||||
"""
|
||||
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
|
||||
@@ -553,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.
|
||||
@@ -739,6 +806,43 @@ def compute(
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
|
||||
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
|
||||
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
|
||||
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
|
||||
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# limit because it only removes an ask the battery is not meeting.
|
||||
if refused is not None:
|
||||
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||
and abs(prev_w) > tuning.saturation_w
|
||||
and abs(actual_w) < REFUSED_BELOW_W):
|
||||
refused = 1 if prev_w > 0 else -1
|
||||
if refused and refused * i_w < 0:
|
||||
refused = 0 # the loop wants the other way
|
||||
elif refused and refused * actual_w >= PROBE_ANSWER_W:
|
||||
refused = 0 # it delivers: a probe answered,
|
||||
# or a slow reading arriving
|
||||
elif refused:
|
||||
wants = refused * i_w > 0
|
||||
i_w = 0.0
|
||||
target = refused * PROBE_W if probe and wants else 0.0
|
||||
reason = PROBING if probe and wants else REFUSED
|
||||
|
||||
# --- 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
|
||||
@@ -746,10 +850,17 @@ def compute(
|
||||
# 10 W simply avoids a visible staircase on dashboards.
|
||||
step = max(1, int(tuning.step_w))
|
||||
target = round(target / step) * step
|
||||
if bound is not None:
|
||||
# OPT-11: rounding must not step outside the bound. The bound holds
|
||||
# 0 W, so one step toward 0 is always inside it.
|
||||
if target > bound[1]:
|
||||
target -= step
|
||||
elif target < bound[0]:
|
||||
target += step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
float(clamped_w))
|
||||
float(clamped_w), refused)
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -175,6 +175,8 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from . import messages
|
||||
|
||||
|
||||
class FormulaInvalid(Exception):
|
||||
"""`price_formula` cannot be used. Raised by the parser, refused by
|
||||
@@ -285,30 +287,28 @@ _LEGS = {
|
||||
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
|
||||
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
||||
'"expect_incl_vat":15.55}}'),
|
||||
when=("Leave it empty unless “Dynamic price source” (price_provider) "
|
||||
"is 'custom_formula'."),
|
||||
when="Leave it empty unless “Dynamic price source” is 'custom_formula'.",
|
||||
zero_factor=" and trading never finds a price difference",
|
||||
flat="use “Fixed or day/night tariff” (static_profile) instead",
|
||||
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||
card_required=True,
|
||||
card_note="The add-on needs it to check your two numbers",
|
||||
card_causes=("that network charges are in “Distribution, levies and VAT” "
|
||||
"(price_taxes_eur_kwh), not in the offset"),
|
||||
card_causes=("the units, the VAT, and that network charges are not "
|
||||
"in the offset"),
|
||||
),
|
||||
EXPORT_FORMULA_OPTION: _Leg(
|
||||
option=EXPORT_FORMULA_OPTION,
|
||||
what="injection",
|
||||
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
||||
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
|
||||
when=("Fill it in only if your injection price follows the market. It "
|
||||
"is used whatever the price source is."),
|
||||
when="Fill it in only if your injection price follows the market.",
|
||||
zero_factor="",
|
||||
flat="use “Injection price” (export_price_eur_kwh) instead",
|
||||
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||
card_required=False,
|
||||
card_note=("It is optional here. Without it the add-on only checks "
|
||||
"the size of your two numbers"),
|
||||
card_causes="that vat_pct is 0 for injection",
|
||||
card_causes="the units, and that the VAT is 0 for injection",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -527,10 +527,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise FormulaInvalid(
|
||||
f"{option} is not valid JSON ({e}). Write it on one line, for "
|
||||
f"example\n"
|
||||
f" {leg.example}\n"
|
||||
f"{leg.when}")
|
||||
f"{messages.not_valid(option, e)}. Copy the example from the "
|
||||
f"documentation. {leg.when}")
|
||||
if not isinstance(value, dict):
|
||||
raise FormulaInvalid(
|
||||
f"{option} is {type(value).__name__}, not a JSON object with "
|
||||
@@ -685,9 +683,9 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
||||
leg = _LEGS[spec.option]
|
||||
return (
|
||||
f"{spec.option} does not match its own card: it gives {got:.4f} "
|
||||
f"{spec.units} and the card says {spec.card_expect_incl_vat:g}. Check "
|
||||
f"the units, the VAT and {leg.card_causes}.")
|
||||
f"{spec.option} gives {got:.4f} "
|
||||
f"{messages.UNIT_WORDS.get(spec.units, spec.units)}, the card says "
|
||||
f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -424,15 +424,17 @@ class FreezeWatchdog:
|
||||
# page was telling, printed into the log and the HA notification where
|
||||
# it would outlive the page.
|
||||
ceiling = rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max)
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
|
||||
capped = ("" if ceiling >= abs(float(max_w))
|
||||
else f", capped from max_w {float(max_w):.0f} W by the "
|
||||
f"setpoint device's own rating")
|
||||
f"inverter's own rating")
|
||||
self.reason = (
|
||||
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
|
||||
f"without its measurement changing while the command stands at "
|
||||
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W rail{capped} "
|
||||
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
|
||||
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)")
|
||||
f"METER_FROZEN: the meter has not changed for "
|
||||
f"{float(unchanged_s):.0f} s while the command is "
|
||||
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W limit"
|
||||
f"{capped}. Limits: unchanged over {self.cfg.hold_s:.0f} s, "
|
||||
f"command over "
|
||||
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W")
|
||||
if self.active:
|
||||
return False
|
||||
self.active = True
|
||||
|
||||
@@ -594,31 +594,26 @@ class Freshness:
|
||||
# ("what did the register do" against "can I trust this entity"). One
|
||||
# helper serving both would have to drop whichever half the other does
|
||||
# not need, and the half it dropped would be the operator's next step.
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
|
||||
f"CANNOT TELL THEM APART: the register may have stopped reporting "
|
||||
f"reality, or the source may be unable to comply with the command "
|
||||
f"- a pack the BMS is refusing holds still under any setpoint, and "
|
||||
f"an honest register then reads exactly like a frozen one. Either "
|
||||
f"way the number cannot be read as this command's effect, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
# UX-10: two plain sentences (test_messages.py part 6). SAFETY-56's
|
||||
# two explanations stay, in this order, and so does the admission
|
||||
# that this check cannot tell them apart. The reading is treated as
|
||||
# missing either way, which the log line around this says. The
|
||||
# limits (`self.bound`) are on main.py's DEBUG line beside it, so the
|
||||
# logged line stays within 3 sentences.
|
||||
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
|
||||
# in its own slot: the register's unchanged time is in the sentence
|
||||
# above, the WINDOW this verdict actually rests on is here, and so is
|
||||
# how many times this unit has been round the loop. Without the second
|
||||
# one the sentence reads as though 92 s of unchanged register were what
|
||||
# was compared against the 35 s limit, and it was not.
|
||||
if self.releases:
|
||||
self.reason += (
|
||||
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
|
||||
f"opened when the previous one was withdrawn, not when the "
|
||||
f"register last moved - this unit has now been graded "
|
||||
f"{self.releases + 1} times]")
|
||||
# in its own slot: the register's unchanged time, the WINDOW this
|
||||
# verdict actually rests on after a release (`regraded`), and how many
|
||||
# times this unit has been round the loop. Without the second one the
|
||||
# sentence reads as though 92 s of unchanged register were what was
|
||||
# compared against the 35 s limit, and it was not.
|
||||
regraded = (f", checked {self.releases + 1} times over a {held_s:.0f} s "
|
||||
f"window since the last stop was withdrawn"
|
||||
if self.releases else "")
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} W unchanged for {shown_s:.0f} s "
|
||||
f"while the command moved {moved_w:.0f} W to {standing_w:+.0f} W"
|
||||
f"{regraded}. The reading may have stopped, or the battery may be "
|
||||
f"unable to follow the command, and this check cannot tell which")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page.
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""UX-13: Home Assistant's entities, for the settings page's entity picker.
|
||||
|
||||
A read of `GET /api/states` through the add-on's own session (hass.states()),
|
||||
cached for 30 s and trimmed to what a picker shows. Nothing here writes, so
|
||||
UX-02 §6 holds. When Home Assistant does not answer, the last good list is
|
||||
served with its age, or an empty one that says why: the route never fails.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
CACHE_S = 30.0
|
||||
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
|
||||
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
|
||||
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
|
||||
_TO_W = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1e6}
|
||||
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
|
||||
|
||||
|
||||
def row(state: dict) -> dict | None:
|
||||
"""One state object -> {entity_id, name, state, unit, device_class}."""
|
||||
if not isinstance(state, dict) or not isinstance(state.get("entity_id"), str):
|
||||
return None
|
||||
attrs = state.get("attributes") if isinstance(state.get("attributes"), dict) else {}
|
||||
return {"entity_id": state["entity_id"],
|
||||
"name": str(attrs.get("friendly_name") or state["entity_id"]),
|
||||
"state": state.get("state"),
|
||||
"unit": attrs.get("unit_of_measurement"),
|
||||
"device_class": attrs.get("device_class")}
|
||||
|
||||
|
||||
def watts(r: dict | None):
|
||||
"""A power entity's reading in W, or None when it is not a number in a
|
||||
power unit (the setup wizard's kettle test)."""
|
||||
if not r or r.get("unit") not in _TO_W:
|
||||
return None
|
||||
try:
|
||||
value = float(r.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return value * _TO_W[r["unit"]] if value == value else None
|
||||
|
||||
|
||||
def matches(r: dict, kind: str) -> bool:
|
||||
"""Is `r` what a picker of this kind offers first? `kind` is one of
|
||||
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
|
||||
unit, cls = r.get("unit"), r.get("device_class")
|
||||
if kind in ("power", "power_w"):
|
||||
return cls == "power" or unit in _POWER_UNITS
|
||||
if kind == "energy":
|
||||
return cls == "energy" or unit in _ENERGY_UNITS
|
||||
if kind == "pct":
|
||||
return unit == "%"
|
||||
if kind == "number":
|
||||
return r["entity_id"].startswith(("number.", "input_number."))
|
||||
return True
|
||||
|
||||
|
||||
class EntityCache:
|
||||
"""The last entity list read from Home Assistant, at most CACHE_S old."""
|
||||
|
||||
def __init__(self, hass, clock=time.monotonic):
|
||||
self.hass = hass
|
||||
self.clock = clock
|
||||
self.rows: dict | None = None
|
||||
self.read_at: float | None = None
|
||||
self.tried_at: float | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def get(self, max_age: float = CACHE_S
|
||||
) -> tuple[dict | None, float | None]:
|
||||
"""(rows by entity id, age in s), or (None, None) if never read.
|
||||
`max_age` is how old a try may be before HA is asked again: the setup
|
||||
wizard's last step asks for a list at most a few seconds old."""
|
||||
async with self._lock: # one HA read however many pickers ask
|
||||
now = self.clock()
|
||||
# A failed read also waits CACHE_S, so polls while HA is down do
|
||||
# not queue one 10 s read each behind this lock.
|
||||
if self.tried_at is None or now - self.tried_at >= max_age:
|
||||
self.tried_at = now
|
||||
states = await self.hass.states()
|
||||
if states is not None:
|
||||
rows = (row(s) for s in states)
|
||||
self.rows = {r["entity_id"]: r for r in rows if r}
|
||||
self.read_at = now
|
||||
if self.read_at is None:
|
||||
return None, None
|
||||
return self.rows, now - self.read_at
|
||||
|
||||
async def reading(self, entity_id: str) -> dict:
|
||||
"""One entity read now, not from the list: the kettle test compares
|
||||
two readings seconds apart, which a 30 s list cannot. A read only."""
|
||||
r = row(await self.hass.state(entity_id)) if entity_id else None
|
||||
return {"entity_id": entity_id, "found": r is not None,
|
||||
"state": r and r["state"], "unit": r and r["unit"],
|
||||
"watts": watts(r)}
|
||||
|
||||
def peek(self) -> dict | None:
|
||||
"""The cached rows if they are fresh, without asking Home Assistant."""
|
||||
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
|
||||
return None
|
||||
return self.rows
|
||||
|
||||
async def payload(self, kind: str = "") -> dict:
|
||||
"""The route's JSON: matching entities first, then the rest."""
|
||||
rows, age = await self.get()
|
||||
if rows is None:
|
||||
return {"available": False, "text": DOWN_TEXT, "entities": []}
|
||||
ordered = sorted(rows.values(), key=lambda r: r["entity_id"])
|
||||
first = [dict(r, match=True) for r in ordered if matches(r, kind)]
|
||||
rest = [dict(r, match=False) for r in ordered if not matches(r, kind)]
|
||||
out = {"available": True, "age_s": round(age, 1), "entities": first + rest}
|
||||
if age >= CACHE_S:
|
||||
out["text"] = STALE_TEXT
|
||||
return out
|
||||
@@ -16,12 +16,53 @@ _LOG = logging.getLogger("goodwe.hass")
|
||||
CORE_API = "http://supervisor/core/api"
|
||||
SUPERVISOR_API = "http://supervisor"
|
||||
BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
# What the Supervisor's proxy answers while Home Assistant restarts.
|
||||
UNAVAILABLE = (502, 503, 504)
|
||||
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
|
||||
|
||||
|
||||
class Outage:
|
||||
"""UX-10: Home Assistant not answering, said in one plain line per outage.
|
||||
|
||||
A restart takes the REST API and the meter websocket down together, and
|
||||
each used to log the library's own text for every entity and service
|
||||
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
|
||||
not str"). Now the first channel to fail says it once at WARNING, the raw
|
||||
text goes to DEBUG, and the last channel to recover says so at INFO.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.down_on = set()
|
||||
|
||||
def down(self, channel: str, what: str, detail) -> None:
|
||||
if not self.down_on:
|
||||
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
|
||||
"restarting. The add-on keeps trying.", what)
|
||||
self.down_on.add(channel)
|
||||
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
|
||||
|
||||
def up(self, channel: str) -> None:
|
||||
if channel in self.down_on:
|
||||
self.down_on.discard(channel)
|
||||
if not self.down_on:
|
||||
_LOG.info("Home Assistant answers again.")
|
||||
|
||||
|
||||
OUTAGE = Outage()
|
||||
|
||||
|
||||
class ReadOnly(RuntimeError):
|
||||
"""A service call on a read-only HomeAssistant (UX-15 setup-only mode)."""
|
||||
|
||||
|
||||
class HomeAssistant:
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None):
|
||||
def __init__(self, session: aiohttp.ClientSession, token: str | None = None,
|
||||
*, read_only: bool = False):
|
||||
self.session = session
|
||||
self.token = token or os.environ.get("SUPERVISOR_TOKEN", "")
|
||||
# UX-15: setup-only mode reads Home Assistant and calls no service.
|
||||
# Fixed at construction; `call()` refuses before it sends anything.
|
||||
self._read_only = bool(read_only)
|
||||
self._last_moan: dict[str, float] = {}
|
||||
if not self.token:
|
||||
_LOG.error("SUPERVISOR_TOKEN missing - is this running as an add-on?")
|
||||
@@ -58,10 +99,18 @@ class HomeAssistant:
|
||||
async with self.session.get(
|
||||
f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"read {entity_id}: {resp.status}")
|
||||
return None
|
||||
OUTAGE.up("api")
|
||||
if resp.status == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
obj = await resp.json()
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection", f"read {entity_id}: {err}")
|
||||
return None
|
||||
# SAFETY-61: an undecodable body is an absence like any other failed
|
||||
# read, never an exception into the control loop.
|
||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
||||
@@ -73,6 +122,39 @@ class HomeAssistant:
|
||||
return None
|
||||
return obj
|
||||
|
||||
async def states(self) -> list | None:
|
||||
"""Every entity's state object (`GET /api/states`), or None when Home
|
||||
Assistant does not answer. A read only (UX-13 entity picker)."""
|
||||
try:
|
||||
async with self.session.get(
|
||||
f"{CORE_API}/states", headers=self._headers,
|
||||
timeout=STATES_TIMEOUT_S
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"read all states: {resp.status}")
|
||||
return None
|
||||
OUTAGE.up("api")
|
||||
resp.raise_for_status()
|
||||
obj = await resp.json()
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection", f"read all states: {err}")
|
||||
return None
|
||||
# A timeout's own text is empty, so the line names the wait instead
|
||||
# (UX-13 P1 test, c).
|
||||
except TimeoutError:
|
||||
self._moan("read:*", "Reading the entity list failed: no answer "
|
||||
"within %s seconds.", STATES_TIMEOUT_S)
|
||||
return None
|
||||
except (aiohttp.ClientError, ValueError) as err:
|
||||
self._moan("read:*", "Reading the entity list failed: %s",
|
||||
str(err) or type(err).__name__)
|
||||
return None
|
||||
if not isinstance(obj, list):
|
||||
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
|
||||
return None
|
||||
return obj
|
||||
|
||||
async def number(self, entity_id: str, invert: bool = False) -> float | None:
|
||||
"""Numeric state, or None. Never substitutes a default."""
|
||||
obj = await self.state(entity_id)
|
||||
@@ -107,23 +189,53 @@ class HomeAssistant:
|
||||
return None
|
||||
return (_f("min"), _f("max"), _f("step"))
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return self._read_only
|
||||
|
||||
async def call(self, domain: str, service: str, data: dict) -> bool:
|
||||
if self._read_only:
|
||||
# Every service call, `number.set_value` included, goes through
|
||||
# here, so setup-only mode has no way to command (DECISIONS UX-15).
|
||||
_LOG.error("Setup-only mode called no service, because it may not "
|
||||
"command anything.")
|
||||
raise ReadOnly(f"{domain}.{service} refused: setup-only mode")
|
||||
try:
|
||||
async with self.session.post(
|
||||
f"{CORE_API}/services/{domain}/{service}",
|
||||
headers=self._headers, json=data, timeout=10,
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"service {domain}.{service}: {resp.status}")
|
||||
return False
|
||||
OUTAGE.up("api")
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
# 400 here usually means out-of-range for the entity - the
|
||||
# value is rejected outright, not clamped. Always log it:
|
||||
# silently dropped commands are how a controller ends up
|
||||
# believing something the hardware never did.
|
||||
# UX-10: plain words. While Home Assistant loads its
|
||||
# entities it answers 400 with only "400: Bad Request",
|
||||
# which says nothing, so only a body with more is quoted.
|
||||
said = body.strip()[:200]
|
||||
if said.lower() in (f"{resp.status}: {resp.reason}".lower(),
|
||||
""):
|
||||
said = "no details"
|
||||
entity = (data.get("entity_id", "no entity")
|
||||
if isinstance(data, dict) else "no entity")
|
||||
self._moan(f"svc:{domain}.{service}",
|
||||
"service %s.%s rejected (%s): %s",
|
||||
domain, service, resp.status, body[:200])
|
||||
"Home Assistant refused %s.%s for %s (error %s, "
|
||||
"%s). The entity may still be loading, or the "
|
||||
"value is out of range.", domain, service,
|
||||
entity, resp.status, said)
|
||||
return False
|
||||
return True
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection",
|
||||
f"service {domain}.{service}: {err}")
|
||||
return False
|
||||
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
|
||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
||||
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
|
||||
|
||||
@@ -317,6 +317,18 @@ class Inverter:
|
||||
tripped=bool(data.get(self.key("delivery_tripped"), False)),
|
||||
reason=str(data.get(self.key("delivery_reason"), "") or ""))
|
||||
|
||||
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
|
||||
# take, 0 none), when that refusal or its last probe started, the
|
||||
# current probe's start, and the cycles of evidence so far and when
|
||||
# they began. Memory only, like SAFETY-52's: stop and start end it.
|
||||
# Used at N>1 only.
|
||||
self.refused = 0
|
||||
self.refused_at = None
|
||||
self.probe_from = None
|
||||
self.ref_count = 0
|
||||
self.ref_since = None
|
||||
self.ref_sign = 0
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
@@ -408,7 +420,15 @@ class Inverter:
|
||||
stop the site (`inputs_ok`). Folding one into the other is how a
|
||||
measurement fault ends up handing a sibling extra power.
|
||||
"""
|
||||
return self.batt_fresh.note(self.name)
|
||||
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
|
||||
else "")
|
||||
|
||||
def refusal_note(self) -> str:
|
||||
"""SAFETY-73's status row while this unit's battery refuses."""
|
||||
if not self.refused:
|
||||
return ""
|
||||
return messages.row_battery_refuses(self.name, self.refused,
|
||||
self.probe_from is not None)
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say.
|
||||
|
||||
@@ -351,7 +351,7 @@ class Maintenance:
|
||||
the action log rather than watching nothing happen.
|
||||
"""
|
||||
if blocked_by:
|
||||
msg = (f"maintenance: forced start REFUSED on {self.name} - "
|
||||
msg = (f"maintenance: {self.name} cannot start now, because "
|
||||
f"{blocked_by}")
|
||||
_LOG.warning("%s", msg)
|
||||
return [msg]
|
||||
@@ -378,10 +378,9 @@ class Maintenance:
|
||||
"""
|
||||
if self._deferred != why:
|
||||
self._deferred = why
|
||||
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
|
||||
f"{why}. It is idle under the control loop and will "
|
||||
f"recalibrate at the next start hour once the slot is free; "
|
||||
f"this is not a stalled phase.")
|
||||
msg = (f"maintenance: {self.name} is due and waits its turn, "
|
||||
f"because {why}. It starts at the next start hour once the "
|
||||
f"other battery is done. This is not a stalled phase.")
|
||||
_LOG.info("%s", msg)
|
||||
events.append(msg)
|
||||
return events
|
||||
|
||||
@@ -10,6 +10,7 @@ per title; the body ends with a link to it.
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
|
||||
# that no action a person must take is lost; everything else gets one.
|
||||
@@ -310,10 +311,37 @@ def seconds(value) -> str:
|
||||
return f"{n} second" if n == "1" else f"{n} seconds"
|
||||
|
||||
|
||||
# One token each, like a code, so the line keeps its length and stays greppable.
|
||||
NO_REASON_SAVED, NO_REASON_CODE = "REASON_NOT_SAVED", "REASON_WITHOUT_CODE"
|
||||
_REASON_CODE = re.compile(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b")
|
||||
|
||||
|
||||
def reason_code(reason) -> str:
|
||||
"""UX-11: the code in a safety reason (`STALE_READING`), for the one plain
|
||||
WARNING or ERROR line that reports it. The reason with its figures goes on
|
||||
a DEBUG line beside it and into the action log. Saved reasons have always
|
||||
opened with their code, so an old state file reads the same way."""
|
||||
if not reason:
|
||||
return NO_REASON_SAVED
|
||||
m = _REASON_CODE.search(str(reason))
|
||||
return m.group(0) if m else NO_REASON_CODE
|
||||
|
||||
|
||||
def times(n) -> str:
|
||||
return "1 time" if int(n) == 1 else f"{int(n)} times"
|
||||
|
||||
|
||||
def count(n, noun, plural=None) -> str:
|
||||
"""`count(1, "price")` -> `1 price`, `count(3, "price")` -> `3 prices`."""
|
||||
return f"{int(n)} {noun if int(n) == 1 else plural or noun + 's'}"
|
||||
|
||||
|
||||
def when(at) -> str:
|
||||
"""`27 Sep at 09:15`, as the page writes a moment. `at` is already in the
|
||||
time zone the reader wants (the caller passes `price.brussels(t)`)."""
|
||||
return f"{at.day} {at:%b} at {at:%H:%M}"
|
||||
|
||||
|
||||
def for_unit(unit) -> str:
|
||||
"""` for inverter-b` on a site with several inverters, else nothing."""
|
||||
return f" for {unit}" if unit else ""
|
||||
@@ -335,13 +363,13 @@ def banner_circulating() -> str:
|
||||
|
||||
|
||||
def banner_site_direction() -> str:
|
||||
return "Total command and meter point opposite ways. All inverters at 0 W."
|
||||
return "Total power went the wrong way. All inverters at 0 W."
|
||||
|
||||
|
||||
def banner_direction(several: bool) -> str:
|
||||
if several:
|
||||
return "An inverter's own power opposes its command. It is at 0 W."
|
||||
return "Command and meter point opposite ways. Inverter set to 0 W."
|
||||
return "An inverter's own power went the wrong way. Set to 0 W."
|
||||
return "The meter saw power go the wrong way. Inverter at 0 W."
|
||||
|
||||
|
||||
def banner_not_delivering(units) -> str:
|
||||
@@ -350,6 +378,20 @@ def banner_not_delivering(units) -> str:
|
||||
return f"{names(units)} {verb} not delivering. Control keeps running."
|
||||
|
||||
|
||||
def banner_stopped() -> str:
|
||||
return "Stopped — the inverters are set to 0 W"
|
||||
|
||||
|
||||
def banner_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.
|
||||
|
||||
@@ -378,6 +420,11 @@ def row_controller_stalled(age_s, veto_s) -> str:
|
||||
f"sets the inverter to 0 W after {seconds(veto_s)}.")
|
||||
|
||||
|
||||
def row_meter_waiting(limit_s) -> str:
|
||||
"""UX-10: no reading yet since the start, and still inside the limit."""
|
||||
return f"Meter: waiting for the first reading, limit {seconds(limit_s)}"
|
||||
|
||||
|
||||
def row_meter_stale(age_s, limit_s) -> str:
|
||||
return (f"Meter: no reading for {seconds(age_s)}, limit "
|
||||
f"{seconds(limit_s)}. The add-on log shows why.")
|
||||
@@ -502,9 +549,15 @@ def row_commands_refused(name, held_s, limit_s) -> str:
|
||||
|
||||
|
||||
def row_control(lo_w, hi_w, unit=None) -> str:
|
||||
def w(v):
|
||||
return "?" if v is None else signed_watts(v)
|
||||
return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}"
|
||||
"""The inverter's own range: charging below 0 W, giving power above it."""
|
||||
head = f"Inverter control{for_unit(unit)}: ready"
|
||||
if lo_w is None or hi_w is None or lo_w > 0 or hi_w < 0:
|
||||
def w(v):
|
||||
return "?" if v is None else signed_watts(v)
|
||||
return f"{head}, from {w(lo_w)} to {w(hi_w)}"
|
||||
if abs(float(lo_w)) == float(hi_w):
|
||||
return f"{head}, up to {watts(hi_w)} each way"
|
||||
return f"{head}, {watts(lo_w)} charging, {watts(hi_w)} giving"
|
||||
|
||||
|
||||
# Rows: the two loop alarms. The names are the notification titles, so the
|
||||
@@ -541,9 +594,9 @@ def row_write_refused(unit=None) -> str:
|
||||
# Rows: the direction checks (SAFETY-02/30).
|
||||
def row_direction_trip(unit=None) -> str:
|
||||
"""Per inverter. `unit` only at several inverters, graded on its own power."""
|
||||
what = (f"{unit}: command and its own power point opposite ways, so it is"
|
||||
if unit else "Command and meter point opposite ways, so the "
|
||||
"inverter is")
|
||||
what = (f"{unit}'s own power went against what the add-on asked, so it is"
|
||||
if unit else
|
||||
"Grid power went against what the add-on asked, so the inverter is")
|
||||
return (f"{what} set to 0 W. Find out why, then start control. A restart "
|
||||
f"does not clear this.")
|
||||
|
||||
@@ -557,9 +610,9 @@ def row_direction_check(held_s, hold_s, unit=None) -> str:
|
||||
|
||||
|
||||
def row_site_direction_trip() -> str:
|
||||
return ("Total command and meter point opposite ways, so every inverter is "
|
||||
"set to 0 W. The cause is the total, not one inverter. Only starting "
|
||||
"control clears this.")
|
||||
return ("Grid power went against the add-on's total request, so every "
|
||||
"inverter is set to 0 W. The cause is the total, not one inverter. "
|
||||
"Only starting control clears this.")
|
||||
|
||||
|
||||
def row_site_direction_check(held_s, hold_s) -> str:
|
||||
@@ -575,10 +628,15 @@ def row_circulating_trip() -> str:
|
||||
"0 W. The meter cannot see this. Only starting control clears this.")
|
||||
|
||||
|
||||
CHECK_CIRCULATING = "Check for inverters working against each other"
|
||||
|
||||
|
||||
def row_circulating_check(now_w, held_s, hold_s) -> str:
|
||||
return ("Circulating power check: on"
|
||||
+ (f", now {watts(now_w)}" if now_w else "")
|
||||
+ (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s else ""))
|
||||
if held_s:
|
||||
return (f"{CHECK_CIRCULATING}: {watts(now_w or 0)} for {held_s:.0f} of "
|
||||
f"{seconds(hold_s)}")
|
||||
return (f"{CHECK_CIRCULATING}: on"
|
||||
+ (f", now {watts(now_w)}" if now_w else ""))
|
||||
|
||||
|
||||
def row_one_inverter(check: str) -> str:
|
||||
@@ -676,6 +734,243 @@ def row_pv_stray(n) -> str:
|
||||
f"entity in use")
|
||||
|
||||
|
||||
# Rows: this month's peak and the peak guard (UX-08 phase 2d). The detail
|
||||
# (the projection's reason, the store's state at start) is in the add-on log.
|
||||
def row_month_peak(peak_w, at, frac) -> str:
|
||||
"""`at` is the local start of the 15-minute period that set the peak."""
|
||||
return (f"Month peak: {watts(peak_w)} on {at.day} {at:%b} at {at:%H:%M}, "
|
||||
f"{frac * 100:.0f} % of the peak limit")
|
||||
|
||||
|
||||
def row_month_peak_none() -> str:
|
||||
return "Month peak: none recorded yet this month"
|
||||
|
||||
|
||||
def row_month_peak_unreadable() -> str:
|
||||
"""The store did not answer. This is not a zero peak (TARIFF-01)."""
|
||||
return "Month peak: cannot be read, so only the peak limit is used"
|
||||
|
||||
|
||||
def row_periods_measured(n) -> str:
|
||||
"""TARIFF-13's coverage figure. `n` is an int, or a text when unreadable."""
|
||||
shown = n if isinstance(n, int) else "cannot be read"
|
||||
return f"15-minute periods measured this month: {shown}"
|
||||
|
||||
|
||||
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool) -> str:
|
||||
"""The projection first and the limit second (SAFETY-38's ordered pair)."""
|
||||
limit = (f"this month's {watts(limit_w)} peak" if month_peak
|
||||
else f"the {watts(limit_w)} peak limit")
|
||||
return f"Peak guard: idle, heading for {watts(projected_w)}, under {limit}"
|
||||
|
||||
|
||||
def row_peak_guard_quiet() -> str:
|
||||
return "Peak guard: idle"
|
||||
|
||||
|
||||
BLIND_WORDS = {
|
||||
"no_block": "waiting for meter readings",
|
||||
"gap": "meter readings have a gap",
|
||||
"frozen": "meter reading is stuck",
|
||||
}
|
||||
|
||||
|
||||
def row_peak_guard_blind(why: str) -> str:
|
||||
"""`why` is a key of BLIND_WORDS. The battery may give power, not charge."""
|
||||
return (f"Peak guard: {BLIND_WORDS[why]}, so the battery does not charge "
|
||||
f"meanwhile")
|
||||
|
||||
|
||||
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
|
||||
if shadow:
|
||||
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
|
||||
f"{watts(limit_w)}")
|
||||
return (f"Peak guard: asks the battery for {watts(floor_w)} to stay under "
|
||||
f"{watts(limit_w)}")
|
||||
|
||||
|
||||
def row_battery_stuck(unit) -> str:
|
||||
"""SAFETY-37/56. A fact, not a verdict: the reading may be stuck, or the
|
||||
battery may be refusing, and the add-on cannot tell which."""
|
||||
return (f"{unit}: battery power reading stopped changing, so it is not used "
|
||||
f"for now")
|
||||
|
||||
|
||||
def row_battery_refuses(unit, sign, probing) -> str:
|
||||
"""SAFETY-73. One inverter's battery refuses, the others take its share."""
|
||||
way = "give" if sign > 0 else "take"
|
||||
if probing:
|
||||
return f"{unit}: testing if the battery will {way} power again"
|
||||
return f"{unit}: the battery refuses to {way} power, the others take over"
|
||||
|
||||
|
||||
# The top of the page: why the battery does what it does (the "Why" line).
|
||||
WHY_WINNER = {
|
||||
"loop": "Keeping the grid near zero",
|
||||
"peak": "Keeping under the peak limit",
|
||||
"arbitrage": "Trading on the price",
|
||||
"maintenance": "Battery maintenance",
|
||||
"failsafe": "Safety stop, power went the wrong way",
|
||||
"sign_site": "Safety stop, total power went the wrong way",
|
||||
"circulation": "Safety stop, inverters worked against each other",
|
||||
"stopped": "Control is stopped",
|
||||
"inputs missing": "A reading is missing",
|
||||
"no claim was made": "Nothing asked for power",
|
||||
# UX-10: the claim reasons main.why_key() can return for a safety or
|
||||
# failsafe winner. test_messages.py part 7 finds every one in the code.
|
||||
"sign disagreement": "Safety stop, power went the wrong way",
|
||||
"unsplit": "The power could not be split, all at 0 W",
|
||||
# SAFETY-52: the loop's two words for a battery that gives nothing.
|
||||
"battery refuses": "The battery refuses to give power",
|
||||
"battery probe": "Testing if the battery gives power again",
|
||||
# SAFETY-73: a refusing inverter's own claim, closed or probing.
|
||||
"refused": "A battery refuses power, the others take over",
|
||||
}
|
||||
WHY_LIMIT = {
|
||||
"peak(quarter peak)": "the peak limit",
|
||||
"peak(blind)": "no charging while the meter is unclear",
|
||||
"peak(SoC floor)": "the peak reserve",
|
||||
"supervised": RATING,
|
||||
"device": "the inverter's own limit",
|
||||
"pack": "the battery level",
|
||||
"reserve": "the battery reserve",
|
||||
"maintenance": "battery maintenance",
|
||||
"refused": "a battery that refuses power",
|
||||
}
|
||||
|
||||
|
||||
def why(winner, target_w, bound_by=None, *, contradiction=False,
|
||||
raised=False) -> str:
|
||||
"""`winner` is the arbiter's winning source, or its reason for the two
|
||||
that share a source ("stopped", "inputs missing", "no claim was made").
|
||||
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)".
|
||||
`raised` is True when that limit pushed the power UP (the peak limit
|
||||
asking for more): then it did not hold anything back (OPT-11 P1 test F2).
|
||||
"""
|
||||
if contradiction:
|
||||
return "Two limits clash, so every inverter is set to 0 W"
|
||||
out = f"{WHY_WINNER.get(winner, 'Other')}: {signed_watts(target_w)}"
|
||||
if bound_by:
|
||||
limit = WHY_LIMIT.get(bound_by) or WHY_LIMIT.get(
|
||||
str(bound_by).split("(")[0], "another limit")
|
||||
out += f", raised for {limit}" if raised else \
|
||||
f", held back by {limit}"
|
||||
return out
|
||||
|
||||
|
||||
def why_missing(what, limit_s) -> str:
|
||||
"""UX-11: past the hold, which reading is missing and what it does."""
|
||||
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
|
||||
|
||||
|
||||
def why_waiting(missing_s) -> str:
|
||||
"""A reading went missing a moment ago: control holds and waits."""
|
||||
return f"A reading is missing for {seconds(missing_s)}, control waits"
|
||||
|
||||
|
||||
# The last thing that happened, shown under the buttons. The add-on keeps its
|
||||
# own technical record of each event (`Controller.events`); the page shows
|
||||
# these words instead. Every `log_event()` call names one (test_messages.py).
|
||||
HINTS = {
|
||||
"control_started": "Control started",
|
||||
"control_stopped": "Control stopped",
|
||||
"settings_saved": "Settings saved, they apply after a restart",
|
||||
"restart_requested": "Restart requested from the settings page",
|
||||
"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",
|
||||
"battery_stuck": "{unit}: battery power reading stopped changing",
|
||||
"battery_back": "{unit}: battery power reading is used again",
|
||||
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
|
||||
"battery_gives": "The battery refusal ended, control asks for power again",
|
||||
"unit_refuses": "{unit}: the battery refuses power, the others take over",
|
||||
"unit_gives": "{unit}: the battery refusal ended, it is asked for power again",
|
||||
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
|
||||
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
|
||||
"all_blocked": "A power request was blocked, inverters set to 0 W",
|
||||
"write_cut": "{unit}: " + T_OUTPUT_CLAMP.lower(),
|
||||
"write_slow": "{unit}: the inverter answered too slowly, trying again",
|
||||
"direction": "{unit}: power went the wrong way, it is set to 0 W",
|
||||
"site_direction": "Total power went the wrong way, inverters set to 0 W",
|
||||
"circulating": T_CIRCULATING + ", all set to 0 W",
|
||||
"not_delivering": T_NOT_DELIVERING,
|
||||
"peak_on": "Peak guard started",
|
||||
"peak_off": "Peak guard idle",
|
||||
"peak_blind": "Peak guard cannot read the meter well",
|
||||
"peak_saturated": T_PEAK_SATURATED,
|
||||
"peak_reserve": T_PEAK_RESERVE,
|
||||
"meter_frozen": T_METER_FROZEN,
|
||||
"power_too_high": T_OUTPUT_CLAMP,
|
||||
"not_keeping_up": T_LOOP_BOUND,
|
||||
"trade_charge": "Trading: charging while the price is low",
|
||||
"trade_give": "Trading: giving power while the price is high",
|
||||
"trade_off": "Trading: waiting for a better price",
|
||||
"split": "The power split between inverters changed",
|
||||
"clash": "Two limits clash, inverters set to 0 W",
|
||||
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
|
||||
"unit_dropped": "{unit} left out of control, the others take its share",
|
||||
"units_back": "Every inverter is back in control",
|
||||
"unmet": "The inverters cannot give {w} of the power asked",
|
||||
"unmet_gone": "The inverters can give all the power asked again",
|
||||
"shadow_on": "Shadow mode on, inverters stay at 0 W",
|
||||
"shadow_off": "Shadow mode off, the add-on controls the battery",
|
||||
"shadow_decision": "Shadow mode: would ask the battery for {sw}",
|
||||
"refused": "Settings refused, the add-on log shows why",
|
||||
"inputs_missing": "A reading is missing, inverters set to 0 W",
|
||||
"inputs_back": "All readings are back",
|
||||
"stop_cleared": "{unit}: safety stop cleared by starting control",
|
||||
"site_stop_cleared": "Safety stop cleared by starting control",
|
||||
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
||||
"maintenance": "Battery maintenance: {phase}",
|
||||
"maint_paused": "Maintenance charging paused to stay under the peak limit",
|
||||
"pack_on": "{unit}: battery switched on",
|
||||
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
|
||||
"pack_mode": "{unit}: battery mode changed",
|
||||
"pack_sell": "{unit}: selling switched",
|
||||
"maint_off": "{unit}: maintenance stopped, the battery is switched off",
|
||||
"maint_waits": "{unit} cannot give power now, so its monthly cycle waits",
|
||||
"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.",
|
||||
# OPT-11 P3, owner decision 2026-09-29: selling is off for now.
|
||||
"sell_off": "Selling is off for now.",
|
||||
# OPT-11 P2: a Home Assistant command dropped before it reaches a battery.
|
||||
"retained": "An old saved command is not used.",
|
||||
"size": "That command is too long.",
|
||||
"encoding": "That command is not plain text.",
|
||||
"topic": "That is not a battery command.",
|
||||
}
|
||||
|
||||
|
||||
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)."""
|
||||
if "w" in kw:
|
||||
kw["w"] = watts(kw["w"])
|
||||
if "sw" in kw:
|
||||
kw["sw"] = signed_watts(kw["sw"])
|
||||
return HINTS[key].format(**kw)
|
||||
|
||||
|
||||
def hint_samples() -> list:
|
||||
"""`(key, text)` for every hint, rendered, for the test."""
|
||||
kw = {"n": 2, "unit": "inverter-b", "w": 1200, "sw": -1200,
|
||||
"phase": "drain"}
|
||||
return [(k, hint(k, **{f: kw[f] for f in kw if "{" + f + "}" in v}))
|
||||
for k, v in HINTS.items()]
|
||||
|
||||
|
||||
RED, AMBER, GREEN = "bad", "warn", "ok"
|
||||
|
||||
|
||||
@@ -699,11 +994,14 @@ 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)),
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
("row_meter_stale", RED, row_meter_stale(40, 10)),
|
||||
("row_meter_waiting", AMBER, row_meter_waiting(30)),
|
||||
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
|
||||
("row_meter_frozen", RED, row_meter_frozen()),
|
||||
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
|
||||
@@ -769,6 +1067,30 @@ def status_samples() -> list:
|
||||
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
|
||||
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
||||
("row_pv_stray", AMBER, row_pv_stray(2)),
|
||||
("banner_stopped", None, banner_stopped()),
|
||||
("row_control", GREEN, row_control(-4000, 5000, "inverter-b")),
|
||||
("row_month_peak", GREEN, row_month_peak(
|
||||
4998, datetime(2026, 8, 12, 19, 15), 0.83)),
|
||||
("row_month_peak_none", GREEN, row_month_peak_none()),
|
||||
("row_month_peak_unreadable", AMBER, row_month_peak_unreadable()),
|
||||
("row_periods_measured", GREEN, row_periods_measured(120)),
|
||||
("row_periods_measured", GREEN, row_periods_measured("unreadable")),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
|
||||
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
|
||||
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
|
||||
for k in BLIND_WORDS),
|
||||
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
|
||||
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
|
||||
shadow=True)),
|
||||
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
|
||||
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
|
||||
False)),
|
||||
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", -1,
|
||||
False)),
|
||||
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
|
||||
True)),
|
||||
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
||||
]
|
||||
|
||||
|
||||
@@ -840,9 +1162,43 @@ LABELS = {
|
||||
"unit_sign_disagree_w": "Per-inverter wrong-direction threshold",
|
||||
"slew_w": "Largest change per cycle",
|
||||
"shadow_mode": "Shadow mode",
|
||||
# UX-13: named by the settings page's warnings (validate.Note).
|
||||
"integrator_max_w": "Catch-up limit",
|
||||
"maintenance_discharge_w": "Draw-down rate",
|
||||
"peak_cap_w": "Peak limit",
|
||||
"peak_margin_w": "Margin below the peak limit",
|
||||
"peak_forecast_entity": "15-minute demand forecast entity",
|
||||
"meter_entity": "Grid power entity",
|
||||
"soc_entity": "Battery level entity",
|
||||
"batt_entity": "Battery power entity",
|
||||
"pv_entity": "Solar production entity",
|
||||
"pv_entities": "Solar production entities, per inverter",
|
||||
"p1_phase_import_entities": "Import entities per phase",
|
||||
"p1_phase_export_entities": "Export entities per phase",
|
||||
}
|
||||
|
||||
|
||||
def shown(value) -> str:
|
||||
"""A setting's value inside a refusal: `'abc'`, `5`, or `empty` for a value
|
||||
that is missing or blank, never Python's `None` (UX-08 2d)."""
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return "empty"
|
||||
return repr(value)
|
||||
|
||||
|
||||
def not_valid(option: str, err) -> str:
|
||||
"""A setting whose text does not parse, without the parser's own words:
|
||||
`static_profile is not valid near character 6` (UX-08 2d)."""
|
||||
pos = getattr(err, "pos", None)
|
||||
if isinstance(pos, int):
|
||||
return f"{option} is not valid near character {pos + 1}"
|
||||
return f"{option} is not valid"
|
||||
|
||||
|
||||
# The units a formula's "units" field may name, as a reader writes them.
|
||||
UNIT_WORDS = {"c_eur_per_kwh": "c/kWh", "eur_per_kwh": "EUR/kWh"}
|
||||
|
||||
|
||||
def setting(key: str, unit: str = "") -> str:
|
||||
"""`max_w` -> `“Inverter rating” (max_w)`. For one inverter in a list it is
|
||||
`“Inverter rating” for inverter-b (inverters.max_w)`, so the config panel
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""OPT-11 P3: each battery's role this tick, from its mode and the price runs.
|
||||
|
||||
Pure: no I/O, no clock. main.py hands in the run plan (`priceruns`), each
|
||||
battery's state and the peak guard's answers, files each battery's range as
|
||||
its `mode` claim (P_MODE), and files the site trade as the `arbitrage` claim
|
||||
(P_ARBITRAGE), sized inside the modes (`size()`), only with battery modes on.
|
||||
With them off, `arbitrage.decide()` runs exactly as before.
|
||||
|
||||
Roles (the page's Why line names them):
|
||||
off Off: [0, 0].
|
||||
house follows the house: charges from spare solar, gives power at any
|
||||
hour (Solar first always; the other modes when there is no plan).
|
||||
hold Leftover solar / Cheap hours outside a give run: may charge from
|
||||
spare solar, gives nothing, so its energy is kept for the run.
|
||||
give the same inside a give run: covers the house.
|
||||
sell inside the sell run, with selling on: gives its rating, to the
|
||||
house first and the rest to the grid.
|
||||
charge inside the cheap run: charges from the grid.
|
||||
rest another battery charges from the grid, so this one may not charge
|
||||
(the owner's 2026-09-27 answer 3: while one battery charges from the
|
||||
grid the others cannot give power either; the allocator never
|
||||
splits a target both ways).
|
||||
|
||||
Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* every range holds 0 W (a mode may forbid a direction, never force one);
|
||||
* a Solar first battery never charges from the grid (no forecast yet, P4);
|
||||
* grid charging needs a measured peak guard that is clear and a measured
|
||||
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
|
||||
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
||||
more, and once the guard or the headroom stops it, it stays stopped for
|
||||
the rest of that 15-minute period (`blocked`): a charge makes the other
|
||||
batteries stop giving power, so a few watts of charge flipping on and
|
||||
off would swing the house between them and the grid (measured on the
|
||||
2026-09-09 replay: 13 flips in 4 minutes at a 3.5 kW house);
|
||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||
it must only have measured the quarter;
|
||||
* a battery out of a run (full, or at the floor) stays out until the run
|
||||
ends, so the ceiling and the floor do not re-arm within one run;
|
||||
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
|
||||
run in that direction: the refusal wins over the run;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
|
||||
SOLAR_FIRST)
|
||||
|
||||
OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = (
|
||||
"off", "house", "hold", "give", "sell", "charge", "rest")
|
||||
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
|
||||
# The smallest grid charge worth starting, in W. Under it the other batteries
|
||||
# would stop covering the house for less than they give.
|
||||
START_W = 500.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pack:
|
||||
"""One battery as this module needs it."""
|
||||
name: str
|
||||
use: bool
|
||||
mode: str
|
||||
sell: bool
|
||||
rating_w: float
|
||||
soc: float | None
|
||||
capacity_kwh: float | None
|
||||
live: bool = True
|
||||
idle: bool = True # not in a maintenance phase
|
||||
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tick:
|
||||
ranges: dict # name -> (lo, hi), only for idle batteries
|
||||
roles: dict # name -> role
|
||||
action: str | None # "charge" | "sell" | None
|
||||
ask_w: float # signed site ask before sizing (0 if none)
|
||||
why: str | None # why no trade when a run is on, else None
|
||||
|
||||
|
||||
def _room_kwh(p: Pack, pct: float) -> float:
|
||||
"""kWh to `pct` (charging) or above it (selling); a battery with no
|
||||
capacity set counts one hour at its rating, so a run is still sized."""
|
||||
if p.capacity_kwh is None or p.soc is None:
|
||||
return abs(p.rating_w) / 1000.0
|
||||
return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh)
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=()) -> list:
|
||||
"""Batteries that may charge from the grid in a cheap run: the modes in
|
||||
`packmode.GRID_CHARGE_MODES` (Cheap hours only until P4's forecast)."""
|
||||
return [p for p in packs
|
||||
if p.use and p.idle and p.live and p.mode in GRID_CHARGE_MODES
|
||||
and p.soc is not None and p.soc < ceiling_pct
|
||||
and p.refused >= 0 and p.name not in done]
|
||||
|
||||
|
||||
def sellers(packs, floor_pct, done=()) -> list:
|
||||
"""Batteries that may sell in the sell run. Solar first never sells."""
|
||||
return [p for p in packs
|
||||
if p.use and p.idle and p.live and p.sell
|
||||
and p.mode in PRICE_MODES
|
||||
and p.soc is not None and p.soc > floor_pct
|
||||
and p.refused <= 0 and p.name not in done]
|
||||
|
||||
|
||||
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()):
|
||||
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`."""
|
||||
ch = chargers(packs, ceiling_pct, done_charge)
|
||||
se = sellers(packs, floor_pct, done_sell)
|
||||
return (sum(_room_kwh(p, ceiling_pct) for p in ch),
|
||||
sum(abs(p.rating_w) for p in ch) / 1000.0,
|
||||
sum(_room_kwh(p, floor_pct) for p in se),
|
||||
sum(abs(p.rating_w) for p in se) / 1000.0)
|
||||
|
||||
|
||||
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
||||
blocked=False, done_charge=(), done_sell=(),
|
||||
draining=False) -> Tick:
|
||||
"""This tick's ranges, roles and site ask.
|
||||
|
||||
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
||||
`charge_ok` is True only when `TariffPlan.arbitrage()` allowed a trade
|
||||
this tick with the MEASURED peak guard clear; `sell_ok` when it allowed
|
||||
one with the guard measured (either state); else `trade_why` says why.
|
||||
`headroom_w` is `peakguard.charge_headroom_w()` (None: unmeasured).
|
||||
`house_w` is the measured house (grid + batteries), or None.
|
||||
`charging_now` says last tick charged (then any headroom above 0 keeps
|
||||
it going); `blocked` that the guard or the headroom stopped a charge in
|
||||
this 15-minute period. `draining` says a battery drains for its
|
||||
monthly cycle, so none may charge from the grid (why "cycle").
|
||||
"""
|
||||
plan_on = runs is not None and runs.on
|
||||
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
||||
in_give = plan_on and runs.give_at(now) is not None
|
||||
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
|
||||
|
||||
why = None
|
||||
ch = chargers(packs, ceiling_pct, done_charge) if in_cheap else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
charging = False
|
||||
if ch:
|
||||
need = 0.0 if charging_now else START_W
|
||||
if blocked:
|
||||
why = "headroom"
|
||||
elif not charge_ok:
|
||||
why = trade_why
|
||||
elif headroom_w is None or not headroom_w > need:
|
||||
why = "headroom"
|
||||
else:
|
||||
charging = True
|
||||
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
|
||||
else []
|
||||
selling = False
|
||||
if se:
|
||||
if not sell_ok:
|
||||
why = why or trade_why
|
||||
elif price_now is None or price_now < 0.0 or house_w is None:
|
||||
why = why or "negative"
|
||||
else:
|
||||
selling = True
|
||||
|
||||
ranges, roles = {}, {}
|
||||
ch_names = {p.name for p in ch} if charging else set()
|
||||
se_names = {p.name for p in se} if selling else set()
|
||||
for p in packs:
|
||||
r = abs(float(p.rating_w))
|
||||
if not p.use:
|
||||
role, lo, hi = OFF, 0.0, 0.0
|
||||
elif p.name in ch_names:
|
||||
role, lo, hi = CHARGE, -r, 0.0
|
||||
elif charging:
|
||||
# Another battery charges from the grid: this one may not charge
|
||||
# (it would be grid charging too), and cannot give (one sign).
|
||||
role, lo, hi = REST, 0.0, (r if p.mode == SOLAR_FIRST
|
||||
or not plan_on or in_give else 0.0)
|
||||
elif p.name in se_names:
|
||||
role, lo, hi = SELL, -r, r
|
||||
elif p.mode == SOLAR_FIRST or not plan_on:
|
||||
role, lo, hi = HOUSE, -r, r
|
||||
elif in_give:
|
||||
role, lo, hi = GIVE, -r, r
|
||||
else:
|
||||
role, lo, hi = HOLD, -r, 0.0
|
||||
roles[p.name] = role
|
||||
if p.idle:
|
||||
ranges[p.name] = (lo, hi)
|
||||
|
||||
if charging:
|
||||
ask = -sum(abs(p.rating_w) for p in ch)
|
||||
return Tick(ranges, roles, CHARGE, ask, None)
|
||||
if selling:
|
||||
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
|
||||
return Tick(ranges, roles, SELL, ask, None)
|
||||
return Tick(ranges, roles, None, 0.0,
|
||||
why if (in_cheap or in_sell) else None)
|
||||
|
||||
|
||||
def size(tick: Tick, bound, headroom_w) -> float:
|
||||
"""The site trade in W, inside what the batteries may place with their
|
||||
modes (`bound` = main's loop bound) and, for a charge, the billed
|
||||
quarter's headroom. 0.0 means no trade (a 0 W trade is a park order and
|
||||
is never filed)."""
|
||||
if tick.action is None or bound is None:
|
||||
return 0.0
|
||||
lo, hi = bound
|
||||
if tick.action == CHARGE:
|
||||
cap = min(-float(tick.ask_w), -float(lo),
|
||||
float(headroom_w) if headroom_w is not None else 0.0)
|
||||
return -max(0.0, cap)
|
||||
return max(0.0, min(float(tick.ask_w), float(hi)))
|
||||
|
||||
|
||||
def weights(roles: dict, base, target_w: float):
|
||||
"""Who carries the site target first (design 3.5). Selling: the sellers.
|
||||
Giving power: batteries in a give run before Solar first. Charging from
|
||||
the grid: only the chargers. Otherwise `base` unchanged. Comfort only;
|
||||
the ranges above are the permission."""
|
||||
names = sorted(roles)
|
||||
first = None
|
||||
if target_w > 0 and any(r == SELL for r in roles.values()):
|
||||
first = {n for n in names if roles[n] == SELL}
|
||||
elif target_w > 0 and any(r == GIVE for r in roles.values()):
|
||||
first = {n for n in names if roles[n] in (GIVE, SELL)}
|
||||
elif target_w < 0 and any(r == CHARGE for r in roles.values()):
|
||||
first = {n for n in names if roles[n] == CHARGE}
|
||||
if not first:
|
||||
return base
|
||||
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
|
||||
out = {n: (w[n] if n in first else 0.0) for n in names}
|
||||
return out if any(v > 0.0 for v in out.values()) else base
|
||||
@@ -7,8 +7,10 @@ That separation is deliberate; observability must never be able to take down
|
||||
control.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
@@ -375,7 +377,26 @@ def _payload(name, object_id, unique_id, state_topic, device,
|
||||
return cfg
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
def unit_unique_id(slug: str, key: str) -> str:
|
||||
"""One unit's entity `unique_id` (also its config topic's middle part)."""
|
||||
return f"{BASE}_unit_{slug}_{key}"
|
||||
|
||||
|
||||
def _unit_places(names):
|
||||
"""`(slug, device, prefix)` for each roster name, in order."""
|
||||
slugs = unit_slugs(names)
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
yield slug, device, prefix
|
||||
|
||||
|
||||
def discovery_payloads(units=(), omit=(), include=(), modes=()) -> list:
|
||||
"""Every `(topic, payload)` this add-on announces, in announcement order.
|
||||
|
||||
Split out of `_announce()` so that the acceptance criterion can be graded as
|
||||
@@ -389,6 +410,8 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
|
||||
ordered LIST precisely because a re-order is a zero-byte edit that
|
||||
re-announces every entity on a live install).
|
||||
|
||||
`modes` (OPT-11 P2) is the battery modes offered, empty when they are off.
|
||||
"""
|
||||
omit = set(omit)
|
||||
include = [k for k in include if k not in omit]
|
||||
@@ -400,19 +423,10 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
names = announced_units(units)
|
||||
slugs = unit_slugs(names)
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
for i, (name, slug) in enumerate(zip(names, slugs)):
|
||||
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
|
||||
# keeps the device it already has and carries its name in the ENTITY
|
||||
# name instead; every later one gets its own device and does not.
|
||||
# Either way HA composes device-name + entity-name into the same
|
||||
# string, which is why `object_id` below can be written once for both.
|
||||
device = DEVICE if i == 0 else unit_device(name, slug)
|
||||
prefix = f"{_title(name)} " if i == 0 else ""
|
||||
for slug, device, prefix in _unit_places(announced_units(units)):
|
||||
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
|
||||
unique_id = f"{BASE}_unit_{slug}_{key}"
|
||||
unique_id = unit_unique_id(slug, key)
|
||||
out.append((
|
||||
f"homeassistant/sensor/{unique_id}/config",
|
||||
_payload(prefix + label,
|
||||
@@ -427,12 +441,135 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
|
||||
f"homeassistant/sensor/{BASE}_{key}/config",
|
||||
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
|
||||
DEVICE, unit, dev_class, state_class, icon)))
|
||||
# OPT-11 P2: last, and only when battery modes are on.
|
||||
out.extend(mode_payloads(units, modes))
|
||||
return out
|
||||
|
||||
|
||||
# --- OPT-11 P2: each battery's switch and mode, as HA entities ---------------
|
||||
# Announced only when battery modes are on, after every other row, so no
|
||||
# existing row moves. A command goes through Controller.handle_action(), the
|
||||
# page's /action path. DECISIONS/OPT-11-battery-modes.md has the record.
|
||||
#
|
||||
# The select's options are these labels, in `packmode.MODES` order. A new mode
|
||||
# (P3) is a new entry here and a new option at the end; no id changes.
|
||||
MODE_LABELS = {"solar_first": "Solar first", "leftover_solar": "Leftover solar",
|
||||
"cheap_hours": "Cheap hours only"} # OPT-11 P3
|
||||
USE_ON, USE_OFF = "ON", "OFF"
|
||||
# A longer command payload is refused before it is decoded.
|
||||
MAX_COMMAND_BYTES = 64
|
||||
# The store key holding the mode config topics last announced, so that turning
|
||||
# modes off can remove exactly those (an empty retained config) and an install
|
||||
# that never turned them on publishes nothing new.
|
||||
MODE_CONFIGS_KEY = "mqtt_mode_configs"
|
||||
_MODE_CONFIG_RE = re.compile(
|
||||
rf"homeassistant/(select|switch)/{BASE}_unit_[a-z0-9_]+_(mode|use)/config")
|
||||
_COMMAND_RE = re.compile(rf"{BASE}/unit/([a-z0-9_]+)/(mode|use)/set")
|
||||
# (entity key, HA component, entity name, icon)
|
||||
MODE_ENTITIES = [
|
||||
("mode", "select", "Mode", "mdi:battery-sync"),
|
||||
("use", "switch", "Use", "mdi:power"),
|
||||
]
|
||||
|
||||
|
||||
def mode_payloads(units, modes) -> list:
|
||||
"""The `(topic, payload)` for each battery's select and switch.
|
||||
|
||||
Every roster entry gets them, also at a roster of one. The device rule is
|
||||
TEL-07's: the first battery's rows go on the site device with its name in
|
||||
front, each later battery's on its own device. Empty when `modes` is empty.
|
||||
"""
|
||||
if not modes:
|
||||
return []
|
||||
site_slug = _slug(DEVICE["name"])
|
||||
options = [MODE_LABELS[m] for m in modes]
|
||||
out = []
|
||||
for slug, device, prefix in _unit_places([str(u) for u in units]):
|
||||
for key, component, label, icon in MODE_ENTITIES:
|
||||
unique_id = unit_unique_id(slug, key)
|
||||
state = f"{BASE}/{unit_state_key(slug, key)}"
|
||||
cfg = {
|
||||
"name": prefix + label,
|
||||
"object_id": f"{site_slug}_{slug}_{key}",
|
||||
"unique_id": unique_id,
|
||||
"state_topic": state,
|
||||
"command_topic": f"{state}/set",
|
||||
}
|
||||
if component == "select":
|
||||
cfg["options"] = options
|
||||
else:
|
||||
cfg["payload_on"] = USE_ON
|
||||
cfg["payload_off"] = USE_OFF
|
||||
# HA sends commands unretained; a retained one is ignored anyway.
|
||||
cfg["retain"] = False
|
||||
cfg["availability_topic"] = AVAILABILITY
|
||||
cfg["device"] = device
|
||||
cfg["icon"] = icon
|
||||
out.append((f"homeassistant/{component}/{unique_id}/config", cfg))
|
||||
return out
|
||||
|
||||
|
||||
def mode_retractions(stored, current) -> list:
|
||||
"""Config topics announced before and not now: each gets an empty
|
||||
retained config, which removes the entity from HA. Anything in the store
|
||||
that is not one of our mode config topics is ignored."""
|
||||
if not isinstance(stored, list):
|
||||
return []
|
||||
keep = set(current)
|
||||
return list(dict.fromkeys(t for t in stored if isinstance(t, str)
|
||||
and _MODE_CONFIG_RE.fullmatch(t)
|
||||
and t not in keep))
|
||||
|
||||
|
||||
def mode_configs(units, modes, stored) -> tuple:
|
||||
"""`(keep, retract)` at start-up: the mode config topics to store, and the
|
||||
ones to remove now. A removal stays in `keep`, so a start with no broker
|
||||
does not forget it; resending an empty config is harmless."""
|
||||
current = [t for t, _ in mode_payloads(units, modes)]
|
||||
retract = mode_retractions(stored, current)
|
||||
return current + retract, retract
|
||||
|
||||
|
||||
def parse_command(topic, payload, retained, modes):
|
||||
"""`(slug, action, value, reject)` for one message on a command topic.
|
||||
|
||||
`action` is `pack_use` or `pack_mode`. `value` is what /action would be
|
||||
sent: a bool for the switch, a mode key for the select, or None for a
|
||||
payload that is neither (the store path then refuses it). `reject` is a
|
||||
reason when the message is dropped here: replayed by the broker (retained),
|
||||
too long, not UTF-8, or not a command topic.
|
||||
"""
|
||||
m = _COMMAND_RE.fullmatch(topic) if isinstance(topic, str) else None
|
||||
if m is None:
|
||||
return None, None, None, "topic"
|
||||
slug, key = m.group(1), m.group(2)
|
||||
action = "pack_use" if key == "use" else "pack_mode"
|
||||
if retained:
|
||||
return slug, action, None, "retained"
|
||||
if not isinstance(payload, (bytes, bytearray)) \
|
||||
or len(payload) > MAX_COMMAND_BYTES:
|
||||
return slug, action, None, "size"
|
||||
try:
|
||||
text = bytes(payload).decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return slug, action, None, "encoding"
|
||||
if key == "use":
|
||||
value = {USE_ON: True, USE_OFF: False}.get(text)
|
||||
else:
|
||||
value = next((k for k in modes if MODE_LABELS.get(k) == text), None)
|
||||
return slug, action, value, None
|
||||
|
||||
|
||||
class MqttPublisher:
|
||||
# OPT-11 P2. Class defaults, so a publisher built without them (and every
|
||||
# test fixture that builds one with __new__) behaves as before.
|
||||
modes = ()
|
||||
retract = ()
|
||||
_loop = None
|
||||
_handler = None
|
||||
|
||||
def __init__(self, host, port, username=None, password=None, omit=(),
|
||||
units=(), include=()):
|
||||
units=(), include=(), modes=(), retract=()):
|
||||
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
||||
# sensor that nothing will ever publish to is not harmless here:
|
||||
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
||||
@@ -448,6 +585,10 @@ class MqttPublisher:
|
||||
# second reading of the option. Defaults to empty, so a caller that has
|
||||
# not been taught about it announces exactly what it announced before.
|
||||
self.include = [str(k) for k in include]
|
||||
# OPT-11 P2: the modes offered (empty when battery modes are off), and
|
||||
# the mode config topics to remove (`mode_retractions()`).
|
||||
self.modes = tuple(modes)
|
||||
self.retract = [str(t) for t in retract]
|
||||
self.enabled = mqtt is not None and bool(host)
|
||||
self.client = None
|
||||
if not self.enabled:
|
||||
@@ -470,7 +611,8 @@ class MqttPublisher:
|
||||
# paho DROPS QoS-0 publishes when disconnected, silently. The result is
|
||||
# an add-on that logs "MQTT connected" and creates no entities at all.
|
||||
# As a bonus, this also re-announces after every reconnect.
|
||||
self.client.on_connect = lambda *_args, **_kw: self._announce()
|
||||
self.client.on_connect = lambda *_args, **_kw: self._on_connect()
|
||||
self.client.on_message = self._on_message
|
||||
try:
|
||||
self.client.connect(host, int(port), keepalive=60)
|
||||
self.client.loop_start()
|
||||
@@ -485,10 +627,35 @@ class MqttPublisher:
|
||||
# diffing what actually goes on the wire - and a payload built here
|
||||
# instead would be one this add-on publishes and no check has ever seen.
|
||||
for topic, cfg in discovery_payloads(self.units, self.omit,
|
||||
self.include):
|
||||
self.include, self.modes):
|
||||
self.client.publish(topic, json.dumps(cfg), retain=True)
|
||||
for topic in self.retract:
|
||||
self.client.publish(topic, "", retain=True)
|
||||
self.client.publish(AVAILABILITY, "online", retain=True)
|
||||
|
||||
def _on_connect(self) -> None:
|
||||
self._announce()
|
||||
if self.modes:
|
||||
for key in ("mode", "use"):
|
||||
self.client.subscribe(f"{BASE}/unit/+/{key}/set")
|
||||
|
||||
def bind(self, loop, handler) -> None:
|
||||
"""OPT-11 P2: hand each command to `handler(topic, payload, retained)`,
|
||||
a coroutine function run on `loop` (the controller's)."""
|
||||
self._loop, self._handler = loop, handler
|
||||
|
||||
def _on_message(self, _client, _userdata, msg) -> None:
|
||||
# paho's network thread: nothing is done here but the handoff.
|
||||
loop, handler = self._loop, self._handler
|
||||
if loop is None or handler is None:
|
||||
_LOG.debug("MQTT command before start-up ignored: %s", msg.topic)
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
handler(msg.topic, bytes(msg.payload), bool(msg.retain)), loop)
|
||||
except RuntimeError as err: # the loop has closed
|
||||
_LOG.debug("MQTT command dropped: %s", err)
|
||||
|
||||
def publish(self, values: dict) -> None:
|
||||
if not self.enabled or self.client is None:
|
||||
return
|
||||
|
||||
@@ -48,6 +48,7 @@ from datetime import datetime, timezone
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
from .hass import OUTAGE
|
||||
|
||||
_S = messages.setting # a refusal names the setting by its label (UX-08)
|
||||
|
||||
@@ -472,7 +473,7 @@ class P1Ingest:
|
||||
"""
|
||||
self.parse_errors += 1
|
||||
self.last_error = str(err)
|
||||
_LOG.warning("P1 telegram rejected: %s", err)
|
||||
_LOG.warning("A meter message could not be read: %s", err)
|
||||
|
||||
# -- what consumers read -------------------------------------------------
|
||||
def age_s(self) -> float | None:
|
||||
@@ -524,6 +525,16 @@ WS_URL = "ws://supervisor/core/websocket"
|
||||
BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
|
||||
|
||||
async def _receive_json(ws):
|
||||
"""The next JSON message. A socket Home Assistant closed (a restart) is a
|
||||
ConnectionResetError, not aiohttp's "Received message 8:1000 is not str"."""
|
||||
msg = await ws.receive()
|
||||
if msg.type is not aiohttp.WSMsgType.TEXT:
|
||||
raise ConnectionResetError(
|
||||
f"Home Assistant closed the connection ({msg.type.name})")
|
||||
return json.loads(msg.data)
|
||||
|
||||
|
||||
class HaDsmrSource:
|
||||
"""Subscribes to state_changed for the configured DSMR entities.
|
||||
|
||||
@@ -574,8 +585,12 @@ class HaDsmrSource:
|
||||
backoff = 1.0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (aiohttp.ClientError, ConnectionError) as err:
|
||||
# UX-10: Home Assistant restarting, said once (hass.Outage).
|
||||
OUTAGE.down("websocket", "the meter connection closed", err)
|
||||
except Exception as err: # noqa: BLE001 - any transport fault retries
|
||||
_LOG.warning("P1 HA websocket: %s - reconnecting in %.0fs", err, backoff)
|
||||
_LOG.warning("The meter connection to Home Assistant failed "
|
||||
"(%s), trying again in %.0f s.", err, backoff)
|
||||
finally:
|
||||
self.connected = False
|
||||
await asyncio.sleep(backoff)
|
||||
@@ -583,16 +598,17 @@ class HaDsmrSource:
|
||||
|
||||
async def _session_once(self) -> None:
|
||||
async with self.session.ws_connect(WS_URL, heartbeat=30) as ws:
|
||||
hello = await ws.receive_json()
|
||||
hello = await _receive_json(ws)
|
||||
if hello.get("type") == "auth_required":
|
||||
await ws.send_json({"type": "auth", "access_token": self.token})
|
||||
reply = await ws.receive_json()
|
||||
reply = await _receive_json(ws)
|
||||
if reply.get("type") != "auth_ok":
|
||||
raise RuntimeError(f"auth rejected: {reply.get('message', reply)}")
|
||||
await ws.send_json({"id": 1, "type": "subscribe_events",
|
||||
"event_type": "state_changed"})
|
||||
await ws.send_json({"id": 2, "type": "get_states"})
|
||||
self.connected = True
|
||||
OUTAGE.up("websocket")
|
||||
_LOG.info("P1 ingest: subscribed to %s", ", ".join(sorted(self.ids)))
|
||||
|
||||
async for msg in ws:
|
||||
@@ -618,7 +634,7 @@ class HaDsmrSource:
|
||||
new = data.get("new_state") or {}
|
||||
self._absorb(data.get("entity_id"), new.get("state"))
|
||||
self._schedule()
|
||||
raise RuntimeError("websocket closed")
|
||||
raise ConnectionResetError("Home Assistant closed the connection")
|
||||
|
||||
def _absorb(self, entity_id: str | None, state) -> None:
|
||||
if not entity_id or entity_id not in self.ids:
|
||||
@@ -1167,7 +1183,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
||||
problems.append(
|
||||
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
|
||||
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
|
||||
f"are {imp or '<blank>'!r} and {exp or '<blank>'!r}.")
|
||||
f"are {messages.shown(imp)} and {messages.shown(exp)}.")
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||
phase_net = [str(e).strip() for e in
|
||||
@@ -1194,7 +1210,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
||||
try:
|
||||
int(port_s)
|
||||
except ValueError:
|
||||
problems.append(f"{_S('p1_host')} is {opts.get('p1_host')!r}, and "
|
||||
problems.append(f"{_S('p1_host')} is {messages.shown(opts.get('p1_host'))}, and "
|
||||
"its port is not a number.")
|
||||
try:
|
||||
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
|
||||
@@ -1204,7 +1220,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
||||
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||
except (TypeError, ValueError):
|
||||
problems.append(f"{_S('meter_poll_s')} is "
|
||||
f"{opts.get('meter_poll_s')!r}, which is not a "
|
||||
f"{messages.shown(opts.get('meter_poll_s'))}, which is not a "
|
||||
"number.")
|
||||
else:
|
||||
if poll_s <= 0:
|
||||
@@ -1269,9 +1285,8 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
|
||||
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
|
||||
# can NEVER be fresh.
|
||||
if poll_s > ingest.max_age_s / 2:
|
||||
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
|
||||
"%.1f: one missed poll makes the reading stale. The meter "
|
||||
"updates every ~5 s; 5 s against 30 s is the tested pair.",
|
||||
_LOG.warning("“Time between meter reads” (%.1f s) is too close to "
|
||||
"“Reading too old after” (%.1f s). Use 5 s and 30 s.",
|
||||
poll_s, ingest.max_age_s)
|
||||
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
|
||||
if source == SOURCE_MQTT:
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""OPT-11: each battery's mode and on/off switch, as limits and weights.
|
||||
|
||||
Pure: no I/O, no clock. main.py reads the store, calls these, and files the
|
||||
result as a `mode` claim (arbiter.P_MODE) and as the charge weights.
|
||||
DECISIONS/OPT-11-battery-modes.md has the record; OPT-11-design section 1 has
|
||||
the modes.
|
||||
|
||||
Two rules hold for every mode, and they are what keeps modes safe:
|
||||
|
||||
1. A mode range always holds 0 W. A mode may forbid a direction, never force
|
||||
one, so batteries never move power in opposite directions because of a mode
|
||||
(allocator.unit_interval() drops a range without 0 as a software fault).
|
||||
2. A mode is a limit, not a weight. A weight of 0 does not keep a battery out:
|
||||
allocator._fill()'s last-resort pass still uses it. So "may not" is a range,
|
||||
and weights only set the order among batteries that may.
|
||||
|
||||
`mode_range()` is the switch alone (Off is [0, 0]). P3 adds the price runs:
|
||||
`modetrade.decide()` narrows each battery's range by its role this tick
|
||||
(hold for the expensive hours, charge from the grid, sell), and main.py files
|
||||
the intersection. Selling is a per-battery choice (`pack_sell`, the owner's
|
||||
2026-09-27 answer), for Leftover solar and Cheap hours only.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .store import unit_key
|
||||
|
||||
SOLAR_FIRST = "solar_first"
|
||||
LEFTOVER_SOLAR = "leftover_solar"
|
||||
CHEAP_HOURS = "cheap_hours" # OPT-11 P3
|
||||
|
||||
# The modes a battery may be set to, in the order the page and the HA select
|
||||
# list them. The first is the default when the store has no value. A new mode
|
||||
# is appended, never inserted: automations send the HA labels (P2).
|
||||
MODES = (SOLAR_FIRST, LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
DEFAULT_MODE = SOLAR_FIRST
|
||||
|
||||
# Store keys, per battery: `unit/<name>/<key>` (store.unit_key()).
|
||||
USE_KEY = "pack_use"
|
||||
MODE_KEY = "pack_mode"
|
||||
SELL_KEY = "pack_sell" # OPT-11 P3: sell in the sell run
|
||||
|
||||
# Owner decision 2026-09-29 (option A): selling is off until it can be judged
|
||||
# on the price the grid pays. While False, `read()` answers `sell=False`
|
||||
# whatever the store holds, the page shows no switch and `pack_sell` is
|
||||
# refused; the sell code in modetrade/priceruns stays, unreachable.
|
||||
SELLING_ENABLED = False
|
||||
|
||||
# The modes that may charge from the grid in a cheap run. Leftover solar joins
|
||||
# when the solar forecast exists (P4); until then it takes solar only (owner
|
||||
# decision 2026-09-29, option A).
|
||||
GRID_CHARGE_MODES = (CHEAP_HOURS,)
|
||||
|
||||
|
||||
def enabled(opts) -> bool:
|
||||
"""Battery modes are on only when the option is exactly true. One answer,
|
||||
read by the controller and by the MQTT announcement."""
|
||||
return opts.get("battery_modes_enabled") is True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackMode:
|
||||
"""One battery's choice: on or off, its mode, and selling."""
|
||||
use: bool = True
|
||||
mode: str = DEFAULT_MODE
|
||||
sell: bool = False
|
||||
|
||||
@property
|
||||
def can_sell(self) -> bool:
|
||||
"""Selling applies to the price modes only; Solar first never sells.
|
||||
False for every battery while SELLING_ENABLED is False."""
|
||||
return SELLING_ENABLED and self.mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
|
||||
@property
|
||||
def key(self) -> str:
|
||||
"""The page's word for this state: a mode, or `off`."""
|
||||
return self.mode if self.use else "off"
|
||||
|
||||
|
||||
def read(data: dict, name: str) -> PackMode:
|
||||
"""Battery `name`'s choice from the store's data. A missing or unknown
|
||||
value falls back to the default (on, Solar first), never to Off: a store
|
||||
written by a later version must not switch a battery off here."""
|
||||
use = data.get(unit_key(name, USE_KEY), True)
|
||||
mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE)
|
||||
return PackMode(use=use is not False,
|
||||
mode=mode if mode in MODES else DEFAULT_MODE,
|
||||
sell=SELLING_ENABLED
|
||||
and data.get(unit_key(name, SELL_KEY)) is True)
|
||||
|
||||
|
||||
def mode_range(pm: PackMode, rating_w: float) -> tuple:
|
||||
"""`(lo, hi)` in W for this battery's switch. Always holds 0 W.
|
||||
|
||||
Off asks for 0 W both ways; On allows the rating both ways. main.py files
|
||||
this intersected with the role range from `modetrade.decide()`.
|
||||
"""
|
||||
if not pm.use:
|
||||
return 0.0, 0.0
|
||||
r = abs(float(rating_w))
|
||||
return -r, r
|
||||
|
||||
|
||||
def charge_weights(base, modes: dict, target_w: float):
|
||||
"""The allocator's weights with the solar order applied (design 3.5).
|
||||
|
||||
`base` is packshare.weights()' answer (None for "all 1.0"). While the site
|
||||
charges (`target_w < 0`), a Leftover solar battery weighs 0 and a Solar
|
||||
first battery keeps its weight, so the fill gives the Solar first batteries
|
||||
everything they can take before the last-resort pass reaches the others.
|
||||
Giving power keeps `base`; `modetrade.weights()` adds the run order.
|
||||
|
||||
When no battery that is on would keep a positive weight (all Leftover
|
||||
solar, or every Solar first one at 0), `base` is returned unchanged, so the
|
||||
order among equals stays OPT-09's rather than an equal split.
|
||||
"""
|
||||
if float(target_w) >= 0.0 or not modes:
|
||||
return base
|
||||
names = sorted(modes)
|
||||
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
|
||||
out = {n: (0.0 if modes[n].mode in (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
else w[n])
|
||||
for n in names}
|
||||
if not any(out[n] > 0.0 for n in names if modes[n].use):
|
||||
return base
|
||||
return out
|
||||
@@ -143,6 +143,10 @@ because the money is spent on `max(month's peak so far, this quarter)`:
|
||||
|
||||
ceiling_w = max(peak_cap_w, the month's banked peak)
|
||||
|
||||
SAFETY-79: the guard AIMS `peak_margin_w` under that ceiling
|
||||
(`quarter_target_w()`), so a quarter that lands a little high still stays under
|
||||
it. `ceiling_w` stays the real limit for reports and the bill.
|
||||
|
||||
Once a month's peak already stands at 5013 W, holding every later quarter to
|
||||
3500 W buys nothing - the bill for that month is already 5013 W - while the
|
||||
holding itself costs real charging the site wanted. Raising the ceiling to a peak
|
||||
@@ -395,33 +399,34 @@ not need re-testing. Nothing here indexes a phase.
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import messages
|
||||
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
||||
from .control import peak_at_risk
|
||||
from .tariff import QUARTER_S
|
||||
|
||||
# Why the projection may not be graded. Reasons, not booleans: the status page
|
||||
# and the action log both have to say WHICH, and "blind" alone is unactionable.
|
||||
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
|
||||
BLIND_NO_BLOCK = "no open 15-minute period, the meter has given no reading yet"
|
||||
BLIND_COVERAGE = "meter gap"
|
||||
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
||||
BLIND_FROZEN = "the meter is answering without changing"
|
||||
|
||||
# Where the ceiling came from. Only the second is a relaxation, and only an
|
||||
# OBSERVED reading may produce it.
|
||||
CEILING_CAP = "peak_cap_w"
|
||||
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
CEILING_MONTH_PEAK = "this month's peak"
|
||||
|
||||
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
|
||||
# for BLIND_*'s reason: the status page, the action log and the operator
|
||||
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
|
||||
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
|
||||
"cycle, and an unreadable SoC is not a full battery")
|
||||
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
|
||||
# possessive is load-bearing: this string is what the status page, the action log
|
||||
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
|
||||
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
|
||||
# reads at the moment the battery stops shaving into a claim about the pack that
|
||||
# the grid-following loop is at that instant disproving.
|
||||
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
|
||||
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
|
||||
"unread is not full")
|
||||
# ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
|
||||
# load-bearing: this string is what the action log and the add-on log print,
|
||||
# and `peak_soc_floor` (“Peak reserve”) bounds THIS GUARD ONLY. Widening it to
|
||||
# "the SoC floor" would make the one sentence an operator reads at the moment
|
||||
# the battery stops shaving into a claim about the pack that the
|
||||
# grid-following loop is at that instant disproving.
|
||||
FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
|
||||
|
||||
SOURCE = "peak"
|
||||
|
||||
@@ -452,7 +457,13 @@ PEAK_OPTION_DEFAULTS = {
|
||||
# and it is consistent with DoD 90 on a full pack. It exists so validate.py
|
||||
# rule 8 can refuse a `peak_soc_floor` at or below it - see that rule.
|
||||
"bms_soc_floor": 10,
|
||||
# SAFETY-79. Watts the guard aims under `peak_cap_w`, so a quarter that
|
||||
# lands a little high still stays under the cap. Owner decision 2026-09-28.
|
||||
"peak_margin_w": 50,
|
||||
}
|
||||
# SAFETY-79. config.yaml's schema range for `peak_margin_w`, mirrored so
|
||||
# validate.py can refuse it in words; test_peakguard.py pins it to the file.
|
||||
PEAK_MARGIN_RANGE_W = (0, 500)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -473,6 +484,7 @@ class PeakDecision:
|
||||
projected_w: float | None = None
|
||||
excess_w: float = 0.0
|
||||
wanted_w: float | None = None # the floor before `achievable_w` clamped it
|
||||
target_w: float | None = None # SAFETY-79: what the guard aims at
|
||||
saturated: bool = False
|
||||
# SAFETY-24. True when a forced discharge WAS wanted and the EMS-side SoC
|
||||
# floor refused to size it. Mutually exclusive with `saturated` by
|
||||
@@ -485,17 +497,29 @@ class PeakDecision:
|
||||
reason: str = ""
|
||||
|
||||
def explain(self) -> str:
|
||||
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
|
||||
technical record with every figure, for the log and the action log."""
|
||||
if self.floor_limited:
|
||||
return self.reason
|
||||
if not self.engaged:
|
||||
if self.projected_w is None:
|
||||
return f"peak guard idle - {self.reason}"
|
||||
return (f"peak guard idle - quarter projects "
|
||||
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
|
||||
f"ceiling ({self.ceiling_from})")
|
||||
return messages.row_peak_guard_quiet()
|
||||
return messages.row_peak_guard_idle(
|
||||
self.projected_w, self.ceiling_w,
|
||||
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
|
||||
return self.reason
|
||||
|
||||
|
||||
def blind_kind(blind: str) -> str:
|
||||
"""Which of the three kinds of blindness, as a key of
|
||||
`messages.BLIND_WORDS`, for the status row."""
|
||||
if blind == BLIND_FROZEN:
|
||||
return "frozen"
|
||||
if blind == BLIND_NO_BLOCK:
|
||||
return "no_block"
|
||||
return "gap"
|
||||
|
||||
|
||||
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||
"""The average this quarter may not exceed, and where the number came from.
|
||||
|
||||
@@ -511,6 +535,15 @@ def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||
return cap, CEILING_CAP
|
||||
|
||||
|
||||
def quarter_target_w(cap_w, margin_w, reading) -> float:
|
||||
"""SAFETY-79. The average the guard AIMS at: `margin_w` under
|
||||
`quarter_ceiling_w()`, so under a month peak already banked as well as
|
||||
under the cap. Aiming AT a banked peak can bank a few W over it, which
|
||||
raises the bill as surely as going over the cap. Reports and the bill keep
|
||||
the real limit (`ceiling_w`)."""
|
||||
return quarter_ceiling_w(cap_w, reading)[0] - float(margin_w)
|
||||
|
||||
|
||||
def excess_w(projection, ceiling_w) -> float:
|
||||
"""Watts that must come off the meter NOW to land the quarter on the ceiling.
|
||||
|
||||
@@ -553,8 +586,9 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
return BLIND_NO_BLOCK
|
||||
gap = uncovered_s(projection)
|
||||
if max_hold_s is not None and gap > float(max_hold_s):
|
||||
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
|
||||
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
|
||||
return (f"{BLIND_COVERAGE}, {gap:.0f} s of this 15-minute period not "
|
||||
f"covered, past the {float(max_hold_s):.0f} s a reading is "
|
||||
f"held")
|
||||
return None
|
||||
|
||||
|
||||
@@ -699,8 +733,8 @@ def binding_soc_floor(packs, soc_floor):
|
||||
return max(floors)
|
||||
|
||||
|
||||
def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
|
||||
soc_floor, max_hold_s=None, frozen: bool = False) -> PeakDecision:
|
||||
"""The whole guard. Pure: no clock, no I/O, no state carried between calls.
|
||||
|
||||
`batt_w` MEASURED battery power, positive = discharge. None when the
|
||||
@@ -718,8 +752,13 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
the pack for money. REQUIRED, and deliberately without a
|
||||
default: a default here would be CFG-03's fourth defaults
|
||||
site and TEST-14's quiet fallback in one line.
|
||||
`margin_w` SAFETY-79. Watts to aim under `cap_w` (`peak_margin_w`).
|
||||
REQUIRED for the same reason. `ceiling_w` stays the real
|
||||
limit for every report; only `excess_w` uses the target.
|
||||
"""
|
||||
ceiling, from_where = quarter_ceiling_w(cap_w, reading)
|
||||
target = quarter_target_w(cap_w, margin_w, reading)
|
||||
aim = "" if target == ceiling else f", aiming at {target:.0f} W"
|
||||
ceil_top = abs(float(achievable_w))
|
||||
|
||||
blind = blindness(projection, max_hold_s, frozen)
|
||||
@@ -729,10 +768,10 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# see", which is one-directional and cannot cost pack energy.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=blind, projected_w=None,
|
||||
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
|
||||
f"while the billed quarter cannot be measured; commanding no "
|
||||
f"charging (floor 0 W)"))
|
||||
target_w=target, blind=blind, projected_w=None,
|
||||
reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
|
||||
f"while this 15-minute period cannot be measured, so it "
|
||||
f"is asked for no charging, floor 0 W"))
|
||||
|
||||
projected = float(projection.projected_w)
|
||||
if batt_w is None:
|
||||
@@ -741,7 +780,7 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# measurement that is not there is a made-up number.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
ceiling_from=from_where, target_w=target, projected_w=projected,
|
||||
reason="no battery measurement to size a floor from")
|
||||
if QUARTER_S - float(projection.elapsed_s) <= 0.0:
|
||||
# A block with no seconds left cannot be influenced. This is degenerate
|
||||
@@ -749,10 +788,10 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# the battery's own current output would be a claim that says nothing.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected,
|
||||
ceiling_from=from_where, target_w=target, projected_w=projected,
|
||||
reason="the quarter has no seconds left to influence")
|
||||
|
||||
ex = excess_w(projection, ceiling)
|
||||
ex = excess_w(projection, target)
|
||||
wanted = float(batt_w) + ex
|
||||
if wanted <= 0.0:
|
||||
# ⚠️ THE TEST IS ON THE FLOOR, NOT ON `excess > 0`, AND THE REPLAY IS WHY.
|
||||
@@ -773,7 +812,8 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# the quantity being thresholded is simply the right one.
|
||||
return PeakDecision(
|
||||
engaged=False, floor_w=None, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
ceiling_from=from_where, target_w=target, projected_w=projected,
|
||||
excess_w=ex,
|
||||
wanted_w=wanted,
|
||||
reason="the load is under the level this quarter can afford")
|
||||
|
||||
@@ -791,35 +831,73 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
# not the grid-following loop, which still has no SoC guard.
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
ceiling_from=from_where, target_w=target, projected_w=projected,
|
||||
excess_w=ex,
|
||||
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
|
||||
soc_floor_reason=denied,
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py
|
||||
# part 6). The code is one token, so it is not read as shouting.
|
||||
reason=(
|
||||
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
|
||||
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
|
||||
f"it, but {denied}"
|
||||
+ ("" if soc is None else f" ({float(soc):.0f} %")
|
||||
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W limit "
|
||||
f"({from_where}){aim}, and {wanted:+.0f} W from the battery "
|
||||
f"would hold it. But {denied}"
|
||||
+ ("" if soc is None else f", at {float(soc):.0f} %")
|
||||
+ ("" if soc is None or soc_floor is None
|
||||
else f" vs {float(soc_floor):.0f} %")
|
||||
+ ("" if soc is None else ")")
|
||||
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
|
||||
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
|
||||
else f" against {float(soc_floor):.0f} %")
|
||||
+ ", so no forced discharge and no charging, and this period "
|
||||
"will go over the limit"))
|
||||
|
||||
floor = min(wanted, ceil_top)
|
||||
saturated = floor < wanted
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=floor, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||
saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||
target_w=target, blind=None, projected_w=projected, excess_w=ex,
|
||||
wanted_w=wanted, saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||
reason=(
|
||||
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||
f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
|
||||
f"against a {ceiling:.0f} W limit ({from_where}){aim}, with "
|
||||
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
|
||||
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
|
||||
f"floor {floor:+.0f} W"
|
||||
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
|
||||
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
|
||||
f"and is asked for {floor:+.0f} W"
|
||||
# Saturated means floor == ceil_top: the ask IS the most the
|
||||
# inverters give, so one clause says both.
|
||||
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
|
||||
if saturated else "")))
|
||||
|
||||
|
||||
# SAFETY-81. A quarter must end this many W over the real limit before
|
||||
# "Load too high for battery" fires. Above the guard's own landing error at
|
||||
# margin 0 (up to +5.7 W, SAFETY-79 test check 5), which margin 0 accepts.
|
||||
ALARM_MISS_W = 10.0
|
||||
# ...and for this long without a break, so a load that peaks for a few seconds
|
||||
# (a hob or heater cycling) is not read as the rest of the quarter.
|
||||
ALARM_HOLD_S = 10.0
|
||||
|
||||
|
||||
def load_too_high(projection, *, batt_w, ceiling_w, achievable_w) -> bool:
|
||||
"""SAFETY-81. May "Load too high for battery" fire? Only if, with the
|
||||
battery at full power from now on, the quarter still ends more than
|
||||
`ALARM_MISS_W` over the real limit (`ceiling_w`, not the aim).
|
||||
|
||||
This is the miss itself, in W of the quarter average, so it does not have
|
||||
`decide()`'s gain (900 / remaining): near a quarter's end that gain turns
|
||||
a watt over the aim into thousands of W wanted.
|
||||
|
||||
The load is the held meter reading plus `batt_w`. The caller passes the
|
||||
lower of this cycle's and the last cycle's battery reading, so a meter
|
||||
sample taken before the battery's last increase is not counted twice
|
||||
(DECISIONS/SAFETY-81-load-too-high.md). A real overload shows on the meter
|
||||
before the battery moves, so that filter costs it no cycle.
|
||||
"""
|
||||
remaining = QUARTER_S - float(projection.elapsed_s)
|
||||
if batt_w is None or remaining <= 0.0:
|
||||
return False
|
||||
rest = float(projection.held_w) + float(batt_w) - float(achievable_w)
|
||||
done_ws = (QUARTER_S * float(projection.projected_w)
|
||||
- max(float(projection.held_w), 0.0) * remaining)
|
||||
ends_w = (done_ws + max(rest, 0.0) * remaining) / QUARTER_S
|
||||
return ends_w - float(ceiling_w) > ALARM_MISS_W
|
||||
|
||||
|
||||
def charge_headroom_w(decision) -> float | None:
|
||||
|
||||
@@ -150,6 +150,7 @@ from enum import Enum
|
||||
from textwrap import dedent
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from . import messages
|
||||
from .custom_formula import (
|
||||
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
|
||||
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
|
||||
@@ -1314,8 +1315,8 @@ def code_leaks(cls) -> list:
|
||||
src = dedent(inspect.getsource(cls))
|
||||
tree = ast.parse(src)
|
||||
except (OSError, TypeError, IndentationError, SyntaxError) as e:
|
||||
_LOG.warning("could not read %s's source for the framework-key scan "
|
||||
"(%s); the double-counting guard is NOT enforced for it",
|
||||
_LOG.warning("Could not read the code of price source %s (%s), so its "
|
||||
"double-counting check is skipped.",
|
||||
getattr(cls, "id", cls), e)
|
||||
return []
|
||||
for node in ast.walk(tree):
|
||||
@@ -1718,9 +1719,8 @@ def _points_from_columnar_json(body, expect_unit, scale,
|
||||
# Not a refusal: today's prices are still prices. But §11.4 makes a
|
||||
# dead feed look exactly like a quiet one, so the warning that the
|
||||
# source is going away has to be emitted while it still works.
|
||||
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
|
||||
"but this endpoint is scheduled to stop",
|
||||
doc.get("deprecated"))
|
||||
_LOG.warning("The price feed says it will stop (deprecated=%r), "
|
||||
"though it still answers for now.", doc.get("deprecated"))
|
||||
out = []
|
||||
dropped = []
|
||||
for i, (t, v) in enumerate(zip(times, prices)):
|
||||
@@ -1739,12 +1739,11 @@ def _points_from_columnar_json(body, expect_unit, scale,
|
||||
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
|
||||
provisional=bool(provisional_default)))
|
||||
if dropped:
|
||||
_LOG.warning("the feed published no price for %d of %d instants "
|
||||
"(index %s); those instants are left OUT of the series, "
|
||||
"never zero-filled - a missing hour is not a free hour",
|
||||
len(dropped), len(times),
|
||||
_LOG.warning("The price feed gave no price for %d of %s, at index "
|
||||
"%s. Those are left out, not counted as free.",
|
||||
len(dropped), messages.count(len(times), "time"),
|
||||
", ".join(str(i) for i in dropped[:5])
|
||||
+ (", ..." if len(dropped) > 5 else ""))
|
||||
+ (" and more" if len(dropped) > 5 else ""))
|
||||
if not out:
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried {len(times)} instants and a price for none of "
|
||||
@@ -1790,16 +1789,16 @@ def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
|
||||
override = str(opts.get("price_feed_url") or "").strip()
|
||||
if override:
|
||||
_LOG.warning(
|
||||
"price_feed_url is set, so %s is requesting %s exactly as "
|
||||
"given and is adding NO date range to it. The public feed "
|
||||
"answers a single Brussels day to a request that names no "
|
||||
"range, and every hour past that day is then carried forward "
|
||||
"from yesterday's profile and flagged provisional - §8.3 "
|
||||
"drops provisional hours from the trade set, so arbitrage "
|
||||
"would never see tomorrow as firm. Clear price_feed_url to "
|
||||
"let this plugin ask for the %s..%s it was given.",
|
||||
"price_feed_url is set, so %s asks for %s as given, and "
|
||||
"tomorrow's prices may stay provisional. Clear it to ask for %s "
|
||||
"to %s.",
|
||||
plugin_id, override, *(d.isoformat()
|
||||
for d in brussels_date_range(since, until)))
|
||||
_LOG.debug(
|
||||
"The public feed answers one Brussels day to a request that names "
|
||||
"no range. Every later hour is then copied from yesterday and "
|
||||
"marked provisional, and §8.3 leaves provisional hours out of "
|
||||
"trading.")
|
||||
return override
|
||||
first, last = brussels_date_range(since, until)
|
||||
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
|
||||
@@ -2083,8 +2082,8 @@ def _emit_startup_preview(plugin, pid: str, points) -> None:
|
||||
try:
|
||||
lines = list(plugin.startup_preview(points))
|
||||
except Exception as e: # noqa: BLE001
|
||||
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
|
||||
"unaffected - this is a log line, not a decision",
|
||||
_LOG.warning("%s could not show its start-up preview (%s: %s). Prices "
|
||||
"are not affected.",
|
||||
pid, type(e).__name__, e)
|
||||
return
|
||||
for line in lines:
|
||||
@@ -2108,12 +2107,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
pid = provider_id(opts)
|
||||
if not provider_configured(opts):
|
||||
return PriceRead.no_data(
|
||||
"no price provider configured (price_provider is 'off'); "
|
||||
"§11.4: static profile, plan provisional, no arbitrage")
|
||||
"no price feed is set (price_provider is 'off'), so the fixed "
|
||||
"tariff is used, the plan is estimated and there is no trading")
|
||||
cls = PROVIDERS.get(pid)
|
||||
if cls is None:
|
||||
return PriceRead.no_data(
|
||||
f"unknown price provider {pid!r}; shipped: "
|
||||
f"unknown price provider {pid!r}. The known ones are "
|
||||
f"{', '.join(sorted(PROVIDERS))}")
|
||||
|
||||
caps = cls.capabilities()
|
||||
@@ -2131,12 +2130,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
# Not silent, and not a traceback into the 30 s tick. validate.py
|
||||
# should have refused this at startup; if the add-on is running with it
|
||||
# anyway, the operator is told which key and what is wrong with it.
|
||||
_LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch "
|
||||
"has NO tax component to add - it is not falling back to "
|
||||
"price_taxes_eur_kwh, because a silently different tax "
|
||||
"basis is the defect this option exists to remove. Fix the "
|
||||
"table or clear it; validate.py refuses it at startup",
|
||||
_LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes "
|
||||
"are added to this price update. Fix the table or clear it.",
|
||||
type(e).__name__, e)
|
||||
_LOG.debug("price_taxes_eur_kwh is not used instead, because a "
|
||||
"silently different tax basis is the fault this setting "
|
||||
"exists to remove.")
|
||||
return PriceRead.no_data(
|
||||
f"price_taxes_profile cannot be used: {e}")
|
||||
if table is not None:
|
||||
|
||||
@@ -164,6 +164,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
|
||||
from . import messages
|
||||
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
|
||||
from .price import PricePoint, PriceRead, _Outcome, brussels
|
||||
|
||||
@@ -411,13 +412,14 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
if not out:
|
||||
reason = read.match(
|
||||
series=lambda r: (f"the {len(r.points)} published hours all fall "
|
||||
f"outside {since.isoformat()}..{until.isoformat()}"),
|
||||
series=lambda r: (f"all {messages.count(len(r.points), 'published price')} "
|
||||
f"fall outside {messages.when(brussels(since))} to "
|
||||
f"{messages.when(brussels(until))}"),
|
||||
no_data=lambda r: r.reason)
|
||||
return PriceRead.no_data(
|
||||
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
|
||||
f"profile has nothing to fill them with either. §11.4 applies: "
|
||||
f"static profile, plan provisional, no arbitrage")
|
||||
f"no hours could be filled from yesterday's prices: {reason}. So "
|
||||
f"the fixed tariff is used, the plan is estimated and there is no "
|
||||
f"trading")
|
||||
|
||||
_audit(out, published)
|
||||
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
|
||||
@@ -431,10 +433,11 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
|
||||
# feed's is the POINT; nothing derived from one answers for the other.
|
||||
total_h = len({_utc_hour(p.start) for p in out})
|
||||
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
|
||||
"profile and flagged provisional; the feed published %d "
|
||||
"points over the other %d hours",
|
||||
carried, total_h, len(out) - carried, total_h - carried)
|
||||
_LOG.info("%d of %s use yesterday's prices, marked as estimated. "
|
||||
"The feed published %s over the other %s.",
|
||||
carried, messages.count(total_h, "hour"),
|
||||
messages.count(len(out) - carried, "price"),
|
||||
messages.count(total_h - carried, "hour"))
|
||||
|
||||
product = read.match(series=lambda r: r.product,
|
||||
no_data=lambda _r: yesterday.product)
|
||||
@@ -525,8 +528,8 @@ class _Permitted(Gate):
|
||||
"one rule this type has.")
|
||||
self.action = action
|
||||
self.hours = pts
|
||||
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
|
||||
f"({pts[0].start.isoformat()} ..)")
|
||||
self.words = (f"allowed on {messages.count(len(pts), 'published price')} "
|
||||
f"from {messages.when(brussels(pts[0].start))}")
|
||||
|
||||
|
||||
class _Blocked(Gate):
|
||||
@@ -570,8 +573,8 @@ def price_permits(read: PriceRead, now: datetime,
|
||||
return read.match(
|
||||
series=lambda r: _walk(r, hours, action),
|
||||
no_data=lambda r: Gate.block(
|
||||
f"{action.name} is gated on a price and there is no price data: "
|
||||
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
|
||||
f"{action.name} needs a price and there is none: {r.reason}. A "
|
||||
f"missing price is not a cheap one"))
|
||||
|
||||
|
||||
def _walk(series, hours, action) -> Gate:
|
||||
@@ -579,19 +582,18 @@ def _walk(series, hours, action) -> Gate:
|
||||
for u in hours:
|
||||
if not series.covers(u):
|
||||
return Gate.block(
|
||||
f"{action.name} commits {action.commits_h:g} h from "
|
||||
f"{hours[0].isoformat()} and no price covers "
|
||||
f"{u.isoformat()}. An hour outside the series is an hour with "
|
||||
f"no price (§11.4 for that hour), not a free one")
|
||||
f"{action.name} needs prices for {action.commits_h:g} hours "
|
||||
f"from {messages.when(brussels(hours[0]))}, and no price "
|
||||
f"covers {messages.when(brussels(u))}. An hour with no price "
|
||||
f"is not a free one")
|
||||
p = series.at(u)
|
||||
if p.provisional:
|
||||
return Gate.block(
|
||||
f"{action.name} commits {action.commits_h:g} h from "
|
||||
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
|
||||
f"carried from yesterday's profile or standing in for a dead "
|
||||
f"feed. §8.3: no irreversible action may be taken on a "
|
||||
f"provisional price. It becomes available when the day-ahead "
|
||||
f"print lands (~14-16 h CET, §5.4)")
|
||||
f"{action.name} needs prices for {action.commits_h:g} hours "
|
||||
f"from {messages.when(brussels(hours[0]))}, and the price for "
|
||||
f"{messages.when(brussels(u))} is only estimated. It can start "
|
||||
f"once the day-ahead prices are published, around 14:00 to "
|
||||
f"16:00")
|
||||
pts.append(p)
|
||||
return Gate.permit(action, pts)
|
||||
|
||||
@@ -622,5 +624,6 @@ def _go(maint, now, gate) -> Gate:
|
||||
|
||||
|
||||
def _no(gate) -> Gate:
|
||||
_LOG.warning("irreversible action refused - %s", gate.reason)
|
||||
_LOG.warning("An action that cannot be undone does not start: %s.",
|
||||
gate.reason)
|
||||
return gate
|
||||
|
||||
@@ -186,8 +186,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from .price import PricePoint, PriceProduct, PriceRead, brussels
|
||||
from .price_forecast import fill_from_yesterday
|
||||
|
||||
@@ -345,8 +346,19 @@ def _row_of(day: str, points: int, hours: int) -> str:
|
||||
whole day to carry, and that is the HOUR figure; the point count says how
|
||||
finely it is priced. Neither answers for the other.
|
||||
"""
|
||||
return (f"{day} ({points} point{'' if points == 1 else 's'} "
|
||||
f"over {hours} h)")
|
||||
# UX-10: the day as the page writes dates ("26 Sep"), not ISO.
|
||||
return (f"{points} price{'' if points == 1 else 's'} over {hours} "
|
||||
f"hour{'' if hours == 1 else 's'} for {day_words(day)}")
|
||||
|
||||
|
||||
def day_words(day) -> str:
|
||||
"""`'2026-09-26'` -> `'26 Sep'`, as the page writes a date. A key that is
|
||||
not a date is shown as it is, because this feeds a status row."""
|
||||
try:
|
||||
d = date.fromisoformat(str(day))
|
||||
except ValueError:
|
||||
return str(day)
|
||||
return f"{d.day} {d:%b}"
|
||||
|
||||
|
||||
def _bad_rows(rec) -> int:
|
||||
@@ -393,9 +405,8 @@ class PriceHistory:
|
||||
self.last_error = f"no durable volume at {parent}"
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong.
|
||||
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
|
||||
"to carry and the horizon stays as short as the feed",
|
||||
self.last_error)
|
||||
_LOG.info("No price history is kept (%s), so missing hours are "
|
||||
"not filled from yesterday's prices.", self.last_error)
|
||||
return
|
||||
self.ok = True
|
||||
self._load()
|
||||
@@ -437,9 +448,9 @@ class PriceHistory:
|
||||
# exactly what this handler says, and a third `ValueError` subclass
|
||||
# nobody has thought of is how this defect arrived the first time.
|
||||
self.last_error = f"unreadable ({err})"
|
||||
_LOG.warning("price history at %s is %s - starting from empty. "
|
||||
"§8.3 carry-forward has nothing to carry until the "
|
||||
"next refresh has written a full day",
|
||||
_LOG.warning("Price history at %s is %s, so it starts empty. "
|
||||
"Missing hours cannot be filled in until a full day "
|
||||
"is saved.",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
found = blob.get("version") if isinstance(blob, dict) else None
|
||||
@@ -449,15 +460,15 @@ class PriceHistory:
|
||||
# would not notice a writer this build has never met.
|
||||
if not isinstance(blob, dict) or isinstance(found, bool) \
|
||||
or not isinstance(found, int) or found != SCHEMA:
|
||||
self.last_error = f"schema {found!r}, not {SCHEMA}"
|
||||
_LOG.warning("price history at %s has %s - discarding it rather "
|
||||
"than guessing what its points mean",
|
||||
self.last_error = f"version {found!r}, not {SCHEMA}"
|
||||
_LOG.warning("Price history at %s has %s, so it is not used.",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
days = blob.get("days")
|
||||
if not isinstance(days, dict):
|
||||
self.last_error = "no `days` object"
|
||||
_LOG.warning("price history at %s has %s", self.path, self.last_error)
|
||||
self.last_error = "no days in it"
|
||||
_LOG.warning("Price history at %s has %s, so it is not used.",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
self._days = self._usable_days(days)
|
||||
|
||||
@@ -504,20 +515,22 @@ class PriceHistory:
|
||||
kept[key] = rec
|
||||
notes = []
|
||||
if dropped:
|
||||
notes.append(f"{len(dropped)} unusable day record(s) dropped "
|
||||
f"({', '.join(sorted(dropped))})")
|
||||
notes.append(f"{messages.count(len(dropped), 'unusable day')} "
|
||||
f"dropped, " + ", ".join(day_words(d)
|
||||
for d in sorted(dropped)))
|
||||
if malformed:
|
||||
notes.append(
|
||||
f"{sum(malformed.values())} malformed point row(s) in "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
|
||||
f"{messages.count(sum(malformed.values()), 'bad price')} in "
|
||||
+ ", ".join(f"{day_words(d)} ({n})"
|
||||
for d, n in sorted(malformed.items())))
|
||||
if notes:
|
||||
# Recorded, not silent: `yesterday()` and `words` both report
|
||||
# `last_error`, and a cache that quietly discards half a file reads
|
||||
# to an operator exactly like a normal first day.
|
||||
self.last_error = "; ".join(notes)
|
||||
_LOG.warning("price history at %s: %s - the controller starts "
|
||||
"anyway and the horizon is as short as the feed makes "
|
||||
"it", self.path, self.last_error)
|
||||
self.last_error = " and ".join(notes)
|
||||
_LOG.warning("Price history at %s: %s. The add-on starts anyway, "
|
||||
"and fills in only what the rest holds.",
|
||||
self.path, self.last_error)
|
||||
return kept
|
||||
|
||||
def _save(self) -> bool:
|
||||
@@ -680,10 +693,11 @@ class PriceHistory:
|
||||
# undone with the rest of the merge, so those rows are still in the
|
||||
# file and the next write that completes drops them again.
|
||||
self.last_error = (
|
||||
f"{sum(junked.values())} malformed point row(s) dropped from "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
|
||||
_LOG.warning("price history at %s: %s - the record is rewritten "
|
||||
"without them and the refresh continues",
|
||||
f"{messages.count(sum(junked.values()), 'bad price')} dropped "
|
||||
f"from " + ", ".join(f"{day_words(d)} ({n})"
|
||||
for d, n in sorted(junked.items())))
|
||||
_LOG.warning("Price history at %s: %s. The file is saved without "
|
||||
"them and the price update goes on.",
|
||||
self.path, self.last_error)
|
||||
self._prune(stamp)
|
||||
if not self._save():
|
||||
@@ -752,21 +766,23 @@ class PriceHistory:
|
||||
if self.last_error and not self._days:
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} is {self.last_error}, so "
|
||||
f"§8.3 has no profile to carry forward for {want}; the plan "
|
||||
f"covers only the hours the feed published")
|
||||
f"there are no prices from yesterday to fill in for "
|
||||
f"{day_words(want)}. The plan covers only the hours the feed "
|
||||
f"published")
|
||||
rec = self._days.get(want)
|
||||
if rec is None:
|
||||
have = ", ".join(sorted(self._days)) or "no days at all"
|
||||
have = (", ".join(day_words(d) for d in sorted(self._days))
|
||||
or "no days at all")
|
||||
return PriceRead.no_data(
|
||||
f"no published prices recorded for {want} (held: {have}), so "
|
||||
f"§8.3 has no profile to carry forward. A record for another "
|
||||
f"day is not a yesterday")
|
||||
f"no published prices are kept for {day_words(want)}, only for "
|
||||
f"{have}, so there are no prices from yesterday to fill in. A "
|
||||
f"record for another day is not a yesterday")
|
||||
points, bad = _parse_points(rec.get("points"))
|
||||
if not points:
|
||||
why = bad or "it is empty"
|
||||
return PriceRead.no_data(
|
||||
f"the {want} record holds no usable point ({why}), and an "
|
||||
f"empty series is not a free day")
|
||||
f"the {day_words(want)} record holds no usable price, {why}, "
|
||||
f"and an empty series is not a free day")
|
||||
try:
|
||||
product = PriceProduct(rec.get("product"))
|
||||
except ValueError:
|
||||
@@ -775,8 +791,9 @@ class PriceHistory:
|
||||
f"which is not a PriceProduct. The optimiser switches on that "
|
||||
f"enum, so a record that cannot name one cannot be a series")
|
||||
if bad:
|
||||
_LOG.warning("price history: %s in the %s record were dropped; "
|
||||
"%d point(s) carried forward", bad, want, len(points))
|
||||
_LOG.warning("Price history: %s dropped from the %s record, and "
|
||||
"%s kept.", bad, day_words(want),
|
||||
messages.count(len(points), "price"))
|
||||
return PriceRead.series(points, product,
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
@@ -805,7 +822,7 @@ class PriceHistory:
|
||||
if not self._days:
|
||||
why = f" - {self.last_error}" if self.last_error else ""
|
||||
return f"empty{why} - nothing to carry forward yet"
|
||||
held = "; ".join(_row_of(d, *_held(self._days[d]))
|
||||
held = ", ".join(_row_of(d, *_held(self._days[d]))
|
||||
for d in sorted(self._days, key=str))
|
||||
# A file that PARTLY loaded says so, or the dropped days read as days
|
||||
# that were simply never written.
|
||||
@@ -833,7 +850,7 @@ def _parse_points(raw) -> tuple:
|
||||
continue
|
||||
out.append(pt)
|
||||
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
|
||||
return tuple(out), (messages.count(dropped, "bad price") if dropped else "")
|
||||
|
||||
|
||||
class NoHistory:
|
||||
@@ -861,5 +878,5 @@ class NoHistory:
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data(
|
||||
"this caller keeps no price history (NoHistory), so §8.3 has no "
|
||||
"profile to carry forward")
|
||||
"this caller keeps no price history, so there are no prices from "
|
||||
"yesterday to fill in")
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""OPT-11 P3: price runs. Which consecutive firm price slots are cheap, which
|
||||
are expensive, and which one a battery may sell in.
|
||||
|
||||
Pure: prices, the time and a few energy figures in, a `RunPlan` out. No I/O,
|
||||
no clock. `modetrade.py` turns the plan into each battery's range and the
|
||||
site's trade; main.py rebuilds the plan at most once per 15 minutes and keeps
|
||||
a run that has started until its end (`keep_started()`).
|
||||
|
||||
The owner's SAFETY-46 answer (2026-09-25): a trade is a run of consecutive
|
||||
slots, not one 15-minute interval. OPT-11-design section 2 ("Prices") and
|
||||
DECISIONS/OPT-11-battery-modes.md have the record.
|
||||
|
||||
* The CHEAP run is the cheapest consecutive firm window long enough to put
|
||||
the planned energy in at the chargers' combined rating.
|
||||
* A slot is EXPENSIVE when its price is above the cheap run's mean by at
|
||||
least the required spread (`cycle_cost_threshold_eur`, widened as
|
||||
`TariffPlan.arbitrage()` widens it). Give runs are the maximal consecutive
|
||||
expensive slots. Holding batteries cover the house in them. The mean is
|
||||
the lower of the next cheap run's and `ref_eur_kwh`, the cheap run the
|
||||
batteries last filled in (main.py keeps it for 12 h): energy bought at
|
||||
03:00 is still worth that price at 08:00, when the next cheap run in sight
|
||||
is today's dearer midday (measured: without it a Cheap hours battery spent
|
||||
its night charge on flat morning prices, then had nothing at 19:00).
|
||||
* The SELL run is the dearest consecutive window inside one give run long
|
||||
enough to sell the sellers' energy at their rating, with no slot below 0
|
||||
(section 17 of the spec: never export at a negative price).
|
||||
* A provisional (carried) price is never part of any run (section 8.3).
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from .price import _cover_table
|
||||
|
||||
# How far ahead runs are looked for. A day: tonight's cheap hours and the next
|
||||
# evening's expensive ones, without holding a battery for a peak two days out.
|
||||
HORIZON = timedelta(hours=24)
|
||||
|
||||
# No plan, and why (the page words are in homeview / ui_text).
|
||||
NO_PRICES = "no_prices" # fewer than two firm slots ahead
|
||||
NOT_WORTH = "not_worth" # no slot clears the required spread
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Run:
|
||||
start: datetime
|
||||
end: datetime
|
||||
mean_eur_kwh: float
|
||||
|
||||
def holds(self, when: datetime) -> bool:
|
||||
return self.start <= when < self.end
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunPlan:
|
||||
"""What the prices say for the next day. `reason` is None when there is
|
||||
a plan (a cheap run and at least one give run), else why there is none."""
|
||||
cheap: Run | None
|
||||
give: tuple
|
||||
sell: Run | None
|
||||
level_eur_kwh: float | None
|
||||
reason: str | None
|
||||
# A slot after the cheap run clears the spread over the CHEAP RUN'S OWN
|
||||
# price (not the reference): only then is buying in it worth a cycle.
|
||||
charge_worth: bool = False
|
||||
|
||||
@property
|
||||
def on(self) -> bool:
|
||||
return self.reason is None
|
||||
|
||||
def give_at(self, when):
|
||||
return next((g for g in self.give if g.holds(when)), None)
|
||||
|
||||
def next_give(self, when):
|
||||
"""The give run under way at `when`, else the next one, else None."""
|
||||
return next((g for g in self.give if g.end > when), None)
|
||||
|
||||
def charge_useful(self) -> bool:
|
||||
"""Charging in the cheap run pays only if, after it, a slot clears
|
||||
the spread over the cheap run's own price."""
|
||||
return self.cheap is not None and self.charge_worth
|
||||
|
||||
|
||||
def firm_slots(points, now: datetime, horizon: timedelta = HORIZON) -> list:
|
||||
"""`[(start, end, eur_kwh)]`, ascending: the firm slots that have not
|
||||
ended and start within the horizon. A slot's span is TARIFF-14's rule
|
||||
(`price._cover_table()`), so 15-minute and hourly series both work."""
|
||||
out = []
|
||||
for start, end, p in _cover_table(points):
|
||||
if p.provisional or end <= now or start >= now + horizon:
|
||||
continue
|
||||
if end > start:
|
||||
out.append((start, end, float(p.eur_per_kwh)))
|
||||
return out
|
||||
|
||||
|
||||
def _segments(slots) -> list:
|
||||
"""Split slots into runs of consecutive ones (each starts where the last
|
||||
ended). A gap or a provisional slot breaks a run."""
|
||||
segs, cur = [], []
|
||||
for s in slots:
|
||||
if cur and s[0] != cur[-1][1]:
|
||||
segs.append(cur)
|
||||
cur = []
|
||||
cur.append(s)
|
||||
if cur:
|
||||
segs.append(cur)
|
||||
return segs
|
||||
|
||||
|
||||
def _hours(s) -> float:
|
||||
return (s[1] - s[0]).total_seconds() / 3600.0
|
||||
|
||||
|
||||
def _mean(win) -> float:
|
||||
h = sum(_hours(s) for s in win)
|
||||
return sum(_hours(s) * s[2] for s in win) / h
|
||||
|
||||
|
||||
def _windows(segs, need_h):
|
||||
"""Every consecutive window that lasts at least `need_h` hours, shortest
|
||||
such from each start. If no segment is that long, each whole segment."""
|
||||
out = []
|
||||
for seg in segs:
|
||||
for i in range(len(seg)):
|
||||
h, j = 0.0, i
|
||||
while j < len(seg) and h < need_h - 1e-9:
|
||||
h += _hours(seg[j])
|
||||
j += 1
|
||||
if h >= need_h - 1e-9:
|
||||
out.append(seg[i:j])
|
||||
return out or [seg for seg in segs]
|
||||
|
||||
|
||||
def need_hours(kwh, kw, slots) -> float:
|
||||
"""How long a run must last: `kwh` at `kw`, and at least one slot."""
|
||||
one = min((_hours(s) for s in slots), default=1.0)
|
||||
if not (kwh and kw) or kwh <= 0 or kw <= 0:
|
||||
return one
|
||||
return max(one, float(kwh) / float(kw))
|
||||
|
||||
|
||||
def plan(points, now: datetime, *, required_eur_kwh: float, charge_kwh,
|
||||
charge_kw, sell_kwh, sell_kw, horizon: timedelta = HORIZON,
|
||||
ref_eur_kwh=None) -> RunPlan:
|
||||
"""The run plan for the next `horizon`. Ties go to the earliest window."""
|
||||
if not (isinstance(required_eur_kwh, (int, float))
|
||||
and math.isfinite(required_eur_kwh) and required_eur_kwh >= 0):
|
||||
raise ValueError("required_eur_kwh must be a finite number >= 0")
|
||||
slots = firm_slots(points, now, horizon)
|
||||
if len(slots) < 2:
|
||||
return RunPlan(None, (), None, None, NO_PRICES)
|
||||
segs = _segments(slots)
|
||||
wins = _windows(segs, need_hours(charge_kwh, charge_kw, slots))
|
||||
best = min(wins, key=lambda w: (round(_mean(w), 9), w[0][0]))
|
||||
cheap = Run(best[0][0], best[-1][1], _mean(best))
|
||||
base = cheap.mean_eur_kwh if ref_eur_kwh is None else min(
|
||||
cheap.mean_eur_kwh, float(ref_eur_kwh))
|
||||
level = base + float(required_eur_kwh)
|
||||
# Expensive: clears the spread, strictly dearer than the cheap mean, and
|
||||
# not part of the cheap run itself (a threshold of 0 must not overlap).
|
||||
dear = [s for s in slots
|
||||
if s[2] >= level and s[2] > cheap.mean_eur_kwh
|
||||
and not (cheap.start <= s[0] < cheap.end)]
|
||||
give_segs = _segments(dear)
|
||||
give = tuple(Run(g[0][0], g[-1][1], _mean(g)) for g in give_segs)
|
||||
worth = any(s[0] >= cheap.end and s[2] >= cheap.mean_eur_kwh
|
||||
+ float(required_eur_kwh) for s in dear)
|
||||
if not give:
|
||||
return RunPlan(cheap, (), None, level, NOT_WORTH)
|
||||
# Sell: inside one give run, never over a slot priced below 0.
|
||||
sellable = [part for g in give_segs
|
||||
for part in _segments([s for s in g if s[2] >= 0.0])]
|
||||
sell = None
|
||||
if sellable:
|
||||
sw = _windows(sellable, need_hours(sell_kwh, sell_kw, slots))
|
||||
top = max(sw, key=lambda w: (round(_mean(w), 9),
|
||||
-w[0][0].timestamp()))
|
||||
sell = Run(top[0][0], top[-1][1], _mean(top))
|
||||
return RunPlan(cheap, give, sell, level, None, worth)
|
||||
|
||||
|
||||
def keep_started(old: RunPlan | None, new: RunPlan, now: datetime) -> RunPlan:
|
||||
"""A run under way keeps going to its end (no flapping on a new plan): the
|
||||
cheap run, the give run and the sell run each come from `old` while `old`
|
||||
holds `now` in it. The rest is `new`. A safety or peak rule can still stop
|
||||
a run at any tick; that is not decided here."""
|
||||
if old is None or not old.on:
|
||||
return new
|
||||
cheap = old.cheap if old.cheap and old.cheap.holds(now) else new.cheap
|
||||
sell = old.sell if old.sell and old.sell.holds(now) else new.sell
|
||||
g_old = old.give_at(now)
|
||||
give = new.give
|
||||
if g_old is not None:
|
||||
give = (g_old,) + tuple(g for g in new.give
|
||||
if g.start >= g_old.end)
|
||||
if cheap is new.cheap and sell is new.sell and g_old is None:
|
||||
return new
|
||||
worth = old.charge_worth if cheap is old.cheap else new.charge_worth
|
||||
return RunPlan(cheap, give, sell, new.level_eur_kwh if new.on
|
||||
else old.level_eur_kwh, None if give else new.reason,
|
||||
worth)
|
||||
@@ -82,6 +82,7 @@ import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
from .configui import changed_keys
|
||||
from .inverter import build_roster
|
||||
from .validate import validate_startup_config, validate_unit_ranges
|
||||
@@ -130,11 +131,16 @@ class SaveRefused(Exception):
|
||||
own sentences, byte for byte; `changed` names the keys that moved.
|
||||
"""
|
||||
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None):
|
||||
def __init__(self, kind, problems=(), changed=(), stored=None,
|
||||
moved=None):
|
||||
self.kind = kind
|
||||
self.problems = list(problems)
|
||||
self.changed = list(changed)
|
||||
self.stored = stored
|
||||
# UX-13 P4: the keys the OTHER surface changed - stored-on-open
|
||||
# against stored-now - when the caller knows what it opened. None
|
||||
# when it does not. `changed` also holds the operator's own edits.
|
||||
self.moved = None if moved is None else list(moved)
|
||||
super().__init__(f"save refused ({kind})")
|
||||
|
||||
|
||||
@@ -287,7 +293,7 @@ async def problems_for(opts: dict, hass=None) -> list:
|
||||
return problems
|
||||
|
||||
|
||||
async def problems_and_ranges(opts: dict, hass=None):
|
||||
async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
|
||||
|
||||
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
|
||||
@@ -302,6 +308,8 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
has just typed a new `setpoint_entity` must be told about THAT entity, and
|
||||
a route that asked the running controller instead would report on the old
|
||||
one. That was the first shape of this and it disagreed with itself.
|
||||
|
||||
UX-13: a list passed as `notes` collects the validator's warnings too.
|
||||
"""
|
||||
flat = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
|
||||
@@ -328,7 +336,7 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
# disagreement is itself the finding - so it is reported rather than
|
||||
# swallowed. Returning `[]` here would turn a crash into a silent
|
||||
# acceptance, which is worse than the 500 this replaced.
|
||||
found = list(validate_startup_config(opts))
|
||||
found = list(validate_startup_config(opts, notes=notes))
|
||||
if not found:
|
||||
found = [f"the configuration could not be composed ({type(exc).__name__}: "
|
||||
f"{exc}), and the validator did not name a reason. This is a "
|
||||
@@ -346,7 +354,8 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
|
||||
dev_min, dev_max = unit.dev_min, unit.dev_max
|
||||
break
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max))
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max,
|
||||
notes=notes))
|
||||
problems += validate_unit_ranges(units, skip_entity=flat)
|
||||
unread = [unit.setpoint_entity for unit in units
|
||||
if unit.setpoint_entity and unit.dev_min is None
|
||||
@@ -355,7 +364,7 @@ async def problems_and_ranges(opts: dict, hass=None):
|
||||
|
||||
|
||||
async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
hass=None) -> dict:
|
||||
hass=None, base=None) -> dict:
|
||||
"""Validate, compare-and-swap, then write. Refuse rather than half-do.
|
||||
|
||||
Returns `{"fingerprint": <the new one>}` on success. Raises `SaveRefused`
|
||||
@@ -378,11 +387,17 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
genuinely wants what the other surface wrote replaced, the page reloads,
|
||||
shows them what changed, and they save again from THAT - which is a
|
||||
decision made in front of the difference rather than through it.
|
||||
|
||||
`base`, when given, is the blob the page opened (the one `expect` is the
|
||||
fingerprint of). A stale refusal then also names the keys that moved
|
||||
underneath it, so the page does not list the operator's own edits among
|
||||
the settings "changed elsewhere" (UX-13 P2 test, finding a).
|
||||
"""
|
||||
problems = await problems_for(proposed, hass)
|
||||
if problems:
|
||||
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
|
||||
"nothing written. First: %s", len(problems), problems[0])
|
||||
_LOG.warning("The settings page did not save, because of %s. The "
|
||||
"first: %s", messages.count(len(problems), "problem"),
|
||||
problems[0])
|
||||
raise SaveRefused("invalid", problems=problems)
|
||||
|
||||
info = await selfcfg.info()
|
||||
@@ -390,10 +405,15 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
actual = fingerprint(stored)
|
||||
if actual != expect:
|
||||
changed = changed_keys(stored, proposed)
|
||||
_LOG.warning("panel REFUSED to save: the stored options changed "
|
||||
"underneath this page (keys: %s); nothing written",
|
||||
", ".join(changed) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||
moved = (changed_keys(base, stored)
|
||||
if isinstance(base, dict) and fingerprint(base) == expect
|
||||
else None)
|
||||
keys = changed if moved is None else moved
|
||||
_LOG.warning("The settings page did not save, because the settings "
|
||||
"changed while it was open (keys: %s).",
|
||||
", ".join(keys) or "none this page also edited")
|
||||
raise SaveRefused("stale", changed=changed, stored=stored,
|
||||
moved=moved)
|
||||
|
||||
await selfcfg.write_options(proposed)
|
||||
_LOG.info("options saved from the ingress panel; a restart is needed "
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""UX-15: setup-only mode. A new install serves the setup wizard and commands
|
||||
nothing. The rule and the argument are in DECISIONS/UX-15-setup-only-mode.md.
|
||||
|
||||
`applies()` says when: the startup gate's only refusal is that no inverter is
|
||||
named. `serve()` then runs the ingress page on a `SetupOnly` stand-in for the
|
||||
controller. Nothing that can command is built: no Controller, no inverter, no
|
||||
MQTT, no meter task. Its HomeAssistant is read-only, so a service call refuses
|
||||
before anything is sent (hass.py), and `/action` refuses too (web.py).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import signal
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import messages, web
|
||||
from .inverter import roster_entries
|
||||
from .validate import actuator_writers, no_power_request_problem
|
||||
|
||||
_LOG = logging.getLogger("goodwe.setup")
|
||||
|
||||
|
||||
def applies(opts: dict, problems) -> bool:
|
||||
"""True when the options name no inverter at all and that is the gate's
|
||||
only refusal. Anything else refused keeps the exit 78 (CFG-01)."""
|
||||
return (not actuator_writers(opts) and not roster_entries(opts)
|
||||
and list(problems) == [no_power_request_problem()])
|
||||
|
||||
|
||||
class SetupOnly:
|
||||
"""What web.build_app() needs from a controller, and nothing that writes.
|
||||
|
||||
It holds no inverter and no publisher. `hass` must be read-only."""
|
||||
|
||||
setup_only = True
|
||||
auto = False # control is never started here
|
||||
grid = None # no meter is read here
|
||||
inverters = ()
|
||||
|
||||
def __init__(self, opts: dict, hass, store):
|
||||
if getattr(hass, "read_only", False) is not True:
|
||||
raise ValueError("setup-only mode needs a read-only HomeAssistant")
|
||||
self.o, self.hass, self.store = opts, hass, store
|
||||
self.events: list[str] = []
|
||||
self.hint = ""
|
||||
|
||||
def log_event(self, msg: str, hint: str | None = None) -> None:
|
||||
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||
self.events.insert(0, f"{stamp} {msg}")
|
||||
del self.events[40:]
|
||||
if hint is not None:
|
||||
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
|
||||
|
||||
def status_payload(self, lang: str = "en") -> dict:
|
||||
return {"setup_only": True, "mode": "setup_only",
|
||||
"banner": messages.banner_setup_only(), "level": "warn",
|
||||
"rows": [["Control", "not started, in setup"]],
|
||||
"checks": [{"ok": False, "warn": True,
|
||||
"text": messages.row_setup_only()}],
|
||||
"hint": self.hint, "events": list(self.events)}
|
||||
|
||||
async def handle_action(self, what):
|
||||
# web.py answers 409 before it gets here. Nothing is done either way.
|
||||
_LOG.warning("The page asked for an action in setup, so nothing "
|
||||
"was done.")
|
||||
return None
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Nothing to wind down: nothing was ever commanded."""
|
||||
|
||||
|
||||
async def serve(opts: dict, hass, store, *, port: int) -> None:
|
||||
"""Serve the page in setup-only mode until the Supervisor stops us."""
|
||||
controller = SetupOnly(opts, hass, store)
|
||||
_LOG.warning("No inverter is named yet, so the add-on is in setup and "
|
||||
"controls nothing. Open its page to finish setup.")
|
||||
controller.log_event("setup-only mode: no inverter named, nothing is "
|
||||
"commanded", hint=messages.hint("setup_only"))
|
||||
runner = await web.start(controller, port=port)
|
||||
stop = asyncio.Event()
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
with contextlib.suppress(NotImplementedError):
|
||||
loop.add_signal_handler(sig, stop.set)
|
||||
try:
|
||||
await stop.wait()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
_LOG.info("stopped")
|
||||
@@ -318,11 +318,8 @@ WIRE_W = 0.0
|
||||
#: a cap that does not bind - and a number invented for the row would be a
|
||||
#: second helping of the defect this constant exists to disclose.
|
||||
SIZED_FROM_METER = (
|
||||
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
|
||||
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
|
||||
"trade against a meter the battery is moving, so this is an UPPER BOUND "
|
||||
"and not a prediction; how far under it a live run lands is a closed-loop "
|
||||
"question and a shadow run cannot answer it")
|
||||
"This is an upper limit, not a forecast, because the meter did not see "
|
||||
"the battery move.")
|
||||
|
||||
|
||||
def mode(opts: dict) -> str:
|
||||
@@ -373,18 +370,18 @@ def why(opts: dict, *, control_started: bool) -> str:
|
||||
# discount. What is true in every live state is that what this add-on
|
||||
# decides reaches the inverter; whether control is RUNNING is the
|
||||
# `Stopped` banner's question and it is answered there.
|
||||
# UX-08: the same four rungs, in plain words. The setting is named by its
|
||||
# value, "automatic" for `auto`, so each rung still reads differently.
|
||||
m = mode(opts)
|
||||
live = "what the add-on decides reaches the inverters"
|
||||
inert = "the add-on decides but sends nothing"
|
||||
if m == "off":
|
||||
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
|
||||
"add-on decides DOES reach the inverter")
|
||||
return f"Off, {live}"
|
||||
if m == "on":
|
||||
return ("on (shadow_mode: on) - decisions are recorded and nothing "
|
||||
"is commanded")
|
||||
return f"On, {inert}"
|
||||
if control_started:
|
||||
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
|
||||
"mode: what this add-on decides DOES reach the inverter")
|
||||
return ("on (shadow_mode: auto, control is stopped) - decisions are "
|
||||
"recorded and nothing is commanded")
|
||||
return f"Off (automatic, control is started), {live}"
|
||||
return f"On (automatic, control is stopped), {inert}"
|
||||
|
||||
|
||||
def words(opts: dict, *, control_started: bool, would_w: float | None,
|
||||
@@ -411,10 +408,10 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
|
||||
that decided it did not apply, which is the shape of defect this card is
|
||||
about. TEST-14's rule with a new name on it.
|
||||
|
||||
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
|
||||
⚠️ AND THE PHRASE IS "THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
|
||||
CAVEAT FIRES. Every number a shadow run records was reached from a plant
|
||||
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
|
||||
of a live run and no number here is one. The ⚠️ below is the STRONGER,
|
||||
that did not respond; "would ask for -2 000 W" alone reads as a forecast
|
||||
of a live run and no number here is one. UX-08 2d put it in plain words. The ⚠️ below is the STRONGER,
|
||||
conditional statement about the cycles where the answer can be multiples
|
||||
out - see this module's header for the measured table.
|
||||
|
||||
@@ -431,19 +428,19 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
|
||||
if not engaged(opts, control_started=control_started):
|
||||
return head
|
||||
if would_w is None:
|
||||
return (f"{head}; no decision has been reached yet - the inverter is "
|
||||
f"held at {WIRE_W:.0f} W")
|
||||
return (f"{head}. No decision yet, the inverters stay at "
|
||||
f"{WIRE_W:.0f} W.")
|
||||
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
|
||||
# the whole sentence it ate the comma out of `head` - and `head` is the
|
||||
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
|
||||
# masking rule says must survive.
|
||||
num = f"{float(would_w):+,.0f}".replace(",", " ")
|
||||
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
|
||||
f"held at {WIRE_W:.0f} W")
|
||||
out = (f"{head}. This cycle it would ask for {num} W, the inverters stay "
|
||||
f"at {WIRE_W:.0f} W.")
|
||||
if sized_from_meter:
|
||||
out += f" {SIZED_FROM_METER}"
|
||||
if reason:
|
||||
out += f" - {reason}"
|
||||
out += f" Why: {reason}."
|
||||
return out
|
||||
|
||||
|
||||
@@ -504,6 +501,6 @@ def problems(opts: dict) -> list:
|
||||
text = str("" if raw is None else raw).strip().lower()
|
||||
if text in MODES:
|
||||
return []
|
||||
return [f"{messages.setting('shadow_mode')} is {raw!r}. Choose "
|
||||
return [f"{messages.setting('shadow_mode')} is {messages.shown(raw)}. Choose "
|
||||
+ ", ".join(f"'{m}'" for m in MODES[:-1])
|
||||
+ f" or '{MODES[-1]}'."]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/* UX-13 page styles. Colours follow the approved mockup (UX-13-mockup.html,
|
||||
version 2) and prefers-color-scheme. No web fonts, no external files. */
|
||||
:root{
|
||||
--bg:#f3f4f6;--card:#ffffff;--ink:#1b1d21;--muted:#5b626c;--faint:#8a919b;--line:#e2e5e9;
|
||||
--accent:#1c64d9;--accent-soft:#e6efff;--on-accent:#ffffff;
|
||||
--ok:#17773a;--ok-bg:#e6f4eb;--warn:#955300;--warn-bg:#fcefd9;--bad:#c02626;--bad-bg:#fce8e8;--info:#5b626c;--info-bg:#eceef1;
|
||||
--solar:#c98a00;--house:#6c47c2;--grid:#56657a;--batt:#17773a;
|
||||
--cheap:#8fcca4;--dear:#f0ad72;--mid:#cfd5dc;--track:#dde1e6;--shadow:0 1px 2px rgba(0,0,0,.05);
|
||||
}
|
||||
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
|
||||
--bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038;
|
||||
--accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220;
|
||||
--ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d;
|
||||
--solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98;
|
||||
--cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none;
|
||||
}}
|
||||
:root[data-theme="dark"]{
|
||||
--bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038;
|
||||
--accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220;
|
||||
--ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d;
|
||||
--solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98;
|
||||
--cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
[hidden]{display:none!important}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
|
||||
button,input,select{font:inherit;color:inherit}
|
||||
a{color:var(--accent)}
|
||||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
||||
.top{display:flex;align-items:center;gap:12px;padding:12px 16px 8px;max-width:820px;margin:0 auto}
|
||||
.logo{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);display:grid;place-items:center;color:var(--accent);flex:none}
|
||||
.app{font-weight:700;font-size:17px;line-height:1.2}
|
||||
.pill{display:flex;flex-wrap:wrap;align-items:center;gap:0 6px;font-size:13px;color:var(--muted)}
|
||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--ok);flex:none}
|
||||
.dot.warn{background:var(--warn)}.dot.bad{background:var(--bad)}
|
||||
.tabs{display:flex;gap:4px;max-width:820px;margin:0 auto;padding:0 16px;border-bottom:1px solid var(--line)}
|
||||
.tab{border:0;background:none;padding:10px 12px;border-bottom:2px solid transparent;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:6px;min-height:44px;position:relative}
|
||||
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
|
||||
.tab svg{width:20px;height:20px}
|
||||
.badge{font-size:11px;font-weight:700;background:var(--warn);color:var(--card);border-radius:9px;padding:0 6px;line-height:18px}
|
||||
.badge.bad{background:var(--bad)}
|
||||
@media (max-width:640px){
|
||||
.tabs{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--card);border-top:1px solid var(--line);border-bottom:0;padding:0 4px env(safe-area-inset-bottom);justify-content:space-around;gap:0}
|
||||
.tab{flex-direction:column;gap:0;font-size:11px;padding:6px 4px;border-bottom:0;border-top:2px solid transparent;flex:1;min-width:0}
|
||||
.tab[aria-selected="true"]{border-top-color:var(--accent)}
|
||||
.tab .badge{position:absolute;top:2px;left:calc(50% + 6px)}
|
||||
.wrap{padding-bottom:96px!important}
|
||||
}
|
||||
.wrap{max-width:820px;margin:0 auto;padding:16px 16px 40px}
|
||||
h2{font-size:15px;margin:0 0 10px;font-weight:650}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:var(--shadow)}
|
||||
.muted{color:var(--muted)} .small{font-size:13px} .tiny{font-size:12px}
|
||||
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.spread{justify-content:space-between}
|
||||
.grow{flex:1 1 auto;min-width:0}
|
||||
.banner{border-radius:12px;padding:12px 14px;margin-bottom:12px;display:flex;gap:10px;align-items:flex-start;font-weight:600}
|
||||
.banner .ic{flex:none;margin-top:2px}
|
||||
.b-ok{background:var(--ok-bg);color:var(--ok)} .b-warn{background:var(--warn-bg);color:var(--warn)} .b-bad{background:var(--bad-bg);color:var(--bad)}
|
||||
.banner small{display:block;font-weight:400;color:var(--ink);margin-top:2px}
|
||||
.tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
|
||||
@media (max-width:640px){.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.tile{border:1px solid var(--line);border-radius:10px;padding:10px;min-width:0}
|
||||
.tile .lab{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:6px}
|
||||
.tile .val{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.25;margin-top:2px}
|
||||
.tile .val small{font-size:13px;font-weight:500;color:var(--muted)}
|
||||
.tile .sub{font-size:12px;color:var(--muted)}
|
||||
.sw-solar{color:var(--solar)} .sw-house{color:var(--house)} .sw-grid{color:var(--grid)} .sw-batt{color:var(--batt)}
|
||||
.brow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;padding:10px 0;border-top:1px solid var(--line)}
|
||||
.brow:first-child{border-top:0}
|
||||
.lvl{height:10px;background:var(--track);border-radius:6px;overflow:hidden;grid-column:1/-1;margin:4px 0 2px}
|
||||
.lvl>i{display:block;height:100%;background:var(--batt);border-radius:6px}
|
||||
.lvl.none>i{background:var(--faint)}
|
||||
/* the plan chart */
|
||||
.chart{position:relative;margin:18px 0 4px}
|
||||
.bars{display:flex;align-items:flex-end;gap:2px;height:72px}
|
||||
.bars i{flex:1;border-radius:3px 3px 0 0;background:var(--mid);min-width:0}
|
||||
.bars i.cheap{background:var(--cheap)}
|
||||
/* not by colour alone: expensive hours are striped, estimates are faded */
|
||||
.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)}
|
||||
.bars i.est{opacity:.55}
|
||||
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important}
|
||||
.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
|
||||
.lane b{position:absolute;top:2px;bottom:2px;border-radius:3px;min-width:3px}
|
||||
.lane .house{background:repeating-linear-gradient(90deg,var(--mid) 0 6px,transparent 6px 9px);top:6px;bottom:6px}
|
||||
.lane .grid{background:var(--accent)}
|
||||
.lane .give{background:repeating-linear-gradient(135deg,var(--house) 0 4px,color-mix(in srgb,var(--house) 55%,var(--card)) 4px 7px)}
|
||||
.lane .cycle{background:repeating-linear-gradient(90deg,var(--warn) 0 3px,var(--warn-bg) 3px 6px)}
|
||||
.lane .peak{background:var(--bad)}
|
||||
.lanelab{font-size:12px;color:var(--muted);margin-top:6px}
|
||||
.nowline{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none}
|
||||
.nowline span{position:absolute;top:-15px;left:-10px;font-size:10px}
|
||||
.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--faint);margin-top:4px}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted);margin-top:8px}
|
||||
.legend span{display:inline-flex;align-items:center;gap:5px}
|
||||
.legend i{width:14px;height:10px;border-radius:2px;display:inline-block}
|
||||
.legend i.house{background:repeating-linear-gradient(90deg,var(--mid) 0 4px,transparent 4px 6px)}
|
||||
ul.plain{list-style:none;margin:8px 0 0;padding:0}
|
||||
ul.plain li{padding:6px 0;border-top:1px solid var(--line)}
|
||||
ul.plain li:first-child{border-top:0}
|
||||
.meter{height:12px;background:var(--track);border-radius:6px;overflow:hidden;margin:8px 0 4px}
|
||||
.meter i{display:block;height:100%;background:var(--accent)}
|
||||
.meter i.warn{background:var(--warn)} .meter i.bad{background:var(--bad)}
|
||||
.alert{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--line)}
|
||||
.alert:first-child{border-top:0}
|
||||
.sev{flex:none;width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:13px;font-weight:800}
|
||||
.sev.warning{background:var(--warn-bg);color:var(--warn)} .sev.alarm{background:var(--bad-bg);color:var(--bad)} .sev.info{background:var(--info-bg);color:var(--info)} .sev.ok{background:var(--ok-bg);color:var(--ok)}
|
||||
.alert .t{font-weight:600}
|
||||
.btn{border:1px solid var(--line);background:var(--card);padding:9px 14px;border-radius:10px;cursor:pointer;min-height:44px}
|
||||
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}
|
||||
.btn.danger{color:var(--bad);border-color:var(--bad)}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
a.btn{display:inline-flex;align-items:center;text-decoration:none;color:var(--ink)}
|
||||
details.more{margin-top:12px;border-top:1px solid var(--line)}
|
||||
details.more>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
|
||||
table.rows{width:100%;border-collapse:collapse;font-size:13px}
|
||||
table.rows td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
table.rows td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;padding-left:12px;overflow-wrap:anywhere}
|
||||
table.rows tr:last-child td{border-bottom:0}
|
||||
ul.checks{margin:6px 0 0;padding-left:0;list-style:none;font-size:13px}
|
||||
ul.checks li{margin:3px 0}
|
||||
.c-ok{color:var(--ok)} .c-warn{color:var(--warn)} .c-bad{color:var(--bad)}
|
||||
.kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 12px;font-size:14px;margin-top:10px}
|
||||
.kv dt{color:var(--muted)} .kv dd{margin:0}
|
||||
#toast{position:fixed;left:50%;transform:translateX(-50%);bottom:24px;z-index:30;background:var(--ink);color:var(--bg);padding:10px 14px;border-radius:10px;font-size:14px;max-width:calc(100% - 32px);opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
#toast.show{opacity:1}
|
||||
@media (max-width:640px){#toast{bottom:84px}}
|
||||
dialog{border:1px solid var(--line);border-radius:14px;background:var(--card);color:var(--ink);max-width:min(420px,calc(100% - 32px));padding:18px}
|
||||
dialog::backdrop{background:rgba(0,0,0,.45)}
|
||||
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
.tip{position:fixed;z-index:40;background:var(--ink);color:var(--bg);font-size:12px;line-height:1.35;padding:6px 8px;border-radius:8px;pointer-events:none;max-width:240px;opacity:0;transition:opacity .1s;box-shadow:0 2px 8px rgba(0,0,0,.25);left:0;top:0}
|
||||
.tip.show{opacity:1}
|
||||
.tip b{display:block;font-weight:700}
|
||||
.tip span{display:block}
|
||||
.chart [data-tip]{cursor:default}
|
||||
.chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px}
|
||||
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||
/* UX-13 P2: Settings, the Installer view (mockup version 2) */
|
||||
.btn.link{border:0;background:none;color:var(--accent);padding:6px 0;min-height:0}
|
||||
.search{width:100%;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:10px 12px;min-height:44px;margin-bottom:10px}
|
||||
.key{font:11px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--faint);overflow-wrap:anywhere}
|
||||
.chip{display:inline-flex;align-items:center;gap:4px;font-size:12px;border-radius:999px;padding:1px 8px;background:var(--info-bg);color:var(--muted);white-space:nowrap}
|
||||
.chip.now{background:var(--ok-bg);color:var(--ok)} .chip.rs{background:var(--warn-bg);color:var(--warn)}
|
||||
.chip.bad{background:var(--bad-bg);color:var(--bad)} .chip.changed{background:var(--accent-soft);color:var(--accent)}
|
||||
details.grp{background:var(--card);border:1px solid var(--line);border-radius:12px;margin-bottom:10px;box-shadow:var(--shadow)}
|
||||
details.grp>summary{list-style:none;cursor:pointer;padding:14px;display:flex;gap:10px;align-items:center;min-height:56px;flex-wrap:wrap}
|
||||
details.grp>summary::-webkit-details-marker{display:none}
|
||||
details.grp>summary .grow{flex:1 1 200px}
|
||||
details.grp>summary .gdesc{display:block}
|
||||
details.grp>summary .chev{transition:transform .15s;flex:none;font-size:22px;line-height:1;width:18px;text-align:center;color:var(--muted)}
|
||||
details.grp[open]>summary .chev{transform:rotate(90deg)}
|
||||
.gchips,.gbadge{display:inline-flex;flex-wrap:wrap;gap:4px}
|
||||
details.grp .body{padding:0 14px 8px}
|
||||
details.sub{border-top:1px solid var(--line);margin-top:4px}
|
||||
details.sub>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
|
||||
.subhead{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--faint);margin:14px 0 0;font-weight:700}
|
||||
.f{padding:12px 0;border-top:1px solid var(--line);min-width:0}
|
||||
.part>.f:first-child,.subhead+.f{border-top:0}
|
||||
.f .lbl{font-weight:600;font-size:14px}
|
||||
.f .help{font-size:13px;color:var(--muted);margin-top:4px}
|
||||
.f .ctl{margin-top:8px;min-width:0}
|
||||
.f.inline{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;align-items:start}
|
||||
.f.inline .ctl{margin-top:0;grid-row:1;grid-column:2}
|
||||
.f.inline .help,.f.inline .msgs{grid-column:1/-1}
|
||||
.f.changed>.head .lbl::after{content:" •";color:var(--accent)}
|
||||
.msg{font-size:13px;margin-top:6px;display:flex;gap:6px;align-items:flex-start;border-radius:8px;padding:6px 8px;overflow-wrap:anywhere}
|
||||
.msg .mi{font-weight:800;flex:none}
|
||||
.msg.bad{background:var(--bad-bg);color:var(--bad)} .msg.warn{background:var(--warn-bg);color:var(--warn)} .msg.info{background:var(--info-bg);color:var(--info)}
|
||||
.f.has-bad .ctl select,.f.has-bad .ctl input,.f.has-bad .ent-btn{border-color:var(--bad)}
|
||||
.switch{position:relative;width:52px;height:44px;border:0;background:none;cursor:pointer;flex:none;padding:0}
|
||||
.switch::before{content:"";position:absolute;left:2px;top:8px;width:48px;height:28px;border-radius:14px;background:var(--track);transition:background .15s}
|
||||
.switch::after{content:"";position:absolute;top:11px;left:5px;width:22px;height:22px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);transition:left .15s}
|
||||
.switch[aria-checked="true"]::before{background:var(--accent)}
|
||||
.switch[aria-checked="true"]::after{left:25px}
|
||||
.swrap{display:inline-flex;align-items:center;gap:8px;min-height:44px}
|
||||
.swtext{font-size:13px;color:var(--muted)}
|
||||
select.sel,input.txt,input.num{border:1px solid var(--line);background:var(--card);border-radius:10px;padding:9px 10px;min-height:44px;max-width:100%}
|
||||
select.sel{width:100%}
|
||||
.row>select.sel{width:auto;flex:1 1 200px}
|
||||
input.num{width:110px;text-align:right;font-variant-numeric:tabular-nums}
|
||||
input.txt{width:100%}
|
||||
input:disabled{opacity:.6}
|
||||
.seg{display:inline-flex;border:1px solid var(--line);border-radius:10px;overflow:hidden;max-width:100%;flex-wrap:wrap}
|
||||
.seg button{border:0;background:var(--card);padding:8px 12px;min-height:44px;cursor:pointer;border-left:1px solid var(--line)}
|
||||
.seg button:first-child{border-left:0}
|
||||
.seg button[aria-checked="true"]{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
||||
.choice{display:flex;gap:10px;align-items:flex-start;border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:8px;cursor:pointer;width:100%;text-align:left;background:var(--card);min-height:44px}
|
||||
.choice[aria-checked="true"]{border-color:var(--accent);background:var(--accent-soft)}
|
||||
.choice .rad{flex:none;width:18px;height:18px;border-radius:50%;border:2px solid var(--faint);margin-top:2px}
|
||||
.choice[aria-checked="true"] .rad{border:5px solid var(--accent)}
|
||||
.slider{display:flex;align-items:center;gap:10px;width:100%}
|
||||
.slider input[type=range]{flex:1;min-width:0;accent-color:var(--accent);height:28px}
|
||||
.unit{color:var(--muted);font-size:13px;white-space:nowrap}
|
||||
.ent-btn{width:100%;text-align:left;border:1px solid var(--line);background:var(--card);border-radius:10px;padding:8px 10px;min-height:44px;cursor:pointer;display:flex;gap:8px;align-items:center}
|
||||
.ent-btn .grow{min-width:0}
|
||||
.ent-btn .grow>div{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ent-pop{border:1px solid var(--line);border-radius:10px;margin-top:6px;background:var(--card);overflow:hidden}
|
||||
.ent-pop input[type=search]{border:0;border-bottom:1px solid var(--line);width:100%;padding:10px;background:var(--card);min-height:44px}
|
||||
.ent-note{padding:6px 10px}
|
||||
.ent-list{max-height:240px;overflow:auto}
|
||||
.ent-opt{display:flex;gap:8px;align-items:center;width:100%;border:0;border-bottom:1px solid var(--line);background:none;text-align:left;padding:8px 10px;cursor:pointer;min-height:44px}
|
||||
.ent-opt:hover,.ent-opt:focus{background:var(--accent-soft)}
|
||||
.ent-opt .grow{min-width:0}
|
||||
.ent-opt .grow>div{overflow-wrap:anywhere}
|
||||
.ent-opt .v{font-variant-numeric:tabular-nums;font-size:13px;color:var(--muted);white-space:nowrap}
|
||||
.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}
|
||||
.invs>.btn{margin:4px 0 8px}
|
||||
#savebar{position:fixed;left:0;right:0;bottom:0;z-index:15}
|
||||
#savebar .in{max-width:820px;margin:0 auto 10px;padding:10px 12px;background:var(--ink);color:var(--bg);border-radius:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;box-shadow:0 4px 16px rgba(0,0,0,.25);width:calc(100% - 32px)}
|
||||
#savebar .btn{min-height:44px;padding:6px 12px;background:transparent;color:var(--bg);border-color:rgba(127,127,127,.6)}
|
||||
#savebar .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
|
||||
@media (max-width:640px){#savebar{bottom:62px}}
|
||||
#s-set{padding-bottom:80px}
|
||||
/* UX-13 P4: the setup wizard */
|
||||
.steps{display:flex;gap:4px;margin:4px 0 12px}
|
||||
.steps i{flex:1;height:6px;border-radius:3px;background:var(--track)}
|
||||
.steps i.done{background:var(--accent)} .steps i.cur{background:var(--accent);opacity:.55}
|
||||
.msg.ok{background:var(--ok-bg);color:var(--ok)}
|
||||
.sev.bad{background:var(--bad-bg);color:var(--bad)} .sev.warn{background:var(--warn-bg);color:var(--warn)}
|
||||
.checklist li{display:flex;gap:8px;align-items:flex-start;overflow-wrap:anywhere}
|
||||
.kettle .row{margin-top:8px}
|
||||
/* UX-13 P3: the Batteries screen and the Simple settings view */
|
||||
.bcard .hd{display:flex;align-items:center;gap:10px;margin-bottom:10px}
|
||||
.bcard .name{font-weight:700;font-size:16px}
|
||||
.bcard .modebox .lbl{display:block;margin-bottom:4px}
|
||||
.bcard .modebox .help{font-size:13px;color:var(--muted);margin-top:4px}
|
||||
.bcard.off .modebox{opacity:.6}
|
||||
.bcard .kv dd{overflow-wrap:anywhere}
|
||||
.switch:disabled{opacity:.45;cursor:not-allowed}
|
||||
select.sel:disabled{opacity:.6}
|
||||
.simple .card>.row{margin-bottom:4px}
|
||||
.vhide{display:none!important}
|
||||
.simple .card>.f{border-top:0;padding-top:0}
|
||||
.simple .f>.head{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.simple .card .lbl{font-weight:600;font-size:15px}
|
||||
.simple .inv .hd{border-bottom:0}
|
||||
.simple .inv .f{border-top:0;padding-top:0}
|
||||
@@ -0,0 +1,487 @@
|
||||
"use strict";
|
||||
/* UX-13 P1: the page shell and the Home screen. Plain JS, no framework, no
|
||||
external files. Every URL is relative, because ingress serves the add-on
|
||||
under a generated path prefix. Every word comes from app/ui_text (fixed
|
||||
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 () {
|
||||
// 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) {
|
||||
const e = document.createElement(tag);
|
||||
for (const k in attrs || {}) {
|
||||
const v = attrs[k];
|
||||
if (v === false || v == null) continue;
|
||||
if (k === "class") e.className = v;
|
||||
else if (k === "style") e.style.cssText = v;
|
||||
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
|
||||
else e.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
for (const c of kids.flat()) {
|
||||
if (c == null || c === false) continue;
|
||||
e.append(c.nodeType ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
return e;
|
||||
}
|
||||
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) || ""; });
|
||||
document.querySelectorAll("[data-t-label]").forEach((e) => { e.setAttribute("aria-label", t(e.dataset.tLabel) || ""); });
|
||||
|
||||
let S = null; // the last /status
|
||||
let busy = false;
|
||||
|
||||
/* ---------------- toast and confirm dialog ---------------- */
|
||||
function toast(text) {
|
||||
const e = $("#toast"); e.textContent = text; e.classList.add("show");
|
||||
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
|
||||
}
|
||||
let onYes = null;
|
||||
// `key` names Home's words in ui_text, or is {name, description, yes}.
|
||||
function ask(key, fn) {
|
||||
const w = typeof key === "string" ? t("dialog." + key) : key;
|
||||
$("#dlg-t").textContent = w.name;
|
||||
$("#dlg-b").textContent = w.description;
|
||||
$("#dlg-yes").textContent = w.yes;
|
||||
onYes = fn;
|
||||
const d = $("#dlg");
|
||||
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
|
||||
}
|
||||
$("#dlg-no").addEventListener("click", () => $("#dlg").close());
|
||||
$("#dlg-yes").addEventListener("click", () => { $("#dlg").close(); if (onYes) onYes(); });
|
||||
|
||||
/* ---------------- data ---------------- */
|
||||
async function getStatus() {
|
||||
const r = await fetch("status?lang=" + LANG, { cache: "no-store" });
|
||||
if (!r.ok) throw new Error("status " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function act(what) {
|
||||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: what }) });
|
||||
if (!r.ok) throw new Error("action " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
// `force`: redraw the battery cards even under a focused mode list, after
|
||||
// the person used it (P3).
|
||||
async function refresh(force) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try { S = await getStatus(); render(force === true); }
|
||||
catch (e) { offline(); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
|
||||
/* ---------------- shell ---------------- */
|
||||
function banner(el, level, title, sub, role) {
|
||||
el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null },
|
||||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||
h("span", {}, title, sub ? h("small", {}, sub) : null)));
|
||||
}
|
||||
function offline() {
|
||||
banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert");
|
||||
$("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name")));
|
||||
}
|
||||
function renderPill() {
|
||||
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.setup_only === true ? t("setup_only.name") : S.banner;
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
}
|
||||
|
||||
function render(force) {
|
||||
renderPill();
|
||||
const structured = S.now && S.plan && !S.home_error;
|
||||
// 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, setup);
|
||||
renderDetails(!structured && !setup);
|
||||
renderBatteries(force);
|
||||
document.dispatchEvent(new CustomEvent("ux13:status"));
|
||||
}
|
||||
|
||||
/* ---------------- Home: now ---------------- */
|
||||
function val(id, w, show) {
|
||||
const e = $(id);
|
||||
if (w == null || !show) { e.textContent = "–"; return; }
|
||||
e.replaceChildren(kw(w) + " ", h("small", {}, "kW"));
|
||||
}
|
||||
function levelBar(pct, label) {
|
||||
const known = pct != null;
|
||||
return h("div", { class: "lvl" + (known ? "" : " none"), role: "img",
|
||||
"aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") },
|
||||
h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" }));
|
||||
}
|
||||
function renderNow() {
|
||||
const n = S.now;
|
||||
val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar;
|
||||
val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house;
|
||||
val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid;
|
||||
val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt;
|
||||
$("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" },
|
||||
h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)),
|
||||
h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
|
||||
}
|
||||
|
||||
/* ---------------- Home: today's plan ---------------- */
|
||||
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
|
||||
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
|
||||
["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"],
|
||||
["peak", "background:var(--bad)"], ["house", null]];
|
||||
function tipText(lines) { return lines.filter(Boolean).join(" "); }
|
||||
function chartItem(tag, cls, style, title, lines) {
|
||||
return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)),
|
||||
"data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) });
|
||||
}
|
||||
function roving(group) {
|
||||
const items = [...group.querySelectorAll("[data-tip]")];
|
||||
items.forEach((el, i) => {
|
||||
el.tabIndex = i === 0 ? 0 : -1;
|
||||
el.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key];
|
||||
if (d === undefined) return;
|
||||
e.preventDefault();
|
||||
const j = Math.max(0, Math.min(items.length - 1, i + d));
|
||||
items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
function renderPlan() {
|
||||
const p = S.plan;
|
||||
$("#verdict").textContent = p.verdict;
|
||||
$("#plan-head").textContent = p.head;
|
||||
const hours = p.hours || 24;
|
||||
const bars = $("#bars");
|
||||
const known = p.prices.filter((b) => b.eur_kwh != null);
|
||||
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
|
||||
bars.setAttribute("aria-label", t("plan.name"));
|
||||
bars.replaceChildren(...p.prices.map((b) => {
|
||||
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
|
||||
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
|
||||
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
|
||||
const est = (b.points > 1 ? ", " + t("plan.average_tip") : "") + (b.estimate ? ", " + t("plan.estimate_tip") : "");
|
||||
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
|
||||
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
|
||||
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
|
||||
}));
|
||||
roving(bars);
|
||||
$("#no-prices").hidden = known.length > 0;
|
||||
const lanes = $("#lanes");
|
||||
lanes.replaceChildren();
|
||||
S.batteries.forEach((b, i) => {
|
||||
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
|
||||
const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0));
|
||||
for (const r of runs) {
|
||||
if (r.end_h <= r.start_h) continue;
|
||||
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%",
|
||||
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
|
||||
}
|
||||
lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane);
|
||||
roving(lane);
|
||||
});
|
||||
const nl = $("#nowline");
|
||||
nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%";
|
||||
const used = new Set(p.runs.map((r) => r.kind));
|
||||
known.forEach((b) => used.add(b.kind));
|
||||
$("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) =>
|
||||
h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k))));
|
||||
const list = [];
|
||||
S.batteries.forEach((b, i) => {
|
||||
for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) {
|
||||
list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
|
||||
}
|
||||
const house = p.runs.find((x) => x.battery === i && x.kind === "house");
|
||||
if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why])));
|
||||
});
|
||||
for (const line of p.lines) list.push(h("li", {}, line));
|
||||
$("#plan-list").replaceChildren(...list);
|
||||
$("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
/* ---------------- tooltips: hover, tap and keyboard focus ---------------- */
|
||||
(function () {
|
||||
const tip = $("#tip"); let hot = null;
|
||||
const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; };
|
||||
function show(el, x, y) {
|
||||
if (hot && hot !== el) hot.classList.remove("hot");
|
||||
hot = el; el.classList.add("hot");
|
||||
const lines = JSON.parse(el.dataset.tip);
|
||||
tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l)));
|
||||
tip.classList.add("show");
|
||||
const r = tip.getBoundingClientRect();
|
||||
let L = x + 12, T2 = y - r.height - 12;
|
||||
if (L + r.width > innerWidth - 8) L = x - r.width - 12;
|
||||
if (L < 8) L = 8;
|
||||
if (T2 < 8) T2 = y + 16;
|
||||
tip.style.left = L + "px"; tip.style.top = T2 + "px";
|
||||
}
|
||||
const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]");
|
||||
document.addEventListener("pointermove", (e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
const el = target(e);
|
||||
if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide();
|
||||
});
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
const el = target(e);
|
||||
if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); }
|
||||
else if (!el) hide();
|
||||
});
|
||||
document.addEventListener("focusin", (e) => {
|
||||
const el = target(e);
|
||||
if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); }
|
||||
});
|
||||
document.addEventListener("focusout", (e) => { if (target(e)) hide(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
|
||||
addEventListener("scroll", hide, { passive: true });
|
||||
})();
|
||||
|
||||
/* ---------------- Home: peak, attention, control, details ---------------- */
|
||||
function renderPeak() {
|
||||
const m = S.month_peak;
|
||||
$("#peak-month").textContent = m.month;
|
||||
const covered = m.state !== "not_covered";
|
||||
$("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered;
|
||||
$("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW"
|
||||
: m.state === "unreadable" ? t("peak.unreadable") : t("peak.none");
|
||||
$("#peak-limit").textContent = kw(m.limit_w) + " kW";
|
||||
const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct));
|
||||
const bar = $("#peak-meter i");
|
||||
bar.style.width = pct + "%";
|
||||
bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : "";
|
||||
$("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %"));
|
||||
$("#peak-line").textContent = m.line;
|
||||
}
|
||||
function renderAttention() {
|
||||
const list = S.attention;
|
||||
const icon = { alarm: "×", warning: "!", info: "i" };
|
||||
$("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" },
|
||||
h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"),
|
||||
h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title),
|
||||
h("div", { class: "small" }, a.body),
|
||||
a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null))));
|
||||
const n = list.filter((a) => a.severity !== "info").length;
|
||||
$("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing");
|
||||
if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing")));
|
||||
const badge = $("#attn-badge");
|
||||
badge.textContent = n; badge.hidden = !n;
|
||||
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
|
||||
}
|
||||
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" : "");
|
||||
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;
|
||||
}
|
||||
function renderDetails(open) {
|
||||
$("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v))));
|
||||
$("#checks").replaceChildren(...(S.checks || []).map((c) => {
|
||||
const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad");
|
||||
const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗");
|
||||
return h("li", { class: cls }, mark + " " + c.text);
|
||||
}));
|
||||
if (open) $("#c-details").open = true;
|
||||
}
|
||||
|
||||
/* ---------------- Batteries (UX-13 P3) ---------------- */
|
||||
// One card per battery. With battery modes on (`mode_key` in /status) each
|
||||
// card has an On/Off switch and a mode list, sent as `pack_use` and
|
||||
// `pack_mode` through /action (OPT-11 P1). Selling has no switch while it
|
||||
// is off (owner decision 2026-09-29). The Now, Today and Why lines are
|
||||
// homeview.py's sentences. The Simple settings view draws the same switch
|
||||
// and list into its own card (simpleHost).
|
||||
const MODES = ["solar_first", "leftover_solar", "cheap_hours"];
|
||||
const say = (text, vars) => Object.keys(vars).reduce((x, k) => x.replace("{" + k + "}", vars[k]), text);
|
||||
let packBusy = false;
|
||||
let simpleHost = null;
|
||||
async function packAct(what, b, 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: b.name, value: value }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) toast(j.error || t("toast.failed"));
|
||||
else if (what === "pack_use") toast(say(value ? t("batteries.toast.on") : t("batteries.toast.off"), { name: b.name }));
|
||||
else toast(say(t("batteries.toast.mode"), { name: b.name, mode: t("batteries.choice." + value + ".name") }));
|
||||
} catch (e) { toast(t("toast.failed")); }
|
||||
finally { packBusy = false; }
|
||||
await refresh(true);
|
||||
}
|
||||
function onSwitch(b, i, where, lid) {
|
||||
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
|
||||
"aria-checked": String(b.use), "aria-labelledby": lid,
|
||||
onclick: () => packAct("pack_use", b, !b.use) });
|
||||
return h("span", { class: "swrap" }, sw, h("span", { class: "swtext", "aria-hidden": "true" }, b.use ? t("batteries.on") : t("batteries.off")));
|
||||
}
|
||||
function modeSelect(b, i, where, lid) {
|
||||
return h("select", { class: "sel", id: "bsel-" + where + i, "aria-labelledby": lid, disabled: !b.use,
|
||||
onchange: (e) => packAct("pack_mode", b, e.target.value) },
|
||||
...MODES.map((k) => h("option", { value: k, selected: b.mode_choice === k }, t("batteries.choice." + k + ".name"))));
|
||||
}
|
||||
function todayLines(b) {
|
||||
if (!b.today || !b.today.length) return b.today_line || "–";
|
||||
return b.today.map((r) => h("div", {}, r.from + " " + t("plan.time_to") + " " + r.to + ": " + r.what));
|
||||
}
|
||||
function batteryCard(b, i) {
|
||||
const modes = b.mode_key != null;
|
||||
const lid = "bname-" + i;
|
||||
const lvl = b.level_pct == null ? "–" : Math.round(b.level_pct) + " %";
|
||||
const stored = b.stored_kwh != null ? " · " + dec(b.stored_kwh.toFixed(1)) + " kWh " + t("batteries.stored").toLowerCase() : "";
|
||||
return h("div", { class: "card bcard" + (modes && !b.use ? " off" : "") },
|
||||
h("div", { class: "hd" },
|
||||
h("div", { class: "grow" }, h("div", { class: "name", id: lid }, b.name),
|
||||
h("div", { class: "tiny muted" }, b.capacity_kwh ? dec(Number(b.capacity_kwh).toFixed(1)) + " kWh" : "")),
|
||||
modes ? onSwitch(b, i, "c", lid) : null),
|
||||
modes ? h("div", { class: "modebox" },
|
||||
h("label", { class: "lbl small", for: "bsel-c" + i }, t("batteries.mode")),
|
||||
modeSelect(b, i, "c", null),
|
||||
h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description"))) : null,
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("dl", { class: "kv" },
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
|
||||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")));
|
||||
}
|
||||
function simpleRow(b, i) {
|
||||
const lid = "bsname-" + i;
|
||||
return h("div", { class: "inv" },
|
||||
h("div", { class: "hd" }, h("span", { class: "grow", id: lid }, b.name), onSwitch(b, i, "s", lid)),
|
||||
h("div", { class: "f" }, h("label", { class: "lbl", for: "bsel-s" + i }, t("batteries.mode")),
|
||||
h("div", { class: "ctl" }, modeSelect(b, i, "s", null)),
|
||||
h("div", { class: "help" }, b.now + " " + (b.why || ""))));
|
||||
}
|
||||
// Rebuild without losing the focused control, and never under an open list
|
||||
// or a request in flight.
|
||||
function keepFocus(fn) {
|
||||
const id = document.activeElement && document.activeElement.id;
|
||||
fn();
|
||||
if (id && /^bs(w|el)-/.test(id)) { const e = document.getElementById(id); if (e) e.focus(); }
|
||||
}
|
||||
// The modes-off card's button opens Settings on the Installer view, where
|
||||
// the "Battery modes" switch is (settings.js sets window.UX13.settingsView).
|
||||
$("#batt-off a").addEventListener("click", () => { if (window.UX13.settingsView) window.UX13.settingsView("installer"); });
|
||||
function renderBatteries(force) {
|
||||
if (!S || !S.batteries) { $("#bcards").replaceChildren(); return; }
|
||||
const a = document.activeElement;
|
||||
if (!force && (packBusy || (a && a.tagName === "SELECT" && /^bsel-/.test(a.id)))) return;
|
||||
const modes = S.modes_on === true || S.batteries.some((b) => b.mode_key != null);
|
||||
$("#batt-off").hidden = modes;
|
||||
$("#batt-how").hidden = !modes;
|
||||
const docs = $("#batt-docs");
|
||||
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||
if (S.state && ["shadow", "stopped", "alarm"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||||
else $("#batt-banner").replaceChildren();
|
||||
keepFocus(() => {
|
||||
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
|
||||
if (simpleHost && simpleHost.isConnected) {
|
||||
simpleHost.replaceChildren(...(modes ? S.batteries.map(simpleRow)
|
||||
: [h("p", { class: "small muted", style: "margin:4px 0" }, t("batteries.modes_off.name") + ". " + t("batteries.description"))]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- control buttons ---------------- */
|
||||
// The action is a toggle, so the page reads the live state first and sends
|
||||
// nothing when control is already where the person asked it to be.
|
||||
// One request at a time: a second confirm while the first is in flight
|
||||
// would read the same state and toggle it back.
|
||||
let toggling = false;
|
||||
async function setControl(want) {
|
||||
if (toggling) return;
|
||||
toggling = true;
|
||||
try {
|
||||
const now = await getStatus();
|
||||
if (!now.control || now.control.running !== want) await act("auto_toggle");
|
||||
toast(want ? t("toast.started") : t("toast.stopped"));
|
||||
} catch (e) { toast(t("toast.failed")); }
|
||||
finally { toggling = false; }
|
||||
refresh();
|
||||
}
|
||||
$("#btn-ctl").addEventListener("click", () => {
|
||||
const running = S && S.control ? S.control.running : true;
|
||||
if (running) ask("stop", () => setControl(false)); else ask("start", () => setControl(true));
|
||||
});
|
||||
async function simple(what) {
|
||||
try { await act(what); toast(t("toast.done")); } catch (e) { toast(t("toast.failed")); }
|
||||
refresh();
|
||||
}
|
||||
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
|
||||
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
|
||||
|
||||
// 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, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash,
|
||||
// P3: the Simple settings view's battery card, drawn from /status.
|
||||
batteries: (host) => { simpleHost = host; if (S) renderBatteries(); refresh(); },
|
||||
status: () => S, kw };
|
||||
|
||||
/* ---------------- tabs: hash routes, arrow keys ---------------- */
|
||||
const TABS = [...document.querySelectorAll(".tab")];
|
||||
function select(tab, focus) {
|
||||
TABS.forEach((x) => {
|
||||
const on = x === tab;
|
||||
x.setAttribute("aria-selected", String(on)); x.tabIndex = on ? 0 : -1;
|
||||
document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
|
||||
});
|
||||
if (focus) tab.focus();
|
||||
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
|
||||
schedule();
|
||||
document.dispatchEvent(new CustomEvent("ux13:tab", { detail: tab.dataset.hash }));
|
||||
}
|
||||
TABS.forEach((x, i) => {
|
||||
x.addEventListener("click", () => select(x, false));
|
||||
x.addEventListener("keydown", (e) => {
|
||||
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + TABS.length, Home: 0, End: TABS.length - 1 }[e.key];
|
||||
if (j === undefined) return;
|
||||
e.preventDefault(); select(TABS[j % TABS.length], true);
|
||||
});
|
||||
});
|
||||
function fromHash() {
|
||||
const want = (location.hash || "#home").slice(1);
|
||||
select(TABS.find((x) => x.dataset.hash === want) || TABS[0], false);
|
||||
}
|
||||
addEventListener("hashchange", fromHash);
|
||||
|
||||
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
|
||||
let timer = null;
|
||||
function schedule() {
|
||||
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
|
||||
$("#t-batt").getAttribute("aria-selected") === "true");
|
||||
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
|
||||
if (!live && timer) { clearInterval(timer); timer = null; }
|
||||
}
|
||||
document.addEventListener("visibilitychange", schedule);
|
||||
fromHash();
|
||||
refresh();
|
||||
})();
|
||||
@@ -0,0 +1,175 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Battery add-on</title>
|
||||
<style>/*APP_CSS*/</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="top">
|
||||
<div class="logo" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2M8 10l-2 3h4l-2 3"/></svg></div>
|
||||
<div class="grow">
|
||||
<div class="app" data-t="app.name"></div>
|
||||
<div class="pill" id="pill" aria-live="polite"><span class="dot warn"></span><span>…</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs" role="tablist" id="tabs" data-t-label="tabs.label">
|
||||
<button class="tab" role="tab" id="t-home" aria-controls="s-home" aria-selected="true" data-hash="home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span data-t="tabs.home"></span><span class="badge" id="attn-badge" hidden></span></button>
|
||||
<button class="tab" role="tab" id="t-batt" aria-controls="s-batt" aria-selected="false" data-hash="batteries" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2"/><rect x="5.5" y="9.5" width="7" height="5" fill="currentColor" stroke="none"/></svg><span data-t="tabs.batteries"></span></button>
|
||||
<button class="tab" role="tab" id="t-set" aria-controls="s-set" aria-selected="false" data-hash="settings" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg><span data-t="tabs.settings"></span></button>
|
||||
<button class="tab" role="tab" id="t-wiz" aria-controls="s-wiz" aria-selected="false" data-hash="setup" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 7l3 3M5 19l9.5-9.5 3 3L8 22H5z"/><path d="M19 2v4M17 4h4"/></svg><span data-t="tabs.setup"></span></button>
|
||||
</nav>
|
||||
|
||||
<main class="wrap">
|
||||
|
||||
<section class="screen" id="s-home" role="tabpanel" aria-labelledby="t-home">
|
||||
<div id="home-banner"></div>
|
||||
|
||||
<div class="card" id="c-now">
|
||||
<div class="row spread"><h2 data-t="now.name"></h2><span class="tiny muted" data-t="now.description"></span></div>
|
||||
<div class="tiles">
|
||||
<div class="tile"><div class="lab"><span class="sw-solar" aria-hidden="true">☀</span><span data-t="now.solar"></span></div><div class="val" id="v-solar">–</div><div class="sub" id="sub-solar"></div></div>
|
||||
<div class="tile"><div class="lab"><span class="sw-house" aria-hidden="true">⌂</span><span data-t="now.house"></span></div><div class="val" id="v-house">–</div><div class="sub" id="sub-house"></div></div>
|
||||
<div class="tile"><div class="lab"><span class="sw-grid" aria-hidden="true">⇅</span><span data-t="now.grid"></span></div><div class="val" id="v-grid">–</div><div class="sub" id="sub-grid"></div></div>
|
||||
<div class="tile"><div class="lab"><span class="sw-batt" aria-hidden="true">▮</span><span data-t="now.batteries"></span></div><div class="val" id="v-batt">–</div><div class="sub" id="sub-batt"></div></div>
|
||||
</div>
|
||||
<div id="home-batts" style="margin-top:6px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-plan">
|
||||
<div class="row spread"><h2 data-t="plan.name"></h2><span class="chip tiny muted" id="verdict"></span></div>
|
||||
<div class="small" id="plan-head"></div>
|
||||
<div class="chart" id="chart">
|
||||
<div class="bars" id="bars" role="group"></div>
|
||||
<div id="lanes"></div>
|
||||
<div class="nowline" id="nowline" aria-hidden="true"><span data-t="plan.now"></span></div>
|
||||
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
|
||||
</div>
|
||||
<p class="tiny muted" id="no-prices" hidden data-t="plan.no_prices.description"></p>
|
||||
<div class="legend" id="legend"></div>
|
||||
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
|
||||
<h3 class="sr" data-t="plan.list"></h3>
|
||||
<ul class="plain small" id="plan-list"></ul>
|
||||
<p class="tiny muted" style="margin:8px 0 0" id="price-line"></p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-peak">
|
||||
<div class="row spread"><h2 data-t="peak.name"></h2><span class="tiny muted" id="peak-month"></span></div>
|
||||
<div class="row spread small" id="peak-figures"><span><span data-t="peak.this_month"></span>: <strong id="peak-now"></strong></span><span class="muted"><span data-t="peak.limit"></span> <span id="peak-limit"></span></span></div>
|
||||
<div class="meter" id="peak-meter" role="img"><i style="width:0%"></i></div>
|
||||
<div class="tiny muted" id="peak-line"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-attn">
|
||||
<div class="row spread"><h2 data-t="attention.name"></h2><span class="tiny muted" id="attn-count"></span></div>
|
||||
<div id="alerts"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-ctl">
|
||||
<h2 data-t="control.name"></h2>
|
||||
<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>
|
||||
<details class="more">
|
||||
<summary data-t="control.more"></summary>
|
||||
<div class="row" style="padding-bottom:6px">
|
||||
<button class="btn" id="btn-maint" type="button" data-t="control.maint_start"></button>
|
||||
<button class="btn danger" id="btn-abort" type="button" data-t="control.maint_abort"></button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<details class="card more" id="c-details">
|
||||
<summary data-t="details.name"></summary>
|
||||
<table class="rows" id="rows"></table>
|
||||
<h2 style="margin-top:12px" data-t="details.checks"></h2>
|
||||
<ul class="checks" id="checks"></ul>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
|
||||
<div id="batt-banner"></div>
|
||||
<div class="card" id="batt-off" hidden>
|
||||
<h2 data-t="batteries.modes_off.name"></h2>
|
||||
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
|
||||
<a class="btn" href="#settings" data-t="batteries.settings"></a>
|
||||
</div>
|
||||
<div id="bcards"></div>
|
||||
<details class="grp" id="batt-how" hidden>
|
||||
<summary><span class="grow"><strong data-t="batteries.how.name"></strong><span class="small muted gdesc" data-t="batteries.how.description"></span></span><span class="chev" aria-hidden="true">›</span></summary>
|
||||
<div class="body small">
|
||||
<p data-t="batteries.how.solar_first.description"></p>
|
||||
<p data-t="batteries.how.leftover_solar.description"></p>
|
||||
<p data-t="batteries.how.off.description"></p>
|
||||
<p class="muted" data-t="batteries.how.rules.description"></p>
|
||||
<p><a id="batt-docs" hidden target="_top" rel="noopener" data-t="control.docs"></a></p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
|
||||
<div class="row spread" style="margin-bottom:6px">
|
||||
<h2 style="margin:0" data-s="name"></h2>
|
||||
<a class="small" href="config" id="set-classic" data-s="classic"></a>
|
||||
</div>
|
||||
<div class="row" style="margin-bottom:8px">
|
||||
<div class="seg" role="radiogroup" id="set-view" data-s-label="view.name">
|
||||
<button type="button" role="radio" aria-checked="true" data-v="simple" data-s="view.simple"></button>
|
||||
<button type="button" role="radio" aria-checked="false" data-v="installer" tabindex="-1" data-s="view.installer"></button>
|
||||
</div>
|
||||
<span class="tiny muted grow" id="set-desc"></span>
|
||||
</div>
|
||||
<div id="set-banner" aria-live="polite"></div>
|
||||
<div id="simple" class="simple" hidden></div>
|
||||
<div id="set-body">
|
||||
<input class="search" id="set-search" type="search" autocomplete="off" spellcheck="false" data-s-ph="search.description" data-s-label="search.name">
|
||||
<div class="card" id="set-other" hidden><h2 data-s="other"></h2><ul class="plain small" id="set-other-list"></ul></div>
|
||||
<div id="groups"><p class="small muted" data-s="loading"></p></div>
|
||||
<p class="small muted" id="set-nomatch" hidden data-s="no_match.description"></p>
|
||||
<p class="tiny muted" data-s="footer.description"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
|
||||
<div class="card" id="wz">
|
||||
<div class="row spread"><h2 id="wz-title" style="margin:0" data-s="setup.name"></h2><span class="tiny muted" id="wz-count"></span></div>
|
||||
<div class="steps" id="wz-steps" aria-hidden="true"></div>
|
||||
<p class="small muted" id="wz-desc" style="margin:0 0 6px"></p>
|
||||
<div id="wz-banner" aria-live="polite"></div>
|
||||
<div id="wz-body"><p class="small muted" data-s="loading"></p></div>
|
||||
<div class="row spread" id="wz-nav" style="margin-top:14px">
|
||||
<button class="btn" id="wz-back" type="button" data-s="setup.back"></button>
|
||||
<button class="btn primary" id="wz-next" type="button" data-s="setup.next"></button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tiny muted" data-s="setup.description"></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id="dlg" aria-labelledby="dlg-t" aria-describedby="dlg-b">
|
||||
<h2 id="dlg-t"></h2>
|
||||
<p class="small" id="dlg-b"></p>
|
||||
<div class="row" style="justify-content:flex-end">
|
||||
<button class="btn" id="dlg-no" type="button" data-t="dialog.cancel"></button>
|
||||
<button class="btn primary" id="dlg-yes" type="button"></button>
|
||||
</div>
|
||||
</dialog>
|
||||
<div id="savebar" role="region" hidden><div class="in">
|
||||
<span class="grow" id="sb-text" aria-live="polite"></span>
|
||||
<button class="btn" id="sb-discard" type="button" data-s="savebar.discard"></button>
|
||||
<button class="btn primary" id="sb-save" type="button" data-s="savebar.save"></button>
|
||||
</div></div>
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<div id="tip" class="tip" aria-hidden="true"></div>
|
||||
|
||||
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
|
||||
<script>/*APP_JS*/</script>
|
||||
<script>/*SETTINGS_JS*/</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,930 @@
|
||||
"use strict";
|
||||
/* UX-13 P2: Settings, the Installer view. It draws from /config/model's
|
||||
`layout` (app/uilayout.py through app/uipage.py) and `ui` (the page model
|
||||
from app/uimodel.py), so this file names no option key: keys, labels,
|
||||
ranges and choices all arrive as data. It posts the page model back; the
|
||||
add-on turns it into options and runs its own start checks, and nothing
|
||||
they refuse is saved. Every word is from app/ui_text or the add-on, and
|
||||
data is set as text, never as HTML.
|
||||
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 = window.UX13.words; // English or Dutch (app.js)
|
||||
const W = ROOT.settings;
|
||||
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)));
|
||||
const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
||||
|
||||
document.querySelectorAll("[data-s]").forEach((e) => { e.textContent = ws(e.dataset.s) || ""; });
|
||||
document.querySelectorAll("[data-s-ph]").forEach((e) => { e.placeholder = ws(e.dataset.sPh) || ""; });
|
||||
document.querySelectorAll("[data-s-label]").forEach((e) => { e.setAttribute("aria-label", ws(e.dataset.sLabel) || ""); });
|
||||
$("#savebar").setAttribute("aria-label", W.savebar.label);
|
||||
|
||||
let M = null; // the last /config/model
|
||||
let V = null; // the page model being edited
|
||||
let ORIG = null; // the page model as stored
|
||||
let FP = ""; // the stored blob's fingerprint (compare-and-swap)
|
||||
let MSG = []; // [{field, card, tier, text}] from the add-on
|
||||
let DEVICE = "";
|
||||
let LIST = null; // the inverter list field
|
||||
let FILLED = {}; // field key -> the preset field that fills it
|
||||
let view = null; // "settings" or "setup": the tab drawing V
|
||||
let STEPS = {}; // wizard step -> refusals that block it
|
||||
// P3: "simple" (the default, for home owners) or "installer". Remembered in
|
||||
// this browser only; a blocked storage just means Simple each time.
|
||||
let mode = "simple";
|
||||
try { if (localStorage.getItem("ux13.settings.view") === "installer") mode = "installer"; } catch (e) { /* no storage */ }
|
||||
let uid = 0;
|
||||
const ROWS = [];
|
||||
const OPEN = new Set();
|
||||
const UNLOCKED = new Set();
|
||||
|
||||
/* ---------------- the page model ---------------- */
|
||||
const sub = (f) => f.key.slice(LIST.key.length + 1);
|
||||
function valueIn(model, f, card) {
|
||||
if (card == null) return model[f.key];
|
||||
const c = (model[LIST.key] || [])[card];
|
||||
return c ? c[sub(f)] : undefined;
|
||||
}
|
||||
const get = (f, card) => valueIn(V, f, card);
|
||||
function visible(f) {
|
||||
return (f.show || []).every(([name, op, want]) => {
|
||||
const have = V[name];
|
||||
if (op === "==") return same(have, want);
|
||||
if (op === "!=") return !same(have, want);
|
||||
if (op === "in") return want.some((x) => same(x, have));
|
||||
if (have == null || have === "" || isNaN(+have)) return false;
|
||||
return op === ">" ? +have > want : +have < want;
|
||||
});
|
||||
}
|
||||
function put(f, card, v, redraw) {
|
||||
if (card == null) V[f.key] = v;
|
||||
else V[LIST.key][card][sub(f)] = v;
|
||||
if (f.mirror) {
|
||||
const m = findField(f.mirror);
|
||||
if (m && (m.choices || []).some((c) => same(c[0], v))) V[f.mirror] = v;
|
||||
}
|
||||
if (f.fills && f.presets && f.presets[v]) V[f.fills] = f.presets[v].value;
|
||||
if (redraw || f.fills) redrawView(); else paint();
|
||||
queueValidate();
|
||||
}
|
||||
function findField(key) {
|
||||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
|
||||
if (f.key === key) return f;
|
||||
for (const s of f.fields || []) if (s.key === key) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ---------------- numbers: stored units <-> shown units ---------------- */
|
||||
const decimals = (x) => { const s = String(x); return s.includes(".") ? s.split(".")[1].length : 0; };
|
||||
function shown(f, v) {
|
||||
if (v == null || v === "") return "";
|
||||
return String(+(Number(v) / (f.scale || 1)).toFixed(6));
|
||||
}
|
||||
function stored(f, x) {
|
||||
const st = f.step || (f.type === "integer" ? 1 : 0);
|
||||
let v = x * (f.scale || 1);
|
||||
if (st) v = Math.round(v / st) * st;
|
||||
if (f.min != null) v = Math.max(f.min, v);
|
||||
if (f.max != null) v = Math.min(f.max, v);
|
||||
return f.type === "integer" ? Math.round(v) : +v.toFixed(Math.min(10, decimals(st) + 2));
|
||||
}
|
||||
|
||||
/* ---------------- controls ---------------- */
|
||||
// A choice the page can only keep stays offered while it is the stored value.
|
||||
function choicesOf(f, card) {
|
||||
const cur = get(f, card), was = valueIn(ORIG, f, card);
|
||||
const out = (f.choices || []).filter((c) => !c[2] || same(c[0], cur) || same(c[0], was));
|
||||
if (cur != null && cur !== "" && !out.some((c) => same(c[0], cur))) out.push([cur, String(cur)]);
|
||||
return out;
|
||||
}
|
||||
function toggle(f, card, lid) {
|
||||
const on = !!get(f, card);
|
||||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(on), "aria-labelledby": lid });
|
||||
const txt = h("span", { class: "swtext", "aria-hidden": "true" }, on ? W.on : W.off);
|
||||
b.addEventListener("click", () => {
|
||||
const n = b.getAttribute("aria-checked") !== "true";
|
||||
b.setAttribute("aria-checked", String(n)); txt.textContent = n ? W.on : W.off;
|
||||
put(f, card, n);
|
||||
});
|
||||
return h("span", { class: "swrap" }, b, txt);
|
||||
}
|
||||
function radios(f, card, lid, cards) {
|
||||
const cur = get(f, card);
|
||||
const g = h("div", { class: cards ? "choices" : "seg", role: "radiogroup", "aria-labelledby": lid });
|
||||
const items = choicesOf(f, card);
|
||||
const buttons = items.map(([v, label], i) => {
|
||||
const on = same(v, cur);
|
||||
const hint = cards && f.hints ? f.hints[v] : "";
|
||||
const b = h("button", { type: "button", role: "radio", class: cards ? "choice" : null, "aria-checked": String(on),
|
||||
tabindex: on || (!items.some((c) => same(c[0], cur)) && i === 0) ? "0" : "-1" },
|
||||
cards ? [h("span", { class: "rad", "aria-hidden": "true" }), h("span", {}, h("div", {}, label), hint ? h("div", { class: "tiny muted" }, hint) : null)] : label);
|
||||
b.addEventListener("click", () => put(f, card, v, true));
|
||||
b.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
||||
if (!d) return;
|
||||
e.preventDefault();
|
||||
const j = (i + d + items.length) % items.length;
|
||||
put(f, card, items[j][0], true);
|
||||
const again = document.querySelector(`[data-rk="${CSS.escape(g.dataset.rk)}"] [role=radio]:nth-child(${j + 1})`);
|
||||
if (again) again.focus();
|
||||
});
|
||||
return b;
|
||||
});
|
||||
g.dataset.rk = f.key + "|" + card;
|
||||
g.append(...buttons);
|
||||
return g;
|
||||
}
|
||||
function select(f, card, lid) {
|
||||
const cur = get(f, card);
|
||||
const s = h("select", { class: "sel", "aria-labelledby": lid });
|
||||
for (const [v, label] of choicesOf(f, card)) s.append(h("option", { value: JSON.stringify(v), selected: same(v, cur) }, label));
|
||||
for (const [v, label] of f.later || []) s.append(h("option", { value: JSON.stringify(v), disabled: true }, label));
|
||||
s.addEventListener("change", () => put(f, card, JSON.parse(s.value), true));
|
||||
const preset = f.presets && f.presets[cur];
|
||||
return preset ? h("div", { class: "row" }, s, h("span", { class: "chip" }, W.region_year.replace("{year}", preset.year))) : s;
|
||||
}
|
||||
function slider(f, card, lid) {
|
||||
if (f.min == null || f.max == null) return number(f, card, lid);
|
||||
const sc = f.scale || 1;
|
||||
const st = f.step || (f.type === "integer" ? 1 : 0.01);
|
||||
const v = get(f, card);
|
||||
const r = h("input", { type: "range", min: f.min, max: f.max, step: st, value: v == null ? f.min : v, "aria-labelledby": lid });
|
||||
const n = h("input", { class: "num", type: "number", inputmode: "decimal", min: f.min / sc, max: f.max / sc, step: st / sc, value: shown(f, v), "aria-labelledby": lid });
|
||||
r.addEventListener("input", () => { const x = stored(f, +r.value / sc); n.value = shown(f, x); put(f, card, x); });
|
||||
n.addEventListener("change", () => {
|
||||
if (n.value === "" || isNaN(+n.value)) { n.value = shown(f, get(f, card)); return; }
|
||||
const x = stored(f, +n.value); r.value = x; n.value = shown(f, x); put(f, card, x);
|
||||
});
|
||||
return h("div", { class: "slider" }, r, n, f.unit ? h("span", { class: "unit" }, f.unit) : null);
|
||||
}
|
||||
function number(f, card, lid) {
|
||||
const sc = f.scale || 1;
|
||||
const by = FILLED[f.key];
|
||||
const preset = by && by.presets[V[by.key]];
|
||||
const n = h("input", { class: "num", type: "number", inputmode: "decimal", step: f.step ? f.step / sc : "any",
|
||||
min: f.min == null ? null : f.min / sc, max: f.max == null ? null : f.max / sc,
|
||||
value: shown(f, get(f, card)), disabled: !!preset, "aria-labelledby": lid });
|
||||
n.addEventListener("input", () => {
|
||||
if (n.value === "") { if (f.optional) put(f, card, null); return; }
|
||||
if (!isNaN(+n.value)) put(f, card, stored(f, +n.value));
|
||||
});
|
||||
n.addEventListener("change", () => { n.value = shown(f, get(f, card)); });
|
||||
return h("div", {}, h("div", { class: "row" }, n, f.unit ? h("span", { class: "unit" }, f.unit) : null),
|
||||
preset ? h("div", { class: "tiny muted" }, W.preset.description) : null);
|
||||
}
|
||||
function text(f, card, lid) {
|
||||
const v = get(f, card);
|
||||
const t = h("input", { class: "txt", type: "text", value: v == null ? "" : v, autocapitalize: "none", spellcheck: "false", "aria-labelledby": lid });
|
||||
t.addEventListener("input", () => put(f, card, t.value));
|
||||
return t;
|
||||
}
|
||||
function locked(f, card, lid) {
|
||||
const box = h("div", {});
|
||||
const id = f.key + "#" + card;
|
||||
const open = () => { UNLOCKED.add(id); box.replaceChildren(slider(f, card, lid)); };
|
||||
if (UNLOCKED.has(id)) { open(); return box; }
|
||||
const v = get(f, card);
|
||||
box.append(h("div", { class: "row" },
|
||||
h("strong", {}, v == null ? W.not_set : shown(f, v) + (f.unit ? " " + f.unit : "")),
|
||||
h("span", { class: "chip" }, W.locked),
|
||||
h("button", { type: "button", class: "btn link", "aria-label": W.change + ": " + f.label, onclick: open }, W.change)));
|
||||
return box;
|
||||
}
|
||||
|
||||
/* entity picker: search, the matching kind first, the live reading */
|
||||
const ENT = {};
|
||||
function entities(kind) {
|
||||
const now = Date.now();
|
||||
const e = ENT[kind];
|
||||
if (e && now - e.at < 30000) return e.p;
|
||||
const p = fetch("ha/entities?kind=" + encodeURIComponent(kind || ""), { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : { available: false, entities: [] }))
|
||||
.catch(() => ({ available: false, entities: [] }));
|
||||
ENT[kind] = { at: now, p };
|
||||
return p;
|
||||
}
|
||||
const reading = (e) => (e && e.state != null && e.state !== "" ? String(e.state) + (e.unit ? " " + e.unit : "") : "–");
|
||||
function picker(f, lid, value, pick) {
|
||||
let data = null, all = false;
|
||||
const btn = h("button", { type: "button", class: "ent-btn", "aria-expanded": "false", "aria-haspopup": "listbox", "aria-labelledby": lid });
|
||||
const pop = h("div", { class: "ent-pop", hidden: true });
|
||||
const q = h("input", { type: "search", placeholder: W.picker.search.description, "aria-label": W.picker.search.description, autocomplete: "off", spellcheck: "false" });
|
||||
const note = h("div", { class: "tiny muted ent-note" });
|
||||
const list = h("div", { class: "ent-list", role: "listbox", "aria-labelledby": lid });
|
||||
const allBox = h("label", { class: "tiny muted ent-all" },
|
||||
h("input", { type: "checkbox", onchange: (e) => { all = e.target.checked; fill(); } }), W.picker.all);
|
||||
function drawBtn() {
|
||||
const id = value();
|
||||
const e = data && data.entities.find((x) => x.entity_id === id);
|
||||
btn.replaceChildren();
|
||||
if (id) btn.append(h("span", { class: "grow" }, h("div", {}, e ? e.name : id), h("div", { class: "key" }, id)),
|
||||
data && data.available ? h("span", { class: "chip " + (e ? "now" : "rs") }, e ? reading(e) : W.picker.missing) : null);
|
||||
else btn.append(h("span", { class: "grow muted" }, W.picker.choose));
|
||||
btn.append(h("span", { class: "muted", "aria-hidden": "true" }, "▾"));
|
||||
}
|
||||
function opt(id, name, val) {
|
||||
return h("button", { type: "button", class: "ent-opt", role: "option", "aria-selected": String(id === value()),
|
||||
onclick: () => { pick(id); close(); drawBtn(); btn.focus(); } },
|
||||
h("span", { class: "grow" }, h("div", {}, name), h("div", { class: "key" }, id)), val ? h("span", { class: "v" }, val) : null);
|
||||
}
|
||||
function fill() {
|
||||
const typed = q.value.trim();
|
||||
const t = typed.toLowerCase();
|
||||
const rows = (data ? data.entities : []).filter((e) => (all || e.match) && (e.entity_id + " " + e.name).toLowerCase().includes(t)).slice(0, 200);
|
||||
list.replaceChildren(...rows.map((e) => opt(e.entity_id, e.name, reading(e))));
|
||||
if (typed.includes(".") && !rows.some((e) => e.entity_id === typed)) list.append(opt(typed, W.picker.use, ""));
|
||||
if (!list.children.length) list.append(h("div", { class: "small muted", style: "padding:10px" }, W.picker.none.description));
|
||||
note.textContent = data && data.text ? data.text : "";
|
||||
note.hidden = !note.textContent;
|
||||
}
|
||||
function close() { pop.hidden = true; btn.setAttribute("aria-expanded", "false"); }
|
||||
btn.addEventListener("click", () => {
|
||||
if (!pop.hidden) { close(); return; }
|
||||
pop.hidden = false; btn.setAttribute("aria-expanded", "true"); q.value = ""; fill();
|
||||
setTimeout(() => q.focus(), 0);
|
||||
});
|
||||
q.addEventListener("input", fill);
|
||||
pop.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") { e.preventDefault(); close(); btn.focus(); return; }
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
e.preventDefault();
|
||||
const opts = [...list.querySelectorAll(".ent-opt")];
|
||||
const i = opts.indexOf(document.activeElement);
|
||||
const j = e.key === "ArrowDown" ? Math.min(opts.length - 1, i + 1) : i - 1;
|
||||
if (j < 0) q.focus(); else if (opts[j]) opts[j].focus();
|
||||
});
|
||||
pop.append(q, note, list, allBox);
|
||||
drawBtn();
|
||||
entities(f.kind).then((d) => { data = d; drawBtn(); if (!pop.hidden) fill(); });
|
||||
return h("div", { class: "ent" }, btn, pop,
|
||||
f.optional ? h("button", { type: "button", class: "btn link tiny", onclick: () => { pick(""); drawBtn(); } }, W.picker.clear) : null);
|
||||
}
|
||||
function entity(f, card, lid) {
|
||||
return picker(f, lid, () => get(f, card) || "", (v) => put(f, card, v));
|
||||
}
|
||||
function entityList(f, card, lid) {
|
||||
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||||
const box = h("div", { class: "elist" });
|
||||
arr().forEach((_id, i) => {
|
||||
box.append(h("div", { class: "erow" },
|
||||
h("div", { class: "tiny muted" }, W.phase + " " + (i + 1)),
|
||||
picker(f, lid, () => arr()[i] || "", (v) => { const a = arr().slice(); a[i] = v; put(f, card, a); }),
|
||||
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = arr().slice(); a.splice(i, 1); put(f, card, a, true); } }, W.remove)));
|
||||
});
|
||||
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([""]), true) }, "+ " + W.add_row));
|
||||
return box;
|
||||
}
|
||||
function solarList(f, card, lid) {
|
||||
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||||
const box = h("div", { class: "elist" });
|
||||
arr().forEach((row, i) => {
|
||||
const flip = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(!!row.reversed), "aria-label": W.reversed });
|
||||
flip.addEventListener("click", () => { const a = clone(arr()); a[i].reversed = !a[i].reversed; put(f, card, a, true); });
|
||||
box.append(h("div", { class: "erow" },
|
||||
picker(f, lid, () => arr()[i].entity || "", (v) => { const a = clone(arr()); a[i].entity = v; put(f, card, a); }),
|
||||
h("div", { class: "row" }, h("span", { class: "swrap" }, flip, h("span", { class: "swtext" }, W.reversed)),
|
||||
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove))));
|
||||
});
|
||||
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,
|
||||
period_list: periodList };
|
||||
|
||||
/* ---------------- rows, cards and groups ---------------- */
|
||||
function keyText(f, card) {
|
||||
if (view === "setup") return ""; // the wizard shows no keys
|
||||
if (card != null) return V[LIST.shape] === "flat" && f.flat ? f.flat : LIST.key + "[" + card + "]." + sub(f);
|
||||
return f.key.startsWith("ui.") ? (f.writes || []).join(", ") : f.key;
|
||||
}
|
||||
function fieldRow(f, card, gid) {
|
||||
if (f.control === "inverter_list") return cardList(f, gid);
|
||||
const lid = "sf" + (++uid);
|
||||
const keys = gid === "simple" ? "" : keyText(f, card); // Simple shows no keys
|
||||
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 && !["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);
|
||||
ROWS.push({ f, card, el: row, msg, gid, search: (f.label + " " + keys + " " + f.key + " " + (f.help || "")).toLowerCase() });
|
||||
return row;
|
||||
}
|
||||
function blankCard(f, first) {
|
||||
const out = {};
|
||||
for (const s of f.fields) {
|
||||
const k = sub(s);
|
||||
out[k] = s.control === "slider" && first && first[k] != null ? first[k] : s.type === "boolean" ? false : s.control === "number" ? null : "";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function cardList(f, gid) {
|
||||
const wrap = h("div", { class: "invs", "data-key": f.key });
|
||||
const msg = h("div", { class: "msgs" });
|
||||
const cards = V[f.key];
|
||||
if (!Array.isArray(cards)) {
|
||||
wrap.append(h("div", { class: "msg info" }, W.raw_roster.description), msg);
|
||||
} else {
|
||||
cards.forEach((c, i) => {
|
||||
const box = h("div", { class: "inv" }, h("div", { class: "hd" },
|
||||
h("span", { class: "grow" }, c.name || W.inverter + " " + (i + 1)), view === "setup" ? null : h("span", { class: "key" }, f.key + "[" + i + "]"),
|
||||
cards.length > 1 ? h("button", { type: "button", class: "btn link tiny", onclick: () => {
|
||||
V[f.key].splice(i, 1); V[f.count] = V[f.key].length;
|
||||
// Back to one card, the stored shape returns: a flat inverter stays flat.
|
||||
if (V[f.key].length === 1) for (const k in f.grow) V[k] = clone(ORIG[k]);
|
||||
put(f, null, V[f.key], true);
|
||||
} }, W.remove) : null));
|
||||
for (const s of f.fields) box.append(fieldRow(s, i, gid));
|
||||
wrap.append(box);
|
||||
});
|
||||
wrap.append(msg, h("button", { type: "button", class: "btn", onclick: () => {
|
||||
V[f.key].push(blankCard(f, V[f.key][0])); Object.assign(V, f.grow); V[f.count] = V[f.key].length;
|
||||
put(f, null, V[f.key], true);
|
||||
} }, "+ " + W.add_inverter));
|
||||
}
|
||||
ROWS.push({ f, card: null, el: wrap, msg, gid, list: true, search: (f.label + " " + f.key + " " + (f.help || "")).toLowerCase() });
|
||||
return wrap;
|
||||
}
|
||||
const chev = () => h("span", { class: "chev", "aria-hidden": "true" }, "›");
|
||||
function render() {
|
||||
const host = $("#groups");
|
||||
const y = window.scrollY;
|
||||
host.replaceChildren();
|
||||
ROWS.length = 0;
|
||||
for (const g of M.layout.groups) {
|
||||
const d = h("details", { class: "grp", "data-g": g.id },
|
||||
h("summary", {}, h("span", { class: "grow" }, h("strong", {}, g.name), h("span", { class: "small muted gdesc" }, g.description)),
|
||||
h("span", { class: "gchips" }, h("span", { class: "gbadge" }), ...g.applies.map((a) => h("span", { class: "chip applies" }, a))), chev()));
|
||||
d.open = OPEN.has(g.id);
|
||||
d.addEventListener("toggle", () => { if (!$("#set-search").value) (d.open ? OPEN.add(g.id) : OPEN.delete(g.id)); });
|
||||
const body = h("div", { class: "body" });
|
||||
if (g.support_only) body.append(h("div", { class: "msg warn", role: "note" }, W.support.description));
|
||||
for (const p of g.parts) {
|
||||
const pid = g.id + "." + p.id;
|
||||
let box;
|
||||
if (p.collapsed) {
|
||||
box = h("details", { class: "sub part" }, h("summary", {}, p.name));
|
||||
box.open = OPEN.has(pid);
|
||||
box.addEventListener("toggle", () => { if (!$("#set-search").value) (box.open ? OPEN.add(pid) : OPEN.delete(pid)); });
|
||||
} else {
|
||||
box = h("div", { class: "part" }, p.name ? h("div", { class: "subhead" }, p.name) : null);
|
||||
}
|
||||
if (p.description) box.append(h("p", { class: "small muted", style: "margin:4px 0" }, p.description));
|
||||
for (const f of p.fields) box.append(fieldRow(f, null, g.id));
|
||||
body.append(box);
|
||||
}
|
||||
if (g.id === "maintenance") {
|
||||
body.append(h("div", { class: "part" }, h("div", { class: "subhead" }, W.cycle.name),
|
||||
h("p", { class: "small muted", style: "margin:4px 0" }, W.cycle.description),
|
||||
h("div", { class: "row" },
|
||||
h("button", { type: "button", class: "btn", onclick: () => maint("maint_start") }, ROOT.home.control.maint_start),
|
||||
h("button", { type: "button", class: "btn danger", onclick: () => maint("maint_abort") }, ROOT.home.control.maint_abort))));
|
||||
}
|
||||
d.append(body);
|
||||
host.append(d);
|
||||
}
|
||||
renderSimple();
|
||||
paint();
|
||||
window.scrollTo(0, y);
|
||||
}
|
||||
|
||||
/* ---------------- Settings, Simple (P3) ---------------- */
|
||||
// The fields the layout marks `simple`, each in its own card with when it
|
||||
// applies, and the batteries' switches and modes (runtime state, applied at
|
||||
// once) drawn by app.js from /status. The Installer rows stay built but
|
||||
// hidden, so the save bar counts every unsaved change in either view.
|
||||
const chip = (applies) => h("span", { class: "chip " + (applies === "now" ? "now" : "rs") }, ROOT.applies[applies].name);
|
||||
// A note with {kw} is filled from /status (app.js sends ux13:status).
|
||||
function fillNotes() {
|
||||
const st = window.UX13.status();
|
||||
const mp = st && st.month_peak;
|
||||
document.querySelectorAll("#simple [data-kw-note]").forEach((e) => {
|
||||
const ok = mp && mp.peak_w != null;
|
||||
e.hidden = !ok;
|
||||
e.textContent = ok ? e.dataset.kwNote.replace("{kw}", window.UX13.kw(mp.peak_w) + " kW") : "";
|
||||
});
|
||||
}
|
||||
document.addEventListener("ux13:status", fillNotes);
|
||||
function simpleField(f) {
|
||||
const row = fieldRow(f, null, "simple");
|
||||
row.querySelector(".head").append(chip(f.applies));
|
||||
const n = f.note && W.simple.note[f.note];
|
||||
if (n && n.description.includes("{kw}")) {
|
||||
row.append(h("div", { class: "small muted", "data-kw-note": n.description, hidden: true }));
|
||||
} else if (n) {
|
||||
// A plain note replaces the option's installer help on this view.
|
||||
const help = row.querySelector(".help");
|
||||
if (help) help.textContent = n.description;
|
||||
}
|
||||
return h("div", { class: "card" }, row);
|
||||
}
|
||||
function renderSimple() {
|
||||
const host = $("#simple");
|
||||
host.replaceChildren();
|
||||
if (mode !== "simple") return;
|
||||
const cards = allFields().filter((f) => f.simple).map(simpleField);
|
||||
const batts = h("div", {});
|
||||
cards.splice(Math.min(1, cards.length), 0, h("div", { class: "card" },
|
||||
h("div", { class: "row spread" }, h("div", { class: "lbl" }, W.simple.batteries.name), chip("now")), batts));
|
||||
host.append(...cards, h("p", { class: "small muted", style: "margin:4px 2px" }, W.simple.more.description + " ",
|
||||
h("button", { type: "button", class: "btn link", onclick: () => setMode("installer", true) }, W.simple.installer)));
|
||||
window.UX13.batteries(batts);
|
||||
fillNotes();
|
||||
}
|
||||
// `remember`: only the person's own pick of a view is kept for next time;
|
||||
// a jump to Installer (a link, a setting to fix) is not.
|
||||
function setMode(v, redraw, remember) {
|
||||
mode = v;
|
||||
if (remember) try { localStorage.setItem("ux13.settings.view", v); } catch (e) { /* no storage */ }
|
||||
document.querySelectorAll("#set-view [role=radio]").forEach((b) => {
|
||||
const on = b.dataset.v === v;
|
||||
b.setAttribute("aria-checked", String(on)); b.tabIndex = on ? 0 : -1;
|
||||
});
|
||||
$("#set-desc").textContent = v === "simple" ? W.simple.description : W.description;
|
||||
$("#simple").hidden = v !== "simple";
|
||||
["#set-search", "#groups", "#set-nomatch"].forEach((id) => { $(id).classList.toggle("vhide", v === "simple"); });
|
||||
if (redraw && M && view === "settings") render();
|
||||
}
|
||||
document.querySelectorAll("#set-view [role=radio]").forEach((b, i, all) => {
|
||||
b.addEventListener("click", () => setMode(b.dataset.v, true, true));
|
||||
b.addEventListener("keydown", (e) => {
|
||||
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
|
||||
if (!d) return;
|
||||
e.preventDefault();
|
||||
const j = all[(i + d + all.length) % all.length];
|
||||
setMode(j.dataset.v, true, true); j.focus();
|
||||
});
|
||||
});
|
||||
setMode(mode, false);
|
||||
window.UX13.settingsView = (v) => setMode(v, true);
|
||||
|
||||
/* ---------------- messages, visibility, search, badges ---------------- */
|
||||
const ICON = { bad: "×", warn: "!", info: "i" };
|
||||
const RANK = { bad: 0, warn: 1, info: 2 };
|
||||
function mine(r, m) {
|
||||
if (m.field !== r.f.key) return false;
|
||||
if (r.list || r.card == null) return true;
|
||||
return m.card == null ? r.card === 0 : m.card === r.card;
|
||||
}
|
||||
function paint() {
|
||||
const q = view === "setup" || mode === "simple" ? "" : $("#set-search").value.trim().toLowerCase();
|
||||
// A message counts as shown only beside a row of the view on screen.
|
||||
const onScreen = (r) => view === "setup" || (mode === "simple") === (r.gid === "simple");
|
||||
const placed = new Set();
|
||||
for (const r of ROWS) {
|
||||
r.visible = visible(r.f);
|
||||
const msgs = MSG.filter((m, i) => { const hit = mine(r, m); if (hit && r.visible && onScreen(r)) placed.add(i); return hit; })
|
||||
.sort((a, z) => RANK[a.tier] - RANK[z.tier]);
|
||||
r.tiers = new Set(msgs.map((m) => m.tier));
|
||||
r.msg.replaceChildren(...msgs.map((m) => h("div", { class: "msg " + m.tier },
|
||||
h("span", { class: "mi", "aria-hidden": "true" }, ICON[m.tier] || "i"),
|
||||
h("span", {}, h("span", { class: "sr" }, W.tier[m.tier] + ": "), m.text))));
|
||||
r.el.classList.toggle("has-bad", r.tiers.has("bad"));
|
||||
// A card list counts as one change when cards were added or removed;
|
||||
// an edit inside a card counts on its own row.
|
||||
r.changed = r.list ? !same((get(r.f) || []).length, (valueIn(ORIG, r.f) || []).length)
|
||||
: !same(get(r.f, r.card), valueIn(ORIG, r.f, r.card));
|
||||
r.el.classList.toggle("changed", r.changed && !r.list);
|
||||
}
|
||||
// Search: a row shows when it is visible and matches. A card list shows
|
||||
// while any of its rows does.
|
||||
for (const r of ROWS) if (!r.list) r.el.hidden = !(r.visible && (!q || r.search.includes(q)));
|
||||
for (const r of ROWS) if (r.list) r.el.hidden = !!q && !r.search.includes(q) && !ROWS.some((s) => !s.list && s.card != null && !s.el.hidden);
|
||||
let any = false;
|
||||
document.querySelectorAll("#groups details.grp").forEach((d) => {
|
||||
const rows = ROWS.filter((r) => r.gid === d.dataset.g);
|
||||
d.querySelectorAll(".part").forEach((p) => {
|
||||
const inside = rows.filter((r) => p.contains(r.el));
|
||||
if (inside.length) p.hidden = inside.every((r) => r.el.hidden);
|
||||
});
|
||||
const hit = rows.some((r) => !r.el.hidden);
|
||||
any = any || hit;
|
||||
d.hidden = !!q && !hit;
|
||||
if (q && hit) { d.open = true; d.querySelectorAll("details.sub").forEach((s) => { s.open = true; }); }
|
||||
const shownRows = rows.filter((r) => r.visible && !r.list);
|
||||
const bad = shownRows.filter((r) => r.tiers.has("bad")).length + rows.filter((r) => r.list && r.tiers.has("bad")).length;
|
||||
const warn = shownRows.filter((r) => !r.tiers.has("bad") && r.tiers.has("warn")).length;
|
||||
const changed = rows.filter((r) => r.changed).length;
|
||||
d.querySelector(".gbadge").replaceChildren(...[
|
||||
bad ? h("span", { class: "chip bad" }, bad + " " + W.to_fix) : null,
|
||||
warn ? h("span", { class: "chip rs" }, warn + " " + W.to_check) : null,
|
||||
changed ? h("span", { class: "chip changed" }, changed + " " + W.changed) : null].filter(Boolean));
|
||||
});
|
||||
$("#set-nomatch").hidden = !q || any;
|
||||
// A sentence shown beside any field is not repeated here.
|
||||
const shownText = new Set(MSG.filter((m, i) => placed.has(i)).map((m) => m.text));
|
||||
const other = MSG.filter((m, i) => !shownText.has(m.text) && MSG.findIndex((x) => x.text === m.text) === i);
|
||||
if (DEVICE) other.push({ tier: "info", text: DEVICE });
|
||||
$("#set-other").hidden = !other.length;
|
||||
$("#set-other-list").replaceChildren(...other.map((m) => h("li", { class: "c-" + ({ bad: "bad", warn: "warn" }[m.tier] || "info") },
|
||||
h("span", { class: "sr" }, W.tier[m.tier] + ": "), (ICON[m.tier] || "i") + " " + m.text)));
|
||||
savebar();
|
||||
}
|
||||
function dirtyCount() {
|
||||
const seen = new Set();
|
||||
for (const r of ROWS) if (r.changed) seen.add(r.f.key + "#" + r.card);
|
||||
return seen.size;
|
||||
}
|
||||
function savebar() {
|
||||
const n = V ? dirtyCount() : 0;
|
||||
$("#savebar").hidden = !(view === "settings" && n);
|
||||
$("#sb-text").textContent = n + " " + (n === 1 ? W.savebar.one : W.savebar.many);
|
||||
}
|
||||
$("#set-search").addEventListener("input", () => { if (V) paint(); });
|
||||
|
||||
/* ---------------- talking to the add-on ---------------- */
|
||||
let vtimer = null, vseq = 0;
|
||||
function queueValidate() { clearTimeout(vtimer); vtimer = setTimeout(validate, 400); }
|
||||
// `extra` asks for more, e.g. the wizard's live checks. Returns the answer,
|
||||
// or null when it failed or a newer check overtook it.
|
||||
async function validate(extra) {
|
||||
const seq = ++vseq;
|
||||
try {
|
||||
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(Object.assign({ model: V, lang }, extra || {})) });
|
||||
const b = await r.json();
|
||||
if (seq !== vseq) return null;
|
||||
MSG = b.messages || [];
|
||||
DEVICE = b.device_note || "";
|
||||
STEPS = b.steps || {};
|
||||
paint();
|
||||
return b;
|
||||
} catch (e) { return null; /* the next change tries again */ }
|
||||
}
|
||||
function banner(level, title, sub, button, onClick) {
|
||||
const host = $("#set-banner");
|
||||
if (!title) { host.replaceChildren(); return; }
|
||||
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
|
||||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
|
||||
}
|
||||
function restart() {
|
||||
ask(W.dialog, async () => {
|
||||
try {
|
||||
await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "restart" }) });
|
||||
} catch (e) { /* the add-on stops answering while it restarts */ }
|
||||
toast(W.toast.restarting.description);
|
||||
});
|
||||
}
|
||||
async function load(after) {
|
||||
let m;
|
||||
try {
|
||||
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");
|
||||
} catch (e) {
|
||||
$("#groups").replaceChildren();
|
||||
$("#set-search").hidden = true;
|
||||
banner("bad", W.load_failed.name, W.load_failed.description);
|
||||
return;
|
||||
}
|
||||
M = m; V = clone(m.ui); ORIG = clone(m.ui); FP = m.fingerprint; MSG = []; DEVICE = "";
|
||||
UNLOCKED.clear();
|
||||
$("#set-search").hidden = false;
|
||||
FILLED = {};
|
||||
LIST = null;
|
||||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) {
|
||||
if (f.fills) FILLED[f.fills] = f;
|
||||
if (f.control === "inverter_list") LIST = f;
|
||||
}
|
||||
if (!OPEN.size && M.layout.groups.length) OPEN.add(M.layout.groups[0].id);
|
||||
if (after === "saved") banner("ok", W.saved.name, W.saved.description, W.restart, restart);
|
||||
else if ((m.running_differs || []).length) banner("warn", W.differs.name, W.differs.description, W.restart, restart);
|
||||
else banner();
|
||||
if (view === "setup") drawStep(); else render();
|
||||
validate();
|
||||
}
|
||||
function firstBad() {
|
||||
if (view === "settings" && mode === "simple" && !ROWS.some((x) => x.gid === "simple" && x.tiers && x.tiers.has("bad") && x.visible)
|
||||
&& ROWS.some((x) => x.tiers && x.tiers.has("bad") && x.visible)) setMode("installer", true);
|
||||
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible && (view !== "settings" || (mode === "simple") === (x.gid === "simple")));
|
||||
if (!r) { if (!$("#set-other").hidden) $("#set-other").scrollIntoView({ block: "center" }); return; }
|
||||
for (let p = r.el.parentElement; p; p = p.parentElement) if (p.tagName === "DETAILS") p.open = true;
|
||||
r.el.scrollIntoView({ block: "center" });
|
||||
const input = r.el.querySelector("input,select,button");
|
||||
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. `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, 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 };
|
||||
}
|
||||
// The settings another surface changed (not this page's own edits).
|
||||
function staleText(b) {
|
||||
const names = (b.changed_fields || []).map((k) => { const f = findField(k); return f ? f.label : k; });
|
||||
return W.stale.description + (names.length ? " " + names.join(", ") + "." : "");
|
||||
}
|
||||
async function save() {
|
||||
const btn = $("#sb-save");
|
||||
btn.disabled = true; btn.textContent = W.savebar.saving;
|
||||
try {
|
||||
clearTimeout(vtimer);
|
||||
await validate();
|
||||
if (MSG.some((m) => m.tier === "bad")) { toast(W.toast.blocked.description); firstBad(); return; }
|
||||
const { r, b } = await postSave();
|
||||
if (r.ok) { toast(W.toast.saved); await load("saved"); return; }
|
||||
if (r.status === 409 && b.kind === "stale") {
|
||||
banner("bad", W.stale.name, staleText(b));
|
||||
return;
|
||||
}
|
||||
if (r.status === 422) { MSG = b.messages || []; paint(); toast(W.toast.blocked.description); firstBad(); return; }
|
||||
toast(W.toast.failed);
|
||||
} catch (e) { toast(W.toast.failed); }
|
||||
finally { btn.disabled = false; btn.textContent = W.savebar.save; }
|
||||
}
|
||||
$("#sb-save").addEventListener("click", save);
|
||||
// Discard reloads what is stored: after a stale save ORIG is no longer it.
|
||||
$("#sb-discard").addEventListener("click", async () => {
|
||||
await load(); toast(W.toast.discarded);
|
||||
});
|
||||
addEventListener("beforeunload", (e) => { if (V && dirtyCount()) { e.preventDefault(); e.returnValue = ""; } });
|
||||
|
||||
/* ---------------- the setup wizard (P4) ---------------- */
|
||||
const WS = W.setup;
|
||||
const WZ = { step: 1, kettle: null, shadowOn: true, done: "", checked: null };
|
||||
function redrawView() { if (view === "setup") drawStep(); else render(); }
|
||||
function allFields() {
|
||||
const out = [];
|
||||
for (const g of M.layout.groups) for (const p of g.parts) for (const f of p.fields) out.push(f);
|
||||
return out;
|
||||
}
|
||||
const say = (text, vars) => Object.keys(vars || {}).reduce((t, k) => t.replace("{" + k + "}", vars[k]), text);
|
||||
function watts(w, signed) {
|
||||
const n = String(Math.round(Math.abs(w))).replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
return (signed && w < 0 ? "-" : "") + n + " W";
|
||||
}
|
||||
function line(tier, text) {
|
||||
const icon = { ok: "✓", bad: "×", warn: "!", info: "i" }[tier] || "i";
|
||||
return h("li", {}, h("span", { class: "sev " + tier, "aria-hidden": "true" }, icon),
|
||||
h("span", {}, h("span", { class: "sr" }, (W.tier[tier] || "") + ": "), text));
|
||||
}
|
||||
|
||||
// Step 1's kettle test. The table is app/uilayout.py's KETTLE; this is
|
||||
// uipage.kettle() (test_setup.py grades that one) on the page.
|
||||
function kettleSource() { const K = M.setup.kettle; return K.sources[V[K.source]] || null; }
|
||||
function kettleSig(src) { return JSON.stringify([V[M.setup.kettle.source], src.read.map(([k]) => V[k] || "")]); }
|
||||
function kettleOutcome(src, before, after) {
|
||||
const d = after - before;
|
||||
if (Math.abs(d) < M.setup.kettle.min_w) return { say: "none", sets: null, d };
|
||||
const o = src[d > 0 ? "up" : "down"];
|
||||
return { say: o.say, sets: o.sets, d };
|
||||
}
|
||||
function kettleOk() {
|
||||
const src = kettleSource();
|
||||
return !src || !!(WZ.kettle && WZ.kettle.sig === kettleSig(src) && WZ.kettle.ok);
|
||||
}
|
||||
async function readMeter(src) {
|
||||
let total = 0;
|
||||
for (const [key, sign] of src.read) {
|
||||
if (!V[key]) return null;
|
||||
const r = await fetch("ha/entities?entity=" + encodeURIComponent(V[key]), { cache: "no-store" })
|
||||
.then((x) => (x.ok ? x.json() : null)).catch(() => null);
|
||||
if (!r || r.watts == null) return null;
|
||||
total += sign * r.watts;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
function kettleBox() {
|
||||
const src = kettleSource();
|
||||
const box = h("div", { class: "f kettle" }, h("div", { class: "lbl" }, WS.kettle.name));
|
||||
if (!src) { box.append(h("div", { class: "help" }, WS.kettle.self.description)); return box; }
|
||||
const sig = kettleSig(src);
|
||||
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));
|
||||
// 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; };
|
||||
const read = h("button", { type: "button", class: "btn" }, k.before != null && !k.ok ? WS.kettle.again : WS.kettle.read);
|
||||
read.addEventListener("click", async () => {
|
||||
busy(read);
|
||||
const sig = kettleSig(src); // the entity may have changed since drawing
|
||||
if (k.sig !== sig) { k.before = null; k.ok = false; k.sig = sig; }
|
||||
const w = await readMeter(src);
|
||||
if (w == null) WZ.kettle = Object.assign({}, k, { sig, say: "unread" });
|
||||
else if (k.before == null || k.ok) WZ.kettle = { sig, before: w, wrong: !k.ok && !!k.wrong };
|
||||
else {
|
||||
const o = kettleOutcome(src, k.before, w);
|
||||
// `wrong`: the reading went the way no option can fix. A skip after
|
||||
// that keeps saying so on the last step (review R2).
|
||||
const wrong = o.say === "none" ? !!k.wrong : o.sets == null;
|
||||
WZ.kettle = { sig, before: o.say === "none" ? k.before : null, say: o.say, d: o.d, ok: o.sets != null, wrong };
|
||||
if (o.sets) { Object.assign(V, o.sets); queueValidate(); }
|
||||
}
|
||||
drawStep();
|
||||
});
|
||||
const row = h("div", { class: "row" }, read);
|
||||
if (!k.ok) row.append(h("button", { type: "button", class: "btn link", onclick: () => {
|
||||
WZ.kettle = { sig: kettleSig(src), say: "skipped", ok: true, skipped: true, wrong: !!k.wrong }; drawStep();
|
||||
} }, WS.kettle.skip));
|
||||
box.append(row);
|
||||
return box;
|
||||
}
|
||||
|
||||
// Step 6: the shadow switch, the checks, and Save and restart.
|
||||
// The shadow switch goes only on the model the wizard posts, never into V,
|
||||
// which the Installer view saves too (review R3).
|
||||
function shadowValue() {
|
||||
const s = M.setup.shadow;
|
||||
return WZ.shadowOn ? s.on : (ORIG[s.field] !== s.on ? ORIG[s.field] : s.off);
|
||||
}
|
||||
function posted() { return Object.assign({}, V, { [M.setup.shadow.field]: shadowValue() }); }
|
||||
function shadowRow() {
|
||||
const lid = "wzs";
|
||||
const b = h("button", { type: "button", class: "switch", role: "switch", "aria-checked": String(WZ.shadowOn), "aria-labelledby": lid });
|
||||
b.addEventListener("click", () => { WZ.shadowOn = !WZ.shadowOn; drawStep(); });
|
||||
return h("div", { class: "f inline" },
|
||||
h("div", {}, h("div", { class: "lbl", id: lid }, WS.shadow.name), h("div", { class: "help" }, WS.shadow.description)),
|
||||
h("div", { class: "ctl" }, b));
|
||||
}
|
||||
async function runChecks() {
|
||||
const list = $("#wz-checks");
|
||||
if (list) list.replaceChildren(line("info", WS.check.running.description));
|
||||
clearTimeout(vtimer);
|
||||
const b = await validate({ setup: true, model: posted() });
|
||||
WZ.checked = b;
|
||||
if (!list || !list.isConnected) return b;
|
||||
if (!b) { list.replaceChildren(line("bad", WS.check.failed.description)); return b; }
|
||||
const items = [], seen = new Set();
|
||||
const add = (tier, text) => { if (!seen.has(text)) { seen.add(text); items.push([tier, text]); } };
|
||||
const bad = (b.messages || []).filter((m) => m.tier === "bad");
|
||||
if (!bad.length) add("ok", WS.check.pass.description);
|
||||
for (const m of bad) add("bad", m.text);
|
||||
for (const m of b.setup || []) add(m.tier, m.text);
|
||||
if (b.device_note) add("info", b.device_note);
|
||||
const src = kettleSource();
|
||||
if (!src) add("info", WS.kettle.self.description);
|
||||
else if (WZ.kettle && WZ.kettle.skipped) add("warn", (WZ.kettle.wrong ? WS.kettle.skipped_wrong : WS.kettle.skipped).description);
|
||||
else if (kettleOk()) add("ok", WS.kettle.tested.description);
|
||||
const rank = { bad: 0, warn: 1, ok: 2, info: 3 };
|
||||
items.sort((a, z) => rank[a[0]] - rank[z[0]]);
|
||||
list.replaceChildren(...items.map(([t, x]) => line(t, x)));
|
||||
WZ.blocked = items.some(([t]) => t === "bad");
|
||||
return b;
|
||||
}
|
||||
function finalBox() {
|
||||
const again = h("button", { type: "button", class: "btn", onclick: runChecks }, WS.check.again);
|
||||
setTimeout(runChecks, 0);
|
||||
return [h("div", { class: "subhead" }, WS.check.name),
|
||||
h("ul", { class: "plain checklist small", id: "wz-checks", "aria-live": "polite" }), h("div", { class: "row" }, again),
|
||||
shadowRow()];
|
||||
}
|
||||
function wzBanner(level, title, sub, button, onClick) {
|
||||
const host = $("#wz-banner");
|
||||
if (!title) { host.replaceChildren(); return; }
|
||||
host.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn") },
|
||||
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
|
||||
h("span", {}, title, h("small", {}, sub, button ? [" ", h("button", { type: "button", class: "btn link", onclick: onClick }, button)] : null))));
|
||||
}
|
||||
async function finish() {
|
||||
const b = await runChecks();
|
||||
if (!b) { toast(WS.check.failed.description); return; }
|
||||
if (WZ.blocked) { toast(WS.check.blocked.description); return; }
|
||||
const shadow = shadowValue() === M.setup.shadow.on;
|
||||
// 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; }
|
||||
const { r, b: body } = res;
|
||||
if (r.status === 409 && body.kind === "stale") { wzBanner("bad", W.stale.name, staleText(body)); return; }
|
||||
if (r.status === 422) {
|
||||
MSG = body.messages || []; STEPS = body.steps || {};
|
||||
const first = Object.keys(STEPS).map(Number).sort((a, z) => a - z).find((n) => STEPS[n] > 0);
|
||||
if (first) WZ.step = first;
|
||||
drawStep(); toast(W.toast.blocked.description); firstBad();
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { toast(W.toast.failed); return; }
|
||||
let restarted = true;
|
||||
try {
|
||||
const rr = await fetch("config/restart", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "restart" }) });
|
||||
restarted = rr.ok;
|
||||
} catch (e) { /* the add-on stops answering while it restarts */ }
|
||||
WZ.done = restarted ? (shadow ? "done" : "done_live") : "restart_failed";
|
||||
drawStep();
|
||||
if (!restarted) await load("saved"); // still running: read what is stored
|
||||
});
|
||||
}
|
||||
function drawDone() {
|
||||
const w = WS[WZ.done];
|
||||
$("#wz-count").textContent = "";
|
||||
$("#wz-steps").replaceChildren();
|
||||
$("#wz-desc").textContent = "";
|
||||
$("#wz-nav").hidden = true;
|
||||
ROWS.length = 0;
|
||||
if (WZ.done === "restart_failed") wzBanner("warn", w.name, w.description, W.restart, restart);
|
||||
else wzBanner("ok", w.name, w.description);
|
||||
$("#wz-body").replaceChildren(h("a", { class: "btn primary", href: "#home" }, WS.home));
|
||||
}
|
||||
function drawStep() {
|
||||
if (!M) return;
|
||||
if (WZ.done) { drawDone(); return; }
|
||||
const S = M.setup, n = WZ.step, words = WS.steps[n];
|
||||
$("#wz-nav").hidden = false;
|
||||
$("#wz-title").textContent = words.name;
|
||||
$("#wz-count").textContent = say(WS.step, { n, total: S.steps });
|
||||
$("#wz-steps").replaceChildren(...Array.from({ length: S.steps }, (_x, i) =>
|
||||
h("i", { class: i + 1 < n ? "done" : i + 1 === n ? "cur" : null })));
|
||||
$("#wz-desc").textContent = words.description;
|
||||
ROWS.length = 0;
|
||||
const body = [];
|
||||
if (n === 1 && !M.setup_needed) body.push(h("div", { class: "msg info" }, WS.done_before.description));
|
||||
for (const f of allFields()) if (f.wizard === n && f.key !== S.shadow.field) body.push(fieldRow(f, null, "wz"));
|
||||
if (n === 1) body.push(kettleBox());
|
||||
if (n === 5) body.push(h("p", { class: "small muted" }, WS.modes_later.description));
|
||||
if (n === S.final) body.push(...finalBox());
|
||||
$("#wz-body").replaceChildren(...body);
|
||||
$("#wz-back").textContent = n === 1 ? WS.not_now : WS.back;
|
||||
$("#wz-next").textContent = n === S.final ? WS.finish : S.optional.includes(n) ? WS.skip : WS.next;
|
||||
paint();
|
||||
}
|
||||
$("#wz-back").addEventListener("click", () => {
|
||||
if (WZ.step === 1) { location.hash = "#home"; return; }
|
||||
WZ.step -= 1; drawStep(); window.scrollTo(0, 0);
|
||||
});
|
||||
$("#wz-next").addEventListener("click", async () => {
|
||||
if (!M) return;
|
||||
const btn = $("#wz-next");
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const n = WZ.step;
|
||||
if (n === M.setup.final) { await finish(); return; }
|
||||
clearTimeout(vtimer);
|
||||
const b = await validate();
|
||||
if (!b) { toast(W.toast.failed); return; }
|
||||
if (n === 1 && !kettleOk()) { toast(WS.kettle.needed.description); return; }
|
||||
if ((STEPS[n] || 0) > 0) { toast(W.toast.blocked.description); firstBad(); return; }
|
||||
WZ.step = n + 1; drawStep(); window.scrollTo(0, 0);
|
||||
} finally { btn.disabled = false; }
|
||||
});
|
||||
|
||||
document.addEventListener("ux13:tab", (e) => {
|
||||
view = e.detail === "settings" || e.detail === "setup" ? e.detail : null;
|
||||
if (view && !M) load(); else if (view) { redrawView(); validate(); } else savebar();
|
||||
});
|
||||
// app.js chose the first tab before this file ran.
|
||||
const sel = document.querySelector(".tab[aria-selected=true]");
|
||||
if (sel && (sel.dataset.hash === "settings" || sel.dataset.hash === "setup")) { view = sel.dataset.hash; load(); }
|
||||
// First run: opened with no route, on a site the add-on says is new
|
||||
// (uipage.setup_needed), the page goes to Setup by itself.
|
||||
else if (window.UX13.first) {
|
||||
load().then(() => { if (M && M.setup_needed && location.hash === "#home") location.hash = "#setup"; });
|
||||
}
|
||||
})();
|
||||
@@ -204,6 +204,7 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
@@ -597,11 +598,9 @@ def parse_static_profile(value, *, option: str = "static_profile",
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise ProfileInvalid(
|
||||
f"{option} is not valid JSON ({e}). Write it on one line with "
|
||||
f"a \"periods\" list, for example\n"
|
||||
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
||||
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
||||
f"Leave it empty if you have no fixed tariff.")
|
||||
f"{messages.not_valid(option, e)}. Copy the example from the "
|
||||
f"documentation, or leave it empty if you have no fixed "
|
||||
f"tariff.")
|
||||
|
||||
if isinstance(value, dict):
|
||||
tz = str(value.get("timezone", "") or "").strip().lower()
|
||||
@@ -1120,18 +1119,39 @@ class TariffPlan:
|
||||
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
|
||||
surface said either.
|
||||
"""
|
||||
# UX-08: plain words, every figure in its own slot as before.
|
||||
if not self.has_prices:
|
||||
return f"no tariff - {self.read.reason}"
|
||||
tag = {"provider": self.read.provider, "static": "static_profile",
|
||||
"fallback": "static_profile (feed down)"}.get(self.origin,
|
||||
self.origin)
|
||||
return f"no prices, {self.read.reason}"
|
||||
tag = {"provider": "the price feed", "static": "your fixed tariff",
|
||||
"fallback": "your fixed tariff, because the feed is down"}.get(
|
||||
self.origin, self.origin)
|
||||
what = {PriceProduct.DAY_AHEAD: "day-ahead prices",
|
||||
PriceProduct.REALTIME: "real-time prices",
|
||||
PriceProduct.STATIC: "day/night prices",
|
||||
PriceProduct.FIXED: "a fixed price"}.get(self.product,
|
||||
str(self.product))
|
||||
# UX-10: the spacing in words, and no "not final yet": a plan is
|
||||
# provisional exactly when it holds an estimated price, which the
|
||||
# count already says.
|
||||
gaps = self.spacings_s
|
||||
return (f"{self.product.value} from {tag}, "
|
||||
f"{self.n_points} points over {self.hours} h, "
|
||||
f"{self.n_firm} firm / {self.n_carried} carried"
|
||||
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
|
||||
if gaps else "")
|
||||
+ (", provisional" if self.provisional else ""))
|
||||
return (f"{what} from {tag}, "
|
||||
f"{self.n_points} prices over {self.hours} hours, "
|
||||
f"{self.n_firm} published and {self.n_carried} estimated"
|
||||
+ (f", {spacing_words(gaps)}" if gaps else ""))
|
||||
|
||||
|
||||
def spacing_words(gaps) -> str:
|
||||
"""`(900, 3600)` -> `one every 15 minutes or every hour`."""
|
||||
def one(s):
|
||||
s = int(s)
|
||||
if s == 3600:
|
||||
return "every hour"
|
||||
if s % 3600 == 0:
|
||||
return f"every {s // 3600} hours"
|
||||
if s % 60 == 0:
|
||||
return f"every {s // 60} minutes"
|
||||
return f"every {s} seconds"
|
||||
return "one " + " or ".join(one(g) for g in gaps)
|
||||
|
||||
|
||||
def _whole_hours(points) -> int:
|
||||
@@ -1201,9 +1221,9 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
|
||||
if profile is None:
|
||||
return TariffPlan(
|
||||
PriceRead.no_data(
|
||||
"no static_profile configured; §11.4: plan provisional, no "
|
||||
"arbitrage. Self-consumption, peak shaving and reserve "
|
||||
"management are unaffected"),
|
||||
"no fixed tariff is set, so the plan is estimated and there "
|
||||
"is no trading. Self-consumption, peak limiting and the "
|
||||
"reserve are not affected"),
|
||||
None, "none")
|
||||
points = profile.series(since, until, provisional=provisional)
|
||||
return TariffPlan(
|
||||
@@ -1225,21 +1245,22 @@ def static_status(opts: dict, now: datetime):
|
||||
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
|
||||
now=now)
|
||||
except ProfileInvalid as e:
|
||||
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
|
||||
"should have been refused at startup - see validate.py", e)
|
||||
return False, f"static_profile unusable: {e}"
|
||||
_LOG.warning("static_profile cannot be used (%s), so there is no "
|
||||
"cheap window. The start-up check should have refused "
|
||||
"it.", e)
|
||||
return False, f"Fixed tariff cannot be used: {e}"
|
||||
if not plan.has_prices:
|
||||
return False, "no static profile"
|
||||
return False, "No fixed tariff set"
|
||||
cheap = plan.cheap_now(now)
|
||||
window = plan.window_at(now)
|
||||
price = plan.price_at(now)
|
||||
when = plan.profile.next_change(now)
|
||||
until = "" if when is None else f" until {brussels(when):%H:%M}"
|
||||
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
|
||||
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
|
||||
f"mean {plan.mean_eur_kwh():.4f} over "
|
||||
f"{MEAN_WINDOW_DAYS} d - "
|
||||
f"{'CHEAP window' if cheap else 'not a cheap window'}")
|
||||
kind = ("Fixed" if plan.product is PriceProduct.FIXED else "Day/night")
|
||||
return cheap, (f"{kind} tariff, {window}{until}: {price:.4f} EUR/kWh, "
|
||||
f"{'cheap' if cheap else 'not cheap'} against the "
|
||||
f"{MEAN_WINDOW_DAYS}-day average of "
|
||||
f"{plan.mean_eur_kwh():.4f}")
|
||||
|
||||
|
||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
@@ -1332,17 +1353,18 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
|
||||
try:
|
||||
profile = parse_static_profile(price_option(opts, "static_profile"))
|
||||
except ProfileInvalid as e:
|
||||
_LOG.warning("the price feed is down AND static_profile is unusable "
|
||||
"(%s), so there is no tariff at all", e)
|
||||
_LOG.warning("The price feed is down and the fixed tariff "
|
||||
"(static_profile) cannot be used either (%s), so there is "
|
||||
"no tariff at all.", e)
|
||||
profile = None
|
||||
if profile is None:
|
||||
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
|
||||
"back to. The plan is provisional and arbitrage is off; "
|
||||
"self-consumption, peak shaving and reserve management "
|
||||
"continue (§6, §10.1).", read.reason)
|
||||
_LOG.warning("The price feed failed and no fixed tariff is set, so "
|
||||
"trading is off, while peak limiting and home use go on. "
|
||||
"%s.", read.reason)
|
||||
return TariffPlan(read, None, "none")
|
||||
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
|
||||
"PROVISIONAL, arbitrage off.", read.reason)
|
||||
_LOG.warning("The price feed failed, so the fixed tariff is used "
|
||||
"instead, marked as estimated, and trading is off. %s.",
|
||||
read.reason)
|
||||
points = profile.series(since, until, provisional=True)
|
||||
return TariffPlan(
|
||||
PriceRead.series(points, profile.product, "static_profile", stamp),
|
||||
|
||||
@@ -222,6 +222,8 @@ class MigrationReport:
|
||||
self.misdirected = []
|
||||
# Why nothing ran, when nothing ran. Empty means "already at schema".
|
||||
self.skipped = ""
|
||||
# The inverter list the moves were graded against, for `explain()`.
|
||||
self.roster = []
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -237,19 +239,27 @@ class MigrationReport:
|
||||
# the flat keys ARE its namespace - and an operator reading the
|
||||
# startup log needs to be told which, because only one of them
|
||||
# changes when a second inverter is declared.
|
||||
return self.skipped or (f"store already at schema "
|
||||
f"{self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||
return self.skipped or (f"the saved state is already at version "
|
||||
f"{self.from_version}, so nothing moves")
|
||||
# UX-10: plain sentences (test_messages.py part 6). Each problem is
|
||||
# named by its key and what went wrong, and `problems`, `lost` and
|
||||
# `misdirected` keep their full notes for the checks that read them.
|
||||
n = len(self.moved)
|
||||
out = (f"moved {n} saved {'value' if n == 1 else 'values'} to one part "
|
||||
f"per inverter: " + ", ".join(sorted(self.moved)) if n
|
||||
else "moved nothing")
|
||||
if self.kept:
|
||||
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
|
||||
if self.problems:
|
||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||
if self.lost:
|
||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||
if self.misdirected:
|
||||
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
|
||||
out += ". Already in place: " + ", ".join(sorted(self.kept))
|
||||
def keys(notes):
|
||||
return ", ".join(note.split(" ", 1)[0] for note in notes)
|
||||
other = [n for n in self.problems
|
||||
if n not in self.lost and n not in self.misdirected]
|
||||
parts = ([f"lost {keys(self.lost)}"] if self.lost else []) + (
|
||||
[f"moved under an inverter not in the list {self.roster!r}: "
|
||||
f"{keys(self.misdirected)}"] if self.misdirected else []) + (
|
||||
[f"read back differently {keys(other)}"] if other else [])
|
||||
if parts:
|
||||
out += ". Problems: " + ", and ".join(parts)
|
||||
return out
|
||||
|
||||
|
||||
@@ -328,8 +338,8 @@ def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
|
||||
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
|
||||
# next start rather than be recorded as finished - the one thing worse
|
||||
# than a failed migration is a failed migration a later version skips.
|
||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
||||
"marked done: %s", rep.explain())
|
||||
_LOG.error("Moving the saved state to one part per inverter lost "
|
||||
"data, so it is not marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
@@ -436,6 +446,7 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
if roster is None:
|
||||
return
|
||||
names = [str(n) for n in roster]
|
||||
rep.roster = names
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
|
||||
# through the same spelling the writer used rather than re-deriving it.
|
||||
#
|
||||
@@ -469,8 +480,8 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
if unit in names:
|
||||
continue
|
||||
rep.verified = False
|
||||
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
|
||||
f"on the roster {names!r} - nothing reads that key")
|
||||
note = (f"{target} was moved under {unit!r}, which is not in the "
|
||||
f"inverter list {names!r}, so nothing reads it")
|
||||
rep.problems.append(note)
|
||||
rep.misdirected.append(note)
|
||||
|
||||
@@ -536,14 +547,12 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
||||
names = [str(n) for n in roster]
|
||||
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
|
||||
if len(names) <= 1:
|
||||
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
|
||||
"history and are read in place, so there is nothing to "
|
||||
"move and the store layout does not change")
|
||||
rep.skipped = ("one inverter, so it is read where it is and nothing "
|
||||
"moves")
|
||||
return rep
|
||||
if rep.from_version >= MAINT_SCHEMA_VERSION:
|
||||
rep.skipped = (f"maintenance history already at schema "
|
||||
f"{rep.from_version}; written by a multi-inverter "
|
||||
f"version and not touched")
|
||||
rep.skipped = (f"already at version {rep.from_version}, written for "
|
||||
f"several inverters, so it is left as it is")
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
@@ -567,15 +576,21 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
||||
# the re-run a no-op where the keys did land, so refusing the marker
|
||||
# costs one repeated pass and buys the difference between a migration
|
||||
# that can still be fixed and one the store says already happened.
|
||||
_LOG.error("per-pack maintenance-history migration did NOT land and "
|
||||
"has not been marked done: %s", rep.explain())
|
||||
_LOG.error("Moving the maintenance history to one part per inverter "
|
||||
"did not work, so it is not marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
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).
|
||||
self.fresh = False
|
||||
self.load()
|
||||
|
||||
def load(self) -> None:
|
||||
@@ -589,6 +604,7 @@ class Store:
|
||||
self.data.get("phase"), self.data.get("last_completed"),
|
||||
self.data.get("sign_tripped"))
|
||||
except FileNotFoundError:
|
||||
self.fresh = True
|
||||
_LOG.info("no saved state, starting fresh")
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
# A corrupt state file must not stop the controller: losing the
|
||||
|
||||
@@ -114,6 +114,8 @@ import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
|
||||
from . import messages
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff")
|
||||
|
||||
QUARTER_S = 900
|
||||
@@ -339,8 +341,8 @@ class PeakStore:
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong. A store that OPENED and then broke is
|
||||
# the error case, and it is logged as one below.
|
||||
_LOG.info("no peak store: %s - the monthly peak will read "
|
||||
"NOT_OBSERVED", self.last_error)
|
||||
_LOG.info("No peak store (%s), so the month peak cannot be read "
|
||||
"and only the peak limit is used.", self.last_error)
|
||||
return
|
||||
try:
|
||||
self._db = sqlite3.connect(self.path, timeout=5.0,
|
||||
@@ -360,8 +362,9 @@ class PeakStore:
|
||||
# fault must not stop a controller that is regulating a battery. The
|
||||
# cost of continuing is a peak that reads NOT_OBSERVED, and every
|
||||
# reader is built to treat that as "do not use me", not as 0 W.
|
||||
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
|
||||
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
|
||||
_LOG.error("The peak store at %s cannot be used (%s), so only the "
|
||||
"peak limit is used. The month peak is unknown, not "
|
||||
"zero.", self.path, err)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._db is not None:
|
||||
@@ -672,12 +675,12 @@ class PeakTracker:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
snap = self.store.load_partial()
|
||||
if not snap:
|
||||
self.restore_state = ("fresh: no checkpoint" if self.store.ok
|
||||
else "fresh: store unreadable")
|
||||
self.restore_state = ("fresh start, nothing saved" if self.store.ok
|
||||
else "fresh start, the store cannot be read")
|
||||
return self.restore_state
|
||||
block = snap.get("block")
|
||||
if block is None:
|
||||
self.restore_state = "fresh: checkpoint had no open block"
|
||||
self.restore_state = "fresh start, no open 15-minute period saved"
|
||||
return self.restore_state
|
||||
|
||||
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
|
||||
@@ -686,11 +689,13 @@ class PeakTracker:
|
||||
# A reshaped accumulator (meter_phases changed) is a different
|
||||
# measurement, not a resumable one. Say so; do not half-resume.
|
||||
self.store.clear_partial()
|
||||
self.restore_state = "fresh: checkpoint does not fit this averager"
|
||||
self.restore_state = ("fresh start, the saved period does not "
|
||||
"fit the number of phases")
|
||||
return self.restore_state
|
||||
self.restore_state = (
|
||||
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
|
||||
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
|
||||
f"resumed the open 15-minute period at "
|
||||
f"{snap.get('elapsed', 0.0):.0f} s of {QUARTER_S} s, with "
|
||||
f"{snap.get('acc', 0.0) / QUARTER_S:.0f} W counted so far")
|
||||
return self.restore_state
|
||||
|
||||
# The block ended while the add-on was down. Its measured part really
|
||||
@@ -704,10 +709,14 @@ class PeakTracker:
|
||||
if snap.get("pp_acc") and snap.get("last_pp") else None))
|
||||
banked = self.store.record_block(closed, self.policy)
|
||||
self.store.clear_partial()
|
||||
# UX-10: plain words and the local time, not ISO and "banked".
|
||||
self.restore_state = (
|
||||
f"closed a stale block from {closed.start.isoformat()} at "
|
||||
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
|
||||
+ ("banked" if banked else "NOT banked - store unwritable"))
|
||||
f"closed the 15-minute period from "
|
||||
f"{messages.when(closed.start.astimezone(BILLING_TZ))} at "
|
||||
f"{closed.offtake_avg_w:.0f} W, partly measured, "
|
||||
+ ("and saved it" if banked
|
||||
else "but could not save it, because the store cannot be "
|
||||
"written"))
|
||||
return self.restore_state
|
||||
|
||||
# -- steady state -------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
{
|
||||
"groups": {
|
||||
"meter": {"name": "Meter", "description": "Where the grid reading comes from."},
|
||||
"inverters": {"name": "Inverters and batteries", "description": "The inverters the add-on controls, and your solar."},
|
||||
"tariff": {"name": "Tariff and prices", "description": "What you pay and are paid per kWh."},
|
||||
"safety": {"name": "Safety limits", "description": "The peak limit, starting and stopping, and the safety stops."},
|
||||
"maintenance": {"name": "Maintenance", "description": "The monthly battery cycle and the log."},
|
||||
"tuning": {"name": "Control tuning", "description": "For support only, because these were measured on real hardware."}
|
||||
},
|
||||
"parts": {
|
||||
"meter.timing": {"name": "Timing"},
|
||||
"inverters.solar": {"name": "Solar"},
|
||||
"inverters.modes": {"name": "Battery modes"},
|
||||
"tariff.selling": {"name": "Charging and selling on price"},
|
||||
"tariff.feed": {"name": "Price feed details"},
|
||||
"safety.peak": {"name": "Peak limit", "description": "The add-on aims a little below your limit, to be safe."},
|
||||
"safety.start": {"name": "Starting and stopping"},
|
||||
"safety.stops": {"name": "Safety stops", "description": "Measured on real hardware, so leave them unchanged."},
|
||||
"maintenance.support": {"name": "Support"}
|
||||
},
|
||||
"fields": {
|
||||
"solar": {"name": "Solar inverters", "description": "Each solar inverter's power sensor, which the add-on records but does not use for control."},
|
||||
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
|
||||
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
|
||||
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
|
||||
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
|
||||
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
|
||||
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
|
||||
"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"},
|
||||
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
|
||||
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
|
||||
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
|
||||
"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"},
|
||||
"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"},
|
||||
"now": {"name": "Applies right away"}
|
||||
},
|
||||
"home": {
|
||||
"app": {"name": "Battery add-on"},
|
||||
"tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
|
||||
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
|
||||
"plan": {
|
||||
"name": "Today's plan",
|
||||
"description": "Point at a bar or a block for details, or tap it on a phone.",
|
||||
"cheap": "Low price",
|
||||
"dear": "High price",
|
||||
"estimate": "Estimate",
|
||||
"grid": "Charges from the grid",
|
||||
"give": "Gives power",
|
||||
"cycle": "Monthly battery cycle",
|
||||
"peak": "Peak limit",
|
||||
"house": "Follows the house",
|
||||
"now": "now",
|
||||
"time_to": "to",
|
||||
"per_kwh": "per kWh",
|
||||
"cheap_tip": "low for today",
|
||||
"dear_tip": "high for today",
|
||||
"estimate_tip": "estimate from yesterday",
|
||||
"none_tip": "No price known",
|
||||
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
|
||||
"list": "The plan in words",
|
||||
"average_tip": "hour average"
|
||||
},
|
||||
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
|
||||
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
|
||||
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
|
||||
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
|
||||
"dialog": {
|
||||
"cancel": "Cancel",
|
||||
"stop": {"name": "Stop control?", "description": "Every inverter is set to 0 W until you start control again.", "yes": "Stop control"},
|
||||
"start": {"name": "Start control?", "description": "The add-on controls the batteries again and clears any safety stop.", "yes": "Start control"},
|
||||
"maint_start": {"name": "Start a battery cycle?", "description": "The battery empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"},
|
||||
"maint_abort": {"name": "Stop the battery cycle?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}
|
||||
},
|
||||
"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."},
|
||||
"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",
|
||||
"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": "Gets solar the others cannot take, and gives power in expensive hours."},
|
||||
"cheap_hours": {"name": "Cheap hours only", "description": "Charges in the cheapest hours and gives power in the expensive ones."},
|
||||
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
|
||||
},
|
||||
"today": "Today", "why": "Why",
|
||||
"modes_off": {"name": "Battery modes are off"},
|
||||
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
|
||||
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
|
||||
"leftover_solar": {"description": "Leftover solar gets the solar the Solar first batteries cannot take, and saves its power for expensive hours."},
|
||||
"cheap_hours": {"description": "Cheap hours only charges in the cheapest hours and gives power in the expensive ones."},
|
||||
"off": {"description": "Off rests the battery and starts no monthly cycle, but the peak limit may still use it."},
|
||||
"rules": {"description": "The peak limit and the safety stops come first, and while one battery charges from the grid the others rest."}},
|
||||
"toast": {"on": "{name} is on.", "off": "{name} is off.", "mode": "{name}: {mode}."}},
|
||||
"settings": {"name": "Settings", "description": "The settings are grouped by task below.", "open": "Open the settings page"}
|
||||
},
|
||||
"choices_later": {
|
||||
"region": {"wallonia": "Wallonia, no preset yet", "brussels": "Brussels, no preset yet"}
|
||||
},
|
||||
"choice_help": {
|
||||
"meter_source": {
|
||||
"off": {"description": "A Home Assistant entity that reads grid power in W, positive when you take from the grid."},
|
||||
"ha_dsmr": {"description": "Two Home Assistant entities from the DSMR integration, one for import and one for export."},
|
||||
"mqtt_p1": {"description": "The meter's readings arrive over MQTT on a topic you name."},
|
||||
"ha_signed": {"description": "One Home Assistant entity that reads net grid power, positive for import."},
|
||||
"homewizard_local": {"description": "The add-on reads a HomeWizard meter on your network, and can spot a stuck meter."}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"name": "Settings",
|
||||
"description": "Every setting, grouped by task.",
|
||||
"view": {"name": "Settings view", "simple": "Simple", "installer": "Installer"},
|
||||
"simple": {"description": "The choices a home owner makes.",
|
||||
"note": {"month_peak": {"description": "This month's peak so far: {kw}."}, "shadow": {"description": "Use Always on while you test a new installation."}},
|
||||
"batteries": {"name": "Batteries"},
|
||||
"more": {"description": "Looking for the meter, inverters or prices?"},
|
||||
"installer": "Open the installer view"},
|
||||
"classic": "Classic settings",
|
||||
"search": {"name": "Find a setting", "description": "Find a setting, for example peak or meter."},
|
||||
"no_match": {"description": "No setting matches your search."},
|
||||
"loading": "Loading the settings",
|
||||
"load_failed": {"name": "Cannot read the settings", "description": "The add-on could not read its stored settings, so use Classic settings or the Configuration tab."},
|
||||
"footer": {"description": "Every change is checked by the add-on's own start checks before it is saved."},
|
||||
"to_fix": "to fix",
|
||||
"to_check": "to check",
|
||||
"changed": "changed",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"not_set": "Not set",
|
||||
"locked": "Locked",
|
||||
"change": "Change",
|
||||
"support": {"description": "These were measured on real hardware, so change them only when support asks."},
|
||||
"picker": {
|
||||
"choose": "Choose an entity",
|
||||
"search": {"description": "Search by name or entity id."},
|
||||
"all": "Show every entity",
|
||||
"use": "Use this entity id",
|
||||
"clear": "Clear",
|
||||
"missing": "Not in Home Assistant",
|
||||
"none": {"description": "Nothing matches, so check the entity in Developer Tools."}
|
||||
},
|
||||
"add_inverter": "Add an inverter",
|
||||
"inverter": "Inverter",
|
||||
"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."},
|
||||
"region_year": "{year} figures",
|
||||
"hour": "{hour}:00 UTC",
|
||||
"preset": {"description": "Filled in by the region above, so choose your own figure to change it."},
|
||||
"cycle": {"name": "Run a cycle by hand", "description": "The same actions as on Home, each asking first."},
|
||||
"savebar": {"label": "Unsaved changes", "one": "change not saved", "many": "changes not saved", "discard": "Discard", "save": "Save", "saving": "Saving"},
|
||||
"saved": {"name": "Saved, not running yet", "description": "The add-on runs the settings it started with until you restart it."},
|
||||
"differs": {"name": "Stored settings are not running", "description": "Some stored settings differ from what runs now, so restart the add-on to apply them."},
|
||||
"stale": {"name": "Changed somewhere else", "description": "These settings changed elsewhere while you edited, so save again to keep yours or discard to load theirs."},
|
||||
"other": "Other messages",
|
||||
"restart": "Restart now",
|
||||
"dialog": {"name": "Restart the add-on?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Restart now"},
|
||||
"toast": {
|
||||
"saved": "Saved. Restart to apply.",
|
||||
"discarded": "Changes discarded.",
|
||||
"failed": "That did not work. Try again.",
|
||||
"blocked": {"description": "Fix the settings marked in red, then save again."},
|
||||
"restarting": {"description": "Restart requested, so this page stops answering for a moment."}
|
||||
},
|
||||
"tier": {"bad": "Must fix", "warn": "Check this", "info": "Note", "ok": "Passed"},
|
||||
"derive": {
|
||||
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
|
||||
"no_inverter": {"description": "Add at least one inverter."},
|
||||
"flat_named": {"description": "Add a second inverter before naming this one."},
|
||||
"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": {
|
||||
"name": "Setup",
|
||||
"description": "For installers: six steps to a working site, and each can be changed later under Settings.",
|
||||
"done_before": {"description": "This site is set up already, so a change here replaces its stored settings."},
|
||||
"step": "Step {n} of {total}",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"skip": "Next, or skip",
|
||||
"finish": "Save and restart",
|
||||
"not_now": "Not now",
|
||||
"steps": {
|
||||
"1": {"name": "Your meter", "description": "The add-on steers the grid reading to zero, so it needs your meter first."},
|
||||
"2": {"name": "Inverters and batteries", "description": "One card for each inverter with a battery, with its entities in Home Assistant."},
|
||||
"3": {"name": "Solar", "description": "Optional, so skip this step if the add-on should not record solar production."},
|
||||
"4": {"name": "Prices", "description": "What you pay per kWh, so the batteries can charge when power is cheap."},
|
||||
"5": {"name": "Peak limit", "description": "The grid stays under this limit, averaged over each 15-minute period."},
|
||||
"6": {"name": "Check and start", "description": "The add-on checks everything with its own start checks, then restarts."}
|
||||
},
|
||||
"modes_later": {"description": "After setup, turn on battery modes to choose a mode for each battery on the Batteries screen."},
|
||||
"kettle": {
|
||||
"name": "Check the direction",
|
||||
"description": "Read the meter, switch on a kettle, then read the meter again.",
|
||||
"read": "Read the meter",
|
||||
"again": "Read again",
|
||||
"skip": "Skip this test",
|
||||
"before": {"description": "The meter reads {w} now, so switch on a kettle and press Read again."},
|
||||
"unread": {"description": "The meter cannot be read now, so check the entity and try again."},
|
||||
"none": {"description": "The reading moved only {d}, so switch the kettle on and read again."},
|
||||
"ok": {"description": "Good, the reading went up by {d} when the kettle came on."},
|
||||
"flip": {"description": "The reading went down by {d}, so the add-on flips its sign for you."},
|
||||
"wrong_signed": {"description": "The reading went down by {d}, so choose “One grid power entity” above, which can flip it."},
|
||||
"wrong_dsmr": {"description": "The reading went down by {d}, so check that import and export are not swapped."},
|
||||
"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."}
|
||||
},
|
||||
"check": {
|
||||
"name": "What the add-on checked",
|
||||
"running": {"description": "Checking with the add-on's own start checks."},
|
||||
"pass": {"description": "The add-on's start checks pass."},
|
||||
"failed": {"description": "The check did not run, so try again."},
|
||||
"again": "Check again",
|
||||
"blocked": {"description": "Fix the items marked in red, then check again."}
|
||||
},
|
||||
"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", "cheap_hours": "Cheap hours only", "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.",
|
||||
"off_resting": "Off. Resting now.",
|
||||
"off_peak": "Off, but gives {kw} for the peak limit.",
|
||||
"off_going": "Off. Still at {kw}, going to 0 W."
|
||||
},
|
||||
"why": {
|
||||
"no_reading": "No reading, so the add-on cannot tell.",
|
||||
"held": "An alarm stopped control. See Needs attention.",
|
||||
"stopped": "Control is stopped.",
|
||||
"shadow": "Shadow mode: the add-on only shows what it would do.",
|
||||
"cycle": "The monthly cycle keeps its level reading right.",
|
||||
"cycle_mode": "The monthly cycle comes first. Its mode resumes after.",
|
||||
"off": "You switched it off.",
|
||||
"off_peak": "The peak limit comes before Off. It rests again after.",
|
||||
"refuses_give": "The battery refuses to give power. The add-on tries again every 15 minutes.",
|
||||
"refuses_charge": "The battery refuses to charge. The add-on tries again every 15 minutes.",
|
||||
"peak": "The peak limit needs power from the batteries.",
|
||||
"modes_off": "Battery modes are off, so it follows the house.",
|
||||
"solar_first": "Solar first: it gets spare solar before the others.",
|
||||
"leftover": "It gets the solar that Solar first batteries cannot take.",
|
||||
"leftover_alone": "No Solar first battery is on, so it gets solar first.",
|
||||
"covers_house": "It gives power to cover the house.",
|
||||
"other_refuses": "{name} refuses to give power, so this battery takes over.",
|
||||
"other_cycle": "{name} is emptying for its monthly cycle. This one does not charge.",
|
||||
"grid_charge": "Prices are low, so it charges from the grid.",
|
||||
"rests_charge": "Another battery charges from the grid, so it rests.",
|
||||
"selling": "Prices are high, so it sells to the grid.",
|
||||
"expensive": "Prices are high, so it covers the house.",
|
||||
"saves": "Saves its power for {at}.",
|
||||
"saves_later": "Saves its power for higher prices.",
|
||||
"no_prices": "No prices, so it follows the house.",
|
||||
"no_spread": "Prices differ too little, so it follows the house."
|
||||
},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
"off": "Off, so no monthly cycle today.",
|
||||
"house": "Follows the house.",
|
||||
"house_would": "Would follow the house.",
|
||||
"stopped": "Nothing planned while control is stopped.",
|
||||
"held": "Nothing planned until the alarm clears."
|
||||
},
|
||||
"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.",
|
||||
"sell_what": "Sells up to {kwh}, to the house first.",
|
||||
"sell_what_would": "Would sell up to {kwh}, to the house first.",
|
||||
"cheap_why": "Cheapest hours, about {eur} per kWh.",
|
||||
"dear_why": "Expensive hours, about {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,374 @@
|
||||
{
|
||||
"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", "description": "De add-on mikt iets onder uw grens, voor de zekerheid."},
|
||||
"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",
|
||||
"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": "Krijgt zonne-energie die de andere niet opnemen, en levert in dure uren."},
|
||||
"cheap_hours": {"name": "Alleen goedkope uren", "description": "Laadt in de goedkoopste uren en levert in de dure uren."},
|
||||
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
|
||||
},
|
||||
"today": "Vandaag", "why": "Waarom",
|
||||
"modes_off": {"name": "Batterijmodi staan uit"},
|
||||
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
|
||||
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
|
||||
"leftover_solar": {"description": "Overschot zonne-energie krijgt wat de batterijen met Eerst zonne-energie niet opnemen, en spaart haar stroom voor dure uren."},
|
||||
"cheap_hours": {"description": "Alleen goedkope uren laadt in de goedkoopste uren en levert in de dure uren."},
|
||||
"off": {"description": "Uit laat de batterij rusten en start geen maandelijkse cyclus, maar de piekgrens mag haar nog gebruiken."},
|
||||
"rules": {"description": "De piekgrens en de veiligheidsstops gaan voor, en terwijl een batterij van het net laadt rusten de andere."}},
|
||||
"toast": {"on": "{name} staat aan.", "off": "{name} staat uit.", "mode": "{name}: {mode}."}},
|
||||
"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.",
|
||||
"view": {"name": "Weergave van de instellingen", "simple": "Eenvoudig", "installer": "Installateur"},
|
||||
"simple": {"description": "De keuzes die een eigenaar maakt.",
|
||||
"note": {"month_peak": {"description": "De piek van deze maand tot nu: {kw}."}, "shadow": {"description": "Kies Altijd aan terwijl u een nieuwe installatie test."}},
|
||||
"batteries": {"name": "Batterijen"},
|
||||
"more": {"description": "Zoekt u de meter, de omvormers of de prijzen?"},
|
||||
"installer": "De installateursweergave openen"},
|
||||
"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": "Zet na de installatie batterijmodi aan om per batterij een modus te kiezen op het scherm Batterijen."},
|
||||
"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."}},
|
||||
"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", "cheap_hours": "Alleen goedkope uren", "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.",
|
||||
"off_resting": "Uit. Rust nu.",
|
||||
"off_peak": "Uit, maar levert {kw} voor de piekgrens.",
|
||||
"off_going": "Uit. Nog {kw}, gaat naar 0 W."
|
||||
},
|
||||
"why": {
|
||||
"no_reading": "Geen meting, dus de add-on weet het niet.",
|
||||
"held": "Een alarm stopte de regeling. Zie Vraagt aandacht.",
|
||||
"stopped": "De regeling is gestopt.",
|
||||
"shadow": "Schaduwmodus: de add-on toont alleen wat hij zou doen.",
|
||||
"cycle": "De maandelijkse cyclus houdt de meting van het laadniveau juist.",
|
||||
"cycle_mode": "De maandelijkse cyclus gaat voor. Daarna volgt ze weer haar modus.",
|
||||
"off": "U hebt haar uitgezet.",
|
||||
"off_peak": "De piekgrens gaat voor op Uit. Daarna rust ze weer.",
|
||||
"refuses_give": "De batterij weigert te leveren. De add-on probeert het elke 15 minuten opnieuw.",
|
||||
"refuses_charge": "De batterij weigert te laden. De add-on probeert het elke 15 minuten opnieuw.",
|
||||
"peak": "De piekgrens vraagt vermogen van de batterijen.",
|
||||
"modes_off": "Batterijmodi staan uit, dus ze volgt het huis.",
|
||||
"solar_first": "Eerst zonne-energie: ze krijgt het overschot vóór de andere.",
|
||||
"leftover": "Ze krijgt de zonne-energie die de batterijen met Eerst zonne-energie niet opnemen.",
|
||||
"leftover_alone": "Geen batterij met Eerst zonne-energie staat aan, dus zij krijgt eerst zonne-energie.",
|
||||
"covers_house": "Ze levert stroom om het huis te dekken.",
|
||||
"other_refuses": "{name} weigert te leveren, dus deze batterij neemt het over.",
|
||||
"other_cycle": "{name} loopt leeg voor haar maandelijkse cyclus. Deze laadt niet.",
|
||||
"grid_charge": "De prijs is laag, dus ze laadt van het net.",
|
||||
"rests_charge": "Een andere batterij laadt van het net, dus deze rust.",
|
||||
"selling": "De prijs is hoog, dus ze verkoopt aan het net.",
|
||||
"expensive": "De prijs is hoog, dus ze dekt het huis.",
|
||||
"saves": "Spaart haar stroom voor {at}.",
|
||||
"saves_later": "Spaart haar stroom voor hogere prijzen.",
|
||||
"no_prices": "Geen prijzen, dus ze volgt het huis.",
|
||||
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis."
|
||||
},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
"off": "Uit, dus vandaag geen maandelijkse cyclus.",
|
||||
"house": "Volgt het huis.",
|
||||
"house_would": "Zou het huis volgen.",
|
||||
"stopped": "Niets gepland zolang de regeling gestopt is.",
|
||||
"held": "Niets gepland tot het alarm weg is."
|
||||
},
|
||||
"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.",
|
||||
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
|
||||
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
|
||||
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
|
||||
"dear_why": "Dure uren, ongeveer {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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
"""UX-13: the new page's layout, written once - screen, group, field.
|
||||
|
||||
UI code names option keys only here and in uimodel.py, which holds the
|
||||
derivations this table lists (DECISIONS/UX-13-one-layout-table.md, amending
|
||||
UX-02 §3). Everything else stays where UX-02 put it: ranges in config.yaml's
|
||||
schema, rules in validate.py. test_uilayout.py fails CI when an option in
|
||||
config.yaml has no place here.
|
||||
|
||||
A field's `key` is an option key, `inverters.<field>` for one inverter in the
|
||||
list, or `ui.<name>` for a page-only control that uimodel.py turns into options.
|
||||
The key is also its text key: label and help are translations/*.yaml's entry
|
||||
for an option, and app/ui_text's `fields.<name>` for a `ui.` field.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
RESTART = "restart" # stored options apply after a restart (UX-02 §5)
|
||||
NOW = "now" # runtime state (OPT-11's battery switches), none yet
|
||||
|
||||
# Controls are names, not string literals, so a key is never followed by a
|
||||
# constant (test_defaults_pin.py reads `f("key", <constant>)` as a fallback).
|
||||
CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = (
|
||||
"choice", "entity", "entity_list", "toggle", "segmented", "slider")
|
||||
NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = (
|
||||
"number", "text", "select", "locked", "inverter_list", "solar_list")
|
||||
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, 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")
|
||||
SHOW_OPS = ("==", "!=", "in", ">", "<")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Field:
|
||||
key: str
|
||||
control: str
|
||||
unit: str = ""
|
||||
scale: int = 1 # shown = stored / scale (the peak limit in kW)
|
||||
step: float = 0 # slider or number step, stored units; 0 = 1
|
||||
kind: str = "" # entity picker filter, one of ENTITY_KINDS
|
||||
show: tuple = () # ((name, op, value), ...), all must hold
|
||||
applies: str = RESTART
|
||||
simple: bool = False # also on Settings, Simple
|
||||
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
|
||||
mirror: str = "" # a choice also copied to this field, if offered
|
||||
fills: str = "" # a preset choice fills this field (uimodel)
|
||||
note: str = "" # P3: a line under it on Settings, Simple
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
"""Where the label and help live: `translations` or `ui_text`."""
|
||||
return "ui_text" if self.key.startswith("ui.") else "translations"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Part:
|
||||
id: str # "" is the group's main part
|
||||
fields: tuple
|
||||
collapsed: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Group:
|
||||
id: str
|
||||
parts: tuple
|
||||
support_only: bool = False # collapsed, with a "for support" warning
|
||||
|
||||
|
||||
def _src(*values):
|
||||
return (("meter_source", "in", values),)
|
||||
|
||||
|
||||
_DYNAMIC = (("ui.contract", "==", "dynamic"),)
|
||||
_MAINT = (("maintenance_enabled", "==", True),)
|
||||
_ONE = (("inverter_count", "==", 1),)
|
||||
_SEVERAL = (("inverter_count", ">", 1),)
|
||||
_PHASES = (("meter_phases", ">", 1),)
|
||||
# One inverter stored flat has no name or capacity (uimodel._roster_encode).
|
||||
_LISTED = (("inverter_shape", "!=", "flat"),)
|
||||
|
||||
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
|
||||
ROSTER_FIELDS = (
|
||||
Field("inverters.name", TEXT, show=_LISTED, wizard=2),
|
||||
Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2),
|
||||
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
|
||||
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
|
||||
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
|
||||
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
|
||||
Field("inverters.batt_invert", TOGGLE, wizard=2),
|
||||
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, show=_LISTED,
|
||||
wizard=2),
|
||||
)
|
||||
|
||||
GROUPS = (
|
||||
Group("meter", (
|
||||
Part("", (
|
||||
Field("meter_source", CHOICE, wizard=1),
|
||||
Field("meter_entity", ENTITY, kind="power_w", show=_src("off"),
|
||||
wizard=1),
|
||||
Field("meter_invert", TOGGLE, show=_src("off"), wizard=1),
|
||||
Field("p1_net_entity", ENTITY, kind="power_w",
|
||||
show=_src("ha_signed"), wizard=1),
|
||||
Field("p1_import_entity", ENTITY, kind="power_w",
|
||||
show=_src("ha_dsmr"), wizard=1),
|
||||
Field("p1_export_entity", ENTITY, kind="power_w",
|
||||
show=_src("ha_dsmr"), wizard=1),
|
||||
Field("p1_host", TEXT, show=_src("homewizard_local"), wizard=1),
|
||||
Field("meter_mqtt_topic", TEXT, show=_src("mqtt_p1"), wizard=1),
|
||||
Field("meter_phases", SEGMENTED, wizard=1),
|
||||
Field("p1_phase_net_entities", ENTITY_LIST, kind="power_w",
|
||||
show=_src("ha_signed") + _PHASES, wizard=1),
|
||||
Field("p1_phase_import_entities", ENTITY_LIST, kind="power_w",
|
||||
show=_src("ha_dsmr") + _PHASES, wizard=1),
|
||||
Field("p1_phase_export_entities", ENTITY_LIST, kind="power_w",
|
||||
show=_src("ha_dsmr") + _PHASES, wizard=1),
|
||||
)),
|
||||
Part("timing", (
|
||||
Field("meter_max_age_s", SLIDER, "s", step=5),
|
||||
Field("meter_poll_s", SLIDER, "s",
|
||||
show=_src("homewizard_local")),
|
||||
), collapsed=True),
|
||||
)),
|
||||
Group("inverters", (
|
||||
Part("", (Field("inverters", INVERTER_LIST, wizard=2),)),
|
||||
# pv.py reads each entity's own unit, so a kW sensor is fine here.
|
||||
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
|
||||
wizard=3),)),
|
||||
# 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("", (
|
||||
# A contract without a feed is also the price used when a feed
|
||||
# is down, so the fallback follows it (uimodel._tariff_encode).
|
||||
Field("ui.contract", SEGMENTED, wizard=4, mirror="ui.fallback"),
|
||||
Field("price_provider", SELECT, show=_DYNAMIC, wizard=4),
|
||||
Field("price_formula", TEXT,
|
||||
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
|
||||
wizard=4),
|
||||
Field("price_csv_path", TEXT,
|
||||
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
|
||||
wizard=4),
|
||||
Field("ui.region", SELECT, wizard=4,
|
||||
fills="price_taxes_eur_kwh"),
|
||||
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
|
||||
wizard=4),
|
||||
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
|
||||
show=_DYNAMIC, wizard=4),
|
||||
Field("export_price_eur_kwh", NUMBER, "€/kWh", step=0.001,
|
||||
wizard=4),
|
||||
Field("price_export_formula", TEXT),
|
||||
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
|
||||
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "fixed"),), wizard=4),
|
||||
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
||||
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
||||
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
|
||||
# modes exist (uimodel.arbitrage_from_batteries). A toggle until then.
|
||||
Field("arbitrage_enabled", TOGGLE),
|
||||
Field("cycle_cost_threshold_eur", NUMBER, "€/kWh", step=0.01),
|
||||
Field("arbitrage_soc_floor", SLIDER, "%"),
|
||||
Field("arbitrage_soc_ceiling", SLIDER, "%"),
|
||||
)),
|
||||
Part("feed", (
|
||||
Field("price_feed_url", TEXT, show=_DYNAMIC),
|
||||
Field("price_horizon_h", SLIDER, "h", show=_DYNAMIC),
|
||||
Field("price_timeout_s", SLIDER, "s", show=_DYNAMIC),
|
||||
Field("price_now_entity", ENTITY, kind="any"),
|
||||
Field("price_avg_entity", ENTITY, kind="any"),
|
||||
), collapsed=True),
|
||||
)),
|
||||
Group("safety", (
|
||||
Part("peak", (
|
||||
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
||||
simple=True, wizard=5, note="month_peak"),
|
||||
Field("peak_margin_w", SLIDER, "W", step=10), # SAFETY-79
|
||||
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
|
||||
Field("peak_soc_floor", LOCKED, "%"),
|
||||
)),
|
||||
Part("start", (
|
||||
Field("shadow_mode", SEGMENTED, simple=True, wizard=6,
|
||||
note="shadow"),
|
||||
Field("auto_start", TOGGLE),
|
||||
Field("estop_fitted", TOGGLE),
|
||||
Field("stale_input_s", SLIDER, "s"),
|
||||
Field("meter_freeze_s", SLIDER, "s", step=30,
|
||||
show=_src("homewizard_local")),
|
||||
)),
|
||||
Part("stops", (
|
||||
Field("sign_disagree_w", LOCKED, "W", step=10, show=_ONE),
|
||||
Field("sign_disagree_s", LOCKED, "s", step=5, show=_ONE),
|
||||
Field("unit_sign_disagree_w", LOCKED, "W", step=10,
|
||||
show=_SEVERAL),
|
||||
Field("unit_sign_disagree_s", LOCKED, "s", step=5,
|
||||
show=_SEVERAL),
|
||||
Field("site_sign_disagree_w", LOCKED, "W", step=10,
|
||||
show=_SEVERAL),
|
||||
Field("site_sign_disagree_s", LOCKED, "s", step=5,
|
||||
show=_SEVERAL),
|
||||
Field("circulation_w", LOCKED, "W", step=10, show=_SEVERAL),
|
||||
Field("circulation_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
|
||||
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
|
||||
), collapsed=True),
|
||||
)),
|
||||
Group("maintenance", (
|
||||
Part("", (
|
||||
Field("maintenance_enabled", TOGGLE),
|
||||
Field("maintenance_interval_days", SLIDER, "days", show=_MAINT),
|
||||
Field("maintenance_start_hour", SELECT, show=_MAINT),
|
||||
Field("maintenance_discharge_w", SLIDER, "W", step=100,
|
||||
show=_MAINT),
|
||||
Field("maintenance_charge_w", SLIDER, "W", step=100,
|
||||
show=_MAINT),
|
||||
Field("maintenance_soc_floor", SLIDER, "%", show=_MAINT),
|
||||
Field("maintenance_soc_target", SLIDER, "%", show=_MAINT),
|
||||
Field("maintenance_hold_min", SLIDER, "min", step=5,
|
||||
show=_MAINT),
|
||||
)),
|
||||
Part("support", (Field("log_level", SELECT),)),
|
||||
)),
|
||||
Group("tuning", (
|
||||
Part("", (
|
||||
Field("gain", LOCKED, step=0.05),
|
||||
Field("slew_w", LOCKED, "W", step=50),
|
||||
Field("deadband_w", LOCKED, "W"),
|
||||
Field("target_grid_w", LOCKED, "W"),
|
||||
Field("step_w", LOCKED, "W"),
|
||||
Field("saturation_w", LOCKED, "W", step=10),
|
||||
Field("saturation_cycles", LOCKED),
|
||||
Field("integrator_max_w", LOCKED, "W", step=100),
|
||||
Field("heartbeat_s", LOCKED, "s"),
|
||||
Field("cycle_floor_s", LOCKED, "s"),
|
||||
)),
|
||||
), support_only=True),
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard. Its fields are the ones with `wizard=` above; this
|
||||
# is what the table cannot say per field (DECISIONS/UX-13-setup-wizard.md).
|
||||
# --------------------------------------------------------------------------- #
|
||||
WIZARD_STEPS = 6
|
||||
FINAL_STEP = WIZARD_STEPS # a refusal on no wizard field is shown here
|
||||
OPTIONAL_STEPS = (3,) # solar: "Next, or skip"
|
||||
|
||||
# Step 1's kettle test. Per meter source Home Assistant reads: the page fields
|
||||
# read before and after a kettle is switched on, each with the sign it adds to
|
||||
# the grid reading, and what each direction means as (outcome, fields it
|
||||
# sets). Sets None: the options cannot fix it, so the step is not passed. A
|
||||
# source not listed is read by the add-on itself, so the page cannot test it.
|
||||
KETTLE_SOURCE = "meter_source"
|
||||
KETTLE_MIN_W = 500.0 # a kettle is 2 000 W, a house wobbles less
|
||||
KETTLE = {
|
||||
"off": ((("meter_entity", 1),),
|
||||
{"up": ("ok", {"meter_invert": False}),
|
||||
"down": ("flip", {"meter_invert": True})}),
|
||||
"ha_signed": ((("p1_net_entity", 1),),
|
||||
{"up": ("ok", {}), "down": ("wrong_signed", None)}),
|
||||
"ha_dsmr": ((("p1_import_entity", 1), ("p1_export_entity", -1)),
|
||||
{"up": ("ok", {}), "down": ("wrong_dsmr", None)}),
|
||||
}
|
||||
# Step 6 starts the site in shadow mode unless the installer switches it off:
|
||||
# (field, value when on, value when off).
|
||||
SHADOW = ("shadow_mode", "on", "auto")
|
||||
|
||||
# Option keys the page never shows. uimodel.py writes each from page fields.
|
||||
DERIVED = {
|
||||
"setpoint_entity": "inverters", # one inverter, stored flat
|
||||
"soc_entity": "inverters",
|
||||
"batt_entity": "inverters",
|
||||
"batt_invert": "inverters",
|
||||
"max_w": "inverters",
|
||||
"bms_soc_floor": "inverters",
|
||||
"pv_entity": "ui.solar", # one solar row, stored flat
|
||||
"pv_entities": "ui.solar",
|
||||
"pv_invert_entities": "ui.solar", # the rows' "reads negative" toggles
|
||||
"static_profile": "ui.fallback", # built from the price fields
|
||||
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
|
||||
}
|
||||
# Option keys the page never shows and never writes: passed through as stored.
|
||||
HIDDEN: dict = {}
|
||||
|
||||
|
||||
def fields():
|
||||
"""Every field once, in page order, roster fields after `inverters`."""
|
||||
for group in GROUPS:
|
||||
for part in group.parts:
|
||||
for field in part.fields:
|
||||
yield group, part, field
|
||||
if field.control == INVERTER_LIST:
|
||||
for sub in ROSTER_FIELDS:
|
||||
yield group, part, sub
|
||||
|
||||
|
||||
def field(key: str) -> Field | None:
|
||||
return next((f for _g, _p, f in fields() if f.key == key), None)
|
||||
|
||||
|
||||
def visible(fld: Field, model: dict) -> bool:
|
||||
"""Whether `fld` is shown for this page model (uimodel.options_to_ui)."""
|
||||
for name, op, value in fld.show:
|
||||
have = model.get(name)
|
||||
if op == "==" and have != value:
|
||||
return False
|
||||
if op == "!=" and have == value:
|
||||
return False
|
||||
if op == "in" and have not in value:
|
||||
return False
|
||||
try:
|
||||
if op == ">" and not float(have) > value:
|
||||
return False
|
||||
if op == "<" and not float(have) < value:
|
||||
return False
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def range_of(key: str, schema_ui: list):
|
||||
"""(min, max) from the Supervisor's schema (`info["schema"]`), or None.
|
||||
|
||||
Never typed here: config.yaml is the one place a range is written. A range
|
||||
arrives as lengthMin/lengthMax (tools/supervisor_selfapi_probe.py)."""
|
||||
path = key.split(".")
|
||||
nodes = schema_ui or []
|
||||
for step in path[:-1]:
|
||||
parent = next((n for n in nodes if n.get("name") == step), None)
|
||||
nodes = (parent or {}).get("schema") or []
|
||||
node = next((n for n in nodes if n.get("name") == path[-1]), None)
|
||||
if not node or "lengthMin" not in node or "lengthMax" not in node:
|
||||
return None
|
||||
return node["lengthMin"], node["lengthMax"]
|
||||
@@ -0,0 +1,488 @@
|
||||
"""UX-13: stored options <-> the page model, for the keys the page derives.
|
||||
|
||||
`options_to_ui()` turns the stored blob into what the page edits: always a list
|
||||
of inverter cards, solar rows with a "reads negative" toggle, a contract and
|
||||
simple prices 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
|
||||
of the derived keys (`keep`). A derivation whose page fields still read what
|
||||
`keep` decodes to writes `keep` back untouched, and only an edited one is
|
||||
re-encoded. So a blob the page cannot express (a `static_profile` with odd
|
||||
windows, a malformed roster) survives a save of some other field unchanged.
|
||||
See DECISIONS/UX-13-one-layout-table.md.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
|
||||
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", "ui_to_options_partial",
|
||||
"REGION_PRESETS", "PERIOD_DAYS",
|
||||
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
|
||||
|
||||
|
||||
class DeriveError(ValueError):
|
||||
"""The page model asks for something no options blob can say. `code` names
|
||||
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="", **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
|
||||
# a card sets: a second inverter turns the one flat inverter into a list.
|
||||
COUNT = "inverter_count"
|
||||
GROWN = {"inverter_shape": "list"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Region presets for "Grid costs, levies and VAT". Year-stamped, updated each
|
||||
# January (owner, 2026-09-27). Only presets with a sourced figure are listed:
|
||||
# Flanders is app/price.py's Fluvius Antwerpen sum (TARIFF-18: Eneco
|
||||
# residential card, Flanders, September 2026, VAT included). Wallonia and
|
||||
# Brussels have a day and a night band (config.yaml) and join with sourced
|
||||
# figures; until then those sites choose "own".
|
||||
# --------------------------------------------------------------------------- #
|
||||
REGION_PRESETS = {
|
||||
"flanders": {
|
||||
"year": 2026,
|
||||
"taxes_eur_kwh": round(sum(v for _n, v in
|
||||
FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5),
|
||||
"taxes_profile": "",
|
||||
},
|
||||
}
|
||||
OWN = "own"
|
||||
|
||||
_ROSTER_FLAT = ("setpoint_entity", "soc_entity", "batt_entity", "batt_invert",
|
||||
"max_w", "bms_soc_floor")
|
||||
_TOL = 1e-9
|
||||
|
||||
|
||||
def _blank(v) -> bool:
|
||||
return v is None or (isinstance(v, str) and not v.strip())
|
||||
|
||||
|
||||
def _num(v):
|
||||
try:
|
||||
return None if _blank(v) or isinstance(v, bool) else float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
# --- 1. one inverter (flat keys) or the `inverters` list -------------------- #
|
||||
def _roster_decode(o):
|
||||
entries = o.get("inverters")
|
||||
if entries is None or entries == "" or entries == []:
|
||||
card = {k: o.get(k) for k in _ROSTER_FLAT}
|
||||
card.update(name="", capacity_kwh=None)
|
||||
return {"inverter_shape": "flat", "inverters": [card]}
|
||||
if isinstance(entries, list) and all(isinstance(e, dict) for e in entries):
|
||||
return {"inverter_shape": "list",
|
||||
"inverters": [dict(e) for e in entries]}
|
||||
# Not something a card can show: edited in the Configuration tab only.
|
||||
return {"inverter_shape": "raw", "inverters": None}
|
||||
|
||||
|
||||
def _roster_encode(m, keep):
|
||||
out = {}
|
||||
shape, cards = m.get("inverter_shape"), m.get("inverters")
|
||||
if shape == "raw":
|
||||
raise DeriveError("the inverter list is edited in the Configuration tab",
|
||||
"raw_roster", "inverters")
|
||||
if not cards:
|
||||
raise DeriveError("at least one inverter is needed", "no_inverter",
|
||||
"inverters")
|
||||
if shape == "flat" and len(cards) == 1:
|
||||
card = cards[0]
|
||||
if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")):
|
||||
raise DeriveError("one inverter stored flat has no name or "
|
||||
"capacity", "flat_named", "inverters")
|
||||
for k in _ROSTER_FLAT:
|
||||
if card.get(k) is not None or k in keep:
|
||||
out[k] = card.get(k)
|
||||
out["inverters"] = []
|
||||
return out
|
||||
out["inverters"] = [{k: v for k, v in c.items() if v is not None}
|
||||
for c in cards]
|
||||
# The flat keys beside a list are not read: they stay as stored.
|
||||
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
|
||||
if shape == "flat" or _roster_decode(keep)["inverter_shape"] == "flat":
|
||||
# CFG-04 rule 9e: a flat power request entity beside a list is refused,
|
||||
# so a list that replaces the flat inverter clears it.
|
||||
out["setpoint_entity"] = ""
|
||||
return out
|
||||
|
||||
|
||||
# --- 2. solar rows: pv_entity / pv_entities / pv_invert_entities ------------ #
|
||||
def _strs(v):
|
||||
return [str(x).strip() for x in (v if isinstance(v, list) else [])
|
||||
if not _blank(x)]
|
||||
|
||||
|
||||
def _solar_rows(o):
|
||||
listed = _strs(o.get("pv_entities"))
|
||||
if listed:
|
||||
return listed, True
|
||||
one = "" if _blank(o.get("pv_entity")) else str(o.get("pv_entity")).strip()
|
||||
return ([one] if one else []), False
|
||||
|
||||
|
||||
def _solar_decode(o):
|
||||
rows, _listed = _solar_rows(o)
|
||||
flipped = set(_strs(o.get("pv_invert_entities")))
|
||||
return {"ui.solar": [{"entity": e, "reversed": e in flipped}
|
||||
for e in rows]}
|
||||
|
||||
|
||||
def _solar_encode(m, keep):
|
||||
out = {}
|
||||
rows = [r for r in (m.get("ui.solar") or [])
|
||||
if not _blank(r.get("entity"))]
|
||||
entities = [str(r["entity"]).strip() for r in rows]
|
||||
old_rows, was_list = _solar_rows(keep)
|
||||
if len(entities) <= 1 and not was_list:
|
||||
out["pv_entity"] = entities[0] if entities else ""
|
||||
if "pv_entities" in keep:
|
||||
out["pv_entities"] = []
|
||||
else:
|
||||
out["pv_entities"] = entities
|
||||
if "pv_entity" in keep:
|
||||
out["pv_entity"] = "" # TEL-12: the list replaces it
|
||||
# Flips of entities that were never a row stay as stored (pv.stray_inverts
|
||||
# reports them). Flips of removed rows go with the row.
|
||||
extras = [x for x in _strs(keep.get("pv_invert_entities"))
|
||||
if x not in old_rows and x not in entities]
|
||||
flips = [e for e, r in zip(entities, rows) if r.get("reversed")] + extras
|
||||
if flips or "pv_invert_entities" in keep:
|
||||
out["pv_invert_entities"] = flips
|
||||
return out
|
||||
|
||||
|
||||
# --- 3. contract and static_profile ----------------------------------------- #
|
||||
def _fixed_json(price) -> str:
|
||||
return json.dumps({"periods": [
|
||||
{"name": "all", "from": "00:00", "to": "24:00", "days": "mon-sun",
|
||||
"eur_per_kwh": price}]})
|
||||
|
||||
|
||||
def _daynight_json(day, night) -> str:
|
||||
return json.dumps({"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
|
||||
"eur_per_kwh": day},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
|
||||
"eur_per_kwh": night},
|
||||
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
|
||||
"eur_per_kwh": night}]})
|
||||
|
||||
|
||||
def _shape(text):
|
||||
"""The periods of a profile without their names, for comparing, or None."""
|
||||
try:
|
||||
prof = parse_static_profile(text)
|
||||
except ProfileInvalid:
|
||||
return None
|
||||
if prof is None:
|
||||
return ()
|
||||
return tuple(sorted((p.start_min, p.end_min, tuple(sorted(p.days)),
|
||||
p.eur_per_kwh) for p in prof.periods))
|
||||
|
||||
|
||||
def _profile_decode(raw):
|
||||
"""("none"|"fixed"|"daynight"|"custom", fixed, day, night)."""
|
||||
if isinstance(raw, str) and _blank(raw):
|
||||
return "none", None, None, None
|
||||
if not isinstance(raw, str):
|
||||
return "custom", None, None, None
|
||||
got = _shape(raw)
|
||||
if not got:
|
||||
return ("none" if got == () else "custom"), None, None, None
|
||||
if len(got) == 1 and got == _shape(_fixed_json(got[0][3])):
|
||||
return "fixed", got[0][3], None, None
|
||||
day = next((p[3] for p in got if p[:2] == (420, 1320)), None)
|
||||
night = next((p[3] for p in got if p[:2] == (1320, 420)), None)
|
||||
if (day is not None and night is not None
|
||||
and got == _shape(_daynight_json(day, night))):
|
||||
return "daynight", None, day, night
|
||||
return "custom", None, None, None
|
||||
|
||||
|
||||
# --- 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):
|
||||
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.periods": _periods_decode(raw)}
|
||||
|
||||
|
||||
def _tariff_encode(m, keep):
|
||||
out = {}
|
||||
contract = m.get("ui.contract")
|
||||
if contract == "dynamic":
|
||||
if _blank(m.get("price_provider")) or m.get("price_provider") == "off":
|
||||
raise DeriveError("a dynamic contract needs a price source",
|
||||
"no_source", "price_provider")
|
||||
out["price_provider"] = m["price_provider"]
|
||||
kind = m.get("ui.fallback")
|
||||
else:
|
||||
out["price_provider"] = "off"
|
||||
kind = contract
|
||||
if kind == "none":
|
||||
out["static_profile"] = ""
|
||||
elif kind == "fixed":
|
||||
out["static_profile"] = _fixed_json(_price_of(m, "ui.fixed_price"))
|
||||
elif kind == "daynight":
|
||||
out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"),
|
||||
_price_of(m, "ui.night_price"))
|
||||
elif kind == "custom":
|
||||
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")
|
||||
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")
|
||||
return out
|
||||
|
||||
|
||||
def _price_of(m, name):
|
||||
value = _num(m.get(name))
|
||||
if value is None:
|
||||
raise DeriveError(f"{name} needs a price", "no_price", name)
|
||||
return value
|
||||
|
||||
|
||||
# --- 4. region preset -> price_taxes_eur_kwh / price_taxes_profile ---------- #
|
||||
def _region_decode(o):
|
||||
taxes, profile = o.get("price_taxes_eur_kwh"), o.get("price_taxes_profile", "")
|
||||
region = OWN
|
||||
for rid, p in REGION_PRESETS.items():
|
||||
same = _num(taxes) is not None and abs(_num(taxes) - p["taxes_eur_kwh"]) < _TOL
|
||||
if same and str(profile or "").strip() == p["taxes_profile"]:
|
||||
region = rid
|
||||
return {"ui.region": region, "price_taxes_eur_kwh": taxes}
|
||||
|
||||
|
||||
def _region_encode(m, keep):
|
||||
region = m.get("ui.region")
|
||||
if region == OWN:
|
||||
out = {"price_taxes_eur_kwh": m.get("price_taxes_eur_kwh")}
|
||||
if "price_taxes_profile" in keep: # "own" keeps the stored table
|
||||
out["price_taxes_profile"] = keep["price_taxes_profile"]
|
||||
return out
|
||||
if region not in REGION_PRESETS:
|
||||
raise DeriveError(f"unknown region {region!r}", "unknown", "ui.region")
|
||||
p = REGION_PRESETS[region]
|
||||
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
|
||||
if p["taxes_profile"] or "price_taxes_profile" in keep:
|
||||
out["price_taxes_profile"] = p["taxes_profile"]
|
||||
return out
|
||||
|
||||
|
||||
# --- 5. arbitrage_enabled: the OPT-11 hook ---------------------------------- #
|
||||
def arbitrage_from_batteries(m):
|
||||
"""OPT-11 hook. With battery modes on, `arbitrage_enabled` is true when any
|
||||
battery may sell. Battery modes are not built yet, so this returns None and
|
||||
the toggle is stored as the page has it: no behaviour change."""
|
||||
return None
|
||||
|
||||
|
||||
def _arbitrage_decode(o):
|
||||
return {"arbitrage_enabled": o.get("arbitrage_enabled")}
|
||||
|
||||
|
||||
def _arbitrage_encode(m, keep):
|
||||
derived = arbitrage_from_batteries(m)
|
||||
value = m.get("arbitrage_enabled") if derived is None else derived
|
||||
return {} if value is None and "arbitrage_enabled" not in keep \
|
||||
else {"arbitrage_enabled": value}
|
||||
|
||||
|
||||
# (option keys it writes, decode, encode). Each is independent of the others.
|
||||
DERIVATIONS = (
|
||||
(("inverters",) + _ROSTER_FLAT, _roster_decode, _roster_encode),
|
||||
(("pv_entity", "pv_entities", "pv_invert_entities"), _solar_decode,
|
||||
_solar_encode),
|
||||
(("price_provider", "static_profile"), _tariff_decode, _tariff_encode),
|
||||
(("price_taxes_eur_kwh", "price_taxes_profile"), _region_decode,
|
||||
_region_encode),
|
||||
(("arbitrage_enabled",), _arbitrage_decode, _arbitrage_encode),
|
||||
)
|
||||
_OWNED = {k for keys, _d, _e in DERIVATIONS for k in keys}
|
||||
_MODEL_ONLY = ("keep", "inverter_shape", "inverter_count", "region_year")
|
||||
|
||||
|
||||
def options_to_ui(options: dict) -> dict:
|
||||
"""The page model for a stored blob. Pure: reads nothing else."""
|
||||
model = {k: copy.deepcopy(v) for k, v in options.items()
|
||||
if k not in _OWNED}
|
||||
for _keys, decode, _encode in DERIVATIONS:
|
||||
model.update(decode(options))
|
||||
model["keep"] = {k: copy.deepcopy(options[k]) for k in _OWNED
|
||||
if k in options}
|
||||
cards = model.get("inverters")
|
||||
model["inverter_count"] = len(cards) if isinstance(cards, list) else None
|
||||
preset = REGION_PRESETS.get(model["ui.region"])
|
||||
model["region_year"] = preset["year"] if preset else None
|
||||
return model
|
||||
|
||||
|
||||
def ui_to_options(model: dict) -> dict:
|
||||
"""The options blob for a page model. Raises DeriveError on a model no blob
|
||||
can express, so the page says so instead of saving something else."""
|
||||
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})
|
||||
continue
|
||||
try:
|
||||
out.update(encode(model, keep))
|
||||
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
|
||||
@@ -0,0 +1,403 @@
|
||||
"""UX-13 P2: the settings view's server half. It names no option key.
|
||||
|
||||
It turns app/uilayout.py's table into the JSON the page draws from (labels
|
||||
from the Supervisor's translations, ranges and choices from its schema, page
|
||||
words from app/ui_text), turns a posted page model into options through
|
||||
app/uimodel.py, and says beside which page field each validator sentence
|
||||
goes. The rules stay in validate.py and the sentences are passed on as they
|
||||
are (DECISIONS/UX-13-one-layout-table.md).
|
||||
"""
|
||||
|
||||
import copy
|
||||
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
|
||||
|
||||
# A choice the page cannot make, only keep. None since P5's period editor.
|
||||
ONLY_IF_HELD = ()
|
||||
_TIERS = ("bad", "warn", "info")
|
||||
|
||||
|
||||
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():
|
||||
"""The inverter list field's key, read off the table."""
|
||||
return next(f.key for _g, _p, f in L.fields()
|
||||
if f.control == L.INVERTER_LIST)
|
||||
|
||||
|
||||
def _node(schema_ui, key):
|
||||
"""The Supervisor's schema entry for `key` (`list.sub` too), or {}."""
|
||||
nodes, path = schema_ui or [], key.split(".")
|
||||
for step in path[:-1]:
|
||||
parent = next((n for n in nodes if n.get("name") == step), None)
|
||||
nodes = (parent or {}).get("schema") or []
|
||||
return next((n for n in nodes if n.get("name") == path[-1]), None) or {}
|
||||
|
||||
|
||||
def _words(translations, lang, key, T):
|
||||
"""(label, help) for a field: ui_text for `ui.` fields, else the
|
||||
translation without its `Group · ` prefix, and the raw key last."""
|
||||
if key.startswith("ui."):
|
||||
entry = T["fields"].get(key[3:], {})
|
||||
return entry.get("name", key), entry.get("description", "")
|
||||
path = tuple(key.split(".")[:-1])
|
||||
name = key.split(".")[-1]
|
||||
label, _lang = label_of(translations, lang, name, path)
|
||||
text, _lang = help_of(translations, lang, name, path)
|
||||
return split_group(label)[1], text
|
||||
|
||||
|
||||
def _choices(fld, node, T, value_type):
|
||||
"""[[value, label, only_if_held], ...] for a choice control, or None."""
|
||||
if fld.control not in (L.SEGMENTED, L.SELECT, L.CHOICE):
|
||||
return None
|
||||
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
|
||||
words = T["choices"].get(name) or {}
|
||||
if fld.key.startswith("ui."):
|
||||
values = list(words)
|
||||
elif node.get("options"):
|
||||
values = [v for v in node["options"] if not words or v in words]
|
||||
elif value_type == "integer" and node.get("lengthMax") is not None:
|
||||
values = list(range(int(node["lengthMin"]), int(node["lengthMax"]) + 1))
|
||||
if words:
|
||||
values = [v for v in values if str(v) in words]
|
||||
else:
|
||||
return []
|
||||
out = []
|
||||
for v in values:
|
||||
label = words.get(str(v))
|
||||
if label is None:
|
||||
label = (T["settings"]["hour"].replace("{hour}", f"{v:02d}")
|
||||
if isinstance(v, int) else str(v))
|
||||
preset = U.REGION_PRESETS.get(v) if fld.fills else None
|
||||
if preset:
|
||||
label = f"{label}, " + T["settings"]["region_year"].replace(
|
||||
"{year}", str(preset["year"]))
|
||||
out.append([v, label, v in ONLY_IF_HELD])
|
||||
return out
|
||||
|
||||
|
||||
def _field(fld, schema_ui, translations, lang, T):
|
||||
node = _node(schema_ui, fld.key)
|
||||
label, text = _words(translations, lang, fld.key, T)
|
||||
if fld.unit:
|
||||
# The page shows the unit beside the control, in its own scale (kW),
|
||||
# so the translation's "(W)" goes; "(optional)" stays.
|
||||
label = re.sub(r"\s*\([^()\s]{1,6}\)", "", label)
|
||||
value_type = ("float" if fld.control == L.NUMBER and fld.key.startswith("ui.")
|
||||
else node.get("type") or "")
|
||||
out = {"key": fld.key, "control": fld.control, "label": label,
|
||||
"help": text, "unit": fld.unit, "scale": fld.scale,
|
||||
"step": fld.step, "kind": fld.kind,
|
||||
"show": [list(c) for c in fld.show], "applies": fld.applies,
|
||||
"wizard": fld.wizard, "simple": fld.simple, "note": fld.note,
|
||||
"type": value_type, "optional": not node.get("required", False),
|
||||
# A choice mirrored into a field also shows (and finds) its keys.
|
||||
"writes": sorted(k for k, how in L.DERIVED.items()
|
||||
if how in (fld.key, fld.mirror))}
|
||||
if node.get("lengthMin") is not None:
|
||||
out["min"] = node["lengthMin"]
|
||||
if node.get("lengthMax") is not None:
|
||||
out["max"] = node["lengthMax"]
|
||||
choices = _choices(fld, node, T, value_type)
|
||||
if choices is not None:
|
||||
out["choices"] = choices
|
||||
name = fld.key[3:] if fld.key.startswith("ui.") else fld.key
|
||||
later = T.get("choices_later", {}).get(name)
|
||||
if later:
|
||||
out["later"] = [[v, words] for v, words in later.items()]
|
||||
hints = T.get("choice_help", {}).get(name)
|
||||
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:
|
||||
out["fills"] = fld.fills
|
||||
out["presets"] = {rid: {"year": p["year"], "value": p["taxes_eur_kwh"]}
|
||||
for rid, p in U.REGION_PRESETS.items()}
|
||||
roster = _roster()
|
||||
if fld.key.startswith(roster + "."):
|
||||
sub = fld.key[len(roster) + 1:]
|
||||
if L.DERIVED.get(sub) == roster:
|
||||
out["flat"] = sub # how the one flat inverter stores it
|
||||
if fld.control == L.INVERTER_LIST:
|
||||
out["fields"] = [_field(sub, schema_ui, translations, lang, T)
|
||||
for sub in L.ROSTER_FIELDS]
|
||||
out["count"] = U.COUNT
|
||||
out["grow"] = dict(U.GROWN)
|
||||
out["shape"] = next(iter(U.GROWN))
|
||||
return out
|
||||
|
||||
|
||||
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(lang)
|
||||
groups = []
|
||||
for g in L.GROUPS:
|
||||
parts = []
|
||||
for p in g.parts:
|
||||
words = T["parts"].get(f"{g.id}.{p.id}", {}) if p.id else {}
|
||||
parts.append({"id": p.id, "name": words.get("name", ""),
|
||||
"description": words.get("description", ""),
|
||||
"collapsed": p.collapsed,
|
||||
"fields": [_field(f, schema_ui, translations, lang, T)
|
||||
for f in p.fields]})
|
||||
words = T["groups"].get(g.id, {})
|
||||
applies = sorted({f.applies for p in g.parts for f in p.fields})
|
||||
groups.append({"id": g.id, "name": words.get("name", g.id),
|
||||
"description": words.get("description", ""),
|
||||
"support_only": g.support_only,
|
||||
"applies": [T["applies"][a]["name"] for a in applies],
|
||||
"parts": parts})
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The page model -> options, for the validator and the save
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _same(a, b) -> bool:
|
||||
"""Equal as the Supervisor stores it: 3 and 3.0 alike, True and 1 not."""
|
||||
if isinstance(a, bool) or isinstance(b, bool):
|
||||
return type(a) is type(b) and a == b
|
||||
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
||||
return a == b
|
||||
if isinstance(a, list) and isinstance(b, list):
|
||||
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
|
||||
if isinstance(a, dict) and isinstance(b, dict):
|
||||
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
|
||||
return type(a) is type(b) and a == b
|
||||
|
||||
|
||||
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)."""
|
||||
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]):
|
||||
out[k] = copy.deepcopy(stored[k])
|
||||
return ({k: out[k] for k in stored if k in out}
|
||||
| {k: v for k, v in out.items() if k not in stored})
|
||||
|
||||
|
||||
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": 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))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Where a sentence goes on the page
|
||||
# --------------------------------------------------------------------------- #
|
||||
# `inverters.max_w`, or `inverters[1].setpoint_entity` for one card.
|
||||
_SUB = re.compile(r"(?<![A-Za-z0-9_])([a-z_0-9]+)(?:\[(\d+)\])?\.([a-z_0-9]+)")
|
||||
|
||||
|
||||
def _mirror_of(key):
|
||||
return next((f.key for _g, _p, f in L.fields() if f.mirror == key), None)
|
||||
|
||||
|
||||
def targets(key: str, text: str, model: dict) -> list:
|
||||
"""[(page field, card)] for a sentence that names option `key`. `card` is
|
||||
the inverter card's index, or None."""
|
||||
roster = _roster()
|
||||
cards = model.get(roster) if isinstance(model.get(roster), list) else []
|
||||
names = [c.get("name") for c in cards if isinstance(c, dict)]
|
||||
# messages.setting(): “Inverter rating” for inverter-b (inverters.max_w),
|
||||
# or a roster rule's own `inverters[1] (inverter-b)`.
|
||||
card = next((i for i, n in enumerate(names)
|
||||
if n and f"for {n} ({roster}." in text), None)
|
||||
index = re.search(re.escape(roster) + r"\[(\d+)\]", text)
|
||||
if card is None and index and int(index.group(1)) < len(cards):
|
||||
card = int(index.group(1))
|
||||
if key == roster:
|
||||
subs = [(f"{roster}.{s}", int(i) if i and int(i) < len(cards) else card)
|
||||
for r, i, s in _SUB.findall(text) if r == roster]
|
||||
subs = [x for x in dict.fromkeys(subs) if L.field(x[0])]
|
||||
return subs or [(roster, card)]
|
||||
if key in L.DERIVED:
|
||||
how = L.DERIVED[key]
|
||||
if how == roster:
|
||||
return [(f"{roster}.{key}", 0 if card is None else card)]
|
||||
fld = L.field(how)
|
||||
if fld is not None and not L.visible(fld, model) and _mirror_of(how):
|
||||
return [(_mirror_of(how), None)]
|
||||
return [(how, None)]
|
||||
if L.field(key) is not None:
|
||||
return [(key, None)]
|
||||
return [(None, None)]
|
||||
|
||||
|
||||
def anchors(problems, notes, model, keys) -> list:
|
||||
"""[{field, card, tier, text}] for the refusals and notes. `field` is
|
||||
None for a sentence that names no setting on the page."""
|
||||
out, seen = [], set()
|
||||
|
||||
def add(field, card, tier, text):
|
||||
if (field, card, text) not in seen:
|
||||
seen.add((field, card, text))
|
||||
out.append({"field": field, "card": card, "tier": tier,
|
||||
"text": text})
|
||||
|
||||
by_key, unanchored = problems_by_field(problems, keys)
|
||||
for key, texts in by_key.items():
|
||||
for text in texts:
|
||||
for field, card in targets(key, text, model):
|
||||
add(field, card, "bad", text)
|
||||
for text in unanchored:
|
||||
add(None, None, "bad", text)
|
||||
for note in notes:
|
||||
tier = note.tier if note.tier in _TIERS else "info"
|
||||
for key in note.keys or (None,):
|
||||
for field, card in (targets(key, note.text, model) if key
|
||||
else [(None, None)]):
|
||||
add(field, card, tier, note.text)
|
||||
# A sentence placed on one inverter's field is not also put on the list.
|
||||
roster = _roster()
|
||||
placed = {m["text"] for m in out
|
||||
if (m["field"] or "").startswith(roster + ".")}
|
||||
return [m for m in out if not (m["field"] == roster
|
||||
and m["text"] in placed)]
|
||||
|
||||
|
||||
def fields_of(keys, model) -> list:
|
||||
"""The page fields that show these option keys (a stale save's list)."""
|
||||
return list(dict.fromkeys(f for k in keys for f, _c in targets(k, "", model)
|
||||
if f))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard (DECISIONS/UX-13-setup-wizard.md)
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Store keys (app/store.py), not options. SETUP_DONE is written when the
|
||||
# wizard saves. STARTED is the one control's Start and Stop write.
|
||||
SETUP_DONE = "setup_done"
|
||||
STARTED = "auto"
|
||||
|
||||
|
||||
def setup_needed(stored: dict, store_data, started, fresh=False) -> str | None:
|
||||
"""Why the page opens the wizard by itself, or None.
|
||||
|
||||
"no_power_request": the stored options name no inverter to control, so
|
||||
the add-on cannot start on them. "first_start": a new install. This boot
|
||||
found no Store file (`fresh`), the wizard never saved and control was never
|
||||
started or stopped. `fresh` is what tells a new install from an old site
|
||||
that was never started, e.g. one left in shadow for weeks (TEST-23).
|
||||
`store_data` is the Store's dict (None: unknown, so no), `started` is
|
||||
whether control is started now."""
|
||||
if not power_request_entities(stored or {}):
|
||||
return "no_power_request"
|
||||
if store_data is None or not fresh:
|
||||
return None
|
||||
if store_data.get(SETUP_DONE) or STARTED in store_data or started:
|
||||
return None
|
||||
return "first_start"
|
||||
|
||||
|
||||
def setup_spec() -> dict:
|
||||
"""What the wizard needs beyond each field's step: read off the table."""
|
||||
kettle = {}
|
||||
for source, (read, outcomes) in L.KETTLE.items():
|
||||
kettle[source] = {"read": [[f, sign] for f, sign in read]}
|
||||
for way, (say, sets) in outcomes.items():
|
||||
kettle[source][way] = {"say": say, "sets": sets}
|
||||
field, on, off = L.SHADOW
|
||||
return {"steps": L.WIZARD_STEPS, "final": L.FINAL_STEP,
|
||||
"optional": list(L.OPTIONAL_STEPS),
|
||||
"kettle": {"source": L.KETTLE_SOURCE, "min_w": L.KETTLE_MIN_W,
|
||||
"sources": kettle},
|
||||
"shadow": {"field": field, "on": on, "off": off}}
|
||||
|
||||
|
||||
def kettle(source, before_w, after_w):
|
||||
"""The kettle test's verdict, as the page reaches it (settings.js
|
||||
`kettleOutcome`): (outcome, fields it sets or None, change in W), or None
|
||||
for a source the page cannot read."""
|
||||
spec = L.KETTLE.get(source)
|
||||
if spec is None:
|
||||
return None
|
||||
change = float(after_w) - float(before_w)
|
||||
if abs(change) < L.KETTLE_MIN_W:
|
||||
return "none", None, change
|
||||
say, sets = spec[1]["up" if change > 0 else "down"]
|
||||
return say, (dict(sets) if sets is not None else None), change
|
||||
|
||||
|
||||
def step_of(field_key, model) -> int:
|
||||
"""The wizard step a sentence on this page field blocks: the field's own
|
||||
step while it is shown, else the last step, which lists everything."""
|
||||
fld = L.field(field_key) if field_key else None
|
||||
if fld is None or not fld.wizard or not L.visible(fld, model):
|
||||
return L.FINAL_STEP
|
||||
return fld.wizard
|
||||
|
||||
|
||||
def step_problems(messages, model) -> dict:
|
||||
"""{"1": n, ...}: how many refusals block each wizard step."""
|
||||
out = {str(n): 0 for n in range(1, L.WIZARD_STEPS + 1)}
|
||||
for m in messages:
|
||||
if m.get("tier") == "bad":
|
||||
out[str(step_of(m.get("field"), model))] += 1
|
||||
return out
|
||||
@@ -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
|
||||
@@ -243,6 +243,9 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
note below on `gain`, which is the term that owns that and which SAFETY-39
|
||||
measured and did not change.
|
||||
|
||||
12. `peak_margin_w` outside 0-500 W, not whole, or not below `peak_cap_w`.
|
||||
SAFETY-79; the Supervisor checks the range too, but not for sim/inject.py.
|
||||
|
||||
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
||||
@@ -321,8 +324,9 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import NamedTuple
|
||||
|
||||
from . import messages
|
||||
from . import messages, pv
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
@@ -330,8 +334,9 @@ from .custom_formula import (
|
||||
parse_export_formula, parse_price_formula, stale_card)
|
||||
from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import is_enabled, validate_meter_options
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .p1 import (
|
||||
SOURCE_HA, SOURCE_HA_SIGNED, is_enabled, validate_meter_options)
|
||||
from .peakguard import PEAK_MARGIN_RANGE_W, PEAK_OPTION_DEFAULTS
|
||||
from .price import (
|
||||
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
|
||||
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
@@ -343,6 +348,47 @@ from .watchdog import SIGN_OPTION_DEFAULTS
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
|
||||
|
||||
class _WarnOnce(logging.Filter):
|
||||
"""UX-10: a settings warning is said once per start. The validator runs
|
||||
twice at start (before the first cycle and again once the inverter's range
|
||||
is known), and a warning about options that have not changed is the same
|
||||
news both times. Refusals are ERROR and pass untouched."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.seen = set()
|
||||
|
||||
def filter(self, record):
|
||||
if record.levelno != logging.WARNING:
|
||||
return True
|
||||
text = record.getMessage()
|
||||
if text in self.seen:
|
||||
return False
|
||||
self.seen.add(text)
|
||||
return True
|
||||
|
||||
|
||||
WARN_ONCE = _WarnOnce()
|
||||
_LOG.addFilter(WARN_ONCE)
|
||||
|
||||
|
||||
class Note(NamedTuple):
|
||||
"""UX-13: a warning or info for the settings page. Never a refusal: the
|
||||
add-on starts. `keys` are the options it is about, so the page can show it
|
||||
beside them."""
|
||||
tier: str # "warn" or "info"; the wizard's checks
|
||||
# also "ok" and "bad" (setup_checks)
|
||||
keys: tuple
|
||||
text: str
|
||||
|
||||
|
||||
def _advise(notes, tier: str, keys, text: str) -> None:
|
||||
"""Add a Note when the caller collects them (the settings page)."""
|
||||
if notes is not None:
|
||||
notes.append(Note(tier, tuple(keys), text))
|
||||
|
||||
|
||||
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
|
||||
# threshold is a compile-time YAML substitution baked into the ESP32 image
|
||||
# (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at
|
||||
@@ -647,7 +693,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
where = f"inverters[{i}]"
|
||||
if not isinstance(entry, dict):
|
||||
problems.append(
|
||||
f"{where} in the “Inverter list” is {entry!r}. Each entry needs "
|
||||
f"{where} in the “Inverter list” is {messages.shown(entry)}. Each entry needs "
|
||||
"a name, a setpoint entity and an inverter rating.")
|
||||
continue
|
||||
name = str(entry.get("name") or "").strip()
|
||||
@@ -670,7 +716,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
|
||||
problems.append(
|
||||
f"{who} has no usable {S('max_w')}: it is "
|
||||
f"{entry.get('max_w')!r}. Set this inverter's own rating in "
|
||||
f"{messages.shown(entry.get('max_w'))}. Set this inverter's own rating in "
|
||||
"watts.")
|
||||
|
||||
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
||||
@@ -690,7 +736,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
val = None
|
||||
if val is None or not (val > 0.0):
|
||||
problems.append(
|
||||
f"{who} has {S('capacity_kwh')} {raw!r}, which is not a usable "
|
||||
f"{who} has {S('capacity_kwh')} {messages.shown(raw)}, which is not a usable "
|
||||
"number. Set it in kWh, or leave it empty on every inverter.")
|
||||
else:
|
||||
stated.append(who)
|
||||
@@ -765,7 +811,16 @@ class ConfigRefused(Exception):
|
||||
super().__init__(problems[0] if problems else "configuration refused")
|
||||
|
||||
|
||||
def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]:
|
||||
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.
|
||||
|
||||
Empty list means go. `dev_min`/`dev_max` are the setpoint device's own
|
||||
@@ -775,15 +830,16 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
|
||||
Pure and side-effect free apart from two warnings, so it can be called from
|
||||
a test or from sim/inject.py without a Home Assistant anywhere near it.
|
||||
|
||||
UX-13: pass a list as `notes` to also get each logged warning as a `Note`
|
||||
for the settings page. The refusals returned do not depend on it.
|
||||
"""
|
||||
problems: list[str] = []
|
||||
|
||||
# --- 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.
|
||||
@@ -831,7 +887,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# has answered; it is not called from here because at startup no device has.
|
||||
max_w = _number(opts, "max_w", 2000.0)
|
||||
if max_w is None:
|
||||
problems.append(f"{S('max_w')} is {opts.get('max_w')!r}, which is not "
|
||||
problems.append(f"{S('max_w')} is {messages.shown(opts.get('max_w'))}, which is not "
|
||||
"a number. Set it in watts.")
|
||||
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
|
||||
# the check is not for the Supervisor path - it is because
|
||||
@@ -866,8 +922,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if phases is None or max_age_s is None:
|
||||
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
|
||||
f"both be numbers. They are "
|
||||
f"{opts.get('meter_phases')!r} and "
|
||||
f"{opts.get('meter_max_age_s')!r}.")
|
||||
f"{messages.shown(opts.get('meter_phases'))} and "
|
||||
f"{messages.shown(opts.get('meter_max_age_s'))}.")
|
||||
else:
|
||||
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
||||
|
||||
@@ -918,14 +974,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
weakest = min(caps) if caps else None
|
||||
if sign_w is None:
|
||||
problems.append(f"{S('sign_disagree_w')} is "
|
||||
f"{opts.get('sign_disagree_w')!r}, which is not a "
|
||||
f"{messages.shown(opts.get('sign_disagree_w'))}, which is not a "
|
||||
"number. Set it back to 300 W.")
|
||||
elif weakest is not None and weakest > 0 and sign_w >= weakest:
|
||||
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||
problems.append(
|
||||
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the "
|
||||
f"{S('max_w', where)} is {weakest:.0f} W, so this safety stop can "
|
||||
f"never trip. Lower it below {weakest:.0f} W, or raise the rating.")
|
||||
f"{S('sign_disagree_w')} is {sign_w:.0f} W, not below "
|
||||
f"{S('max_w', where)} at {weakest:.0f} W, so this safety stop can "
|
||||
f"never trip. Lower it or raise the rating.")
|
||||
|
||||
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
|
||||
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
|
||||
@@ -972,15 +1028,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if caps_ix else "")
|
||||
if unit_sign_w is None:
|
||||
problems.append(f"{S('unit_sign_disagree_w')} is "
|
||||
f"{opts.get('unit_sign_disagree_w')!r}, which is not a "
|
||||
f"{messages.shown(opts.get('unit_sign_disagree_w'))}, which is not a "
|
||||
"number. Set it back to "
|
||||
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
|
||||
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||
problems.append(
|
||||
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, but the "
|
||||
f"{S('max_w', unit_where)} is {weakest:.0f} W, so this safety stop "
|
||||
f"can never trip. Lower it below {weakest:.0f} W, or raise the "
|
||||
"rating.")
|
||||
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, not below "
|
||||
f"{S('max_w', unit_where)} at {weakest:.0f} W, so this safety stop "
|
||||
f"can never trip. Lower it or raise the rating.")
|
||||
|
||||
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
||||
@@ -1054,7 +1109,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if charge_w is None:
|
||||
problems.append(
|
||||
f"{S('maintenance_charge_w')} is "
|
||||
f"{opts.get('maintenance_charge_w')!r}, which is not a number. Set "
|
||||
f"{messages.shown(opts.get('maintenance_charge_w'))}, which is not a number. Set "
|
||||
"it in watts.")
|
||||
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
||||
problems.append(
|
||||
@@ -1089,7 +1144,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
slew_w = _number(opts, "slew_w", 0.0)
|
||||
rail = site_rail_w(opts)
|
||||
if slew_w is None:
|
||||
problems.append(f"{S('slew_w')} is {opts.get('slew_w')!r}, which is "
|
||||
problems.append(f"{S('slew_w')} is {messages.shown(opts.get('slew_w'))}, which is "
|
||||
"not a number. Set it to 0 to follow the inverter "
|
||||
"rating.")
|
||||
elif slew_w < 0:
|
||||
@@ -1104,7 +1159,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
f"site. Set it to 0 or to at most {bound:.0f} W.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
problems.extend(_price_problems(opts, notes))
|
||||
|
||||
# --- 8. an EMS energy floor the BMS does not already enforce -------------
|
||||
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
|
||||
@@ -1133,8 +1188,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if soc_floor is None or bms_floor is None:
|
||||
problems.append(
|
||||
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
|
||||
f"both be numbers. They are {opts.get('peak_soc_floor')!r} and "
|
||||
f"{_raw!r}.")
|
||||
f"both be numbers. They are {messages.shown(opts.get('peak_soc_floor'))} and "
|
||||
f"{messages.shown(_raw)}.")
|
||||
elif soc_floor <= bms_floor:
|
||||
problems.append(
|
||||
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
|
||||
@@ -1142,16 +1197,20 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
f"reserve above {bms_floor:.0f} %, or correct the cut-out "
|
||||
"level.")
|
||||
|
||||
# --- 12. SAFETY-79: the margin the peak guard keeps under the limit -----
|
||||
problems += _margin_problems(opts)
|
||||
|
||||
# --- warnings: real, but not worth refusing over -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
||||
if integ_w is not None and max_w is not None and integ_w > max_w:
|
||||
_LOG.warning(
|
||||
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
|
||||
"arbiter still bounds the wire to +-max_w - but the only thing the "
|
||||
"extra headroom buys is more wrong-direction power to unwind after "
|
||||
"saturation. 0 (follow max_w) is the recommended value.",
|
||||
"integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
|
||||
"but it only adds overshoot, so 0 is recommended.",
|
||||
integ_w, max_w)
|
||||
_advise(notes, "warn", ("integrator_max_w",),
|
||||
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
|
||||
"adds overshoot, so set it to 0.")
|
||||
|
||||
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
||||
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
|
||||
@@ -1173,20 +1232,22 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if (disch_w is not None and maint_cap is not None and maint_cap > 0
|
||||
and disch_w > maint_cap):
|
||||
_LOG.warning(
|
||||
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
|
||||
"refused - the drain phase SETS the setpoint and the arbiter clamps "
|
||||
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
|
||||
"so nothing is silent and the cycle still completes. But it drains "
|
||||
"at %.0f W, not the %.0f W you configured, and the drain phase has "
|
||||
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
|
||||
"surprise.",
|
||||
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
|
||||
maint_cap)
|
||||
"maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
|
||||
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
|
||||
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
|
||||
_advise(notes, "warn", ("maintenance_discharge_w",),
|
||||
f"{S('maintenance_discharge_w')} is above "
|
||||
f"{S('max_w', maint_where)}, so the battery empties at "
|
||||
f"{messages.watts(maint_cap)} and the cycle takes longer.")
|
||||
_LOG.debug(
|
||||
"Not refused: the drain phase sets the setpoint, the arbiter "
|
||||
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
||||
"a 720 min timeout.", maint_cap)
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
def _price_problems(opts: dict) -> list[str]:
|
||||
def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
|
||||
|
||||
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
|
||||
@@ -1289,10 +1350,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if str(opts.get(k, "") or "").strip()]
|
||||
if pid != "off" and ha_price:
|
||||
problems.append(
|
||||
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} "
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} also set and would win. "
|
||||
f"Clear {'it' if len(ha_price) == 1 else 'them'}, or set the price "
|
||||
"source to 'off'.")
|
||||
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} would "
|
||||
f"win over it. Clear {'it' if len(ha_price) == 1 else 'them'}, or "
|
||||
"set the source to 'off'.")
|
||||
|
||||
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
|
||||
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
|
||||
@@ -1302,7 +1362,8 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if ha_price and str(opts.get("static_profile", "") or "").strip():
|
||||
problems.append(
|
||||
f"{S('static_profile')} is set, but {_labels(ha_price)} would win "
|
||||
"over it. Clear the one you do not use.")
|
||||
f"over it. Clear the fixed tariff or "
|
||||
f"{'the entity' if len(ha_price) == 1 else 'the two entities'}.")
|
||||
|
||||
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
|
||||
# that does not tile the day has hours with no price, and the only ways to
|
||||
@@ -1319,12 +1380,11 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if profile is not None and profile.product is not None \
|
||||
and len(set(profile.prices)) == 1:
|
||||
_LOG.info(
|
||||
"static_profile carries one distinct price (%.4f EUR/kWh "
|
||||
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
|
||||
"off by arithmetic - a flat series has no spread - and the "
|
||||
"add-on runs self-consumption only, which is §9.2's handling "
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
"static_profile has one price (%.4f EUR/kWh all-in), so this "
|
||||
"site has a fully fixed price. A flat price has no spread to "
|
||||
"trade on, so the add-on uses the battery for self-consumption "
|
||||
"only. Peak limiting, the reserve and battery maintenance are "
|
||||
"not affected.", profile.prices[0])
|
||||
|
||||
# ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT
|
||||
# 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read
|
||||
@@ -1374,9 +1434,8 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
|
||||
problems.append(
|
||||
f"{S(TAXES_PROFILE_OPTION)} is set, so "
|
||||
f"{S('price_taxes_eur_kwh')} is not used, but it reads {scalar} "
|
||||
f"instead of {shipped}. Set it back to {shipped}, or clear the "
|
||||
"table.")
|
||||
f"{S('price_taxes_eur_kwh')} is not used. Set it back to "
|
||||
f"{shipped} from {scalar}, or clear the table.")
|
||||
|
||||
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
||||
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
|
||||
@@ -1403,10 +1462,12 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# observe. §14's warning box governs CONTRADICTIONS, and this is
|
||||
# not one.
|
||||
_LOG.warning(
|
||||
"%s carries negative levies %s EUR/kWh. That is accepted, but "
|
||||
"a tax component below zero is usually a sign error: the "
|
||||
"add-on will SUBTRACT it from the published price in those "
|
||||
"hours.", TAXES_PROFILE_OPTION, negative)
|
||||
"%s has negative levies %s EUR/kWh, which are subtracted from "
|
||||
"the price. This is allowed, but it is usually a sign error.",
|
||||
TAXES_PROFILE_OPTION, negative)
|
||||
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
|
||||
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
|
||||
"lower the price, and that is usually a sign error.")
|
||||
|
||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||
# STARTUP RATHER THAN AT 03:00.
|
||||
@@ -1459,11 +1520,13 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# silent loser this module's case (a) is about, so it is said out
|
||||
# loud rather than left to be discovered from a bill.
|
||||
_LOG.warning(
|
||||
"price_formula is filled in but price_provider is %r, so the "
|
||||
"coefficients decide NOTHING - only 'custom_formula' reads "
|
||||
"them. Set price_provider to 'custom_formula' to use them, or "
|
||||
"clear price_formula so the configuration says what the "
|
||||
"add-on is doing.", pid)
|
||||
"price_formula is filled in, but price_provider is %r, so the "
|
||||
"formula is not used. Set price_provider to 'custom_formula' "
|
||||
"or clear price_formula.", pid)
|
||||
_advise(notes, "warn", ("price_formula", "price_provider"),
|
||||
f"{S('price_formula')} is not used, because "
|
||||
f"{S('price_provider')} is not 'custom_formula', so clear "
|
||||
"it or choose that source.")
|
||||
if spec is not None and pid == "custom_formula":
|
||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
@@ -1477,6 +1540,8 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# exactly right and a site refused over one is a site that
|
||||
# cannot start.
|
||||
_LOG.warning("%s", aged)
|
||||
_advise(notes, "warn", ("price_formula",),
|
||||
_card_note("price_formula", spec))
|
||||
|
||||
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
|
||||
#
|
||||
@@ -1514,9 +1579,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
_LOG.info(
|
||||
"%s is set: injection priced as (%g x index %+g) x "
|
||||
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
|
||||
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS "
|
||||
"IT: arbitrage.py prices the sell side as displaced IMPORT "
|
||||
"and compares two all-in CONSUMPTION prices, so this option "
|
||||
"%+.5f EUR/kWh offset. No part of the add-on reads it yet: "
|
||||
"trading prices the sell side as displaced import and "
|
||||
"compares two all-in consumption prices, so this setting "
|
||||
"changes no decision the add-on takes today. It is what you "
|
||||
"are shown, and the shape a future export-aware strategy "
|
||||
"would read.",
|
||||
@@ -1552,24 +1617,27 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# bounds in `implausible_injection()` are applied, and the
|
||||
# operator is told which of the two checks did NOT run.
|
||||
_LOG.warning(
|
||||
"%s has no \"card\" block, so its coefficients are "
|
||||
"UNGRADED: nothing recomputed a printed price from them. "
|
||||
"That is accepted because it is the real market - Eneco "
|
||||
"prints its injection formula with no price beside it, so "
|
||||
"there is nothing to grade against on that card - and "
|
||||
"refusing would make the published line inexpressible. "
|
||||
"What IS checked is the shape of the coefficients: the "
|
||||
"pass-through (%.4g x the wholesale price) and the offset "
|
||||
"(%+.5f EUR/kWh). If YOUR card prints an injection "
|
||||
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
|
||||
"..., \"expect_incl_vat\": ...} and the add-on will "
|
||||
"refuse to start unless your numbers reproduce it.",
|
||||
"%s has no \"card\" block, so its numbers are not checked "
|
||||
"against a printed price, only their shape (pass-through "
|
||||
"%.4g, offset %+.5f EUR/kWh).",
|
||||
EXPORT_FORMULA_OPTION, xspec.passthrough,
|
||||
xspec.offset_eur_kwh)
|
||||
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
|
||||
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
|
||||
"only the shape of its numbers is checked.")
|
||||
_LOG.debug(
|
||||
"Accepted because some suppliers (Eneco) print the "
|
||||
"injection formula with no price beside it. If your card "
|
||||
"prints an injection estimate, add {\"month\": ..., "
|
||||
"\"index_eur_per_mwh\": ..., \"expect_incl_vat\": ...} "
|
||||
"and the add-on refuses to start unless your numbers "
|
||||
"reproduce it.")
|
||||
else:
|
||||
xaged = stale_card(xspec)
|
||||
if xaged:
|
||||
_LOG.warning("%s", xaged)
|
||||
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
|
||||
_card_note(EXPORT_FORMULA_OPTION, xspec))
|
||||
|
||||
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
||||
# site with no price data falls back to the static profile; a dynamic site
|
||||
@@ -1578,11 +1646,12 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# every install TARIFF-02 shipped, none of which could have had one.
|
||||
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||
_LOG.warning(
|
||||
"price_provider is %r but static_profile is empty. §11.4's "
|
||||
"degradation row is 'no price data -> fall back to the static "
|
||||
"profile'; with none configured, a feed outage leaves this site "
|
||||
"with no tariff at all rather than with a coarse one. Not refused, "
|
||||
"but filling it in is what makes that row work.", pid)
|
||||
"price_provider is %r but static_profile is empty, so a price "
|
||||
"feed outage leaves no tariff at all. Fill in static_profile as a "
|
||||
"fallback.", pid)
|
||||
_advise(notes, "warn", ("static_profile",),
|
||||
f"{S('static_profile')} is empty, so the site has no tariff "
|
||||
"at all while the price feed is down.")
|
||||
|
||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||
@@ -1598,7 +1667,7 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if thresh is None or thresh < 0:
|
||||
problems.append(
|
||||
f"{S('cycle_cost_threshold_eur')} is "
|
||||
f"{opts.get('cycle_cost_threshold_eur')!r}. Set it to 0 or more, "
|
||||
f"{messages.shown(opts.get('cycle_cost_threshold_eur'))}. Set it to 0 or more, "
|
||||
"in EUR/kWh.")
|
||||
|
||||
if arbitrage_on(opts):
|
||||
@@ -1634,6 +1703,348 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def _card_note(option: str, spec) -> str:
|
||||
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
|
||||
"the new figures if your contract changed.")
|
||||
|
||||
|
||||
# UX-13: entity options whose number the add-on takes as watts, with no unit
|
||||
# conversion. pv.py converts each solar entity's own unit, so those are not here.
|
||||
WATT_ENTITY_KEYS = (
|
||||
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
|
||||
"p1_phase_net_entities", "p1_phase_import_entities",
|
||||
"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
|
||||
are left out, so a stale default is not reported."""
|
||||
out = []
|
||||
|
||||
def add(key, value, where=""):
|
||||
for e in (value if isinstance(value, list) else [value]):
|
||||
if isinstance(e, str) and e.strip():
|
||||
out.append((key, where, e.strip()))
|
||||
|
||||
source = str(opts.get("meter_source") or "off").strip()
|
||||
for key in METER_ENTITY_KEYS.get(source, ()):
|
||||
add(key, opts.get(key))
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
|
||||
add(key, opts.get(key))
|
||||
for i, entry in enumerate(entries):
|
||||
if isinstance(entry, dict):
|
||||
where = _unit_label(opts, i).strip().strip("()")
|
||||
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
|
||||
add(key, entry.get(key), where)
|
||||
solar = [e for e in (opts.get("pv_entities") or []) if str(e or "").strip()]
|
||||
add("pv_entities" if solar else "pv_entity", list(pv.entities(opts)))
|
||||
for key in ("peak_forecast_entity", "price_now_entity", "price_avg_entity"):
|
||||
add(key, opts.get(key))
|
||||
return out
|
||||
|
||||
|
||||
def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
month_peak_w=None) -> list[Note]:
|
||||
"""UX-13: warnings that need live data, for the settings page. Never a
|
||||
refusal, and nothing here changes what `validate_startup_config()` says.
|
||||
|
||||
`entities` is Home Assistant's entity list by id (app/haentities.py), or
|
||||
None when it could not be read, in which case those checks are skipped.
|
||||
`month_peak_w` is this month's peak so far, or None.
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is not None:
|
||||
_entity_notes(notes, opts, entities)
|
||||
cap = _number(opts, "peak_cap_w", None)
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
f"This month's peak is already {messages.watts(month_peak_w)}, "
|
||||
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
|
||||
return notes
|
||||
|
||||
|
||||
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
"""A note for each entity these options read that Home Assistant does not
|
||||
have, or that reads in kW where watts are taken. A missing entity whose
|
||||
key is in `bad` is a "bad" note, which blocks the setup wizard."""
|
||||
for key, where, entity in _entities_read(opts):
|
||||
row = entities.get(entity)
|
||||
if row is None:
|
||||
_advise(notes, "bad" if key in bad else "warn", (key,),
|
||||
f"Home Assistant has no entity {entity}, which "
|
||||
f"{S(key, where)} names.")
|
||||
continue
|
||||
unit = str(row.get("unit") or "")
|
||||
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# UX-13 P4: the setup wizard's last step
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Below this a battery or solar reading counts as idle for a sign check.
|
||||
IDLE_W = 50.0
|
||||
# Meter + solar + battery is the house's use, never below zero. Below minus
|
||||
# this, read seconds apart, a direction is reversed (a battery giving power).
|
||||
BALANCE_W = 300.0
|
||||
# The keys that pick the meter. A meter the add-on reads itself (HomeWizard,
|
||||
# MQTT) is only known live when the running options read the same one.
|
||||
_METER_KEYS = ("meter_source", "p1_host", "meter_mqtt_topic")
|
||||
|
||||
|
||||
def power_request_entities(opts: dict) -> list:
|
||||
"""The power request entity of each inverter these options name: the flat
|
||||
one, or each entry's. Blank ones are left out."""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
values = [opts.get("setpoint_entity")]
|
||||
else:
|
||||
values = [e.get("setpoint_entity") for e in entries
|
||||
if isinstance(e, dict)]
|
||||
return [str(v).strip() for v in values
|
||||
if isinstance(v, str) and v.strip()]
|
||||
|
||||
|
||||
def _reading(entities, entity, convert=False):
|
||||
"""(number, unit) of an entity's state, or (None, state text). The number
|
||||
is as the add-on reads it: raw for the meter and battery power, which it
|
||||
takes as watts, and in W for solar (`convert`), which pv.py converts."""
|
||||
row = (entities or {}).get(entity) or {}
|
||||
unit = str(row.get("unit") or "")
|
||||
try:
|
||||
value = float(row.get("state"))
|
||||
except (TypeError, ValueError):
|
||||
return None, str(row.get("state") or "nothing")
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
return None, str(row.get("state"))
|
||||
return value * (pv.WATTS_PER.get(unit, 1.0) if convert else 1.0), unit
|
||||
|
||||
|
||||
def _signed(w) -> str:
|
||||
return ("-" if w < 0 else "") + messages.watts(w)
|
||||
|
||||
|
||||
def _cards(opts: dict) -> list:
|
||||
"""(who, entry) per inverter: who is " for <name>" in a list, else ""."""
|
||||
entries = roster_entries(opts)
|
||||
if not entries:
|
||||
return [("", {k: opts.get(k) for k in (
|
||||
"setpoint_entity", "soc_entity", "batt_entity", "batt_invert")})]
|
||||
out = []
|
||||
for i, entry in enumerate(entries):
|
||||
if isinstance(entry, dict):
|
||||
name = _unit_label(opts, i).strip().strip("()")
|
||||
out.append((f" for {name}" if name else "", entry))
|
||||
return out
|
||||
|
||||
|
||||
def setup_checks(opts: dict, *, entities: dict | None, running=None,
|
||||
meter_now_w=None, prices_known=None) -> list[Note]:
|
||||
"""UX-13 P4: what the setup wizard's last step shows, beside the refusals.
|
||||
|
||||
Live checks on the proposed options: the meter reads, each power request
|
||||
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 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.
|
||||
"""
|
||||
notes: list[Note] = []
|
||||
if entities is None:
|
||||
# Red: only the list can tell a missing power request entity (SAFETY-77).
|
||||
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
|
||||
"answering, so press Check again once it is back.")
|
||||
else:
|
||||
# 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)]
|
||||
solar = []
|
||||
if entities is not None:
|
||||
flipped = pv.inverted(opts)
|
||||
for entity in pv.entities(opts):
|
||||
w, _unit = _reading(entities, entity, convert=True)
|
||||
if entity not in entities or w is None:
|
||||
solar.append(None)
|
||||
continue
|
||||
if w < -IDLE_W and entity not in flipped:
|
||||
solar.append(None)
|
||||
_advise(notes, "warn", ("pv_invert_entities",),
|
||||
f"{entity} reads below zero now, so turn on its "
|
||||
"“Reads negative” switch in the Solar step.")
|
||||
else:
|
||||
w = -w if entity in flipped else w
|
||||
solar.append(w)
|
||||
_advise(notes, "ok", ("pv_entity",),
|
||||
f"{entity} reads {_signed(w)} of solar production "
|
||||
"now.")
|
||||
# A battery sensor that reads the wrong way while giving power makes the
|
||||
# house use less than nothing. Only with every term known: a missing
|
||||
# solar reading could be the difference.
|
||||
known = [meter] + batts + solar
|
||||
if solar and batts and all(w is not None for w in known) \
|
||||
and sum(known) < -BALANCE_W:
|
||||
_advise(notes, "warn", ("batt_invert",),
|
||||
f"The readings add up to {_signed(sum(known))} of house "
|
||||
"use, so check each "
|
||||
"battery's “Battery power sign is reversed” switch in step 2.")
|
||||
_price_check(notes, opts, running, prices_known)
|
||||
return notes
|
||||
|
||||
|
||||
def _meter_check(notes, opts, entities, running, meter_now_w):
|
||||
"""The meter line. Returns the grid reading in W (positive: taken from
|
||||
the grid) as the add-on will read it, or None when it is not known."""
|
||||
source = str(opts.get("meter_source") or "off").strip()
|
||||
if source == "off":
|
||||
parts = [(opts.get("meter_entity"), 1.0)]
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
parts = [(opts.get("p1_net_entity"), 1.0)]
|
||||
elif source == SOURCE_HA:
|
||||
parts = [(opts.get("p1_import_entity"), 1.0),
|
||||
(opts.get("p1_export_entity"), -1.0)]
|
||||
else:
|
||||
same = isinstance(running, dict) and all(
|
||||
running.get(k) == opts.get(k) for k in _METER_KEYS)
|
||||
if same and meter_now_w is not None:
|
||||
_meter_line(notes, float(meter_now_w))
|
||||
return float(meter_now_w)
|
||||
_advise(notes, "info", ("meter_source",), "The add-on reads "
|
||||
"this meter itself, so Home shows its reading after "
|
||||
"the restart.")
|
||||
return None
|
||||
if entities is None:
|
||||
return None
|
||||
total = 0.0
|
||||
for entity, sign in parts:
|
||||
entity = str(entity or "").strip()
|
||||
if not entity or entity not in entities:
|
||||
return None # a refusal or a missing-entity note says so
|
||||
w, text = _reading(entities, entity)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("meter_source",),
|
||||
f"{entity} reads {text} now, so check it in Home "
|
||||
"Assistant before you start.")
|
||||
return None
|
||||
total += sign * w
|
||||
if source == "off" and opts.get("meter_invert"):
|
||||
total = -total
|
||||
_meter_line(notes, total)
|
||||
return total
|
||||
|
||||
|
||||
def _meter_line(notes, w) -> None:
|
||||
if abs(w) < 1.0:
|
||||
text = "The meter reads 0 W now."
|
||||
elif w > 0:
|
||||
text = f"The meter reads {messages.watts(w)} taken from the grid now."
|
||||
else:
|
||||
text = f"The meter reads {messages.watts(w)} fed into the grid now."
|
||||
_advise(notes, "ok", ("meter_source",), text)
|
||||
|
||||
|
||||
def _card_checks(notes, who, entry, entities):
|
||||
"""The checks for one inverter's card. Returns its battery power in W,
|
||||
positive while giving power, or None when it is not known."""
|
||||
if entities is None:
|
||||
return None
|
||||
setpoint = str(entry.get("setpoint_entity") or "").strip()
|
||||
if setpoint and setpoint in entities:
|
||||
_advise(notes, "ok", ("setpoint_entity",),
|
||||
f"Home Assistant has {setpoint}, the power request "
|
||||
f"entity{who}.")
|
||||
soc = str(entry.get("soc_entity") or "").strip()
|
||||
if not soc:
|
||||
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
|
||||
f"level entity{who}, so pick one in step 2 or the add-on "
|
||||
"holds 0 W.")
|
||||
elif soc in entities:
|
||||
level, _unit = _reading(entities, soc)
|
||||
if level is None or not 0.0 <= level <= 100.0:
|
||||
_advise(notes, "warn", ("soc_entity",), f"The battery "
|
||||
f"level{who} reads nothing now, so check {soc}.")
|
||||
else:
|
||||
_advise(notes, "ok", ("soc_entity",),
|
||||
f"The battery level{who} reads {level:.0f} %.")
|
||||
batt = str(entry.get("batt_entity") or "").strip()
|
||||
if batt and batt in entities:
|
||||
w, _unit = _reading(entities, batt)
|
||||
if w is None:
|
||||
_advise(notes, "warn", ("batt_entity",), f"The battery "
|
||||
f"power{who} reads nothing now, so check {batt}.")
|
||||
return None
|
||||
w = -w if entry.get("batt_invert") else w
|
||||
if abs(w) < IDLE_W:
|
||||
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
|
||||
"idle now, so check its direction on Home later.")
|
||||
else:
|
||||
doing = "giving power" if w > 0 else "charging"
|
||||
_advise(notes, "info", ("batt_invert",),
|
||||
f"The battery{who} reads {messages.watts(w)} {doing} "
|
||||
"now, so check the inverter shows the same.")
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
def _price_check(notes, opts, running, prices_known) -> None:
|
||||
provider = str(opts.get("price_provider") or "off").strip()
|
||||
profile = str(opts.get("static_profile") or "").strip()
|
||||
if provider in ("", "off"):
|
||||
if profile:
|
||||
_advise(notes, "ok", ("static_profile",),
|
||||
"Your prices come from your contract, so they are "
|
||||
"always known.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"No prices are set, so the batteries do not charge "
|
||||
"on price.")
|
||||
return
|
||||
same = (isinstance(running, dict)
|
||||
and str(running.get("price_provider") or "") == provider)
|
||||
if same and prices_known:
|
||||
_advise(notes, "ok", ("price_provider",),
|
||||
f"Prices for today are in, for {prices_known} of 24 hours.")
|
||||
else:
|
||||
_advise(notes, "info", ("price_provider",),
|
||||
"Prices are fetched after the restart, and Home shows them.")
|
||||
|
||||
|
||||
def _margin_problems(opts: dict) -> list[str]:
|
||||
"""Rule 12: `peak_margin_w` is a whole number in the schema's range, and
|
||||
below the peak limit, which would leave nothing to aim at."""
|
||||
raw = opts.get("peak_margin_w")
|
||||
margin = _number(opts, "peak_margin_w",
|
||||
PEAK_OPTION_DEFAULTS["peak_margin_w"])
|
||||
lo, hi = PEAK_MARGIN_RANGE_W
|
||||
if margin is None or margin != int(margin) or not lo <= margin <= hi:
|
||||
return [f"{S('peak_margin_w')} must be a whole number from {lo} to "
|
||||
f"{hi} W. It is {messages.shown(raw)}."]
|
||||
cap = _number(opts, "peak_cap_w", None)
|
||||
if cap is not None and margin >= cap:
|
||||
return [f"{S('peak_margin_w')} is {margin:.0f} W, not below the "
|
||||
f"{S('peak_cap_w')} of {cap:.0f} W. Lower the margin."]
|
||||
return []
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
@@ -1655,11 +2066,9 @@ def refuse(problems: list[str]) -> None:
|
||||
at all and even the exit status distinguishes the two.
|
||||
"""
|
||||
_LOG.error("=" * 72)
|
||||
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
|
||||
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
|
||||
_LOG.error("the add-on will start as soon as the settings below are fixed.")
|
||||
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
|
||||
_LOG.error("Configuration, then start the add-on again.")
|
||||
_LOG.error("The add-on did not start, because its settings have problems.")
|
||||
_LOG.error("Nothing is broken. Fix the settings below, then start it again.")
|
||||
_LOG.error("They are on the add-on's Configuration tab.")
|
||||
_LOG.error("-" * 72)
|
||||
for i, problem in enumerate(problems, 1):
|
||||
_LOG.error(" %d. %s", i, problem)
|
||||
|
||||
@@ -670,11 +670,10 @@ class SignWatchdog:
|
||||
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
|
||||
# and it defaults to the wording that shipped - "a meter reading" - so a
|
||||
# one-inverter install's `reason` is byte-identical to what an operator
|
||||
# (and test_watchdog.py) has always read. A per-unit instance names what
|
||||
# it actually compared instead, because "commanded +1500 W against a
|
||||
# meter reading -1500 W" would be a false statement about which
|
||||
# instrument produced the evidence, on the one line somebody woken by
|
||||
# this alarm reads first.
|
||||
# has always read (test_watchdog.py pins its meaning). A per-unit
|
||||
# instance names what it actually compared instead, because "commanded
|
||||
# +1500 W against a meter reading -1500 W" would be a false statement
|
||||
# about which instrument produced the evidence.
|
||||
self.reason = (
|
||||
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
|
||||
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "
|
||||
|
||||
@@ -10,7 +10,9 @@ a cellar, by someone who did not write it. Numbers alone are not enough - it
|
||||
says what is wrong in words.
|
||||
|
||||
⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S.
|
||||
`/` is the status and control surface and is unchanged. `/config` is the
|
||||
`/` is the status and control surface; since UX-13 P1 its files are in
|
||||
app/static/ and its Home screen reads the structured half of `/status`
|
||||
(app/homeview.py). `/config` is the
|
||||
configuration surface: its markup lives in app/config_page.py, its field model
|
||||
in app/configui.py and its Supervisor conversation in app/selfconfig.py. What
|
||||
stays HERE is the routing, so that the question a reviewer has to answer - does
|
||||
@@ -24,97 +26,70 @@ routes below read `GET /addons/self/info`, run the SHIPPED validator, write
|
||||
mode or reaches `controller.handle_action()`. The set of things an operator can
|
||||
do to the inverter through this panel is exactly what `/action` offered before
|
||||
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
|
||||
actions and comparing them with the shipped set.
|
||||
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
|
||||
of Home Assistant's entity list (app/haentities.py) and writes nothing.
|
||||
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); P3 adds
|
||||
`pack_sell`, that battery's selling switch. They
|
||||
are the only actions that name a battery; the configuration routes still reach
|
||||
none of them.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import selfconfig
|
||||
from . import messages, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import region_of
|
||||
from .validate import config_notes, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
from .uimodel import DeriveError, options_to_ui
|
||||
|
||||
_LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
PAGE = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>GoodWe RS485 Controller</title>
|
||||
<style>
|
||||
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
|
||||
--ok:#137333;--warn:#b25e02;--bad:#c5221f}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
|
||||
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;padding:16px;background:var(--bg);color:var(--ink);
|
||||
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
|
||||
h1{font-size:18px;margin:0 0 12px}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
|
||||
padding:14px;margin-bottom:12px}
|
||||
.banner{font-weight:600;padding:12px 14px;border-radius:10px;margin-bottom:12px}
|
||||
.b-ok{background:rgba(19,115,51,.12);color:var(--ok)}
|
||||
.b-warn{background:rgba(178,94,2,.12);color:var(--warn)}
|
||||
.b-bad{background:rgba(197,34,31,.12);color:var(--bad)}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
|
||||
tr:last-child td{border-bottom:0}
|
||||
.k{color:var(--muted);font-weight:400}
|
||||
ul{margin:6px 0 0;padding-left:18px}
|
||||
li{margin:3px 0}
|
||||
.ok{color:var(--ok)} .bad{color:var(--bad)} .warn{color:var(--warn)}
|
||||
button{font:inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);
|
||||
background:var(--card);color:var(--ink);cursor:pointer;margin:0 6px 6px 0}
|
||||
button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}
|
||||
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
|
||||
.muted{color:var(--muted);font-size:13px}
|
||||
</style></head><body>
|
||||
<h1>GoodWe RS485 Controller</h1>
|
||||
<p class="muted"><a href="config">Configuration →</a></p>
|
||||
<div id="banner" class="banner b-warn">loading…</div>
|
||||
# OPT-11: the per-battery actions, which name a battery and a value.
|
||||
PACK_ACTIONS = ("pack_use", "pack_mode", "pack_sell")
|
||||
|
||||
<div class="card">
|
||||
<table id="live"></table>
|
||||
</div>
|
||||
# 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/<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")
|
||||
|
||||
<div class="card">
|
||||
<div class="k">Commissioning</div>
|
||||
<ul id="checks"></ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<button class="primary" onclick="act('auto_toggle')">Start / stop control</button>
|
||||
<button onclick="act('maint_start')">Force maintenance cycle</button>
|
||||
<button class="danger" onclick="act('maint_abort')">Abort maintenance</button>
|
||||
<div class="muted" id="hint"></div>
|
||||
</div>
|
||||
def _read(path):
|
||||
with io.open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
<script>
|
||||
async function refresh(){
|
||||
try{
|
||||
const r = await fetch('status', {cache:'no-store'});
|
||||
const s = await r.json();
|
||||
const b = document.getElementById('banner');
|
||||
b.textContent = s.banner;
|
||||
b.className = 'banner ' + (s.level==='ok'?'b-ok':s.level==='bad'?'b-bad':'b-warn');
|
||||
document.getElementById('live').innerHTML = s.rows
|
||||
.map(([k,v]) => `<tr><td class="k">${k}</td><td>${v}</td></tr>`).join('');
|
||||
document.getElementById('checks').innerHTML = s.checks
|
||||
.map(c => `<li class="${c.ok?'ok':(c.warn?'warn':'bad')}">${c.ok?'✓':(c.warn?'!':'✗')} ${c.text}</li>`)
|
||||
.join('');
|
||||
document.getElementById('hint').textContent = s.hint || '';
|
||||
}catch(e){
|
||||
document.getElementById('banner').textContent = 'cannot reach the add-on';
|
||||
}
|
||||
}
|
||||
async function act(what){
|
||||
await fetch('action', {method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action: what})});
|
||||
refresh();
|
||||
}
|
||||
refresh(); setInterval(refresh, 3000);
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
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({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))
|
||||
if "</script" in scripts[name].lower():
|
||||
raise ValueError(f"{name} may not contain a closing script tag")
|
||||
return (_read(os.path.join(STATIC, "index.html"))
|
||||
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
|
||||
.replace("/*UI_TEXT*/", ui_text.replace("</", "<\\/"))
|
||||
.replace("/*APP_JS*/", scripts["app.js"])
|
||||
.replace("/*SETTINGS_JS*/", scripts["settings.js"]))
|
||||
|
||||
|
||||
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
|
||||
@@ -125,11 +100,8 @@ refresh(); setInterval(refresh, 3000);
|
||||
# them too, and says so, because a green page that is green for a reason the
|
||||
# operator cannot see is the exact failure this add-on keeps refusing to ship.
|
||||
DEVICE_NOTE = (
|
||||
"%s did not report a range, so the two checks that compare a configured "
|
||||
"ceiling against what the device itself accepts were skipped - here and at "
|
||||
"startup, identically. Everything else was checked. If the device answers "
|
||||
"later and disagrees, the add-on refuses to start then, with the same "
|
||||
"message you would have seen here.")
|
||||
"%s did not report its power range, so two checks were skipped, here and "
|
||||
"at start. If they fail later, the add-on will not start then.")
|
||||
|
||||
|
||||
def build_app(controller, selfcfg=None) -> web.Application:
|
||||
@@ -143,12 +115,34 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
if selfcfg is None:
|
||||
selfcfg = selfconfig.SelfConfig(controller.hass.session,
|
||||
controller.hass.token)
|
||||
entities = EntityCache(controller.hass)
|
||||
index_page = page()
|
||||
# UX-13 P4: the blobs /config/model handed out, by fingerprint, so a stale
|
||||
# save can name only the keys that moved underneath the page (finding a).
|
||||
handed: dict = {}
|
||||
|
||||
def hand_out(options):
|
||||
fp = selfconfig.fingerprint(options)
|
||||
handed.pop(fp, None)
|
||||
handed[fp] = options
|
||||
while len(handed) > 16:
|
||||
handed.pop(next(iter(handed)))
|
||||
return fp
|
||||
|
||||
def store_data():
|
||||
store = getattr(controller, "store", None)
|
||||
data = getattr(store, "data", None)
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
async def index(_request):
|
||||
return web.Response(text=PAGE, content_type="text/html")
|
||||
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:
|
||||
@@ -156,7 +150,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})
|
||||
@@ -165,15 +173,31 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
async def config_index(_request):
|
||||
return web.Response(text=CONFIG_PAGE, content_type="text/html")
|
||||
|
||||
async def _proposed(request):
|
||||
async def _proposed(request, stored=None):
|
||||
"""(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.
|
||||
|
||||
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):
|
||||
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")
|
||||
return body, options
|
||||
return body, options, None
|
||||
|
||||
async def config_model(request):
|
||||
"""Everything the page needs to draw itself, from ONE Supervisor call.
|
||||
@@ -198,10 +222,25 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
info.get("translations") or {}, lang)
|
||||
model.update({
|
||||
"options": options,
|
||||
"fingerprint": selfconfig.fingerprint(options),
|
||||
"fingerprint": hand_out(options),
|
||||
"running": controller.o,
|
||||
"running_differs": changed_keys(controller.o, options),
|
||||
"lang": lang,
|
||||
# UX-13 P1: the levies preset and its year, for the P2 Settings
|
||||
# screens, which show the year beside the region choice.
|
||||
"region": region_of(options),
|
||||
# UX-13 P2: the Installer view draws from these two.
|
||||
"layout": uipage.layout(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang),
|
||||
"ui": options_to_ui(options),
|
||||
# UX-13 P4: the setup wizard, and why it opens by itself.
|
||||
"setup": uipage.setup_spec(),
|
||||
"setup_needed": uipage.setup_needed(
|
||||
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)
|
||||
|
||||
@@ -218,16 +257,60 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
product this card is delivering; anything this layer did to them would
|
||||
be a second opinion about a rule it does not own.
|
||||
"""
|
||||
_body, options = await _proposed(request)
|
||||
try:
|
||||
_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)
|
||||
options, controller.hass, notes=notes)
|
||||
by_key, unanchored = problems_by_field(problems, _keys_of(options))
|
||||
# UX-13: warnings and info beside the refusals. Never block a save.
|
||||
# The entity checks use the picker's cached list and never wait on HA.
|
||||
notes += config_notes(options, entities=entities.peek())
|
||||
payload = {"problems": problems, "by_key": by_key,
|
||||
"unanchored": unanchored}
|
||||
"unanchored": unanchored,
|
||||
"notes": [n._asdict() for n in notes]}
|
||||
if unread:
|
||||
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
|
||||
if model is not None:
|
||||
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)]
|
||||
return web.json_response(payload)
|
||||
|
||||
async def _setup_checks(options):
|
||||
"""The wizard's live checks: a list at most a few seconds old, and
|
||||
what the running add-on already knows (its meter, today's prices)."""
|
||||
rows, age = await entities.get(max_age=5.0)
|
||||
if age is not None and age >= 30.0:
|
||||
rows = None # Home Assistant is not answering
|
||||
try:
|
||||
prices = (controller.status_payload().get("plan") or {}).get(
|
||||
"prices") or []
|
||||
known = sum(1 for p in prices if p.get("eur_kwh") is not None)
|
||||
except Exception: # noqa: BLE001 - a display figure, never a failure
|
||||
known = None
|
||||
return setup_checks(options, entities=rows, running=controller.o,
|
||||
meter_now_w=getattr(controller, "grid", None),
|
||||
prices_known=known)
|
||||
|
||||
def _keys_of(options):
|
||||
# ⚠️ THE ANCHOR LIST IS THE KEYS OF THE BLOB UNDER TEST, and that is
|
||||
# sound only because the page posts the WHOLE blob it read - which is
|
||||
@@ -238,33 +321,78 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
|
||||
async def config_save(request):
|
||||
"""Validate, compare-and-swap, write. Or refuse and write NOTHING."""
|
||||
body, options = await _proposed(request)
|
||||
# The page model carries no stored values of the keys it shows, so the
|
||||
# blob is settled against what is stored: an unchanged value is written
|
||||
# exactly as stored (uipage.to_options).
|
||||
try:
|
||||
posted = await request.json()
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
stored_now = None
|
||||
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
|
||||
try:
|
||||
stored_now = (await selfcfg.info()).get("options") or {}
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
try:
|
||||
body, options, model = await _proposed(request, stored_now)
|
||||
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": messages_,
|
||||
"steps": uipage.step_problems(messages_, model)},
|
||||
status=422)
|
||||
expect = str(body.get("fingerprint") or "")
|
||||
try:
|
||||
result = await selfconfig.save(selfcfg, options, expect,
|
||||
hass=controller.hass)
|
||||
hass=controller.hass,
|
||||
base=handed.get(expect))
|
||||
except selfconfig.SaveRefused as refused:
|
||||
if refused.kind == "stale":
|
||||
stored = refused.stored or {}
|
||||
# The fields to look at are the ones the OTHER surface moved,
|
||||
# not this page's own edits (UX-13 P2 test, finding a).
|
||||
moved = (refused.changed if refused.moved is None
|
||||
else refused.moved)
|
||||
return web.json_response(
|
||||
{"kind": "stale", "changed": refused.changed,
|
||||
"moved": refused.moved,
|
||||
"changed_fields": (uipage.fields_of(moved, model)
|
||||
if model is not None else []),
|
||||
"stored": stored,
|
||||
# The page needs the CURRENT token to be able to save
|
||||
# again after the operator has read the difference. That
|
||||
# is not a force door: it takes a second, deliberate press
|
||||
# made in front of what changed.
|
||||
"fingerprint": selfconfig.fingerprint(stored)},
|
||||
"fingerprint": hand_out(stored)},
|
||||
status=409)
|
||||
by_key, unanchored = problems_by_field(refused.problems,
|
||||
_keys_of(options))
|
||||
return web.json_response(
|
||||
{"kind": "invalid", "problems": refused.problems,
|
||||
"by_key": by_key, "unanchored": unanchored}, status=422)
|
||||
payload = {"kind": "invalid", "problems": refused.problems,
|
||||
"by_key": by_key, "unanchored": unanchored}
|
||||
if model is not None:
|
||||
payload["messages"] = uipage.anchors(refused.problems, [],
|
||||
model, _keys_of(options))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"],
|
||||
model)
|
||||
return web.json_response(payload, status=422)
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
controller.log_event("configuration saved from the panel - "
|
||||
"not applied until the add-on restarts")
|
||||
"not applied until the add-on restarts",
|
||||
hint=messages.hint("settings_saved"))
|
||||
# `handed` keeps `expect`: another page may have opened the same blob.
|
||||
store = getattr(controller, "store", None)
|
||||
if (model is not None and body.get("setup") == "done"
|
||||
and store is not None):
|
||||
store.set(uipage.SETUP_DONE,
|
||||
datetime.now(timezone.utc).isoformat(timespec="seconds"))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
async def config_restart(request):
|
||||
@@ -288,9 +416,20 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("restart request failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
controller.log_event("restart requested from the configuration panel")
|
||||
controller.log_event("restart requested from the configuration panel",
|
||||
hint=messages.hint("restart_requested"))
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
async def ha_entities(request):
|
||||
"""UX-13: Home Assistant's entities for the picker. A read only, and
|
||||
never an error: when Home Assistant is down the list says so."""
|
||||
kind = str(request.query.get("kind", "") or "")[:16]
|
||||
one = str(request.query.get("entity", "") or "")[:255]
|
||||
if one:
|
||||
# UX-13 P4's kettle test: one entity, read now.
|
||||
return web.json_response(await entities.reading(one))
|
||||
return web.json_response(await entities.payload(kind))
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes([
|
||||
web.get("/", index),
|
||||
@@ -301,12 +440,21 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
web.post("/config/validate", config_validate),
|
||||
web.post("/config/save", config_save),
|
||||
web.post("/config/restart", config_restart),
|
||||
web.get("/ha/entities", ha_entities),
|
||||
])
|
||||
return app
|
||||
|
||||
|
||||
def access_log():
|
||||
"""UX-10: the page polls /status every few seconds, and aiohttp logs each
|
||||
request at INFO. That access log is kept for a DEBUG log level only."""
|
||||
return (logging.getLogger("aiohttp.access")
|
||||
if _LOG.isEnabledFor(logging.DEBUG) else None)
|
||||
|
||||
|
||||
async def start(controller, port: int = 8099, selfcfg=None):
|
||||
runner = web.AppRunner(build_app(controller, selfcfg))
|
||||
runner = web.AppRunner(build_app(controller, selfcfg),
|
||||
access_log=access_log())
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
|
||||
@@ -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
|
||||
@@ -285,6 +290,8 @@ options:
|
||||
# protects the shallower pack with the deeper pack's number.
|
||||
bms_soc_floor: 10
|
||||
peak_cap_w: 3500
|
||||
# SAFETY-79: the peak guard aims this many watts under peak_cap_w.
|
||||
peak_margin_w: 50
|
||||
peak_forecast_entity: ""
|
||||
|
||||
# The peak guard can COMMAND a discharge to hold a billed quarter under
|
||||
@@ -943,6 +950,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.
|
||||
@@ -1013,6 +1021,7 @@ schema:
|
||||
max_w: int(100,5000)
|
||||
bms_soc_floor: int(0,50)
|
||||
peak_cap_w: int(500,15000)
|
||||
peak_margin_w: int(0,500)
|
||||
peak_forecast_entity: str?
|
||||
|
||||
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"arbitrage_enabled": true,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": false,
|
||||
"batt_entity": "sensor.test23_battery_power",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "ha_signed",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "sensor.test23_p1_meter_active_power",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 3500,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "generic_dayahead",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_timeout_s": 10,
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "number.test23_setpoint_w",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "sensor.test23_soc",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"arbitrage_enabled": false,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": false,
|
||||
"batt_entity": "",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "off",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 3500,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "off",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_timeout_s": 10,
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"arbitrage_enabled": false,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": false,
|
||||
"batt_entity": "sensor.test23_battery_power",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [
|
||||
{
|
||||
"batt_entity": "sensor.goodwe_a_battery_power",
|
||||
"bms_soc_floor": 10,
|
||||
"capacity_kwh": 15.36,
|
||||
"max_w": 5000,
|
||||
"name": "inverter-a",
|
||||
"setpoint_entity": "number.goodwe_a_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_a_soc"
|
||||
},
|
||||
{
|
||||
"batt_entity": "sensor.goodwe_b_battery_power",
|
||||
"bms_soc_floor": 10,
|
||||
"capacity_kwh": 20.48,
|
||||
"max_w": 4600,
|
||||
"name": "inverter-b",
|
||||
"setpoint_entity": "number.goodwe_b_setpoint_w",
|
||||
"soc_entity": "sensor.goodwe_b_soc"
|
||||
}
|
||||
],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "ha_signed",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "sensor.test23_p1_meter_active_power",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 3500,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_export_formula": "",
|
||||
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"price_formula": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "generic_dayahead",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_taxes_profile": "",
|
||||
"price_timeout_s": 10,
|
||||
"pv_entities": [],
|
||||
"pv_entity": "",
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "",
|
||||
"shadow_mode": "auto",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "sensor.test23_soc",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"arbitrage_enabled": false,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": false,
|
||||
"batt_entity": "sensor.test23_battery_power",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "ha_signed",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "sensor.test23_p1_meter_active_power",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 3500,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "generic_dayahead",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_timeout_s": 10,
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "number.test23_setpoint_w",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "sensor.test23_soc",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"arbitrage_enabled": true,
|
||||
"arbitrage_soc_ceiling": 95,
|
||||
"arbitrage_soc_floor": 20,
|
||||
"auto_start": true,
|
||||
"batt_entity": "sensor.test23_battery_power",
|
||||
"batt_invert": false,
|
||||
"bms_soc_floor": 10,
|
||||
"circulation_hold_s": 75,
|
||||
"circulation_w": 700,
|
||||
"cycle_cost_threshold_eur": 0.1,
|
||||
"cycle_floor_s": 15,
|
||||
"deadband_w": 15,
|
||||
"delivery_hold_s": 185,
|
||||
"estop_fitted": false,
|
||||
"export_price_eur_kwh": 0.04,
|
||||
"gain": 0.6,
|
||||
"heartbeat_s": 10,
|
||||
"integrator_max_w": 0,
|
||||
"inverters": [],
|
||||
"log_level": "info",
|
||||
"maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000,
|
||||
"maintenance_enabled": false,
|
||||
"maintenance_hold_min": 120,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_soc_floor": 11,
|
||||
"maintenance_soc_target": 99,
|
||||
"maintenance_start_hour": 10,
|
||||
"max_w": 2000,
|
||||
"meter_entity": "sensor.p1_meter_active_power",
|
||||
"meter_freeze_s": 300,
|
||||
"meter_invert": false,
|
||||
"meter_max_age_s": 30,
|
||||
"meter_mqtt_topic": "",
|
||||
"meter_phases": 1,
|
||||
"meter_poll_s": 5,
|
||||
"meter_source": "ha_signed",
|
||||
"p1_export_entity": "",
|
||||
"p1_host": "",
|
||||
"p1_import_entity": "",
|
||||
"p1_net_entity": "sensor.test23_p1_meter_active_power",
|
||||
"p1_phase_export_entities": [],
|
||||
"p1_phase_import_entities": [],
|
||||
"p1_phase_net_entities": [],
|
||||
"peak_cap_w": 15000,
|
||||
"peak_forecast_entity": "",
|
||||
"peak_soc_floor": 20,
|
||||
"price_avg_entity": "",
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_markup_eur_kwh": 0.03,
|
||||
"price_now_entity": "",
|
||||
"price_provider": "manual_csv",
|
||||
"price_taxes_eur_kwh": 0.06,
|
||||
"price_timeout_s": 10,
|
||||
"saturation_cycles": 3,
|
||||
"saturation_w": 500,
|
||||
"setpoint_entity": "number.test23_setpoint_w",
|
||||
"sign_disagree_s": 180,
|
||||
"sign_disagree_w": 300,
|
||||
"site_sign_disagree_s": 325,
|
||||
"site_sign_disagree_w": 300,
|
||||
"slew_w": 0,
|
||||
"soc_entity": "sensor.test23_soc",
|
||||
"stale_input_s": 15,
|
||||
"static_profile": "",
|
||||
"step_w": 10,
|
||||
"target_grid_w": -10,
|
||||
"unit_sign_disagree_s": 85,
|
||||
"unit_sign_disagree_w": 250
|
||||
}
|
||||
@@ -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: eleven sources, each classified where it is BUILT")
|
||||
print("ARB-03 2. the split: fourteen 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
|
||||
@@ -171,6 +171,10 @@ SITE_SOURCES = {
|
||||
"offending number is the SITE target, which no single "
|
||||
"inverter owns - parking one of them would leave the rest "
|
||||
"pushing the same way with the guard satisfied",
|
||||
# SAFETY-78.
|
||||
"drain": "the site target may not go below a pinned maintenance "
|
||||
"drain, or a sibling would charge against it. Filed at N>1 "
|
||||
"only",
|
||||
}
|
||||
UNIT_SOURCES = {
|
||||
"device": "one inverter's own reported min/max rating",
|
||||
@@ -189,16 +193,24 @@ UNIT_SOURCES = {
|
||||
"is added, `peak_soc_floor` is not consulted, the SITE target "
|
||||
"is untouched, and main.py does not file it at all at a "
|
||||
"roster of one",
|
||||
# SAFETY-73.
|
||||
"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 ELEVEN claim sources are classified, and only those eleven",
|
||||
check("all FOURTEEN claim sources are classified, and only those fourteen",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the six that are about the GRID CONNECTION are site claims",
|
||||
check("the seven 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 five 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 "
|
||||
@@ -216,9 +228,9 @@ 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 five "
|
||||
"per-inverter sources route to the unit this controller drives, the six "
|
||||
"site sources stay on the grid connection",
|
||||
check("with TWO the identity breaks and the split becomes real: the seven "
|
||||
"per-inverter sources route to the unit this controller drives, the "
|
||||
"seven site sources stay on the grid connection",
|
||||
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
|
||||
== {**{s: "inverter-a" for s in UNIT_SOURCES},
|
||||
**{s: ACTUATOR_SITE for s in SITE_SOURCES}})
|
||||
@@ -536,6 +548,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)}")
|
||||
|
||||
@@ -1039,7 +1039,7 @@ def s45_loop(*, sized: bool, cap_w=S45_CAP_W, load_w=S45_LOAD_W,
|
||||
now = t0 + timedelta(seconds=t)
|
||||
claims = []
|
||||
pk = _pg.decide(PeakTracker.project(avg, now), batt_w=batt,
|
||||
cap_w=cap_w, reading=None, achievable_w=MAX_W,
|
||||
cap_w=cap_w, margin_w=0.0, reading=None, achievable_w=MAX_W,
|
||||
soc=50.0, soc_floor=20.0, max_hold_s=900.0,
|
||||
frozen=False)
|
||||
pc = _pg.claim(pk)
|
||||
@@ -1380,7 +1380,7 @@ def _s47_headroom(*, meter_w, batt_w, elapsed_s=450.0):
|
||||
t += 5.0
|
||||
pk = _pg.decide(
|
||||
PeakTracker.project(avg, S45_T0 + timedelta(seconds=elapsed_s)),
|
||||
batt_w=batt_w, cap_w=S45_CAP_W, reading=None, achievable_w=MAX_W,
|
||||
batt_w=batt_w, cap_w=S45_CAP_W, margin_w=0.0, reading=None, achievable_w=MAX_W,
|
||||
soc=50.0, soc_floor=20.0, max_hold_s=900.0, frozen=False)
|
||||
return _pg.charge_headroom_w(pk)
|
||||
|
||||
@@ -1487,13 +1487,14 @@ s47check("the shipped row carries the caveat at the worst cycle: the operator is
|
||||
s47check("...and it still carries the watts, in their own slot, and the standing "
|
||||
"0 W beside them - the caveat NARROWS the claim rather than deleting "
|
||||
"the record a shadow run exists to produce",
|
||||
"WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row
|
||||
"it would ask for" in _s47_row
|
||||
and "the inverters stay at 0 W" in _s47_row
|
||||
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
|
||||
s47check("...and the watts are introduced as what it would have commanded ON "
|
||||
"THIS CYCLE, in every shadow state and not only the caveated ones - no "
|
||||
"number a shadow run records is a forecast of a live run",
|
||||
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row
|
||||
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row)
|
||||
s47check("...and the watts are introduced as what it would ask for THIS CYCLE, "
|
||||
"in every shadow state and not only the caveated ones - no number a "
|
||||
"shadow run records is a forecast of a live run (UX-08: in plain words)",
|
||||
"This cycle it would ask for" in _s47_row
|
||||
and "This cycle it would ask for" in _s47_first_row)
|
||||
|
||||
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
|
||||
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
"""UX-13 P3: the Batteries screen and Settings, Simple. `python3 test_batteries_screen.py`
|
||||
|
||||
1. Each battery's Now, Today and Why lines, in English and Dutch, for the six
|
||||
states the brief names: modes off, modes on, a battery Off, an Off battery
|
||||
giving way to the peak limit, a refusal hold, and the monthly cycle.
|
||||
2. The OPT-11 P1 test's F2 contradictions stay gone: a battery that moves
|
||||
power never reads "0 W" beside it, the Off help names no watts, and no
|
||||
battery line says the peak limit "holds back" a battery it asks for power.
|
||||
3. The plan leaves an Off battery out, except for the peak limit.
|
||||
4. `battery_why()` is pure and picks the most urgent reason first.
|
||||
5. The screen's `/action` round trips over the real web app: the switch and
|
||||
the mode list post `pack_use` and `pack_mode`, `/status` shows the result
|
||||
in both languages, and a refused value comes back as a plain 400.
|
||||
6. The page: the cards, the Simple view and its fields come from the layout
|
||||
table and ui_text, and the scripts post only the existing actions.
|
||||
7. The screen changes no control path: homeview.py reads the controller and
|
||||
calls nothing that writes, and the page adds no /action value.
|
||||
The headless-browser check is in the PR, not in CI (the CI image has no
|
||||
browser). Mutating a module this file grades? Read MUTATION.md first.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import aiohttp # noqa: E402
|
||||
from aiohttp import web as aioweb # noqa: E402
|
||||
|
||||
import ux13_demo as D # noqa: E402
|
||||
from app import homeview, messages, packmode, uilayout as L, uipage, web # noqa
|
||||
from app.maintenance import IDLE, DRAIN # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def words(lang):
|
||||
with io.open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
|
||||
encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
EN, NL = words("en"), words("nl")
|
||||
B_NAME = D.OPTS["inverters"][1]["name"]
|
||||
LANGS = {"en": EN, "nl": NL}
|
||||
|
||||
|
||||
# The fixture's prices are for the day it is built, so the plan depends on
|
||||
# the hour. Pin the clock (review of #150: unpinned, this file failed every
|
||||
# morning): the fixture is built at 00:15 on 2026-09-28, Brussels time. Part 1
|
||||
# looks at 23:30, when the day's trades and cycle start are past, and part 3
|
||||
# at 00:30, with all of them ahead.
|
||||
_BUILT = datetime(2026, 9, 27, 22, 15, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class _Clock(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return _BUILT.astimezone(tz) if tz else _BUILT.replace(tzinfo=None)
|
||||
|
||||
|
||||
D.datetime = _Clock
|
||||
_DAY0 = homeview.local_day(_BUILT)[0]
|
||||
EVENING = _DAY0 + timedelta(hours=23, minutes=30)
|
||||
MORNING = _DAY0 + timedelta(minutes=30)
|
||||
|
||||
|
||||
def build(state, lang="en", c=None, now=None):
|
||||
c = c or D.controller(state)
|
||||
s = c.status_payload()
|
||||
return homeview.build(c, level=s["level"], checks=s["checks"], lang=lang,
|
||||
now=now or EVENING)
|
||||
|
||||
|
||||
# The six states and what each battery's Why line must be.
|
||||
WANT = {
|
||||
"running": ("modes_off", "modes_off"),
|
||||
"modes": ("solar_first", "leftover"),
|
||||
"off": ("solar_first", "off"),
|
||||
"off_peak": ("peak", "off_peak"),
|
||||
"refusal": ("refuses_give", "other_refuses"),
|
||||
# SAFETY-78: the other battery says why it does not charge.
|
||||
"maint": ("cycle_mode", "other_cycle"),
|
||||
}
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. Now, Today and Why per battery, English and Dutch")
|
||||
ROWS = {(st, lang): build(st, lang) for st in WANT for lang in LANGS}
|
||||
for st, want in WANT.items():
|
||||
got = tuple(b["why_key"] for b in ROWS[(st, "en")]["batteries"])
|
||||
en = ROWS[(st, "en")]["batteries"]
|
||||
nl = ROWS[(st, "nl")]["batteries"]
|
||||
print(f" {st}: " + " / ".join(f"{b['now']} | {b['why']} | "
|
||||
f"{b['today_line'] or b['today']}"
|
||||
for b in en))
|
||||
check(f"{st}: the Why lines are {want}, in each language's own words",
|
||||
got == want
|
||||
and all(b["why"] == homeview.uitext.say(
|
||||
W["home_words"]["why"][b["why_key"]],
|
||||
name=D.OPTS["inverters"][0]["name"])
|
||||
for W, rows in ((EN, en), (NL, nl)) for b in rows))
|
||||
check(f"{st}: every battery has a Now, a Why and a Today, and the Dutch "
|
||||
f"lines differ from the English",
|
||||
all(b["now"] and b["why"] and (b["today"] or b["today_line"])
|
||||
for b in en + nl)
|
||||
and all(e["why"] != n["why"] and e["now"] != n["now"]
|
||||
for e, n in zip(en, nl)))
|
||||
|
||||
_modes = ROWS[("modes", "en")]
|
||||
check("modes on: each row carries its switch and mode; Today says Solar "
|
||||
"first follows the house and, before any cycle has made the price "
|
||||
"runs (OPT-11 P3), that Leftover solar is not planned yet",
|
||||
_modes["modes_on"] is True
|
||||
and [(b["use"], b["mode_key"]) for b in _modes["batteries"]]
|
||||
== [(True, "solar_first"), (True, "leftover_solar")]
|
||||
and [b["today_line"] for b in _modes["batteries"]]
|
||||
== [EN["home_words"]["today"]["house"],
|
||||
EN["home_words"]["today"]["not_planned"]])
|
||||
_off = ROWS[("running", "en")]
|
||||
check("modes off: no switch, no mode, and Today and Why say it follows the "
|
||||
"house", _off["modes_on"] is False
|
||||
and all("mode_key" not in b and "use" not in b
|
||||
and b["today_line"] == EN["home_words"]["today"]["house"]
|
||||
for b in _off["batteries"]))
|
||||
_b = ROWS[("off", "en")]["batteries"][1]
|
||||
check("a battery Off: 'Off. Resting now.', 'You switched it off.', and no "
|
||||
"monthly cycle today", (_b["mode_key"], _b["now"], _b["why"],
|
||||
_b["today_line"])
|
||||
== ("off", EN["home_words"]["now"]["off_resting"],
|
||||
EN["home_words"]["why"]["off"], EN["home_words"]["today"]["off"]))
|
||||
_b = ROWS[("off_peak", "en")]["batteries"][1]
|
||||
_bn = ROWS[("off_peak", "nl")]["batteries"][1]
|
||||
check("Off giving way to the peak limit says so plainly, with the figure: "
|
||||
"'Off, but gives 0.5 kW for the peak limit.'",
|
||||
_b["now"] == "Off, but gives 0.5 kW for the peak limit."
|
||||
and _bn["now"] == "Uit, maar levert 0,5 kW voor de piekgrens."
|
||||
and _b["why"] == EN["home_words"]["why"]["off_peak"])
|
||||
_a, _b = ROWS[("refusal", "en")]["batteries"]
|
||||
check("a refusal hold: the refusing battery says it refuses and when it "
|
||||
"tries again, the other says it takes over",
|
||||
_a["why"] == EN["home_words"]["why"]["refuses_give"]
|
||||
and _b["why"] == "Battery A refuses to give power, so this battery "
|
||||
"takes over.")
|
||||
_a = ROWS[("maint", "en")]["batteries"][0]
|
||||
check("maintenance: the cycle comes first, the mode resumes after, and Today "
|
||||
"shows the cycle block", _a["maintenance"] == DRAIN
|
||||
and _a["why"] == EN["home_words"]["why"]["cycle_mode"]
|
||||
and [r["kind"] for r in _a["today"]] == ["cycle"])
|
||||
_sh = build("shadow")["batteries"]
|
||||
_al = build("alarm")["batteries"]
|
||||
check("shadow mode and an alarm: Why says which, and Today plans nothing "
|
||||
"the add-on does not do",
|
||||
all(b["why_key"] == "shadow" and b["today_line"]
|
||||
== EN["home_words"]["today"]["house_would"] for b in _sh)
|
||||
and all(b["why_key"] == "held" and b["today_line"]
|
||||
== EN["home_words"]["today"]["held"] for b in _al))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the OPT-11 P1 test's F2: no line contradicts the power shown")
|
||||
ZERO = re.compile(r"\b0 W\b")
|
||||
GOING = (EN["home_words"]["zero_going"], NL["home_words"]["zero_going"],
|
||||
"going to 0 W", "gaat naar 0 W")
|
||||
_bad = []
|
||||
for (st, lang), payload in sorted(ROWS.items()):
|
||||
for b in payload["batteries"]:
|
||||
moving = b["power_w"] is not None and abs(b["power_w"]) > homeview.IDLE_W
|
||||
if moving and b.get("maintenance", IDLE) == IDLE:
|
||||
kw = homeview._dec(f"{abs(b['power_w']) / 1000:.1f}")
|
||||
kw = kw.replace(".", ",") if lang == "nl" else kw
|
||||
if kw not in b["now"]:
|
||||
_bad.append(f"{st}/{lang}/{b['name']}: no figure in {b['now']!r}")
|
||||
for line in (b["now"], b["why"]):
|
||||
if ZERO.search(line) and not any(g in line for g in GOING):
|
||||
_bad.append(f"{st}/{lang}/{b['name']}: 0 W beside power: "
|
||||
f"{line!r}")
|
||||
if "held back" in b["why"] or "tegengehouden" in b["why"]:
|
||||
_bad.append(f"{st}/{lang}/{b['name']}: {b['why']!r}")
|
||||
print(f" {_bad}")
|
||||
check("a battery that moves power shows the figure, and no line beside it "
|
||||
"says 0 W", not _bad)
|
||||
check("the Off help names no watts, in either language, and says the peak "
|
||||
"limit may use the battery",
|
||||
all(not ZERO.search(W["home"]["batteries"]["choice"]["off"]
|
||||
["description"]) for W in LANGS.values())
|
||||
and "peak limit" in EN["home"]["batteries"]["choice"]["off"]
|
||||
["description"])
|
||||
_going = homeview.battery_now(62, 900, phase=IDLE, shadow=False, running=True,
|
||||
off=True, peak=False)
|
||||
check("an Off battery still moving without the peak limit reads 'going to "
|
||||
"0 W' with its figure, never a bare 0 W",
|
||||
_going == "Off. Still at 0.9 kW, going to 0 W.")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the plan leaves an Off battery out, except for the peak limit")
|
||||
_c = D.controller("off")
|
||||
_c.maints[1].cfg = _c.maints[0].cfg # both due, so both would be drawn
|
||||
_p = build("off", c=_c)
|
||||
_kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i}
|
||||
for i in (0, 1)}
|
||||
print(f" {_kinds}")
|
||||
check("Off: no 'follows the house', no trade, no monthly cycle for B",
|
||||
not _kinds[1] and "house" in _kinds[0])
|
||||
_p = build("off", now=MORNING)
|
||||
# OPT-11 P3: with modes on the plan shows each battery's price runs, which a
|
||||
# control cycle makes; make them as the cycle does (`_mode_roles()`).
|
||||
_mc = D.controller("modes")
|
||||
_mc._mode_roles(MORNING, None)
|
||||
_m = build("modes", c=_mc, now=MORNING)
|
||||
_kinds = {i: {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == i}
|
||||
for i in (0, 1)}
|
||||
print(f" morning: {_kinds}, lines {_p['plan']['lines']}")
|
||||
check("in the morning, with trades and a cycle ahead: Solar first A follows "
|
||||
"the house and has its cycle but no price block (OPT-11 P3: Solar first "
|
||||
"never trades), Off B has no block and waits for no cycle; with B on "
|
||||
"(Leftover solar) B has its expensive run but no grid block (it takes "
|
||||
"solar only until the forecast, owner decision 2026-09-29) and waits",
|
||||
{"house", "cycle"} == _kinds[0] and not _kinds[1]
|
||||
and not any(B_NAME in x for x in _p["plan"]["lines"])
|
||||
and "give" in {r["kind"] for r in _m["plan"]["runs"]
|
||||
if r["battery"] == 1}
|
||||
and "grid" not in {r["kind"] for r in _m["plan"]["runs"]
|
||||
if r["battery"] == 1}
|
||||
and any(B_NAME in x for x in _m["plan"]["lines"]))
|
||||
_p = build("off_peak")
|
||||
check("...but the peak block stays on it while the peak limit needs it",
|
||||
{r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 1}
|
||||
== {"peak"})
|
||||
# Review: an Off battery that A covers alone rests, so it gets no peak block,
|
||||
# and a card never says "every battery gives" beside it.
|
||||
_c = D.controller("off_peak")
|
||||
_c.inverters[1].batt = 0.0
|
||||
_p = build("off_peak", c=_c)
|
||||
_a, _b = _p["batteries"]
|
||||
check("peak limit covered by A alone: Off B rests, has no peak block, and "
|
||||
"A's Why does not claim every battery gives",
|
||||
_b["why_key"] == "off" and _b["now"] == EN["home_words"]["now"]
|
||||
["off_resting"] and not _b["today"]
|
||||
and {r["kind"] for r in _p["plan"]["runs"] if r["battery"] == 0}
|
||||
>= {"peak"} and _a["why_key"] == "peak"
|
||||
and "every" not in _a["why"] and "elke" not in
|
||||
build("off_peak", "nl", c=_c)["batteries"][0]["why"])
|
||||
# Review: a blind or floor-limited guard is engaged with a 0 W floor. It
|
||||
# forbids charging only, so no battery gives power for it.
|
||||
_bad = []
|
||||
for _kw in (dict(blind="meter gap"), dict(floor_limited=True)):
|
||||
_c = D.controller("off_peak")
|
||||
_c.peak_guard = homeview.peakguard.PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=3500.0, ceiling_from="cap",
|
||||
projected_w=None, **_kw)
|
||||
_c.inverters[1].batt = 300.0 # B still going to 0 W
|
||||
_p = build("off_peak", c=_c)
|
||||
_a, _b = _p["batteries"]
|
||||
if (_a["why_key"] == "peak" or _b["why_key"] == "off_peak"
|
||||
or "peak limit" in _b["now"]
|
||||
or any(r["kind"] == "peak" for r in _p["plan"]["runs"])):
|
||||
_bad.append((_kw, _a["why_key"], _b["why_key"], _b["now"]))
|
||||
print(f" {_bad}")
|
||||
check("a blind or floor-limited peak guard (floor 0 W) draws no peak block "
|
||||
"and no line says the peak limit takes power", not _bad)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. battery_why(): pure, most urgent first")
|
||||
BASE = dict(modes_on=True, mode=packmode.SOLAR_FIRST, off=False, reading=True,
|
||||
phase=IDLE, shadow=False, running=True, held=False, refused=0,
|
||||
peak=False, giving=False, sf_ahead=False)
|
||||
|
||||
|
||||
def why(**kw):
|
||||
return homeview.battery_why(**dict(BASE, **kw))
|
||||
|
||||
|
||||
_cases = [
|
||||
(dict(reading=False, held=True), "no_reading"),
|
||||
(dict(held=True, shadow=True), "held"),
|
||||
(dict(running=False, off=True), "stopped"),
|
||||
(dict(shadow=True, phase=DRAIN), "shadow"),
|
||||
(dict(phase=DRAIN, off=True), "cycle_mode"),
|
||||
(dict(phase=DRAIN, modes_on=False), "cycle"),
|
||||
(dict(off=True, peak=True, giving=True), "off_peak"),
|
||||
(dict(off=True, peak=True, giving=False), "off"), # charging or resting
|
||||
(dict(off=True, giving=True), "off"),
|
||||
(dict(refused=1, peak=True), "refuses_give"),
|
||||
(dict(refused=-1), "refuses_charge"),
|
||||
(dict(peak=True, giving=True, other_refuses="B"), "peak"),
|
||||
(dict(giving=True, other_refuses="B", modes_on=False), "other_refuses"),
|
||||
(dict(modes_on=False, giving=True), "modes_off"),
|
||||
(dict(giving=True, mode=packmode.LEFTOVER_SOLAR), "covers_house"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, sf_ahead=True), "leftover"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR), "leftover_alone"),
|
||||
({}, "solar_first"),
|
||||
# OPT-11 P3: the battery's role in the price runs, and no plan.
|
||||
(dict(mode=packmode.CHEAP_HOURS, role="charge"), "grid_charge"),
|
||||
(dict(role="rest"), "rests_charge"),
|
||||
(dict(mode=packmode.CHEAP_HOURS, role="sell", giving=True), "selling"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="give", giving=True),
|
||||
"expensive"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", has_at=True), "saves"),
|
||||
(dict(mode=packmode.CHEAP_HOURS, role="hold"), "saves_later"),
|
||||
(dict(mode=packmode.CHEAP_HOURS, role="house", plan="no_prices"),
|
||||
"no_prices"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="house", plan="no_spread",
|
||||
giving=True), "no_spread"),
|
||||
# ...and the urgent reasons still come first.
|
||||
(dict(role="charge", refused=-1), "refuses_charge"),
|
||||
(dict(role="sell", peak=True), "peak"),
|
||||
(dict(role="hold", off=True), "off"),
|
||||
# SAFETY-78: another battery drains for its monthly cycle.
|
||||
(dict(other_cycle="A"), "other_cycle"),
|
||||
(dict(other_cycle="A", modes_on=False, giving=True), "other_cycle"),
|
||||
(dict(other_cycle="A", mode=packmode.CHEAP_HOURS, role="charge"),
|
||||
"other_cycle"),
|
||||
(dict(other_cycle="A", peak=True), "peak"),
|
||||
(dict(other_cycle="A", refused=-1), "refuses_charge"),
|
||||
(dict(other_cycle="A", off=True), "off"),
|
||||
]
|
||||
_wrong = [(kw, want, why(**kw)) for kw, want in _cases if why(**kw) != want]
|
||||
print(f" {_wrong}")
|
||||
check(f"{len(_cases)} cases, each the expected reason", not _wrong)
|
||||
check("every reason it can return has words in both languages",
|
||||
{w for _k, w in _cases} == set(EN["home_words"]["why"])
|
||||
== set(NL["home_words"]["why"]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. /action round trips from the screen, over the real web app")
|
||||
|
||||
|
||||
async def _serve(state, calls):
|
||||
c = D.controller(state)
|
||||
stored = dict(D.defaults(D.CONFIG), **D.OPTS)
|
||||
runner = aioweb.AppRunner(web.build_app(c, D.Supervisor(stored)))
|
||||
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 body, lang in calls:
|
||||
if body is not None:
|
||||
async with s.post(root + "action", json=body) as r:
|
||||
out.append((r.status,
|
||||
await r.json(content_type=None)))
|
||||
async with s.get(root + "status?lang=" + lang) as r:
|
||||
out.append((r.status, await r.json(content_type=None)))
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
A, B = (i["name"] for i in D.OPTS["inverters"])
|
||||
# Exactly what app.js posts: {action, unit, value}.
|
||||
_r = asyncio.run(_serve("modes", [
|
||||
({"action": "pack_use", "unit": B, "value": False}, "en"),
|
||||
({"action": "pack_mode", "unit": A, "value": packmode.LEFTOVER_SOLAR},
|
||||
"nl"),
|
||||
({"action": "pack_use", "unit": B, "value": True}, "en"),
|
||||
({"action": "pack_mode", "unit": A, "value": "cheap_hours"}, "en"),
|
||||
({"action": "pack_sell", "unit": A, "value": True}, "en"),
|
||||
({"action": "pack_sell", "unit": A, "value": "yes"}, "en"),
|
||||
({"action": "pack_mode", "unit": A, "value": "sell_only"}, "en"),
|
||||
]))
|
||||
(_s1, _j1), (_, _st1), (_s2, _j2), (_, _st2), (_s3, _), (_, _st3), \
|
||||
(_s4, _j4), (_, _st4), (_s5, _j5), (_, _st5), (_s6, _j6), (_, _st6), \
|
||||
(_s7, _j7), (_, _st7) = _r
|
||||
check("switch B off: 200, and /status shows B Off with 'You switched it off.'",
|
||||
_s1 == 200 and _j1["result"]["use"] is False
|
||||
and _st1["batteries"][1]["mode_key"] == "off"
|
||||
and _st1["batteries"][1]["why"] == EN["home_words"]["why"]["off"])
|
||||
check("set A to Leftover solar: 200, and /status?lang=nl shows it in Dutch",
|
||||
_s2 == 200 and _st2["batteries"][0]["mode_choice"]
|
||||
== packmode.LEFTOVER_SOLAR
|
||||
and _st2["batteries"][0]["mode"]
|
||||
== NL["home_words"]["modes"]["leftover_solar"])
|
||||
check("switch B back on: its stored mode returns",
|
||||
_s3 == 200 and _st3["batteries"][1]["mode_key"]
|
||||
== packmode.LEFTOVER_SOLAR)
|
||||
check("OPT-11 P3: Cheap hours only is a mode now: 200, and /status shows it",
|
||||
_s4 == 200 and _st4["batteries"][0]["mode_choice"]
|
||||
== packmode.CHEAP_HOURS)
|
||||
check("owner decision 2026-09-29 (option A): selling is off, so a posted "
|
||||
"pack_sell is refused with the plain reason 'Selling is off for now.', "
|
||||
"and /status says no battery sells or can",
|
||||
_s5 == 400 and _j5["error"] == messages.pack_refused("sell_off")
|
||||
and _s6 == 400 and all(b["sell"] is False and b["can_sell"] is False
|
||||
for b in _st5["batteries"] + _st6["batteries"]))
|
||||
check("...a mode that does not exist is refused with a plain 400, and "
|
||||
"nothing changes", _s7 == 400 and _j7["error"]
|
||||
and _st7["batteries"][0]["mode_choice"] == packmode.CHEAP_HOURS)
|
||||
_r = asyncio.run(_serve("running", [
|
||||
({"action": "pack_use", "unit": B, "value": False}, "en")]))
|
||||
check("with battery modes off the switch is refused with the plain reason, "
|
||||
"and the batteries stay on", _r[0][0] == 400 and _r[0][1]["error"]
|
||||
and "mode_key" not in _r[1][1]["batteries"][1])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the page")
|
||||
PAGE = web.page()
|
||||
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
|
||||
encoding="utf-8").read()
|
||||
SJS = io.open(os.path.join(HERE, "app", "static", "settings.js"),
|
||||
encoding="utf-8").read()
|
||||
check("the Batteries screen has its cards, the modes-off card and 'How the "
|
||||
"modes work'", all(f'id="{i}"' in PAGE for i in
|
||||
("bcards", "batt-off", "batt-how", "batt-banner")))
|
||||
check("Settings has a Simple / Installer choice as a radio group, and Simple "
|
||||
"comes first", re.search(r'id="set-view"[^>]*>\s*<button[^>]*'
|
||||
r'data-v="simple"', PAGE) is not None
|
||||
and PAGE.count('role="radio"') >= 2 and 'id="simple"' in PAGE)
|
||||
_acts = set(re.findall(r'packAct\("(\w+)"', JS))
|
||||
check(f"the cards post only the existing actions ({sorted(_acts)}), as "
|
||||
f"{{action, unit, value}}; selling has no control while it is off",
|
||||
_acts == set(web.PACK_ACTIONS) - {"pack_sell"}
|
||||
and packmode.SELLING_ENABLED is False
|
||||
and "action: what, unit: b.name, value: value" in JS)
|
||||
check("the switch is a real switch and the list offers the three modes, "
|
||||
"cheap hours included, with nothing greyed out",
|
||||
'role: "switch"' in JS and '"aria-checked"' in JS
|
||||
and 'const MODES = ["solar_first", "leftover_solar", "cheap_hours"];'
|
||||
in JS and "batteries.later" not in JS)
|
||||
check("no selling switch anywhere on the page or in Settings, and no words "
|
||||
"for one", "pack_sell" not in JS and "sell" not in SJS
|
||||
and "batteries.sell" not in JS
|
||||
and all("sell" not in W["home"]["batteries"] for W in LANGS.values()))
|
||||
_T = uipage.ui_text("en")
|
||||
_lay = uipage.layout(D.schema_ui(D.CONFIG), {"en": {}})
|
||||
_simple = [f for g in _lay["groups"] for p in g["parts"] for f in p["fields"]
|
||||
if f.get("simple")]
|
||||
check("Settings, Simple draws the fields the layout marks, and each note "
|
||||
"has words in both languages",
|
||||
sorted(f["key"] for f in _simple)
|
||||
== sorted(f.key for g in L.GROUPS for p in g.parts for f in p.fields
|
||||
if f.simple)
|
||||
and all(f["note"] in EN["settings"]["simple"]["note"]
|
||||
and f["note"] in NL["settings"]["simple"]["note"]
|
||||
for f in _simple if f["note"]))
|
||||
check("settings.js picks them by the `simple` flag and names no option key",
|
||||
"f.simple" in SJS and not any(f'"{f.key}"' in SJS for g in L.GROUPS
|
||||
for p in g.parts for f in p.fields))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. no control path changes: the screen reads, it does not write")
|
||||
SRC = io.open(os.path.join(HERE, "app", "homeview.py"), encoding="utf-8").read()
|
||||
WRITES = {"set_number", "store_set", "set", "set_time", "handle_action",
|
||||
"log_event", "abort", "tick", "publish", "call", "write",
|
||||
"_pack_action", "_maint_stop_off"}
|
||||
_calls = sorted({n.func.attr for n in ast.walk(ast.parse(SRC))
|
||||
if isinstance(n, ast.Call)
|
||||
and isinstance(n.func, ast.Attribute)
|
||||
and n.func.attr in WRITES})
|
||||
check(f"homeview.py calls nothing that writes state or commands an inverter "
|
||||
f"({_calls})", not _calls)
|
||||
_imports = sorted({(n.module or "") for n in ast.walk(ast.parse(SRC))
|
||||
if isinstance(n, ast.ImportFrom)})
|
||||
check(f"...and imports no module that sends power requests ({_imports})",
|
||||
not {"main", "inverter", "hass", "control", "allocator",
|
||||
"arbiter"} & {m.lstrip(".") for m in _imports})
|
||||
_c = D.controller("off_peak")
|
||||
_before = (json.dumps(_c.store.data, sort_keys=True, default=str),
|
||||
[(i.last_write, i.target) for i in _c.inverters])
|
||||
for lang in LANGS:
|
||||
build("off_peak", lang, c=_c)
|
||||
check("building the screen twice leaves the store and every power request "
|
||||
"as it was", _before
|
||||
== (json.dumps(_c.store.data, sort_keys=True, default=str),
|
||||
[(i.last_write, i.target) for i in _c.inverters]))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -77,6 +77,7 @@ Read MUTATION.md before you report a survivor.
|
||||
import asyncio
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -496,8 +497,13 @@ check(f"AC 1: NOT ONE of the {len(emitted)} values that reached the device "
|
||||
# largest emitted value is what it is and not 5000 W. The clamp is what makes it
|
||||
# bounded; the freeze is what makes it small. A check that read
|
||||
# `max(emitted) == max_w` would have been asserting the freeze, not the clamp.
|
||||
# SAFETY-52 re-baseline: an inverter reporting 0 W for SETTLE_CYCLES is a
|
||||
# battery that refuses, so the command then drops to 0 W. The freeze still
|
||||
# engaged first, and the event log says so.
|
||||
check(f"...and the saturation freeze is what pins it at {max(emitted):,.0f} W "
|
||||
f"rather than the rail", ctl_r.sat_count >= ctl_r.tuning.saturation_cycles)
|
||||
f"rather than the rail",
|
||||
any("saturation freeze" in e for e in ctl_r.events)
|
||||
and max(emitted) < LIVE_MAX_W)
|
||||
|
||||
notes_r = clamp_notes(hass_r)
|
||||
print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
|
||||
@@ -520,7 +526,8 @@ check(f"the action log records every engagement, coalesced into one row per "
|
||||
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
|
||||
f"engagement(s))",
|
||||
len(rows_r) == ctl_r.clamp_wd.episodes
|
||||
and f"{ctl_r.clamp_wd.engagements} engagement(s)" in rows_r[0])
|
||||
and messages.count(ctl_r.clamp_wd.engagements, "engagement")
|
||||
in rows_r[0])
|
||||
|
||||
# ⚠️ REGRESSION, AND IT WAS A REAL BUG BEFORE IT WAS A CHECK. `log_event()` keeps
|
||||
# 40 rows. A row per engagement flushes that ring inside 40 cycles of a winding
|
||||
@@ -537,7 +544,8 @@ check(f"a long winding episode does NOT flood the 40-row event log "
|
||||
f"({len(rows_l)} rows for {ctl_l.clamp_wd.engagements} engagements)",
|
||||
ctl_l.clamp_wd.engagements > 40 and len(rows_l) <= ctl_l.clamp_wd.episodes)
|
||||
check("...and the count in the row is the counter's, so nothing is lost",
|
||||
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1))
|
||||
messages.count(ctl_l.clamp_wd.engagements, "engagement")
|
||||
in nth(rows_l, -1))
|
||||
# The RED row exists only while engaged, and the episode above is short, so it
|
||||
# is captured mid-run rather than asserted after the hysteresis has cleared it.
|
||||
check("the status page flags it WHILE it is engaged - amber, since it is the "
|
||||
@@ -577,8 +585,12 @@ check("UX-07 item 1: during a bound episode an earlier over-rating count is "
|
||||
|
||||
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
|
||||
# keeps writing the clamped value. If a later cut changes that it must come here.
|
||||
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W, not 0 W",
|
||||
ctl_r.target != 0.0 and ctl_r.target == hass_r.writes[-1])
|
||||
# SAFETY-52: the loop itself now asks 0 W of an inverter that gives nothing,
|
||||
# with its own reason. That is the loop's decision, not a stop.
|
||||
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W because "
|
||||
f"the loop says '{ctl_r.loop_reason}', not because of a stop",
|
||||
ctl_r.loop_reason == "battery refuses"
|
||||
and ctl_r.target == hass_r.writes[-1])
|
||||
check("...and the controller still reports itself running, not failsafe",
|
||||
ctl_r.auto and not ctl_r.sign_wd.tripped)
|
||||
|
||||
@@ -1036,7 +1048,8 @@ check("...and the operator is told WHICH bound bit, so a device-rating clamp "
|
||||
and messages.LIMIT_WORDS["device"] in notes_w[0]["message"]
|
||||
and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"])
|
||||
check("...and the action log carries the row, written by write_setpoint()",
|
||||
any(REASON_CODE in e and "device bound" in e for e in ctl_w.events))
|
||||
any(REASON_CODE in e and messages.LIMIT_WORDS["device"] in e
|
||||
for e in ctl_w.events))
|
||||
|
||||
# The same stimulus, the same clamp, the two calls swapped. This is the ticket
|
||||
# in one pair of runs.
|
||||
@@ -1124,8 +1137,8 @@ check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
|
||||
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
|
||||
"past the 2000 W rating printed four words later - which is the alarm "
|
||||
"reading backwards on the one line an operator is woken by",
|
||||
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in e for e in _rows_x))
|
||||
any(f"asked {_asked_x:+.0f} W and sent {_sent_x:+.0f} W" in e
|
||||
for e in _rows_x))
|
||||
# UX-08: the notification is a SECOND surface off the same numbers, in its own
|
||||
# words (messages.output_clamp): asked first, sent second.
|
||||
def _asked_then_sent(msg, asked, sent):
|
||||
@@ -1141,8 +1154,8 @@ check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION, which is a SECOND surfac
|
||||
bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x))
|
||||
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
|
||||
"checks above discriminate rather than decorate",
|
||||
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" in e for e in _rows_x)
|
||||
not any(f"asked {_sent_x:+.0f} W and sent {_asked_x:+.0f} W" in e
|
||||
for e in _rows_x)
|
||||
and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x)
|
||||
and present(nth(notes_x, 0)))
|
||||
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
|
||||
@@ -1153,8 +1166,8 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the "
|
||||
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
|
||||
"two also render differently",
|
||||
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
|
||||
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
|
||||
and any(f"asked {_asked_w:+.0f} W and sent {_sent_w:+.0f} W" in e
|
||||
for e in ctl_w.events)
|
||||
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
|
||||
|
||||
|
||||
@@ -1874,8 +1887,8 @@ check("...and it is FIRST in the cascade: the same controller has other "
|
||||
"conditions to report and none of them displaces this sentence",
|
||||
_live_pay["banner"] != _shad_pay["banner"]
|
||||
and "Shadow mode" not in _live_pay["banner"])
|
||||
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
|
||||
r"([-\d]+) W", _shad_rows[_ROW])
|
||||
_slot = re.search(r"This cycle it would ask for ([-+][\d ]+) W, the inverters "
|
||||
r"stay at ([-\d]+) W", _shad_rows[_ROW])
|
||||
check("the row answers 'what would it have done' with a NUMBER in its own "
|
||||
"named slot, and says where the inverter actually is in a second one - "
|
||||
"two slots in one sentence, so a transposition moves them",
|
||||
@@ -1889,15 +1902,16 @@ if _slot is not None:
|
||||
check("...and the second slot is the STANDING command, so the two cannot "
|
||||
"be read as one number",
|
||||
float(_slot.group(2)) == 0.0)
|
||||
check("the row answers 'and why' by carrying the arbiter's own sentence, "
|
||||
"rather than a re-derivation of it",
|
||||
_shad_c.reason in _shad_rows[_ROW])
|
||||
check("the row answers 'and why' by carrying the page's own Why line, the "
|
||||
"arbiter's answer in plain words (UX-08), rather than a re-derivation",
|
||||
_shad_c.why_words in _shad_rows[_ROW]
|
||||
and dict(_shad_pay["rows"])["Why"] == _shad_c.why_words)
|
||||
check("with shadow mode off the row says so, and says what follows from it - "
|
||||
"which is the arrangement that makes the one above a claim about this "
|
||||
"controller and not about the string",
|
||||
"NOT in shadow mode" in _live_rows[_ROW]
|
||||
and "DOES reach the inverter" in _live_rows[_ROW]
|
||||
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
|
||||
_live_rows[_ROW].startswith("Off")
|
||||
and "reaches the inverters" in _live_rows[_ROW]
|
||||
and "would ask for" not in _live_rows[_ROW])
|
||||
|
||||
# ⚠️ A STANDING LATCH IS SHOWN RATHER THAN HIDDEN. The shadow banner sits above
|
||||
# the three `bad` branches, so it has to say what they would have said - a
|
||||
@@ -2026,16 +2040,17 @@ check("PRECONDITION: the integrator bound engaged, and on a tick where the "
|
||||
"carried integrator AND the emitted command both differ from the bound",
|
||||
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
|
||||
for d, t, r in wind))
|
||||
check("defect 1: the integrator bound is NOT reported as a BUG SIGNAL",
|
||||
wind and all("BUG SIGNAL" not in r for d, t, r in wind))
|
||||
check("defect 1: ...it names the anti-windup bound, from the same tick",
|
||||
wind and all(f"its {_f(d, 'i_bound_w'):+.0f} W anti-windup bound" in r
|
||||
check("defect 1: the integrator bound is NOT reported as an over-rating",
|
||||
wind and all(" rating" not in r and "fault" not in r
|
||||
for d, t, r in wind))
|
||||
check("defect 1: ...it names the integrator's bound, from the same tick",
|
||||
wind and all(f"reached its {_f(d, 'i_bound_w'):+.0f} W limit" in r
|
||||
for d, t, r in wind))
|
||||
check("defect 2: the integrator accumulator is not printed as a request",
|
||||
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
|
||||
for d, t, r in wind))
|
||||
check("the command quoted is that tick's Decision, not a later read",
|
||||
wind and all(f"command this cycle was {d.target_w:+.0f} W" in r
|
||||
wind and all(f"command now {d.target_w:+.0f} W" in r
|
||||
for d, t, r in wind))
|
||||
check("at the bound nothing is described as 'above' or 'over' the rating",
|
||||
wind and all(" above " not in r and " over " not in r
|
||||
@@ -2047,9 +2062,12 @@ check("it still NOTIFIES (not suppressed), under a title that claims no bug",
|
||||
check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
|
||||
"notice still on screen",
|
||||
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
|
||||
# UX-11: the WARNING quotes the code. The reason is on the DEBUG line beside
|
||||
# it (test_messages.py part 6).
|
||||
check("...logged as a WARNING, not an ERROR",
|
||||
any(x.levelno == logging.WARNING and "anti-windup" in x.getMessage()
|
||||
for x in log_i)
|
||||
any(x.levelno == logging.WARNING
|
||||
and x.getMessage().startswith("The battery gives less than asked: "
|
||||
"OUTPUT_CLAMP.") for x in log_i)
|
||||
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
|
||||
for x in log_i))
|
||||
check("...and the status row is amber, not red",
|
||||
@@ -2068,15 +2086,21 @@ for d, t, r in over[:2]:
|
||||
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
|
||||
"rewrote the command AND the integrator differs from the request",
|
||||
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
|
||||
check("the over-rating KEEPS the BUG SIGNAL",
|
||||
over and all("BUG SIGNAL" in r for d, t, r in over))
|
||||
check("the over-rating says it was cut to the rating",
|
||||
over and all(" W rating, command now " in r for d, t, r in over))
|
||||
check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
|
||||
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("defect 3: 'clamped to' is the clamp's own result, not self.target",
|
||||
over and all(f"clamped to {_f(d, 'clamped_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("...and the 14 768 W rationale is still in its ERROR",
|
||||
any(x.levelno == logging.ERROR and "14 768 W" in x.getMessage()
|
||||
for x in log_o))
|
||||
check("defect 3: 'cut to' is the clamp's own result, not self.target",
|
||||
over and all(f"cut to the {_f(d, 'clamped_w'):+.0f} W rating" in r
|
||||
for d, t, r in over))
|
||||
check("...and its ERROR says it is a software fault and that control keeps "
|
||||
"running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
|
||||
"it, which is still in main.py)",
|
||||
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
|
||||
and "control runs on" in x.getMessage() for x in log_o)
|
||||
and "reached 14 768 W on this project" in
|
||||
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||
"main.py"), encoding="utf-8").read())
|
||||
check("...under the over-rating title, quoting a tick's own asked and sent "
|
||||
"numbers in that order, with a red status row",
|
||||
clamp_notes(hass_o)
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -189,13 +189,13 @@ check(f"the wind-down ran: 0 W written AFTER the fault (writes after = {after})"
|
||||
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
|
||||
["cleanup"] in ev[(fault_at or 0):])
|
||||
died_line = next((i for i, ln in enumerate(err.splitlines())
|
||||
if "CONTROL LOOP DIED (RuntimeError('" + FAULT + "'))" in ln), None)
|
||||
if "Control stopped unexpectedly (RuntimeError('" + FAULT + "'))" in ln), None)
|
||||
wind_line = next((i for i, ln in enumerate(err.splitlines())
|
||||
if "shutting down - commanding 0 W" in ln), None)
|
||||
check("a CRITICAL line names the exception and the exit status",
|
||||
died_line is not None
|
||||
and "CRITICAL" in err.splitlines()[died_line]
|
||||
and "commanding 0 W and exiting with status 70" in err.splitlines()[died_line])
|
||||
and "set to 0 W and the add-on exits with code 70" in err.splitlines()[died_line])
|
||||
check("the traceback is logged, down to the frame that raised",
|
||||
"Traceback (most recent call last)" in err
|
||||
and f"RuntimeError: {FAULT}" in err and "in cycle" in err)
|
||||
@@ -211,7 +211,7 @@ print("2. the same entry point stopped by SIGTERM - the callback must stay quiet
|
||||
rc2, out2, err2, ev2 = run_child("clean")
|
||||
print(f" exit status {rc2}; events {ev2}")
|
||||
check("an ordinary stop still exits 0", rc2 == 0)
|
||||
check("an ordinary stop logs no CONTROL LOOP DIED", "CONTROL LOOP DIED" not in err2)
|
||||
check("an ordinary stop logs no unexpected stop", "Control stopped unexpectedly" not in err2)
|
||||
check("an ordinary stop still winds down to 0 W, from a non-zero command",
|
||||
any(e[0] == "w" and e[1] != 0.0 for e in ev2)
|
||||
and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]])
|
||||
@@ -222,7 +222,7 @@ rc3, out3, err3, ev3 = run_child("refuse")
|
||||
print(f" exit status {rc3}")
|
||||
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
|
||||
rc3 == 78)
|
||||
check("and is not reported as a death", "CONTROL LOOP DIED" not in err3)
|
||||
check("and is not reported as a death", "Control stopped unexpectedly" not in err3)
|
||||
if rc3 != 78:
|
||||
print(tail(err3))
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from named_absence import given, present
|
||||
|
||||
from app import messages
|
||||
from app import control, messages
|
||||
from app.control import Tuning
|
||||
from app.delivery import (
|
||||
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
|
||||
@@ -593,6 +593,15 @@ check("...and the SHARES now differ, which is OPT-09 and not a weakening of the
|
||||
"grades the ALARM, and neither can do the other's half",
|
||||
_pw != _pf
|
||||
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
|
||||
# SAFETY-73. A unit that gives < 100 W while asked > 200 W is refused after
|
||||
# SETTLE_CYCLES cycles that also span REFUSED_SPAN_S (22 s). This fixture's 14
|
||||
# cycles are 0.2 s apart, so the wedged B meets the count and not the span, and
|
||||
# SAFETY-42's report stands alone here. test_refusal_units.py grades the
|
||||
# refusal. On a site the 185 s hold puts the refusal first.
|
||||
check("SAFETY-73: the wedged arm's B is not refused in this fixture: 12 cycles "
|
||||
"of evidence in under 3 s are not the 22 s a refusal needs",
|
||||
_cw.inverters[1].refused == 0
|
||||
and _cw.inverters[1].ref_count >= control.SETTLE_CYCLES)
|
||||
check("...and the state of charge is still what tells them apart",
|
||||
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
|
||||
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
|
||||
@@ -1147,8 +1156,10 @@ async def _one():
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 2000, "gain": 0.6,
|
||||
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
|
||||
# MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock
|
||||
# against a 1 s window, so a hold is not what stops this firing -
|
||||
# MAKES THIS SECTION NON-VACUOUS. The run below is 1.1 s of wall
|
||||
# clock against a 1 s window (SAFETY-52 shortened it from 2 s, and
|
||||
# "the window was never even opened" below is what still makes it
|
||||
# non-vacuous), so a hold is not what stops this firing -
|
||||
# the reduction is. With a shipped-length hold "nothing was
|
||||
# reported" would be true of a fixture that simply ran out of time.
|
||||
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
|
||||
@@ -1158,7 +1169,10 @@ async def _one():
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -2000.0, 2000.0
|
||||
c.auto = True
|
||||
hass.ctrl = c
|
||||
await _drive(c, n=20, dt=0.1)
|
||||
# SAFETY-52: from SETTLE_CYCLES of delivering nothing the loop asks 0 W
|
||||
# (the battery refuses), so the run stops just before that. 11 cycles is
|
||||
# still 1.1 s against the 1 s hold.
|
||||
await _drive(c, n=11, dt=0.1)
|
||||
return c, hass, store
|
||||
|
||||
|
||||
|
||||
@@ -360,7 +360,7 @@ check("...and `inputs_ok` goes False, so the site stops rather than regulating "
|
||||
_cs.inputs_ok is False and _cf.inputs_ok is True)
|
||||
check("...and the operator gets a line saying which unit and why, on the same "
|
||||
"status list the liveness notes use",
|
||||
any("STALE_READING" in n and "inverter-a" in n
|
||||
any("stopped changing" in n and "inverter-a" in n
|
||||
for n in _cs.liveness_notes())
|
||||
and _cf.liveness_notes() == [])
|
||||
check("...and the raw number is still recorded for diagnostics, so the "
|
||||
@@ -1581,7 +1581,7 @@ check("...and the site stops holding inputs bad, which is what ends the "
|
||||
_c57.inputs_bad_since is None and _c57.inputs_ok is True)
|
||||
_ev57 = [e for e in _c57.events if "STALE" in e]
|
||||
_msgs57 = [r.getMessage() for r in _LOGREC]
|
||||
_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m]
|
||||
_rel57 = [m for m in _msgs57 if "is used again and rechecked" in m]
|
||||
print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}")
|
||||
check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR "
|
||||
"CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach "
|
||||
@@ -1592,8 +1592,9 @@ check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATO
|
||||
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
|
||||
"happened: it says in its own words that nothing has been learned about "
|
||||
"the register, and it prints the value the register is STILL holding",
|
||||
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in nth(_rel57, 0)
|
||||
and f"still holding {A_FROZEN_W:+.0f}" in nth(_rel57, 0))
|
||||
"used again" in nth(_rel57, 0) and "recovered" not in nth(_rel57, 0)
|
||||
and f"reading {A_FROZEN_W:+.0f} W is used again and rechecked"
|
||||
in nth(_rel57, 0))
|
||||
|
||||
|
||||
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
|
||||
@@ -1602,7 +1603,8 @@ check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has
|
||||
# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new
|
||||
# escape clause, and 250 W and 17.0 s again in the release note - so each is
|
||||
# asserted with its value embedded in its own literal position.
|
||||
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
|
||||
_stale57 = [m for m in _msgs57
|
||||
if "battery power reading stopped changing" in m]
|
||||
print(f" escape clause: ...{nth(_stale57, 0)[-150:]}")
|
||||
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
|
||||
"PAIR. It used to end 'It clears itself the moment the register reports "
|
||||
@@ -1610,17 +1612,16 @@ check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDER
|
||||
"before it is what stops the register being asked. The new clause names "
|
||||
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
|
||||
"red",
|
||||
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
|
||||
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
|
||||
f"or {BOUNDS['batt'].release_s:.0f} s after the command drops below "
|
||||
f"{BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
|
||||
check("...and the release note names 250 W in the POWER slot and 17 s in the "
|
||||
"TIME slot, in that order, which is the reverse arrangement of the same "
|
||||
"two numbers",
|
||||
f"sat below {BOUNDS['batt'].move_w:.0f} W for "
|
||||
f"stayed below {BOUNDS['batt'].move_w:.0f} W for "
|
||||
f"{BOUNDS['batt'].release_s:.0f} s" in nth(_rel57, 0))
|
||||
check("...and the 35.0 s the register is re-graded over is named in the note "
|
||||
"too, because it is what the operator is being told to wait out",
|
||||
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window"
|
||||
in nth(_rel57, 0))
|
||||
f"rechecked over {BOUNDS['batt'].unchanged_s:.0f} s" in nth(_rel57, 0))
|
||||
check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
|
||||
"WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power "
|
||||
"register is not following its own command' to still be present, so "
|
||||
@@ -1632,7 +1633,7 @@ check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN,
|
||||
present(nth(_stale57, 0))
|
||||
and "battery-power register is not following its own command"
|
||||
not in _stale57[0]
|
||||
and "did not move under a command that did" in _stale57[0])
|
||||
and "stopped changing" in _stale57[0])
|
||||
check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
"beside its own value, because after a release the verdict's window and "
|
||||
"the register's own unchanged time are different facts",
|
||||
@@ -1643,9 +1644,8 @@ check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
|
||||
f.stale and f.releases == 1
|
||||
and "unchanged for 92 s" in f.reason
|
||||
and "the 35 s window this verdict rests on opened when the previous "
|
||||
"one was withdrawn" in f.reason
|
||||
and "graded 2 times" in f.reason)[-1])(
|
||||
and "checked 2 times over a 35 s window since the last stop was "
|
||||
"withdrawn" in f.reason)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
@@ -1798,8 +1798,8 @@ check("...and the two runs were driven at 50 % and 11 % SoC and the sentence "
|
||||
# bytes, and "the register may be unable to comply / the source may have
|
||||
# stopped reporting reality" is a different and wrong claim.
|
||||
_S56_EXPLANATIONS = [
|
||||
("DEAD", "the register may have stopped reporting reality"),
|
||||
("REFUSED", "the source may be unable to comply with the command"),
|
||||
("DEAD", "reading may have stopped"),
|
||||
("REFUSED", "battery may be unable to follow the command"),
|
||||
]
|
||||
# ⚠️ WHAT THE THREE SENTENCES USED TO ASSERT. Each is a claim about the
|
||||
# REGISTER or about the ENTITY, and 11a measured the register to be alive on
|
||||
@@ -1811,7 +1811,13 @@ _S56_ACCUSATIONS = [
|
||||
("app/main.py _read_unit()", "register is not following its own command"),
|
||||
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
|
||||
]
|
||||
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:] if "SAFETY-37" in r.getMessage()]
|
||||
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
|
||||
if "battery power reading stopped changing" in r.getMessage()]
|
||||
# UX-11: the ERROR line quotes the code, and the reason itself is on the DEBUG
|
||||
# line logged beside it.
|
||||
_dbg56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
|
||||
if r.levelno == logging.DEBUG
|
||||
and r.getMessage().startswith("Evidence for ")] or [""]
|
||||
|
||||
|
||||
def _s56_row(c):
|
||||
@@ -1854,7 +1860,7 @@ check("PRECONDITION: all four operator-facing surfaces were really produced by "
|
||||
"two are the same string, which is 11b again on the log surface",
|
||||
len(_log56) == 2 and _log56[0] == _log56[1]
|
||||
and all(len(s) > 30 for s in _S56_SURFACES.values())
|
||||
and _r56_ref in _log56[-1])
|
||||
and _log56[-1].endswith(": STALE_READING.") and _r56_ref in _dbg56[-1])
|
||||
_idx56 = [_r56_ref.find(t) for _w, t in _S56_EXPLANATIONS]
|
||||
check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
|
||||
f"{_S56_EXPLANATIONS[0][1]!r} at {_idx56[0]} then "
|
||||
@@ -1866,7 +1872,7 @@ check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
|
||||
check("...and the sentence says in its own words that it CANNOT choose, rather "
|
||||
"than leaving two explanations side by side for a reader to rank - an "
|
||||
"operator who reads a list picks the first item",
|
||||
"CANNOT TELL THEM APART" in _r56_ref)
|
||||
"this check cannot tell which" in _r56_ref)
|
||||
# ⚠️ THE POSITIVE CONTROL FOR THE `not any(...)` BELOW, AND IT IS NOT OPTIONAL:
|
||||
# a quantifier over an empty list is True, and so is one over three literals
|
||||
# with a typo in each. These are the three sentences as 0.10.1 SHIPPED them,
|
||||
@@ -1952,8 +1958,8 @@ check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which
|
||||
"row below?' answers the question this one declines to answer. A row "
|
||||
"that pointed at a surface present in both states would be worse than "
|
||||
"the accusation it replaced",
|
||||
any("STALE_READING" in r["text"] for r in _c56_ref.checks())
|
||||
and not any("STALE_READING" in r["text"] for r in _c56_abs.checks()))
|
||||
any("stopped changing" in r["text"] for r in _c56_ref.checks())
|
||||
and not any("stopped changing" in r["text"] for r in _c56_abs.checks()))
|
||||
check("...and the row's wording is NOT shared with the log line's, which is a "
|
||||
"design choice rather than an omission: the two sentences have no clause "
|
||||
"in common longer than the word 'entity', because one explains a "
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""UX-13 P0: the entity list for the picker. `python3 test_haentities.py`
|
||||
|
||||
`GET ha/entities` reads Home Assistant's states through the add-on's own
|
||||
session (hass.states()) and must never fail the page: part 1 drives the real
|
||||
HomeAssistant client against a local server that answers 502, garbage, the
|
||||
wrong shape, nothing at all, or too slowly. Part 2 checks the 30 s cache and
|
||||
what is served while Home Assistant is down. Part 3 serves the real route.
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
|
||||
from app import hass as H, haentities as E, web
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
STATES = [
|
||||
{"entity_id": "sensor.grid", "state": "812",
|
||||
"attributes": {"friendly_name": "Grid power", "unit_of_measurement": "W",
|
||||
"device_class": "power"}},
|
||||
{"entity_id": "sensor.soc", "state": "54",
|
||||
"attributes": {"friendly_name": "Battery level", "unit_of_measurement": "%",
|
||||
"device_class": "battery"}},
|
||||
{"entity_id": "number.setpoint", "state": "0", "attributes": {}},
|
||||
{"entity_id": "sensor.pv_kw", "state": "1.2",
|
||||
"attributes": {"unit_of_measurement": "kW"}},
|
||||
{"no_entity_id": True},
|
||||
]
|
||||
|
||||
|
||||
async def serve(handler):
|
||||
app = aioweb.Application()
|
||||
app.router.add_get("/api/states", handler)
|
||||
runner = aioweb.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
return runner, site._server.sockets[0].getsockname()[1]
|
||||
|
||||
|
||||
async def read_with(handler, timeout_s=10):
|
||||
runner, port = await serve(handler)
|
||||
H.CORE_API, H.STATES_TIMEOUT_S = f"http://127.0.0.1:{port}/api", timeout_s
|
||||
try:
|
||||
async with ClientSession() as session:
|
||||
t0 = time.monotonic()
|
||||
got = await H.HomeAssistant(session, "token").states()
|
||||
read_with.took = time.monotonic() - t0
|
||||
return got
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
async def ok(_r):
|
||||
return aioweb.json_response(STATES)
|
||||
|
||||
|
||||
async def bad_gateway(_r):
|
||||
return aioweb.Response(status=502)
|
||||
|
||||
|
||||
async def garbage(_r):
|
||||
return aioweb.Response(text="<html>", content_type="application/json")
|
||||
|
||||
|
||||
async def not_a_list(_r):
|
||||
return aioweb.json_response({"message": "hi"})
|
||||
|
||||
|
||||
async def slow(_r):
|
||||
await asyncio.sleep(2)
|
||||
return aioweb.json_response(STATES)
|
||||
|
||||
|
||||
async def closed_port():
|
||||
H.CORE_API = "http://127.0.0.1:9/api"
|
||||
async with ClientSession() as session:
|
||||
return await H.HomeAssistant(session, "token").states()
|
||||
|
||||
|
||||
print("1. hass.states() answers None, never an exception")
|
||||
check("a normal answer is the state list", asyncio.run(read_with(ok)) == STATES)
|
||||
for name, handler in (("502 while HA restarts", bad_gateway),
|
||||
("a body that is not JSON", garbage),
|
||||
("JSON that is not a list", not_a_list)):
|
||||
check(f"{name} -> None", asyncio.run(read_with(handler)) is None)
|
||||
class _Lines(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.lines = []
|
||||
|
||||
def emit(self, record):
|
||||
if record.levelno >= logging.WARNING:
|
||||
self.lines.append(record.getMessage())
|
||||
|
||||
|
||||
_lines = _Lines()
|
||||
logging.getLogger().addHandler(_lines)
|
||||
check("an answer slower than the timeout -> None, at the timeout",
|
||||
asyncio.run(read_with(slow, timeout_s=0.3)) is None
|
||||
and read_with.took < 1.0)
|
||||
# UX-13 P1 test finding (c): a timeout's own text is empty, which left
|
||||
# "Reading the entity list failed: " with no reason.
|
||||
check("...and its WARNING line gives the reason, not an empty one "
|
||||
f"({_lines.lines})", any(
|
||||
x == "Reading the entity list failed: no answer within 0.3 seconds."
|
||||
for x in _lines.lines)
|
||||
and not any(x.rstrip().endswith("failed:") for x in _lines.lines))
|
||||
logging.getLogger().removeHandler(_lines)
|
||||
check("no server at all -> None", asyncio.run(closed_port()) is None)
|
||||
check("the timeout for the list is the same 10 s as every other read",
|
||||
"STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read())
|
||||
|
||||
|
||||
class FakeHass:
|
||||
def __init__(self):
|
||||
self.answer, self.reads = STATES, 0
|
||||
|
||||
async def states(self):
|
||||
self.reads += 1
|
||||
await asyncio.sleep(0.01)
|
||||
return self.answer
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def __call__(self):
|
||||
return self.t
|
||||
|
||||
|
||||
print("\n2. the cache, and Home Assistant being down")
|
||||
_h, _c = FakeHass(), Clock()
|
||||
_cache = E.EntityCache(_h, clock=_c)
|
||||
|
||||
|
||||
async def burst():
|
||||
return await asyncio.gather(*(_cache.payload("power") for _ in range(5)))
|
||||
|
||||
|
||||
_p = asyncio.run(burst())
|
||||
check("five pickers opening at once cost one read of Home Assistant",
|
||||
_h.reads == 1 and all(x == _p[0] for x in _p))
|
||||
_ids = [r["entity_id"] for r in _p[0]["entities"]]
|
||||
check("power entities come first, then the rest, and a broken state is left "
|
||||
f"out ({_ids})", _ids == ["sensor.grid", "sensor.pv_kw",
|
||||
"number.setpoint", "sensor.soc"]
|
||||
and [r["match"] for r in _p[0]["entities"]] == [True, True, False, False])
|
||||
check("each row carries id, name, state, unit and device class",
|
||||
_p[0]["entities"][0] == {"entity_id": "sensor.grid", "name": "Grid power",
|
||||
"state": "812", "unit": "W",
|
||||
"device_class": "power", "match": True})
|
||||
check("the kinds filter as the pickers need",
|
||||
[r["entity_id"] for r in _p[0]["entities"]
|
||||
if E.matches(r, "pct")] == ["sensor.soc"]
|
||||
and [r["entity_id"] for r in _p[0]["entities"]
|
||||
if E.matches(r, "number")] == ["number.setpoint"])
|
||||
_c.t += 10
|
||||
asyncio.run(_cache.payload())
|
||||
check("within 30 s the cached list is served", _h.reads == 1)
|
||||
check("peek() hands the validator the fresh list without a read",
|
||||
set(_cache.peek()) == set(_ids) and _h.reads == 1)
|
||||
_h.answer = None
|
||||
_c.t += 30
|
||||
_down = asyncio.run(_cache.payload())
|
||||
check("Home Assistant down after a good read: the old list, marked old",
|
||||
_h.reads == 2 and _down["available"] and _down["text"] == E.STALE_TEXT
|
||||
and len(_down["entities"]) == 4 and _down["age_s"] >= 30)
|
||||
check("...and peek() then gives nothing, so no warning rests on an old list",
|
||||
_cache.peek() is None)
|
||||
|
||||
|
||||
async def down_windows():
|
||||
"""One loop throughout: a contended asyncio.Lock binds to its loop."""
|
||||
hass, clock = FakeHass(), Clock()
|
||||
hass.answer = None
|
||||
cache = E.EntityCache(hass, clock=clock)
|
||||
counts = []
|
||||
for step in (0, 1, 30):
|
||||
clock.t += step
|
||||
await asyncio.gather(*(cache.payload() for _ in range(5)))
|
||||
counts.append(hass.reads)
|
||||
return counts
|
||||
|
||||
|
||||
check("five polls while Home Assistant is down cost one read, the polls just "
|
||||
"after wait for the next window, and that window tries once",
|
||||
asyncio.run(down_windows()) == [1, 1, 2])
|
||||
_h2 = FakeHass()
|
||||
_h2.answer = None
|
||||
_empty = asyncio.run(E.EntityCache(_h2).payload("power"))
|
||||
check("Home Assistant down from the start: an empty list that says why",
|
||||
_empty == {"available": False, "text": E.DOWN_TEXT, "entities": []})
|
||||
|
||||
|
||||
print("\n3. the route")
|
||||
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, hass):
|
||||
self.hass, self.o = hass, {}
|
||||
|
||||
|
||||
async def route(hass, query=""):
|
||||
app = web.build_app(FakeController(hass), selfcfg=object())
|
||||
runner = aioweb.AppRunner(app)
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(f"http://127.0.0.1:{port}/ha/entities{query}") as r:
|
||||
return r.status, await r.json()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
_st, _body = asyncio.run(route(FakeHass(), "?kind=pct"))
|
||||
check("GET ha/entities?kind=pct answers 200 with the matching entity first",
|
||||
_st == 200 and _body["entities"][0]["entity_id"] == "sensor.soc")
|
||||
_st, _body = asyncio.run(route(_h2))
|
||||
check("with Home Assistant down it still answers 200, with the reason",
|
||||
_st == 200 and _body["available"] is False and _body["entities"] == [])
|
||||
check("the words shown are short and plain (STYLE: one sentence)",
|
||||
all(len(t.split()) <= 12 and t.count(".") == 1
|
||||
for t in (E.DOWN_TEXT, E.STALE_TEXT)))
|
||||
print(json.dumps(_body))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,654 @@
|
||||
"""UX-13 P1: the Home screen's `/status` and the page shell. `python3 -B test_home.py`
|
||||
|
||||
1. `/status` keeps every old field (`banner`, `level`, `rows`, `checks`,
|
||||
`hint`, `events`) AND carries the structured half the Home screen reads:
|
||||
`state`, `now`, `batteries`, `plan`, `attention`, `month_peak`, `control`.
|
||||
Both are pinned here, by name and shape.
|
||||
2. The plan shows only what the add-on does today. Trading on price is the
|
||||
verdict the control cycle would reach (the same `plan.arbitrage()` call),
|
||||
drawn where the cheapest and dearest prices are, with kWh and € per block.
|
||||
The monthly cycle, the peak limit and "follows the house" are the rest.
|
||||
3. Shadow mode says "would", a stopped site plans nothing, and a red row is an
|
||||
alarm under "Needs attention".
|
||||
4. A fault in the Home view never costs the page its old rows.
|
||||
5. The page: served at `/` from app/static, no external file, relative URLs
|
||||
only, every label resolves in app/ui_text, and the region year reaches
|
||||
`config/model` for the P2 Settings screens.
|
||||
|
||||
The STYLE scan of every Home sentence is test_messages.py part 8.
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb
|
||||
|
||||
from app import homeview, web
|
||||
from app.main import Controller
|
||||
from app.peakguard import PeakDecision
|
||||
from app.price import PricePoint, PriceProduct, PriceRead
|
||||
from app.static_tariff import TariffPlan
|
||||
from app.uimodel import REGION_PRESETS
|
||||
from named_absence import field, nth, present
|
||||
|
||||
FLANDERS = REGION_PRESETS["flanders"]["taxes_eur_kwh"]
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fixtures: a real Controller, with its readings set directly.
|
||||
# --------------------------------------------------------------------------- #
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"capacity_kwh": 9.6},
|
||||
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"capacity_kwh": 5.4}])
|
||||
|
||||
# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day.
|
||||
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
||||
DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc)
|
||||
MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
|
||||
.16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
|
||||
|
||||
|
||||
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
|
||||
auto=True, wrote=0.0):
|
||||
"""`wrote` is each inverter's last accepted power request (None: none yet)."""
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = auto
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt = soc[i], batt[i]
|
||||
inv.last_write = wrote
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = grid
|
||||
return c
|
||||
|
||||
|
||||
def provider_plan(prices=MOCK, day0=DAY0, days=2, provisional_from=None):
|
||||
pts = []
|
||||
for d in range(days):
|
||||
for k, p in enumerate(prices):
|
||||
start = day0 + timedelta(days=d, hours=k)
|
||||
pts.append(PricePoint(start, p, provisional_from is not None
|
||||
and start >= provisional_from))
|
||||
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", NOW), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def quiet_guard(projected_w=1240.0):
|
||||
return PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
||||
ceiling_from="cap", projected_w=projected_w)
|
||||
|
||||
|
||||
def status(c, now=NOW):
|
||||
s = c.status_payload()
|
||||
# The status route builds at wall-clock time; rebuild the structured half
|
||||
# at the fixture's instant so the day and the hours are the mockup's.
|
||||
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=now))
|
||||
return s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. /status: the old fields stay, the structured half is added")
|
||||
# --------------------------------------------------------------------------- #
|
||||
c1 = make(ONE, soc=(62.0,), batt=(960.0,), grid=800.0)
|
||||
s1 = c1.status_payload()
|
||||
OLD = {"banner", "level", "rows", "checks", "hint", "events"}
|
||||
NEW = {"state", "now", "batteries", "plan", "attention", "month_peak",
|
||||
"control"}
|
||||
check(f"every old field is still there, with its old type ({sorted(OLD)})",
|
||||
OLD <= set(s1) and isinstance(s1["rows"], list)
|
||||
and all(len(r) == 2 for r in s1["rows"])
|
||||
and all({"ok", "warn", "text"} <= set(r) for r in s1["checks"])
|
||||
and isinstance(s1["banner"], str) and s1["level"] in ("ok", "warn", "bad")
|
||||
and isinstance(s1["events"], list))
|
||||
check(f"...and the structured half is added beside them ({sorted(NEW)})",
|
||||
NEW <= set(s1) and "home_error" not in s1)
|
||||
check("...and it is JSON, as the route serves it", json.loads(json.dumps(s1))
|
||||
== json.loads(json.dumps(s1)))
|
||||
check("`state` names the kind, the pill word, the control row and a sub-line",
|
||||
set(s1["state"]) == {"kind", "pill", "ran", "sub"}
|
||||
and s1["state"]["kind"] == "running")
|
||||
check("`now` carries the four tiles in W and a plain line for each",
|
||||
{"solar_w", "house_w", "grid_w", "batt_w", "has_solar", "lines"}
|
||||
<= set(s1["now"])
|
||||
and set(s1["now"]["lines"]) == {"solar", "house", "grid", "batt"}
|
||||
and s1["now"]["grid_w"] == 800.0 and s1["now"]["batt_w"] == 960.0)
|
||||
_nh = homeview.now_block(grid_w=-3000.0, pv_w=1000.0, batt_w=1500.0,
|
||||
has_solar=True, shadow=False)
|
||||
_nz = homeview.now_block(grid_w=-1030.0, pv_w=1000.0, batt_w=0.0,
|
||||
has_solar=True, shadow=False)
|
||||
_np = homeview.now_block(grid_w=800.0, pv_w=1000.0, batt_w=-300.0,
|
||||
has_solar=True, shadow=False)
|
||||
check("House = grid (+ import) + solar + battery (+ giving): 800 + 1000 - 300 "
|
||||
"= 1500 W",
|
||||
_np["house_w"] == 1500.0 and _np["lines"]["house"] == "Using")
|
||||
check("...a sum below zero is not shown as a positive figure: 0 W inside "
|
||||
"the idle band, and no figure beyond it",
|
||||
_nz["house_w"] == 0.0 and _nh["house_w"] is None
|
||||
and _nh["lines"]["house"] == homeview.tile_house(None, True, True))
|
||||
check("...and with no solar reading the house figure is NOT invented: it is "
|
||||
"None and the tile says why",
|
||||
s1["now"]["house_w"] is None and s1["now"]["has_solar"] is False
|
||||
and s1["now"]["lines"]["house"] == homeview.tile_house(None, False))
|
||||
_b = s1["batteries"][0]
|
||||
check("`batteries[]` has level, power, state, mode, the now line and why",
|
||||
{"name", "level_pct", "power_w", "state", "mode", "now", "why",
|
||||
"capacity_kwh", "stored_kwh", "maintenance"} <= set(_b)
|
||||
and _b["level_pct"] == 62.0 and _b["state"] == "giving"
|
||||
and _b["now"] == "Giving 1.0 kW now.")
|
||||
_p = s1["plan"]
|
||||
check("`plan` carries the verdict, the prices, the runs, the lines and "
|
||||
"the region",
|
||||
{"verdict", "head", "prices", "runs", "lines", "now_h", "hours",
|
||||
"price_line", "tomorrow_line", "region", "region_line"} <= set(_p)
|
||||
and len(_p["prices"]) in (23, 24, 25))
|
||||
check("`attention[]` has severity, title, body and docs; `month_peak` and "
|
||||
"`control` are there",
|
||||
all({"severity", "title", "body", "docs"} <= set(a)
|
||||
for a in s1["attention"])
|
||||
and {"month", "limit_w", "peak_w", "pct", "period_w", "state", "line"}
|
||||
<= set(s1["month_peak"])
|
||||
and {"running", "shadow", "text", "maintenance", "docs_url"}
|
||||
<= set(s1["control"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the plan is what the add-on actually does")
|
||||
# --------------------------------------------------------------------------- #
|
||||
s_house = status(make(TWO))
|
||||
check("with trading off and no cycle due, each battery follows the house "
|
||||
"from now to the end of the day, and nothing else is drawn",
|
||||
[r["kind"] for r in s_house["plan"]["runs"]] == ["house", "house"]
|
||||
and s_house["plan"]["verdict_key"] == "house"
|
||||
and all(r["end_h"] == 24.0 and abs(r["start_h"] - 11 - 40 / 60) < 0.01
|
||||
for r in s_house["plan"]["runs"])
|
||||
and s_house["plan"]["runs"][0]["to"] == "24:00")
|
||||
|
||||
ARB = dict(TWO, arbitrage_enabled=True, cycle_cost_threshold_eur=0.05)
|
||||
c_arb = make(ARB)
|
||||
c_arb.tariff_plan, c_arb.tariff_at = provider_plan(), NOW
|
||||
c_arb.peak_guard = quiet_guard()
|
||||
s_arb = status(c_arb)
|
||||
_runs = s_arb["plan"]["runs"]
|
||||
_grid = [r for r in _runs if r["kind"] == "grid"]
|
||||
_give = [r for r in _runs if r["kind"] == "give"]
|
||||
check("trading on price: a charge block at the cheapest hour (13:00) and a "
|
||||
"give block at the dearest (19:00), on each battery",
|
||||
[(r["battery"], r["from"], r["to"]) for r in _grid]
|
||||
== [(0, "13:00", "14:00"), (1, "13:00", "14:00")]
|
||||
and [(r["battery"], r["from"], r["to"]) for r in _give]
|
||||
== [(0, "19:00", "20:00"), (1, "19:00", "20:00")]
|
||||
and s_arb["plan"]["verdict_key"] == "trade")
|
||||
check("...placed where the control cycle's own verdict puts them: the same "
|
||||
"plan.arbitrage() call on the same plan",
|
||||
c_arb.fresh_tariff_plan(NOW).arbitrage(
|
||||
peak_guard_active=False, min_spread_eur_kwh=0.05,
|
||||
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
|
||||
.Uncertainty.NO_WEATHER}))
|
||||
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
|
||||
refused=lambda v: None)
|
||||
== (DAY0 + timedelta(hours=13), DAY0 + timedelta(hours=19)))
|
||||
# Battery A: 2.5 kW for 1 h = 2.5 kWh; room to the 95 % ceiling is 33 % of
|
||||
# 9.6 kWh = 3.17 kWh, so 2.5. Battery B: 60 % of 5.4 = 3.24, so 2.5.
|
||||
check("each block carries kWh and €: charging is capped by the battery's own "
|
||||
"rating and the room its level leaves, € = kWh x that hour's price",
|
||||
[r["kwh"] for r in _grid] == [2.5, 2.5]
|
||||
and [r["eur"] for r in _grid] == [0.35, 0.35]
|
||||
and [r["eur_kwh"] for r in _grid] == [0.14, 0.14]
|
||||
and "2.5 kWh" in _grid[0]["what"] and "€0.14" in _grid[0]["why"]
|
||||
and "€0.35" in _grid[0]["amount"])
|
||||
# Giving at 19:00 comes after the 13:00 charge, so each battery may give what
|
||||
# it will hold then: B is at 35 % + 2.5 kWh of 5.4 = 81 %, and 81 - 20 = 61 %
|
||||
# of 5.4 = 3.3 kWh, so 2.5 (an upper bound, like every amount here).
|
||||
check("...and giving is capped by what each battery holds above arbitrage's "
|
||||
"floor after the charge before it",
|
||||
[r["kwh"] for r in _give] == [2.5, 2.5]
|
||||
and [r["eur"] for r in _give] == [0.9, 0.9])
|
||||
|
||||
|
||||
def trade(soc, prices=MOCK, provisional_from=None, now=NOW, opts=ARB):
|
||||
c = make(opts, soc=soc)
|
||||
c.tariff_plan = provider_plan(prices, provisional_from=provisional_from)
|
||||
c.tariff_at, c.peak_guard = now, quiet_guard()
|
||||
s = status(c, now=now)
|
||||
return ([(r["kind"], r["battery"], r["kwh"]) for r in s["plan"]["runs"]
|
||||
if r["kind"] in ("grid", "give")], s["plan"]["lines"])
|
||||
|
||||
|
||||
# Review of #144: only what arbitrage.decide() would still do is drawn.
|
||||
_t, _l = trade((96.0, 35.0))
|
||||
check("a battery at arbitrage's ceiling gets no charge block, not "
|
||||
"'Charges up to 0.0 kWh'", ("grid", 0, 0.0) not in _t
|
||||
and [k for k in _t if k[0] == "grid"] == [("grid", 1, 2.5)])
|
||||
# 20:00 local: the dearest (19:00) is over; tomorrow's charge is a line. Put
|
||||
# the dearest later today instead, with the emptiest pack at the floor.
|
||||
_late = MOCK[:22] + [.40, .24]
|
||||
_t, _l = trade((62.0, 20.0), prices=_late, now=NOW + timedelta(hours=8))
|
||||
_t2, _l2 = trade((62.0, 35.0), prices=_late, now=NOW + timedelta(hours=8))
|
||||
check("no give block while the emptiest battery is at arbitrage's floor: "
|
||||
"the cycle's sale is graded on the emptiest pack (at 35 % both give)",
|
||||
not [k for k in _t if k[0] == "give"]
|
||||
and [k[:2] for k in _t2] == [("give", 0), ("give", 1)])
|
||||
_t, _l = trade((62.0, 35.0), prices=[x - 0.5 for x in MOCK])
|
||||
check("no give block at a negative price (the cycle refuses that sale)",
|
||||
not [k for k in _t if k[0] == "give"]
|
||||
and [k for k in _t if k[0] == "grid"])
|
||||
# Running, a missing level is a red row and the site is held. In shadow mode
|
||||
# the plan is still drawn, so this is where the level gate shows.
|
||||
_t, _l = trade((None, 35.0), opts=dict(ARB, shadow_mode="on"))
|
||||
check("with a battery level unknown the cycle does not trade, so nothing is "
|
||||
"drawn and a line says why (shadow mode, which still plans)",
|
||||
_t == [] and homeview.line_no_trade("no_level") in _l)
|
||||
_t, _l = trade((62.0, 35.0), provisional_from=DAY0)
|
||||
check("a plan with no published prices yet says so, not 'the price gap is "
|
||||
"too small'",
|
||||
_t == [] and homeview.line_no_trade("not_firm") in _l
|
||||
and homeview.line_no_trade("not_worth") not in _l)
|
||||
check("the price bars are today's 24 hours, from the real series, with the "
|
||||
"cheap and expensive thirds marked",
|
||||
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
|
||||
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
|
||||
and s_arb["plan"]["prices"][19]["kind"] == "dear"
|
||||
and s_arb["plan"]["prices"][0]["kind"] == "mid"
|
||||
and s_arb["plan"]["prices"][13]["label"] == "13:00")
|
||||
check("the price line names the price now, today's lowest and highest, and "
|
||||
"that tomorrow's prices are in",
|
||||
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
|
||||
"Today: lowest €0.14 at 13:00, "
|
||||
"highest €0.36 at 19:00.")
|
||||
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
|
||||
|
||||
c_late = make(ARB)
|
||||
c_late.tariff_plan, c_late.tariff_at = provider_plan(), NOW
|
||||
c_late.peak_guard = quiet_guard()
|
||||
s_late = status(c_late, now=NOW + timedelta(hours=9)) # 20:40 local
|
||||
check("a block whose hour is over is not drawn: at 20:40 no trade is left "
|
||||
"today",
|
||||
not [r for r in s_late["plan"]["runs"] if r["kind"] in ("grid", "give")])
|
||||
|
||||
c_peak = make(ARB)
|
||||
c_peak.tariff_plan, c_peak.tariff_at = provider_plan(), NOW
|
||||
c_peak.peak_guard = PeakDecision(engaged=True, floor_w=800.0,
|
||||
ceiling_w=3500.0, ceiling_from="cap",
|
||||
projected_w=3900.0)
|
||||
s_peak = status(c_peak)
|
||||
check("while the peak limit holds, the verdict refuses as the cycle does: no "
|
||||
"trade block, a plain line, and a peak block for this 15-minute period",
|
||||
not [r for r in s_peak["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
||||
and homeview.line_no_trade("peak") in s_peak["plan"]["lines"]
|
||||
and [(r["from"], r["to"]) for r in s_peak["plan"]["runs"]
|
||||
if r["kind"] == "peak"] == [("11:30", "11:45"), ("11:30", "11:45")])
|
||||
|
||||
c_pk = make(ARB)
|
||||
c_pk.p1_enabled = True # the peak line is shown where P1 runs
|
||||
c_pk.tariff_plan, c_pk.tariff_at = provider_plan(), NOW
|
||||
c_pk.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=4200.0,
|
||||
ceiling_from="month_peak", projected_w=1240.0)
|
||||
s_pk = status(c_pk)
|
||||
check("the plan's peak line names the guard's ceiling, which is this month's "
|
||||
"peak once it is above the limit",
|
||||
homeview.line_peak_limit(4200.0, False) in s_pk["plan"]["lines"]
|
||||
and s_pk["month_peak"]["line"].endswith("above 4.2 kW."))
|
||||
|
||||
c_blind = make(ARB)
|
||||
c_blind.tariff_plan, c_blind.tariff_at = provider_plan(), NOW
|
||||
s_blind = status(c_blind) # meter_source off: the guard never runs
|
||||
check("with nothing measuring the 15-minute peak, trading is NOT drawn, as "
|
||||
"the cycle refuses it (guard state None)",
|
||||
not [r for r in s_blind["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
||||
and homeview.line_no_trade("not_covered") in s_blind["plan"]["lines"])
|
||||
|
||||
c_flat = make(ARB)
|
||||
c_flat.tariff_plan, c_flat.tariff_at = provider_plan(prices=[.2] * 24), NOW
|
||||
c_flat.peak_guard = quiet_guard()
|
||||
s_flat = status(c_flat)
|
||||
check("a flat day: no trade, no cheap or expensive hour marked",
|
||||
not [r for r in s_flat["plan"]["runs"] if r["kind"] in ("grid", "give")]
|
||||
and {b["kind"] for b in s_flat["plan"]["prices"]} == {"mid"}
|
||||
and homeview.line_no_trade("not_worth") in s_flat["plan"]["lines"])
|
||||
|
||||
c_old = make(ARB)
|
||||
c_old.tariff_plan = provider_plan()
|
||||
c_old.tariff_at = NOW - timedelta(hours=3) # past its max age
|
||||
c_old.peak_guard = quiet_guard()
|
||||
s_old = status(c_old)
|
||||
check("a stale plan is not traded on by the cycle, so it is not drawn",
|
||||
not [r for r in s_old["plan"]["runs"] if r["kind"] in ("grid", "give")])
|
||||
|
||||
MAINT = dict(TWO, maintenance_enabled=True, maintenance_start_hour=12)
|
||||
c_m = make(MAINT)
|
||||
s_m = status(c_m)
|
||||
_cyc = [r for r in s_m["plan"]["runs"] if r["kind"] == "cycle"]
|
||||
check("a monthly cycle due today is drawn on ONE battery, at the start hour "
|
||||
"the cycle uses (12 UTC is 14:00 here), and the other one waits",
|
||||
len(_cyc) == 1 and field(nth(_cyc), "from") == "14:00"
|
||||
and s_m["plan"]["verdict_key"] == "cycle"
|
||||
and any("waits its turn" in x for x in s_m["plan"]["lines"]))
|
||||
check("...with its duration from the battery's capacity and the cycle's rates "
|
||||
"and hold time",
|
||||
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
|
||||
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
|
||||
c_m.maints[0].store.set("phase", "drain")
|
||||
s_md = status(c_m)
|
||||
check("a cycle under way is drawn from now, and the battery's now line says "
|
||||
"so",
|
||||
[r["from"] for r in s_md["plan"]["runs"] if r["kind"] == "cycle"]
|
||||
== ["11:40"]
|
||||
and s_md["batteries"][0]["now"] == "Monthly cycle: emptying."
|
||||
and any("Battery B waits its turn" in x for x in s_md["plan"]["lines"]))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. shadow mode, a stopped site, an alarm")
|
||||
# --------------------------------------------------------------------------- #
|
||||
c_sh = make(dict(ARB, shadow_mode="on"))
|
||||
c_sh.tariff_plan, c_sh.tariff_at = provider_plan(), NOW
|
||||
c_sh.peak_guard = quiet_guard()
|
||||
s_sh = status(c_sh)
|
||||
_texts = [r["what"] for r in s_sh["plan"]["runs"]] + [s_sh["plan"]["head"]]
|
||||
check("shadow: the state is shadow, the battery tile is held at 0 W, and "
|
||||
"every block and the head say 'would'",
|
||||
s_sh["state"]["kind"] == "shadow"
|
||||
and s_sh["now"]["lines"]["batt"] == "Held at 0 W"
|
||||
and all("ould" in x for x in _texts)
|
||||
and s_sh["plan"]["head"].startswith("Shadow mode:"))
|
||||
check("...and 'Shadow mode is on' is the first item under Needs attention",
|
||||
s_sh["attention"][0]["title"] == homeview.ATTN_SHADOW
|
||||
and s_sh["attention"][0]["severity"] == "info")
|
||||
check("...and each battery's now line says it is held at 0 W",
|
||||
all(b["now"].startswith("Held at 0 W.") for b in s_sh["batteries"]))
|
||||
|
||||
s_stop = status(make(TWO, auto=False))
|
||||
check("stopped: nothing is planned and the head says so",
|
||||
s_stop["plan"]["runs"] == [] and s_stop["state"]["kind"] == "stopped"
|
||||
and s_stop["plan"]["verdict_key"] == "stopped"
|
||||
and s_stop["control"]["running"] is False
|
||||
and all(b["now"] == "Held at 0 W while control is stopped."
|
||||
for b in s_stop["batteries"]))
|
||||
|
||||
c_al = make(ONE, soc=(62.0,), batt=(0.0,))
|
||||
c_al.inverters[0].sign_wd.tripped = True
|
||||
s_al = c_al.status_payload()
|
||||
_red = [a for a in s_al["attention"] if a["severity"] == "alarm"]
|
||||
check("an alarm: level bad, state alarm, and the red row is the first item "
|
||||
"under Needs attention",
|
||||
s_al["level"] == "bad" and s_al["state"]["kind"] == "alarm"
|
||||
and _red and s_al["attention"][0] is _red[0]
|
||||
and _red[0]["title"] == homeview.ATTN_TITLES["direction"])
|
||||
check("...and nothing is planned while every inverter is held at 0 W",
|
||||
s_al["plan"]["runs"] == [] and s_al["plan"]["verdict_key"] == "held")
|
||||
_rows = [r for r in s_al["checks"] if not r["ok"] and not r["warn"]]
|
||||
check("...and every red, amber and settling row reaches Needs attention with "
|
||||
"its own sentence as the body, and no green row does",
|
||||
[a["body"] for a in homeview.attention(s_al["checks"])]
|
||||
== [r["text"] for r in s_al["checks"] if not r["ok"] and not r["warn"]]
|
||||
+ [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]]
|
||||
+ [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]])
|
||||
|
||||
# UX-13 P1 test finding (a): the page said "Held at 0 W" about 15 s before
|
||||
# the inverter's power request reached 0 W. Until the last accepted request
|
||||
# is 0 W it says "Going to 0 W".
|
||||
c_al.inverters[0].last_write = 20.0
|
||||
s_go = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
|
||||
c_al.inverters[0].last_write = 0.0
|
||||
s_at = homeview.build(c_al, level="bad", checks=s_al["checks"], now=NOW)
|
||||
check("an alarm while the last power request is still 20 W says 'Going to "
|
||||
"0 W', and 'Held at 0 W' once 0 W was accepted",
|
||||
s_go["plan"]["verdict"] == "Going to 0 W"
|
||||
and s_go["batteries"][0]["now"] == "Going to 0 W until the alarm clears."
|
||||
and s_go["plan"]["head"].startswith("Every inverter goes to 0 W")
|
||||
and s_at["plan"]["verdict"] == "Held at 0 W"
|
||||
and s_at["batteries"][0]["now"] == "Held at 0 W until the alarm clears.")
|
||||
c_sp = make(TWO, auto=False)
|
||||
c_sp.inverters[1].last_write = -1500.0
|
||||
s_sp = status(c_sp)
|
||||
s_new = status(make(TWO, auto=False, wrote=None))
|
||||
check("...and before any power request was accepted (just started, or every "
|
||||
"write refused) it says 'Going to 0 W', not 'Held'",
|
||||
[b["now"] for b in s_new["batteries"]]
|
||||
== ["Going to 0 W while control is stopped."] * 2)
|
||||
check("...and a stop does the same, per battery",
|
||||
[b["now"] for b in s_sp["batteries"]]
|
||||
== ["Held at 0 W while control is stopped.",
|
||||
"Going to 0 W while control is stopped."])
|
||||
|
||||
# Finding (b): a quarter-hourly feed. The block's price and the price line
|
||||
# name the same 15-minute point, and nothing says "the list".
|
||||
_q = []
|
||||
for _k, _p in enumerate(MOCK):
|
||||
for _m in range(4):
|
||||
_q.append(PricePoint(DAY0 + timedelta(hours=_k, minutes=15 * _m),
|
||||
round(_p + (0.0 if _m != 3 else -0.01), 4), False))
|
||||
_q += [PricePoint(p.start + timedelta(days=1), p.eur_per_kwh, False)
|
||||
for p in list(_q)]
|
||||
c_q = make(ARB)
|
||||
c_q.tariff_plan = TariffPlan(PriceRead.series(tuple(_q), PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", NOW), None,
|
||||
"provider")
|
||||
c_q.tariff_at = NOW
|
||||
c_q.peak_guard = quiet_guard()
|
||||
s_q = status(c_q)
|
||||
_grid = [r for r in s_q["plan"]["runs"] if r["kind"] == "grid"]
|
||||
_lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
|
||||
check("quarter-hour prices: the charge block, its price and the price line's "
|
||||
"lowest are the same 15-minute period",
|
||||
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
|
||||
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
|
||||
"per kWh."
|
||||
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
|
||||
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
|
||||
and "the list" not in json.dumps(s_q["plan"]))
|
||||
check("...and the hour bar says it is an hour average",
|
||||
s_q["plan"]["prices"][13]["points"] == 4
|
||||
and '"plan.average_tip"' in io.open(os.path.join(
|
||||
HERE, "app", "static", "app.js"), encoding="utf-8").read())
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. a fault in the Home view never costs the page its rows")
|
||||
# --------------------------------------------------------------------------- #
|
||||
_real = homeview.build
|
||||
try:
|
||||
homeview.build = lambda *a, **k: 1 / 0
|
||||
s_err = make(ONE, soc=(50.0,), batt=(0.0,)).status_payload()
|
||||
finally:
|
||||
homeview.build = _real
|
||||
check("the old fields are served and `home_error` says the rest is missing",
|
||||
OLD <= set(s_err) and s_err.get("home_error") is True
|
||||
and not (NEW & set(s_err)))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. the page")
|
||||
# --------------------------------------------------------------------------- #
|
||||
PAGE = web.page()
|
||||
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
|
||||
encoding="utf-8").read()
|
||||
UI = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
|
||||
encoding="utf-8"))["home"]
|
||||
|
||||
|
||||
def resolve(path):
|
||||
node = UI
|
||||
for k in path.split("."):
|
||||
if not isinstance(node, dict) or k not in node:
|
||||
return None
|
||||
node = node[k]
|
||||
return node
|
||||
|
||||
|
||||
_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 _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"]
|
||||
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
|
||||
and "ArrowRight" in JS and "ArrowLeft" in JS)
|
||||
_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
|
||||
check(f"no external file and no web font: no URL leaves the add-on "
|
||||
f"({_ext})", not _ext and "@import" not in PAGE
|
||||
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?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))
|
||||
check(f"every label the page asks for is in app/ui_text ({len(_keys)} "
|
||||
f"keys; missing: {_missing})", len(_keys) > 30 and not _missing)
|
||||
check("data is set as text, never as HTML, so no status sentence can inject "
|
||||
"markup", "innerHTML" not in JS and "insertAdjacentHTML" not in JS)
|
||||
check("every function of the old page is still on it: start/stop, the "
|
||||
"maintenance start and abort, the status rows, the checks and the "
|
||||
"event line",
|
||||
"auto_toggle" in JS and '"maint_start"' in JS and '"maint_abort"' in JS
|
||||
and 'id="rows"' in PAGE and 'id="checks"' in PAGE
|
||||
and 'id="hint"' in PAGE and 'href="config"' in PAGE)
|
||||
check("stopping, starting and both maintenance actions ask first",
|
||||
all(f'ask("{k}"' in JS for k in ("stop", "start", "maint_start",
|
||||
"maint_abort")))
|
||||
check("tooltips open on hover, on a tap and on keyboard focus",
|
||||
"pointermove" in JS and "pointerdown" in JS and "focusin" in JS)
|
||||
check("the chart is not colour alone: expensive hours and blocks are "
|
||||
"patterned, and every item has a text label",
|
||||
"repeating-linear-gradient" in PAGE and '"aria-label": tipText' in JS)
|
||||
check("the phone layout puts the tabs at the bottom under 640 px, and dark "
|
||||
"mode follows the system",
|
||||
"@media (max-width:640px)" in PAGE and "position:fixed;bottom:0" in PAGE
|
||||
and "prefers-color-scheme:dark" in PAGE)
|
||||
|
||||
|
||||
async def _serve():
|
||||
class _Sup:
|
||||
async def info(self):
|
||||
return {"options": {"price_taxes_eur_kwh": FLANDERS,
|
||||
"price_taxes_profile": ""},
|
||||
"schema": [], "translations": {}}
|
||||
c = make(ONE, soc=(50.0,), batt=(0.0,))
|
||||
runner = aioweb.AppRunner(web.build_app(c, _Sup()))
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
out = {}
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(f"http://127.0.0.1:{port}/") as r:
|
||||
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:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
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",
|
||||
OLD <= set(E["status"]) and NEW <= set(E["status"]))
|
||||
check("the levies preset and its year reach config/model for the P2 "
|
||||
"Settings screens (P0 review follow-up)",
|
||||
E["model"].get("region") == {"id": "flanders", "year": 2026})
|
||||
c_reg = make(dict(ARB, price_taxes_eur_kwh=FLANDERS))
|
||||
c_reg.tariff_plan, c_reg.tariff_at = provider_plan(), NOW
|
||||
s_reg = status(c_reg)
|
||||
check("...and Home names the preset with its year wherever prices come from "
|
||||
"a feed, and says 'your own figure' otherwise",
|
||||
s_reg["plan"]["region"] == {"id": "flanders", "year": 2026}
|
||||
and s_reg["plan"]["region_line"]
|
||||
== "Levies and VAT: Flanders, Fluvius Antwerpen, 2026 figures."
|
||||
and s_arb["plan"]["region_line"] == "Levies and VAT: your own figure."
|
||||
and s_house["plan"]["region_line"] == "")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -832,7 +832,7 @@ check("...and the per-inverter rows now say WHAT the unit was compared "
|
||||
"look at P1 for a fault that is visible only on one inverter's own power "
|
||||
"sensor",
|
||||
len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
|
||||
and all("its own power" in r["text"] for r in _c3r.checks()
|
||||
and all("own power" in r["text"] for r in _c3r.checks()
|
||||
if r.get("id") == "direction"))
|
||||
|
||||
# The operator's clear, which is one button and has to reach every latch.
|
||||
|
||||
@@ -497,7 +497,7 @@ check("a FORCED start is refused while another pack holds the slot, and the "
|
||||
"phase machine does not move. The calendar is a preference an operator "
|
||||
"may overrule; one pack at a time is a property of the grid connection "
|
||||
"and a button does not add a second one",
|
||||
_b8.phase == IDLE and len(_ev) == 1 and "REFUSED" in _ev[0]
|
||||
_b8.phase == IDLE and len(_ev) == 1 and "cannot start now" in _ev[0]
|
||||
and "inverter-a" in _ev[0])
|
||||
check("...and an unblocked forced start still works, so the refusal is a "
|
||||
"condition and not a disabled feature",
|
||||
@@ -534,7 +534,7 @@ check("...and the deferred pack's PHASE does not move either, so "
|
||||
_b9.phase == IDLE and _b9.store.get_time("phase_started") is None)
|
||||
check("...and the words say WAITING, name the pack it waits for, and say it "
|
||||
"will run next - none of which any failure path in this module says",
|
||||
"WAITING" in _r9[1].events[0]
|
||||
"waits its turn" in _r9[1].events[0]
|
||||
and "inverter-a" in _r9[1].events[0]
|
||||
and "not a stalled phase" in _r9[1].events[0]
|
||||
and "ABORT" not in _r9[1].events[0] and "stuck" not in _r9[1].events[0])
|
||||
@@ -687,7 +687,7 @@ print(f" a store already written by a multi-inverter version: {_rep3.explain(
|
||||
check("a store already at the per-pack schema is NOT TOUCHED - not one key "
|
||||
"written - and says so",
|
||||
not _rep3.ran and _m3.data == _before3 and _m3.writes == []
|
||||
and "not touched" in _rep3.explain())
|
||||
and "left as it is" in _rep3.explain())
|
||||
|
||||
# ⚠️ AND BREAK IT. The read-back postcondition is the whole difference between
|
||||
# this and a two-line copy, and it is inherited from `migrate_unit_keys()`
|
||||
@@ -766,7 +766,7 @@ check("...and the refusal NAMES the namespace it used AND the roster it was "
|
||||
"graded against, rather than merely saying something went wrong - the "
|
||||
"operator's next question is 'which name, and which one should it have "
|
||||
"been', and a bare `not ok` answers neither",
|
||||
"WRONG NAMESPACE" in _rep5.explain()
|
||||
"moved under an inverter not in the list" in _rep5.explain()
|
||||
and unit_key(UNIT_A, "last_completed") in _rep5.explain()
|
||||
and repr(ROSTER) in _rep5.explain())
|
||||
check("⚠️ AND THE MARKER IS NOT STAMPED, WHICH IS THE WHOLE REMEDY. A stamped "
|
||||
@@ -893,7 +893,7 @@ check("...and the counterfactual: the SAME two calls with the roster's own "
|
||||
"`already at schema` early return, not a refusal. So 9c is about the "
|
||||
"NAME surviving into the second start, not about second calls",
|
||||
_rep7ok_a.ok and _m7ok.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION
|
||||
and _rep7ok_b.ran is False and "already at schema" in _rep7ok_b.explain())
|
||||
and _rep7ok_b.ran is False and "already at version" in _rep7ok_b.explain())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1363,50 +1363,24 @@ check("...while the second pack's write reaches only its own namespace",
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("12. RECORDED, NOT FIXED: the site loop still contradicts a drain at N>=2")
|
||||
print("12. FIXED (SAFETY-78): a drain at N>=2 no longer parks the site")
|
||||
|
||||
# ⚠️ THIS IS A DEFECT THIS CARD FOUND AND DELIBERATELY DID NOT FIX, AND IT IS
|
||||
# WRITTEN DOWN AS A RUNNING CHECK RATHER THAN AS A COMMENT SO IT CANNOT BE
|
||||
# FORGOTTEN OR QUIETLY "FIXED" BY A TUNING CHANGE.
|
||||
#
|
||||
# WHAT IT IS. The maintenance SET is a per-unit claim and PINS its pack. The
|
||||
# loop's target is a SITE claim. When the site target is BELOW the pin - a
|
||||
# drain of 2000 W while the house is importing less than that, which is most of
|
||||
# a night - the only split that sums to the site target asks the sibling to
|
||||
# CHARGE while the maintained pack DISCHARGES, and `allocator.plan()` refuses it
|
||||
# by name as circulating power. The refusal is correct and fails safe: the whole
|
||||
# site goes to 0 W with an ARBITER line in the action log.
|
||||
#
|
||||
# WHY IT IS NOT THIS CARD'S. It is PRE-EXISTING - measured on release/1.0 at
|
||||
# 2d025ee, where the same drain filed on `inverters[0]` produces the identical
|
||||
# refusal with the two unit names swapped - and fixing it means changing what
|
||||
# the site loop's target MEANS while a unit is pinned (regulate the meter using
|
||||
# only the unpinned packs), which is loop and allocator territory, not
|
||||
# maintenance's. MAINT-05 moves which pack is pinned and nothing else about it.
|
||||
#
|
||||
# ⚠️ AND THE CONSEQUENCE AN OPERATOR WOULD SEE IS A MISDIAGNOSIS, WHICH IS WHY
|
||||
# IT MATTERS ENOUGH TO PIN. The pack is held at 0 W while its FSM believes it is
|
||||
# draining, so after 12 h the DRAIN timeout fires and reports "drain stuck" -
|
||||
# §12's words for "the battery is not following commands, a fault". The battery
|
||||
# is fine; the site refused to allocate. That is the same class of wrong story
|
||||
# this card's own notes warn about for the deferral.
|
||||
# This section used to pin the defect: with the house below the drain, the
|
||||
# only split summing to the site target asked the sibling to CHARGE, the
|
||||
# allocator refused it and the site sat at 0 W while the FSM believed it was
|
||||
# draining. SAFETY-78 files a site `drain` floor at the pin, so the sibling
|
||||
# only ever adds power the same way. test_safety78.py grades it in full.
|
||||
_cX, _ = controller(TWO_OPTS, grid=0.0)
|
||||
_cX.maints[1].force_start(_now)
|
||||
one_cycle(_cX)
|
||||
print(f" house at 0 W, B draining 2000 W -> reason {_cX.reason!r}; "
|
||||
f"{_cX.allocation.explain()}")
|
||||
check("a maintenance drain BELOW the site target is refused by the allocator "
|
||||
"and the site parks at 0 W - recorded here so the next card can find it, "
|
||||
"not asserted as desirable",
|
||||
_cX.target == 0.0 and _cX.allocation.refused
|
||||
and "circulating power" in _cX.allocation.explain())
|
||||
check("...and the pack that is supposedly draining is commanded 0 W, so its "
|
||||
"12 h DRAIN timeout would eventually report 'drain stuck' - a fault "
|
||||
"message about a healthy battery",
|
||||
_cX.maints[1].phase == DRAIN and _cX.inverters[1].target == 0.0)
|
||||
check("...while the SAME site with the house importing more than the drain "
|
||||
"allocates cleanly, which is what makes this a CONDITION rather than "
|
||||
"'maintenance does not work at two inverters'",
|
||||
check("a maintenance drain above the loop's site target is allocated, not "
|
||||
"refused: B drains at its rate and A is held at 0 W",
|
||||
_cX.allocation.refused is None and _cX.target == 2000.0
|
||||
and _cX.inverters[1].target == 2000.0 and _cX.inverters[0].target == 0.0)
|
||||
check("...and the SAME site with the house importing more than the drain "
|
||||
"still allocates cleanly",
|
||||
_cB.allocation.refused is None and _cB.inverters[1].target == 2000.0)
|
||||
|
||||
|
||||
@@ -1581,9 +1555,11 @@ try:
|
||||
_logged, _ = controller(NAMED_OPTS, store=_log_store)
|
||||
finally:
|
||||
logging.getLogger("goodwe").removeHandler(_h)
|
||||
_s44_lines = [m for m in _records if "SAFETY-44" in m]
|
||||
# UX-08 2d: the line is plain now, so it is found by its words, not a card id.
|
||||
_s44_lines = [m for m in _records
|
||||
if "which is not in the inverter list" in m]
|
||||
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
|
||||
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line "
|
||||
check("⭐ the repair SAYS SO, at ERROR, in one plain line - deleting this line "
|
||||
"left the whole suite green, and it is how an operator learns their "
|
||||
"latch was re-armed rather than never lost",
|
||||
len(_s44_lines) == 1)
|
||||
|
||||