Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b171ada894 | ||
|
|
0b96e3191c | ||
|
|
35cdffe766 | ||
|
|
a692560960 | ||
|
|
fbd884797d | ||
|
|
516bc8b4ec | ||
|
|
4f25b24aa6 | ||
|
|
044fc0d5bd | ||
|
|
f967073cfe | ||
|
|
aabc2a27fd | ||
|
|
af39c10893 | ||
|
|
6eb67c0f9c | ||
|
|
5d27f8496f | ||
|
|
eb10537aee | ||
|
|
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 |
@@ -7,7 +7,7 @@ This applies to everything a home owner or installer reads: notifications, phone
|
||||
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
|
||||
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
|
||||
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
|
||||
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words from `app/homeview.py`, checked by `test_messages.py` part 8.
|
||||
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words in `app/ui_text/<lang>.json` under `home_words`, built by `app/homeview.py`, checked by `test_messages.py` part 8. The page's own words, English and Dutch, are checked by `test_ui_text.py`.
|
||||
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
|
||||
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
|
||||
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# UX-13 screenshots
|
||||
|
||||
Made from the real page by `goodwe_controller/tools/ux13_screenshots.sh`. Do not
|
||||
edit them by hand: run the script again.
|
||||
|
||||
- `home-*.png`: Home, running, in Dutch, in shadow mode, at phone width, and in setup.
|
||||
- `settings-*.png`: Settings, 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,129 @@
|
||||
# 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.
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
today; `pv_forecast_tomorrow_entity` optional), read by `app/solarfc.py`.
|
||||
Supported shapes: Forecast.Solar (state only), Solcast (`detailedForecast`)
|
||||
and Open-Meteo Solar Forecast (`wh_period`). The unit is read, not guessed;
|
||||
older than 3 h is stale (one missed hourly poll must not flag it).
|
||||
- Trust: `solar_trust_pct`, default 70 (owner answer 5).
|
||||
- House use: nothing the add-on recorded fitted. `tariff.db` holds grid
|
||||
import per quarter, which the batteries shape. So `dayplan.HouseProfile`
|
||||
keeps grid + battery per local hour for 7 days in the store
|
||||
(`house_profile`), median per hour. It is net of the solar the house used,
|
||||
so the solar sum never takes it below the night median. Solar readings are
|
||||
not used (TEL-11). The first plan needs one day of it.
|
||||
- Verdict (`dayplan.plan()`): trust x sum max(0, forecast - house) x 0.95
|
||||
until the expensive run the cheap run is for. Solar first takes it first.
|
||||
- Leftover solar may then charge from the grid up to min(ceiling, floor +
|
||||
the house's use in the expensive runs / 0.95), less its sun share: only
|
||||
the shortfall.
|
||||
- Cheap hours only ignores the plan and fills to the ceiling (design
|
||||
section 1; review of #156, see the replay below).
|
||||
- Solar first never charges from the grid (owner, 2026-09-29).
|
||||
- The level is kept unless it moves by min(1 kWh, 10 %). With no plan
|
||||
(no, stale or failed forecast, no house profile, no capacity) every mode
|
||||
is P3 option A exactly: `test_modes_p4.py` part 1 pins 12 digests from
|
||||
5a4cdc2 (modes on, with and without an unavailable forecast).
|
||||
- The plan is made with the runs (every 15 min, or at once when the forecast
|
||||
comes or goes). A lost forecast drops the levels at the next cycle.
|
||||
- Not done: the design's scaling by measured solar from 10:00 (it needs the
|
||||
solar readings TEL-11 keeps out), the HA `goodwe_<inv>_plan` sensor, and
|
||||
FCST-01.
|
||||
- Replay (`tools/opt11p4_replay.py`, 16 recorded days, Solar first + Leftover
|
||||
solar, net EUR, lower is better; site solar rebuilt from the recording,
|
||||
forecast errors from Elia's day-ahead Belgian solar forecast):
|
||||
- year-earlier hourly prices: P2 19.33, P3 option A 19.44, old one-period
|
||||
trade 18.14, P4 19.15 (perfect forecast 19.14, 1.5 x too high 19.16,
|
||||
missing 19.44);
|
||||
- the days' real 2026 quarter-hourly prices (held out): P2 32.26, option A
|
||||
32.01, old trade 31.40, P4 29.19 (perfect 29.21, too high 29.28, missing
|
||||
32.01).
|
||||
- Solar first + Cheap hours only, skipping its sun share (the first
|
||||
build): 19.57 (same as P3) and 29.72 against P3's 29.41. The shorter,
|
||||
moved runs left B lower, so later midday top-ups rested A while the sun
|
||||
shone. Cheap hours now ignores the plan, which is P3 exactly.
|
||||
- A forecast of no sun at all gives 19.14 and 29.33. So nearly all of the
|
||||
gain is Leftover solar buying the evening's shortfall in the cheap run;
|
||||
the forecast itself was worth 0.14 EUR in 16 September days.
|
||||
- Bias: the site's solar is rebuilt from the same net use the replay
|
||||
plays, and "recorded" keeps its hourly shape and adds only the national
|
||||
daily error. Both favour P4; "national" (no house leakage) gives 29.25.
|
||||
No threshold was tuned to these days. The 2026-09-24 PV recording could not
|
||||
be replayed: it is only in the live Home Assistant.
|
||||
@@ -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.
|
||||
@@ -70,6 +70,31 @@ What UX-02 made authoritative stays authoritative:
|
||||
Its tables (`KETTLE`, `SHADOW`) are in `uilayout.py`, and its live checks
|
||||
are rules, so they are in `validate.py` (`setup_checks`). settings.js still
|
||||
names no key. See [[UX-13-setup-wizard]].
|
||||
- P5: the page words are in `app/ui_text/en.json` and `nl.json`, one key set,
|
||||
loaded by `app/uitext.py` with an English fallback per key. The page picks
|
||||
Dutch when the browser's language is `nl`, as the classic page does. Home's
|
||||
sentences moved from `homeview.py` into `home_words`, so `/status?lang=nl`
|
||||
builds them in Dutch. Status rows, banners, hints and validator sentences
|
||||
(`messages.py`, `validate.py`) stay English: they are shared with
|
||||
notifications, the log and the start refusal. `test_ui_text.py` checks both
|
||||
files against STYLE.md and each other, using the rules in
|
||||
`tools/style_rules.py`, which it shares with test_messages.py.
|
||||
- P5: "Other time windows" is a list editor (`ui.periods`, control
|
||||
`period_list`). `uimodel._periods_json()` builds `static_profile` and names
|
||||
the first bad time, empty price, overlap or gap. Its tiling rule matches
|
||||
`static_tariff._check_tiling`. A stored profile whose days the editor has no
|
||||
choice for (for example "mon,wed") is still kept as stored and edited in the
|
||||
Configuration tab. Supervisor 2026.08.0's `AppOptions` accepted all 133
|
||||
wizard blobs, 17 of them with time windows
|
||||
(`tools/ux13_wizard_blobs.py --supervisor`, run offline on 2026-09-28).
|
||||
- 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
|
||||
|
||||
|
||||
@@ -183,6 +183,10 @@ 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 forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. |
|
||||
| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. |
|
||||
| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. |
|
||||
| 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. |
|
||||
@@ -203,6 +207,7 @@ 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. |
|
||||
@@ -273,6 +278,7 @@ 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.
|
||||
@@ -512,6 +518,51 @@ 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. With a solar forecast it may also charge from the grid, but only what the sun will not give (see below). 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.
|
||||
|
||||
**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight.
|
||||
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
|
||||
- *Cheap hours only* ignores the forecast and fills to the top level.
|
||||
- *Solar first* never charges from the grid.
|
||||
- The house use is the add-on's own measurement (grid + battery power) per hour, over the last 7 days. It needs one day of it before the first plan.
|
||||
- The plan is checked every 15 minutes. A planned level moves only by 1 kWh or 10 %, so a wobbling forecast does not flip it.
|
||||
- No forecast, one older than 3 hours or from before midnight, or no house use yet: *Leftover solar* takes solar only and *Cheap hours only* fills to the top level. The Batteries screen says so.
|
||||
- A battery needs its capacity (`inverters.capacity_kwh`) for a plan.
|
||||
|
||||
**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
|
||||
@@ -1600,6 +1651,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 |
|
||||
@@ -1609,6 +1661,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
|
||||
@@ -1701,7 +1757,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
|
||||
@@ -2886,6 +2943,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
|
||||
@@ -3167,9 +3230,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
|
||||
@@ -3221,6 +3287,34 @@ action log (`Controller.events`). The page no longer shows that log, but the
|
||||
page and add `status` to its address. The action-log tables in this document
|
||||
refer to it.*
|
||||
|
||||
### Language and screenshots
|
||||
|
||||
The page is in Dutch when your browser's language is Dutch, and in English
|
||||
otherwise. Status lines, alarms and the start checks' messages stay in English.
|
||||
|
||||
Screenshots of each screen are in the add-on's repository, under
|
||||
`docs/img/ux13/`, 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
|
||||
|
||||
@@ -98,6 +98,12 @@ DROP_STALE = "stale link"
|
||||
DROP_CONTRADICTION = "contradicts itself"
|
||||
DROP_NO_INTEGER = "no integer setpoint in its interval"
|
||||
|
||||
# OPT-11: why a unit's MODE layer was not applied this tick. Only the first is
|
||||
# a fault; the other two are the rule working.
|
||||
MODE_FAULT = "mode range leaves out 0 W"
|
||||
MODE_RELEASED = "mode released by a higher claim"
|
||||
MODE_OUTRANKED = "hard limit leaves no room for the mode"
|
||||
|
||||
REFUSE_SCOPE = "mis-routed claim"
|
||||
REFUSE_EMPTY = "no unit available"
|
||||
REFUSE_SIGN = "opposite directions"
|
||||
@@ -213,6 +219,10 @@ class Allocation:
|
||||
# that calls `plan()` for its arithmetic gets None and does not care;
|
||||
# main.py stamps every record it puts on the write path.
|
||||
tick: int | None = None
|
||||
# OPT-11: ((unit, why), ...) for each unit whose mode range left out 0 W
|
||||
# this tick. That mode was dropped (see `unit_interval()`); main.py logs it
|
||||
# as a software fault. Empty on every tick without a mode.
|
||||
mode_faults: tuple = ()
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -290,6 +300,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,
|
||||
@@ -238,6 +253,13 @@ CLAIM_SCOPE = {
|
||||
# this pack to its floor, charge this pack to full, recalibrate this pack's
|
||||
# coulomb counter. Two packs recalibrate on their own schedules.
|
||||
"maintenance": SCOPE_UNIT,
|
||||
# main.py: `Claim.limit("mode", P_MODE, lo, hi, ...)`, OPT-11. THIS
|
||||
# battery's mode, chosen per battery by its owner. A unit claim that never
|
||||
# enters a `lo > hi` test with a site limit: stage 1 keeps it apart from the
|
||||
# hard limits, and it gives way to a site limit above P_MODE only as far as
|
||||
# that limit needs (`_unit_modes()`). At N=1 it routes to the site, and
|
||||
# `resolve()` takes it out of stage 2 for the same reason.
|
||||
"mode": SCOPE_UNIT,
|
||||
}
|
||||
|
||||
|
||||
@@ -365,6 +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:
|
||||
@@ -410,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."""
|
||||
@@ -433,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:
|
||||
@@ -441,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.
|
||||
@@ -491,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
|
||||
@@ -507,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
|
||||
|
||||
|
||||
@@ -544,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,
|
||||
@@ -606,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))
|
||||
@@ -617,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.
|
||||
#
|
||||
@@ -649,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
|
||||
|
||||
@@ -17,6 +17,7 @@ exists because its absence produced a specific, observed failure.
|
||||
"""
|
||||
|
||||
import math
|
||||
from math import inf
|
||||
from dataclasses import dataclass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -293,6 +294,10 @@ REFUSED_BELOW_W = PROBE_ANSWER_W
|
||||
PROBE_EVERY_S = 900.0
|
||||
PROBE_S = 34.0
|
||||
|
||||
# OPT-11. The loop's reason when the battery modes, not the meter, set the
|
||||
# command: every battery that may move this way is at its mode's limit.
|
||||
MODE_BOUND = "mode-bound"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -475,6 +480,7 @@ def compute(
|
||||
i_w: float | None = None,
|
||||
refused: int | None = None,
|
||||
probe: bool = False,
|
||||
bound: tuple | None = None,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -485,8 +491,24 @@ def compute(
|
||||
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
||||
(the `i_w=None` equivalence path does)
|
||||
`probe` the caller's clock says a refused direction may try PROBE_W now
|
||||
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
|
||||
their modes applied (`allocator.loop_bound()`), or None. See
|
||||
MODE_BOUND below. None is the law as it was.
|
||||
"""
|
||||
reason = "tracking"
|
||||
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||
# mode may do. It is dropped rather than obeyed (a software fault, and
|
||||
# main.py never builds one: every term of `loop_bound()` holds 0).
|
||||
if bound is not None and not (bound[0] <= 0.0 <= bound[1]):
|
||||
bound = None
|
||||
# ...and a side of it that reaches the rail is left to the rail, so that
|
||||
# SAFETY-03's `i_clamped` still fires when the loop winds into it. With
|
||||
# every battery on, the bound IS the rail, and the law is exactly as it was.
|
||||
if bound is not None:
|
||||
_rail = min(tuning.max_w, tuning.max_w if tuning.integrator_max_w is None
|
||||
else tuning.integrator_max_w)
|
||||
bound = (float(bound[0]) if bound[0] > -_rail else -inf,
|
||||
float(bound[1]) if bound[1] < _rail else inf)
|
||||
|
||||
# --- saturation, WITH the duration term -------------------------------
|
||||
# Command and actual diverging means the inverter cannot follow - it is at
|
||||
@@ -589,6 +611,15 @@ def compute(
|
||||
else:
|
||||
i_w = max(moved, i_w)
|
||||
|
||||
# OPT-11: the integrator does not wind past what the modes let the
|
||||
# batteries place. Only a side tighter than the rail is left in `bound`
|
||||
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
|
||||
if bound is not None:
|
||||
held = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||
if held != i_w:
|
||||
i_w = held
|
||||
reason = MODE_BOUND
|
||||
|
||||
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
|
||||
# bound is not. It is what makes the worst-case unwind time finite and
|
||||
# knowable instead of a function of how long the error happened to stand.
|
||||
@@ -796,6 +827,22 @@ def compute(
|
||||
target = refused * PROBE_W if probe and wants else 0.0
|
||||
reason = PROBING if probe and wants else REFUSED
|
||||
|
||||
# --- OPT-11: the command stays inside what the modes allow -------------
|
||||
# Applied after the slew limit and the freeze, so a mode that closes drops
|
||||
# the ask at once rather than one slew step per cycle. That moves no
|
||||
# battery further than the allocator would: it places at most this bound
|
||||
# when the loop decides. What it removes is the unmet part, which would
|
||||
# otherwise hold `prev_w` away from `actual_w` and trip the saturation
|
||||
# freeze on watts no battery was allowed to give.
|
||||
if bound is not None:
|
||||
inside = max(float(bound[0]), min(float(bound[1]), target))
|
||||
if inside != target:
|
||||
target = inside
|
||||
reason = MODE_BOUND
|
||||
# SAFETY-48's lead cap above may pull the integrator back toward the
|
||||
# slewed command; what is CARRIED stays inside the bound too.
|
||||
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
|
||||
|
||||
# --- quantise ----------------------------------------------------------
|
||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
||||
@@ -803,6 +850,13 @@ def compute(
|
||||
# 10 W simply avoids a visible staircase on dashboards.
|
||||
step = max(1, int(tuning.step_w))
|
||||
target = round(target / step) * step
|
||||
if bound is not None:
|
||||
# OPT-11: rounding must not step outside the bound. The bound holds
|
||||
# 0 W, so one step toward 0 is always inside it.
|
||||
if target > bound[1]:
|
||||
target -= step
|
||||
elif target < bound[0]:
|
||||
target += step
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""OPT-11 P4: the day plan. Will the sun fill each battery before the
|
||||
expensive hours? If not, how much should it take from the grid?
|
||||
|
||||
Pure: the forecast (`solarfc`), the house profile, the price runs and the
|
||||
batteries in; a level per battery (`Plan.targets`) out. `modetrade.charge_to()`
|
||||
turns the level into permission to charge from the grid; main.py rebuilds the
|
||||
plan with the price runs (at most every 15 minutes) and keeps a level unless
|
||||
it moves by `HYST_KWH` or `HYST_FRAC` (`hold()`).
|
||||
|
||||
The sums (OPT-11-design section 1, "Excess solar", with the owner's answers):
|
||||
* `sun`: trust x the solar the house will not use, between now and the
|
||||
expensive run the cheap run is for, x the charge efficiency:
|
||||
`trust x sum_h max(0, forecast_h - house_h) x EFF`.
|
||||
* Solar first batteries take it first, up to the ceiling. What is left is
|
||||
shared by the Leftover solar batteries by their room.
|
||||
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
|
||||
the design (section 1). Skipping the sun's share cost money in the replay
|
||||
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
|
||||
* A Leftover solar battery charges only up to what the house will draw from
|
||||
it in the expensive runs before the next cheap run, less its share:
|
||||
`min(ceiling, floor + house use / EFF) - share`. So it buys only the
|
||||
shortfall, not up to the ceiling.
|
||||
* Solar first never charges from the grid (owner, 2026-09-29).
|
||||
|
||||
The house profile is the add-on's own measurement, grid + battery power, per
|
||||
local hour, over the last `PROFILE_DAYS` days; the estimate for an hour is the
|
||||
median of that hour. It is net of the solar the house used itself, so for
|
||||
the solar sum it is never taken below the night-time median (`base()`).
|
||||
The site's own solar readings are not used: TEL-11 keeps them out of every
|
||||
control decision.
|
||||
"""
|
||||
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .tariff import BILLING_TZ
|
||||
|
||||
EFF = 0.95 # charge (and discharge) efficiency
|
||||
HYST_KWH = 1.0 # a kept level moves only by this much...
|
||||
HYST_FRAC = 0.10 # ...or this share of the battery's shortfall
|
||||
PROFILE_DAYS = 7
|
||||
MIN_HOURS = 20 # hours of a day recorded before it counts
|
||||
NIGHT_HOURS = (0, 1, 2, 3, 4, 5, 22, 23)
|
||||
DEFAULT_TRUST_PCT = 70
|
||||
|
||||
# Site verdicts (the page's forecast line).
|
||||
SUNNY, PARTLY, DARK = "sunny", "partly", "dark"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The house profile
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _local(t: datetime):
|
||||
return t.astimezone(BILLING_TZ)
|
||||
|
||||
|
||||
class HouseProfile:
|
||||
"""Hourly means of the house's net use (grid + battery power), kept per
|
||||
local day and hour: `{"YYYY-MM-DD": {hour: [mean_w, samples]}}`. The
|
||||
store holds `dump()`; `load()` takes it back and drops what is bad."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.days = {}
|
||||
self._cur = None # (day, hour, sum, n)
|
||||
if isinstance(data, dict):
|
||||
self.load(data)
|
||||
|
||||
def load(self, data):
|
||||
for day, hours in data.items():
|
||||
try:
|
||||
datetime.strptime(str(day), "%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(hours, dict):
|
||||
continue
|
||||
row = {}
|
||||
for h, v in hours.items():
|
||||
try:
|
||||
hi, w = int(h), float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 0 <= hi < 24 and abs(w) < 1e6:
|
||||
row[hi] = w
|
||||
if row:
|
||||
self.days[str(day)] = row
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {d: {str(h): round(w, 1) for h, w in sorted(r.items())}
|
||||
for d, r in sorted(self.days.items())}
|
||||
|
||||
def add(self, now: datetime, house_w) -> bool:
|
||||
"""One reading. Returns True when an hour closed (then store it)."""
|
||||
lt = _local(now)
|
||||
key = (lt.strftime("%Y-%m-%d"), lt.hour)
|
||||
closed = False
|
||||
if self._cur is not None and self._cur[:2] != key:
|
||||
day, hour, s, n = self._cur
|
||||
if n >= 60: # about 5 minutes of readings
|
||||
self.days.setdefault(day, {})[hour] = s / n
|
||||
closed = True
|
||||
self._cur = None
|
||||
self._prune(lt)
|
||||
if house_w is None:
|
||||
return closed
|
||||
if self._cur is None:
|
||||
self._cur = (key[0], key[1], 0.0, 0)
|
||||
d, h, s, n = self._cur
|
||||
self._cur = (d, h, s + float(house_w), n + 1)
|
||||
return closed
|
||||
|
||||
def _prune(self, lt):
|
||||
"""Keep the last `PROFILE_DAYS` days; a day after today (the clock
|
||||
was wrong once) would never age out, so it goes too."""
|
||||
keep = (lt.date() - timedelta(days=PROFILE_DAYS)).isoformat()
|
||||
today = lt.date().isoformat()
|
||||
for d in [d for d in self.days if d < keep or d > today]:
|
||||
del self.days[d]
|
||||
|
||||
def ready(self) -> bool:
|
||||
"""At least one day with `MIN_HOURS` hours recorded."""
|
||||
return any(len(r) >= MIN_HOURS for r in self.days.values())
|
||||
|
||||
def hour_w(self, hour: int):
|
||||
vals = [r[hour] for r in self.days.values() if hour in r]
|
||||
return statistics.median(vals) if vals else None
|
||||
|
||||
def base(self):
|
||||
vals = [w for h in NIGHT_HOURS if (w := self.hour_w(h)) is not None]
|
||||
return statistics.median(vals) if vals else None
|
||||
|
||||
def estimator(self):
|
||||
"""`f(hour) -> W`, or None when not ready. Fixed at call time."""
|
||||
if not self.ready():
|
||||
return None
|
||||
base = self.base() or 0.0
|
||||
table = {}
|
||||
for h in range(24):
|
||||
w = self.hour_w(h)
|
||||
table[h] = base if w is None else w
|
||||
return lambda h: table[h], max(0.0, base)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The plan
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class Plan:
|
||||
verdict: str # SUNNY / PARTLY / DARK
|
||||
sun_kwh: float # solar the batteries can count on
|
||||
fc_kwh: float # the forecast over the same span
|
||||
until: datetime # the expensive run the plan is for
|
||||
targets: dict # name -> level % (Leftover solar only)
|
||||
need_kwh: dict # name -> kWh from the grid (0: none)
|
||||
share_kwh: dict # name -> kWh of solar counted for it
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
lt = _local(t)
|
||||
nxt = (lt.replace(minute=0, second=0, microsecond=0)
|
||||
+ timedelta(hours=1)).astimezone(timezone.utc)
|
||||
e = min(nxt, t1)
|
||||
out.append((t, e, lt.hour))
|
||||
t = e
|
||||
return out
|
||||
|
||||
|
||||
def _room(p, to_pct):
|
||||
if p.capacity_kwh is None or p.soc is None:
|
||||
return 0.0
|
||||
return max(0.0, float(to_pct) - float(p.soc)) / 100.0 * float(
|
||||
p.capacity_kwh)
|
||||
|
||||
|
||||
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
ceiling_pct):
|
||||
"""The day plan, or None when there is none to make: no forecast, no
|
||||
house profile yet, no price plan, or no expensive run after the cheap
|
||||
run. `house` is `HouseProfile.estimator()`'s answer. `packs` are
|
||||
`modetrade.Pack`s."""
|
||||
if forecast is None or house is None or runs is None or not runs.on \
|
||||
or runs.cheap is None:
|
||||
return None
|
||||
house_w, base_w = house
|
||||
until = next((g for g in runs.give if g.start >= runs.cheap.end), None)
|
||||
if until is None:
|
||||
return None
|
||||
trust = max(0.0, min(1.0, float(trust_pct) / 100.0))
|
||||
sun = fc_sum = 0.0
|
||||
for s, e, h in _hour_slices(now, until.start):
|
||||
pv = forecast.between(s, e)
|
||||
if pv is None:
|
||||
return None # the span reaches a day with no forecast
|
||||
hrs = (e - s).total_seconds() / 3600.0
|
||||
use = max(house_w(h), base_w) * hrs / 1000.0
|
||||
sun += max(0.0, pv - use)
|
||||
fc_sum += pv
|
||||
sun *= trust * EFF
|
||||
|
||||
usable = [p for p in packs if p.use and p.live and p.idle
|
||||
and p.soc is not None and p.capacity_kwh]
|
||||
sf = [p for p in usable if p.mode == SOLAR_FIRST]
|
||||
price = [p for p in usable if p.mode == LEFTOVER_SOLAR]
|
||||
sf_room = sum(_room(p, ceiling_pct) for p in sf)
|
||||
excess = max(0.0, sun - sf_room)
|
||||
rooms = {p.name: _room(p, ceiling_pct) for p in price}
|
||||
total_room = sum(rooms.values())
|
||||
|
||||
# What the house draws in the expensive runs from the one this plan is
|
||||
# for until the next cheap run (or a day), from the profile.
|
||||
horizon = until.start + timedelta(hours=24)
|
||||
use_kwh = 0.0
|
||||
for g in runs.give:
|
||||
if g.start < until.start or g.start >= horizon:
|
||||
continue
|
||||
for s, e, h in _hour_slices(g.start, g.end):
|
||||
use_kwh += max(0.0, house_w(h)) * (
|
||||
e - s).total_seconds() / 3600.0 / 1000.0
|
||||
caps = {p.name: float(p.capacity_kwh) for p in price}
|
||||
cap_sum = sum(caps.values()) or 1.0
|
||||
|
||||
targets, need, share = {}, {}, {}
|
||||
for p in price:
|
||||
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
|
||||
sh_pct = sh / caps[p.name] * 100.0
|
||||
want = use_kwh * caps[p.name] / cap_sum / EFF
|
||||
top = min(float(ceiling_pct),
|
||||
float(floor_pct) + want / caps[p.name] * 100.0)
|
||||
level = max(0.0, top - sh_pct)
|
||||
targets[p.name] = round(level, 2)
|
||||
need[p.name] = round(_room(p, level), 3)
|
||||
share[p.name] = round(sh, 3)
|
||||
if sun < sf_room:
|
||||
verdict = DARK
|
||||
elif any(v > 0.05 for v in need.values()):
|
||||
verdict = PARTLY
|
||||
else:
|
||||
verdict = SUNNY
|
||||
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
|
||||
targets, need, share)
|
||||
|
||||
|
||||
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
|
||||
"""Hysteresis: a battery keeps its old level unless the new one differs
|
||||
by at least `HYST_KWH`, or by `HYST_FRAC` of the larger level's energy
|
||||
(whichever is less). A battery new to the plan takes its level at once;
|
||||
one that left the plan is dropped at once."""
|
||||
out = {}
|
||||
for name, level in new.items():
|
||||
was = (old or {}).get(name)
|
||||
cap = float(capacity.get(name) or 0.0)
|
||||
if was is None or cap <= 0:
|
||||
out[name] = level
|
||||
continue
|
||||
moved = abs(level - was) / 100.0 * cap
|
||||
step = min(HYST_KWH, HYST_FRAC * max(was, level) / 100.0 * cap)
|
||||
out[name] = level if moved >= step else was
|
||||
return out
|
||||
@@ -839,17 +839,22 @@ WHY_LIMIT = {
|
||||
}
|
||||
|
||||
|
||||
def why(winner, target_w, bound_by=None, *, contradiction=False) -> str:
|
||||
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)"."""
|
||||
`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", held back by {limit}"
|
||||
out += f", raised for {limit}" if raised else \
|
||||
f", held back by {limit}"
|
||||
return out
|
||||
|
||||
|
||||
@@ -901,6 +906,8 @@ HINTS = {
|
||||
"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",
|
||||
"forecast_read": "Solar forecast read, the day plan uses it",
|
||||
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
|
||||
"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",
|
||||
@@ -919,9 +926,35 @@ HINTS = {
|
||||
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
||||
"maintenance": "Battery maintenance: {phase}",
|
||||
"maint_paused": "Maintenance charging paused to stay under the peak limit",
|
||||
"pack_on": "{unit}: battery switched on",
|
||||
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
|
||||
"pack_mode": "{unit}: battery mode changed",
|
||||
"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)."""
|
||||
@@ -1135,12 +1168,16 @@ LABELS = {
|
||||
"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",
|
||||
"pv_forecast_entity": "Solar forecast, today",
|
||||
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
|
||||
"solar_trust_pct": "Trust in the forecast",
|
||||
"p1_phase_import_entities": "Import entities per phase",
|
||||
"p1_phase_export_entities": "Export entities per phase",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""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;
|
||||
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
||||
the grid only up to its planned level, never above the ceiling; with
|
||||
none it takes solar only (P3 option A). Cheap hours only fills to the
|
||||
ceiling either way (`charge_to()`);
|
||||
* 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,
|
||||
PLAN_CHARGE_MODES, 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 charge_to(p: Pack, ceiling_pct, targets=None):
|
||||
"""The level this battery may charge to from the grid, or None.
|
||||
|
||||
`targets` is the day plan (`dayplan.plan()`: name -> level), or None
|
||||
when there is none (no forecast, stale, or not configured). With no plan
|
||||
this is P3 option A exactly: Cheap hours to the ceiling, the others never.
|
||||
With a plan, a Leftover solar battery charges only to its planned level
|
||||
(the sun fills the rest); a battery the plan leaves out, Cheap hours only
|
||||
included, gets the no-plan answer. Solar first never charges."""
|
||||
if p.mode in GRID_CHARGE_MODES:
|
||||
return float(ceiling_pct)
|
||||
if targets is None or p.name not in targets \
|
||||
or p.mode not in PLAN_CHARGE_MODES:
|
||||
return None
|
||||
t = targets[p.name]
|
||||
return None if t is None else min(float(ceiling_pct), float(t))
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None) -> list:
|
||||
"""Batteries that may charge from the grid in a cheap run: below the
|
||||
level `charge_to()` allows them (P4: the day plan's)."""
|
||||
out = []
|
||||
for p in packs:
|
||||
to = charge_to(p, ceiling_pct, targets)
|
||||
if (to is not None and p.use and p.idle and p.live
|
||||
and p.soc is not None and p.soc < to
|
||||
and p.refused >= 0 and p.name not in done):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
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=(),
|
||||
targets=None):
|
||||
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`.
|
||||
With a day plan the charge is each battery's shortfall, so the cheap run
|
||||
is only as long as the shortfall needs."""
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets)
|
||||
se = sellers(packs, floor_pct, done_sell)
|
||||
return (sum(_room_kwh(p, charge_to(p, ceiling_pct, targets))
|
||||
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=(),
|
||||
targets=None, 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. `targets` is the day plan (`charge_to()`).
|
||||
`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, targets) 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
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""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 with no day plan.
|
||||
# Leftover solar takes solar only then (owner decision 2026-09-29, option A).
|
||||
GRID_CHARGE_MODES = (CHEAP_HOURS,)
|
||||
# OPT-11 P4: with a fresh solar forecast, the day plan (dayplan.py) lets
|
||||
# Leftover solar charge from the grid, by the shortfall only. Cheap hours only
|
||||
# ignores the plan (fills to the ceiling); Solar first never grid-charges.
|
||||
PLAN_CHARGE_MODES = (LEFTOVER_SOLAR,)
|
||||
|
||||
|
||||
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
|
||||
@@ -453,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)
|
||||
@@ -474,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
|
||||
@@ -524,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.
|
||||
|
||||
@@ -713,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
|
||||
@@ -732,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)
|
||||
@@ -743,7 +768,7 @@ 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,
|
||||
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"))
|
||||
@@ -755,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
|
||||
@@ -763,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.
|
||||
@@ -787,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")
|
||||
|
||||
@@ -805,7 +831,8 @@ 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
|
||||
@@ -813,8 +840,8 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
reason=(
|
||||
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W limit "
|
||||
f"({from_where}), and {wanted:+.0f} W from the battery would "
|
||||
f"hold it. But {denied}"
|
||||
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" against {float(soc_floor):.0f} %")
|
||||
@@ -825,11 +852,11 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
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: this 15-minute period heads for {projected:.0f} W "
|
||||
f"against a {ceiling:.0f} W limit ({from_where}), with "
|
||||
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. The battery measured {float(batt_w):+.0f} W "
|
||||
f"and is asked for {floor:+.0f} W"
|
||||
@@ -839,6 +866,40 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
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:
|
||||
"""SAFETY-45. The hardest this SITE may charge without putting the billed
|
||||
quarter over the ceiling, in W, positive - or None when nothing measured it.
|
||||
|
||||
@@ -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)
|
||||
@@ -52,7 +52,7 @@ class SetupOnly:
|
||||
if hint is not None:
|
||||
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
|
||||
|
||||
def status_payload(self) -> dict:
|
||||
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"]],
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""OPT-11 P4: the solar forecast, read from a Home Assistant sensor.
|
||||
|
||||
Pure: HA state dicts and the time in, a `Forecast` (or None and why) out.
|
||||
main.py reads the entities; dayplan.py uses the answer. FCST-01 replaces
|
||||
this later with the add-on's own forecast (owner answer, 2026-09-27).
|
||||
|
||||
Two options name the sensors: `pv_forecast_entity`, the solar energy still
|
||||
to come TODAY, and `pv_forecast_tomorrow_entity`, TOMORROW's (optional; it
|
||||
is only needed for a cheap run before midnight). Supported, by the entities
|
||||
they create (DOCS.md lists them):
|
||||
|
||||
* Forecast.Solar (HA core): `sensor.energy_production_today_remaining` and
|
||||
`sensor.energy_production_tomorrow`. The state only; no hourly split.
|
||||
* Solcast PV Forecast (HACS `solcast_solar`):
|
||||
`sensor.solcast_pv_forecast_forecast_remaining_today` and
|
||||
`..._forecast_tomorrow`, with `detailedForecast` (or `detailedHourly`):
|
||||
`[{period_start, pv_estimate}]`, kW per period.
|
||||
* Open-Meteo Solar Forecast (HACS `open_meteo_solar_forecast`):
|
||||
`sensor.energy_production_today_remaining` / `..._tomorrow`, with
|
||||
`wh_period`: `{period start: Wh}`.
|
||||
|
||||
Any other sensor whose state is energy in Wh, kWh or MWh works too, as a
|
||||
state only. The unit is read, never guessed (the rule `pv.watts()` has).
|
||||
Without an hourly split, the day's energy is spread over a fixed bell from
|
||||
06:00 to 20:00 local time (`BELL_FROM_H`, `BELL_TO_H`).
|
||||
|
||||
A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
"""
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .hass import BAD_STATES
|
||||
from .tariff import BILLING_TZ
|
||||
|
||||
KWH_PER = {"Wh": 0.001, "kWh": 1.0, "MWh": 1000.0}
|
||||
|
||||
# Stale after 3 h. The integrations above refresh at least hourly, so a
|
||||
# 2 h limit would flag a site whose hourly poll was missed once.
|
||||
FRESH_S = 3 * 3600.0
|
||||
|
||||
# The bell a state-only forecast is spread over, in local hours.
|
||||
BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
|
||||
# Why there is no forecast (the page words are in ui_text `home_words`).
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Day:
|
||||
"""One day's forecast: `kwh` still to come (today) or in all (tomorrow),
|
||||
and the hourly split if the sensor has one: `((start, end, kwh), ...)`."""
|
||||
day0: datetime # local midnight, as UTC
|
||||
kwh: float
|
||||
periods: tuple = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Forecast:
|
||||
today: Day
|
||||
tomorrow: Day | None
|
||||
at: datetime # when the sensor last reported
|
||||
|
||||
def between(self, t0: datetime, t1: datetime) -> float | None:
|
||||
"""kWh the sun should give between `t0` (now or later) and `t1`.
|
||||
None when the span reaches the sunny hours of a day with no
|
||||
forecast (tomorrow, when its sensor is not set)."""
|
||||
if t1 <= t0:
|
||||
return 0.0
|
||||
last = self.tomorrow or self.today
|
||||
if t1 > last.day0 + timedelta(days=1, hours=BELL_FROM_H):
|
||||
return None
|
||||
total = 0.0
|
||||
for day in (self.today, self.tomorrow):
|
||||
if day is None:
|
||||
continue
|
||||
a = max(t0, day.day0)
|
||||
b = min(t1, day.day0 + timedelta(days=1))
|
||||
if b > a:
|
||||
total += _day_between(day, a, b, t0)
|
||||
return total
|
||||
|
||||
|
||||
def _local_hour(t: datetime) -> float:
|
||||
lt = t.astimezone(BILLING_TZ)
|
||||
return lt.hour + lt.minute / 60.0 + lt.second / 3600.0
|
||||
|
||||
|
||||
def _bell(h: float) -> float:
|
||||
"""Cumulative share of a day's sun by local hour `h` (0..1)."""
|
||||
if h <= BELL_FROM_H:
|
||||
return 0.0
|
||||
if h >= BELL_TO_H:
|
||||
return 1.0
|
||||
x = (h - BELL_FROM_H) / (BELL_TO_H - BELL_FROM_H)
|
||||
return (1.0 - math.cos(math.pi * x)) / 2.0
|
||||
|
||||
|
||||
def _hours_since(day0, t):
|
||||
return (t - day0).total_seconds() / 3600.0
|
||||
|
||||
|
||||
def _day_between(day: Day, a: datetime, b: datetime, now: datetime) -> float:
|
||||
if day.periods:
|
||||
out = 0.0
|
||||
for s, e, kwh in day.periods:
|
||||
lo, hi = max(a, s), min(b, e)
|
||||
if hi > lo:
|
||||
out += kwh * (hi - lo).total_seconds() / (e - s).total_seconds()
|
||||
return out
|
||||
# State only: `kwh` is what is left from `max(now, day0)` to the day's
|
||||
# end, spread over the bell.
|
||||
h_from = _hours_since(day.day0, max(now, day.day0))
|
||||
left = 1.0 - _bell(h_from)
|
||||
if left <= 1e-9:
|
||||
return 0.0
|
||||
share = (_bell(_hours_since(day.day0, b))
|
||||
- _bell(_hours_since(day.day0, a))) / left
|
||||
return day.kwh * max(0.0, share)
|
||||
|
||||
|
||||
def _parse_time(v):
|
||||
try:
|
||||
t = datetime.fromisoformat(str(v).replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _periods(attrs: dict, day0: datetime) -> tuple:
|
||||
"""The hourly split, if the sensor has one, for the day at `day0`."""
|
||||
d1 = day0 + timedelta(days=1)
|
||||
out = []
|
||||
for key in ("detailedForecast", "detailedHourly"):
|
||||
rows = attrs.get(key)
|
||||
if isinstance(rows, list) and rows:
|
||||
starts = []
|
||||
for r in rows:
|
||||
if not isinstance(r, dict):
|
||||
continue
|
||||
t = _parse_time(r.get("period_start"))
|
||||
try:
|
||||
kw = float(r.get("pv_estimate"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if t is not None and math.isfinite(kw) and kw >= 0:
|
||||
starts.append((t, kw))
|
||||
starts.sort()
|
||||
for i, (t, kw) in enumerate(starts):
|
||||
step = (starts[i + 1][0] - t if i + 1 < len(starts)
|
||||
else timedelta(minutes=30 if key ==
|
||||
"detailedForecast" else 60))
|
||||
if step <= timedelta(0) or step > timedelta(hours=1):
|
||||
step = timedelta(minutes=30 if key == "detailedForecast"
|
||||
else 60)
|
||||
if day0 <= t < d1:
|
||||
out.append((t, t + step,
|
||||
kw * step.total_seconds() / 3600.0))
|
||||
if out:
|
||||
return tuple(out)
|
||||
wh = attrs.get("wh_period")
|
||||
if isinstance(wh, dict) and wh:
|
||||
rows = []
|
||||
for k, v in wh.items():
|
||||
t = _parse_time(k)
|
||||
try:
|
||||
e = float(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if t is not None and math.isfinite(e) and e >= 0:
|
||||
rows.append((t, e / 1000.0))
|
||||
rows.sort()
|
||||
for i, (t, kwh) in enumerate(rows):
|
||||
nxt = rows[i + 1][0] if i + 1 < len(rows) else t + timedelta(
|
||||
hours=1)
|
||||
if nxt - t > timedelta(hours=1) or nxt <= t:
|
||||
nxt = t + timedelta(hours=1)
|
||||
if day0 <= t < d1:
|
||||
out.append((t, nxt, kwh))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def _day(state, day0, now, *, today):
|
||||
"""`(Day, None)` or `(None, why)` from one sensor's state dict."""
|
||||
if state is None:
|
||||
return None, MISSING
|
||||
raw = str(state.get("state", "")).strip()
|
||||
if raw.lower() in BAD_STATES:
|
||||
return None, MISSING
|
||||
try:
|
||||
v = float(raw)
|
||||
except ValueError:
|
||||
return None, BAD
|
||||
attrs = state.get("attributes") or {}
|
||||
unit = str(attrs.get("unit_of_measurement") or "").strip()
|
||||
if unit not in KWH_PER:
|
||||
return None, UNIT
|
||||
kwh = v * KWH_PER[unit]
|
||||
if not math.isfinite(kwh) or kwh < 0:
|
||||
return None, BAD
|
||||
periods = _periods(attrs, day0)
|
||||
if periods and today:
|
||||
# The split is the whole day; keep what is still to come, scaled so
|
||||
# it adds up to the state (the state is what the integration says
|
||||
# is left).
|
||||
left = sum(k * max(0.0, min(1.0, (e - max(s, now)).total_seconds()
|
||||
/ (e - s).total_seconds()))
|
||||
for s, e, k in periods)
|
||||
if left > 1e-6:
|
||||
f = kwh / left
|
||||
periods = tuple((s, e, k * f) for s, e, k in periods)
|
||||
return Day(day0, kwh, periods), None
|
||||
|
||||
|
||||
def _reported(state):
|
||||
ts = [_parse_time(state.get(k)) for k in
|
||||
("last_reported", "last_updated", "last_changed")]
|
||||
ts = [t for t in ts if t is not None]
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
configured=True):
|
||||
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
|
||||
(the option is not set, or the sensor is missing): the forecast then
|
||||
covers today only."""
|
||||
if not configured:
|
||||
return None, NOT_SET
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
today, why = _day(today_state, day0, now, today=True)
|
||||
if today is None:
|
||||
return None, why
|
||||
at = _reported(today_state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, STALE
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
t, _w = _day(tomorrow_state, day1, now, today=False)
|
||||
t_at = _reported(tomorrow_state)
|
||||
if t is not None and t_at is not None and t_at >= day0 \
|
||||
and (now - t_at).total_seconds() <= FRESH_S:
|
||||
tomorrow = t
|
||||
return Forecast(today, tomorrow, at), None
|
||||
@@ -206,6 +206,10 @@ input:disabled{opacity:.6}
|
||||
.ent-all{display:flex;gap:6px;padding:6px 10px;align-items:center;min-height:44px}
|
||||
.elist .erow{padding:6px 0;border-bottom:1px dashed var(--line)}
|
||||
.elist>.btn{margin-top:8px}
|
||||
.plist .prow{display:grid;gap:6px}
|
||||
.plist .pf{display:flex;flex-direction:column;gap:2px}
|
||||
.plist .pf>select.sel{width:100%}
|
||||
input.txt.time{width:5.5em;text-align:center;font-variant-numeric:tabular-nums}
|
||||
.inv{border:1px solid var(--line);border-radius:10px;padding:0 12px;margin:10px 0}
|
||||
.inv>.hd{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--line);font-weight:600;flex-wrap:wrap}
|
||||
.inv>.f:nth-child(2){border-top:0}
|
||||
@@ -224,3 +228,19 @@ input:disabled{opacity:.6}
|
||||
.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}
|
||||
|
||||
@@ -5,7 +5,19 @@
|
||||
labels) or from /status (sentences built by app/homeview.py), so STYLE.md
|
||||
is checked in Python. Data is always set as text, never as HTML. */
|
||||
(function () {
|
||||
const T = JSON.parse(document.getElementById("ui-text").textContent).home;
|
||||
// P5: English or Dutch, by the browser's language. A word missing in Dutch
|
||||
// falls back to English, key by key (app/uitext.py does the same).
|
||||
const ALL = JSON.parse(document.getElementById("ui-text").textContent);
|
||||
const LANG = /^nl\b/i.test(navigator.language || "") ? "nl" : "en";
|
||||
const merge = (a, b) => {
|
||||
if (!b || typeof a !== "object" || typeof b !== "object" || Array.isArray(a)) return b ?? a;
|
||||
const out = Object.assign({}, a);
|
||||
for (const k in b) out[k] = k in a ? merge(a[k], b[k]) : b[k];
|
||||
return out;
|
||||
};
|
||||
const WORDS = LANG === "en" ? ALL.en : merge(ALL.en, ALL[LANG]);
|
||||
document.documentElement.lang = LANG;
|
||||
const T = WORDS.home;
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T);
|
||||
function h(tag, attrs, ...kids) {
|
||||
@@ -24,8 +36,9 @@
|
||||
}
|
||||
return e;
|
||||
}
|
||||
const kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1);
|
||||
const eur = (v) => "€" + Number(v).toFixed(2);
|
||||
const dec = (x) => (LANG === "nl" ? x.replace(".", ",") : x);
|
||||
const kw = (w) => dec((Math.abs(Number(w)) / 1000).toFixed(1));
|
||||
const eur = (v) => "€" + dec(Number(v).toFixed(2));
|
||||
|
||||
// Fixed labels from ui_text.
|
||||
document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; });
|
||||
@@ -55,7 +68,7 @@
|
||||
|
||||
/* ---------------- data ---------------- */
|
||||
async function getStatus() {
|
||||
const r = await fetch("status", { cache: "no-store" });
|
||||
const r = await fetch("status?lang=" + LANG, { cache: "no-store" });
|
||||
if (!r.ok) throw new Error("status " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
@@ -65,10 +78,12 @@
|
||||
if (!r.ok) throw new Error("action " + r.status);
|
||||
return r.json();
|
||||
}
|
||||
async function refresh() {
|
||||
// `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(); }
|
||||
try { S = await getStatus(); render(force === true); }
|
||||
catch (e) { offline(); }
|
||||
finally { busy = false; }
|
||||
}
|
||||
@@ -87,21 +102,22 @@
|
||||
const st = S.state || null;
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
|
||||
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
|
||||
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner;
|
||||
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
}
|
||||
|
||||
function render() {
|
||||
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, S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
|
||||
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();
|
||||
renderBatteries(force);
|
||||
document.dispatchEvent(new CustomEvent("ux13:status"));
|
||||
}
|
||||
|
||||
/* ---------------- Home: now ---------------- */
|
||||
@@ -295,17 +311,106 @@
|
||||
if (open) $("#c-details").open = true;
|
||||
}
|
||||
|
||||
/* ---------------- Batteries (placeholder until P3) ---------------- */
|
||||
function renderBatteries() {
|
||||
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
|
||||
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
|
||||
h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")),
|
||||
/* ---------------- 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", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
|
||||
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, b.stored_kwh.toFixed(1) + " kWh")] : null,
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
|
||||
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));
|
||||
// OPT-11 P4: the solar forecast line under the cards.
|
||||
const sl = $("#batt-solar"), line = modes && S.solar ? S.solar.line : "";
|
||||
if (sl) { sl.textContent = line || ""; sl.hidden = !line; }
|
||||
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 ---------------- */
|
||||
@@ -339,7 +444,10 @@
|
||||
// Shared with settings.js, which loads after this file.
|
||||
// `first`: the page opened with no route, so settings.js may open the setup
|
||||
// wizard on a new install (UX-13 P4).
|
||||
window.UX13 = { h, ask, toast, t, maint: (what) => ask(what, () => simple(what)), first: !location.hash };
|
||||
window.UX13 = { h, ask, toast, t, words: WORDS, lang: LANG, maint: (what) => ask(what, () => simple(what)), first: !location.hash,
|
||||
// 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")];
|
||||
|
||||
@@ -94,11 +94,24 @@
|
||||
</section>
|
||||
|
||||
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
|
||||
<div id="bcards"></div>
|
||||
<div class="card">
|
||||
<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>
|
||||
<p class="small muted" id="batt-solar" hidden></p>
|
||||
<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>
|
||||
@@ -106,8 +119,15 @@
|
||||
<h2 style="margin:0" data-s="name"></h2>
|
||||
<a class="small" href="config" id="set-classic" data-s="classic"></a>
|
||||
</div>
|
||||
<p class="small muted" style="margin:0 0 10px" data-s="description"></p>
|
||||
<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>
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
P4: the Setup tab's wizard is the same page model, drawn one step at a
|
||||
time from each field's `wizard` step, and saved through the same routes. */
|
||||
(function () {
|
||||
const ROOT = JSON.parse(document.getElementById("ui-text").textContent);
|
||||
const ROOT = window.UX13.words; // English or Dutch (app.js)
|
||||
const W = ROOT.settings;
|
||||
const { h, ask, toast, maint } = window.UX13;
|
||||
const { h, ask, toast, maint, lang } = window.UX13;
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const ws = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), W);
|
||||
const clone = (x) => (x === undefined ? undefined : JSON.parse(JSON.stringify(x)));
|
||||
@@ -32,6 +32,10 @@
|
||||
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();
|
||||
@@ -288,8 +292,45 @@
|
||||
box.append(h("button", { type: "button", class: "btn", onclick: () => put(f, card, arr().concat([{ entity: "", reversed: false }]), true) }, "+ " + W.add_solar));
|
||||
return box;
|
||||
}
|
||||
// P5: time windows, each a start, an end, its days and a price. The add-on
|
||||
// builds the tariff from the rows and names the first overlap or gap.
|
||||
function periodList(f, card) {
|
||||
const P = W.periods;
|
||||
const arr = () => (Array.isArray(get(f, card)) ? get(f, card) : []);
|
||||
const box = h("div", { class: "elist plist" });
|
||||
if (!Array.isArray(get(f, card))) return h("div", { class: "msg info" }, W.derive.custom_profile.description);
|
||||
arr().forEach((row, i) => {
|
||||
const set = (k, v) => { const a = clone(arr()); a[i][k] = v; put(f, card, a); };
|
||||
const say = (what) => P.window + " " + (i + 1) + ", " + what;
|
||||
const time = (k) => {
|
||||
const e = h("input", { class: "txt time", type: "text", inputmode: "numeric", maxlength: "5", placeholder: "00:00",
|
||||
value: row[k] == null ? "" : row[k], autocomplete: "off", spellcheck: "false", "aria-label": say(P[k]) });
|
||||
e.addEventListener("input", () => set(k, e.value.trim()));
|
||||
return e;
|
||||
};
|
||||
const days = h("select", { class: "sel", "aria-label": say(P.days) });
|
||||
for (const [v, label] of f.days || []) days.append(h("option", { value: v, selected: v === row.days }, label));
|
||||
days.addEventListener("change", () => set("days", days.value));
|
||||
const price = h("input", { class: "num", type: "number", inputmode: "decimal", step: "0.01",
|
||||
value: row.price == null ? "" : row.price, "aria-label": say(P.price) });
|
||||
price.addEventListener("input", () => set("price", price.value === "" || isNaN(+price.value) ? null : +price.value));
|
||||
const cell = (label, ...kids) => h("label", { class: "pf" }, h("span", { class: "tiny muted" }, label), ...kids);
|
||||
box.append(h("div", { class: "erow prow" },
|
||||
h("div", { class: "row spread" }, h("strong", { class: "small" }, P.window + " " + (i + 1)),
|
||||
h("button", { type: "button", class: "btn link tiny", onclick: () => { const a = clone(arr()); a.splice(i, 1); put(f, card, a, true); } }, W.remove)),
|
||||
h("div", { class: "row" }, cell(P.from, time("from")), cell(P.to, time("to")),
|
||||
cell(P.price, h("span", { class: "row" }, price, f.unit ? h("span", { class: "unit" }, f.unit) : null))),
|
||||
cell(P.days, days)));
|
||||
});
|
||||
box.append(h("button", { type: "button", class: "btn", onclick: () => {
|
||||
const a = arr(), last = a[a.length - 1];
|
||||
put(f, card, clone(a).concat([{ from: last ? last.to : "00:00", to: "24:00", days: last ? last.days : "mon-sun", price: null }]), true);
|
||||
} }, "+ " + P.add));
|
||||
return box;
|
||||
}
|
||||
const CONTROLS = { toggle, segmented: (f, c, l) => radios(f, c, l, false), choice: (f, c, l) => radios(f, c, l, true),
|
||||
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList };
|
||||
select, slider, number, text, locked, entity, entity_list: entityList, solar_list: solarList,
|
||||
period_list: periodList };
|
||||
|
||||
/* ---------------- rows, cards and groups ---------------- */
|
||||
function keyText(f, card) {
|
||||
@@ -300,11 +341,11 @@
|
||||
function fieldRow(f, card, gid) {
|
||||
if (f.control === "inverter_list") return cardList(f, gid);
|
||||
const lid = "sf" + (++uid);
|
||||
const keys = keyText(f, card);
|
||||
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 && f.control !== "slider" && f.control !== "number" && f.control !== "locked" ? " (" + f.unit + ")" : ""),
|
||||
h("div", { class: "head" }, h("div", { class: "lbl", id: lid }, f.label, f.unit && !["slider", "number", "locked", "period_list"].includes(f.control) ? " (" + f.unit + ")" : ""),
|
||||
keys ? h("div", { class: "key" }, keys) : null),
|
||||
h("div", { class: "ctl" }, make(f, card, lid)),
|
||||
f.help ? h("div", { class: "help" }, f.help) : null, msg);
|
||||
@@ -384,10 +425,92 @@
|
||||
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") : "";
|
||||
});
|
||||
}
|
||||
// OPT-11 P4: the solar forecast card shows the add-on's forecast line.
|
||||
function fillSolar() {
|
||||
const st = window.UX13.status();
|
||||
const line = st && st.solar ? st.solar.line : "";
|
||||
document.querySelectorAll("#simple [data-solar-note]").forEach((e) => {
|
||||
e.textContent = line || "";
|
||||
e.hidden = !line;
|
||||
});
|
||||
}
|
||||
document.addEventListener("ux13:status", fillNotes);
|
||||
document.addEventListener("ux13:status", fillSolar);
|
||||
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;
|
||||
}
|
||||
if (f.note === "forecast") row.append(h("div", { class: "small muted", "data-solar-note": "", hidden: true }));
|
||||
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 };
|
||||
@@ -397,11 +520,13 @@
|
||||
return m.card == null ? r.card === 0 : m.card === r.card;
|
||||
}
|
||||
function paint() {
|
||||
const q = view === "setup" ? "" : $("#set-search").value.trim().toLowerCase();
|
||||
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) placed.add(i); return hit; })
|
||||
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 },
|
||||
@@ -469,7 +594,7 @@
|
||||
const seq = ++vseq;
|
||||
try {
|
||||
const r = await fetch("config/validate", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(Object.assign({ model: V }, extra || {})) });
|
||||
body: JSON.stringify(Object.assign({ model: V, lang }, extra || {})) });
|
||||
const b = await r.json();
|
||||
if (seq !== vseq) return null;
|
||||
MSG = b.messages || [];
|
||||
@@ -498,7 +623,7 @@
|
||||
async function load(after) {
|
||||
let m;
|
||||
try {
|
||||
const r = await fetch("config/model?lang=en", { cache: "no-store" });
|
||||
const r = await fetch("config/model?lang=" + lang, { cache: "no-store" });
|
||||
if (!r.ok) throw new Error("model " + r.status);
|
||||
m = await r.json();
|
||||
if (!m.layout || !m.ui) throw new Error("no layout");
|
||||
@@ -525,7 +650,9 @@
|
||||
validate();
|
||||
}
|
||||
function firstBad() {
|
||||
const r = ROWS.find((x) => x.tiers && x.tiers.has("bad") && x.visible);
|
||||
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" });
|
||||
@@ -538,7 +665,7 @@
|
||||
// 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 }, extra || {})) });
|
||||
body: JSON.stringify(Object.assign({ model: V, base: ORIG, fingerprint: FP, lang }, extra || {})) });
|
||||
const b = await r.json().catch(() => ({}));
|
||||
if (r.status === 409 && b.kind === "stale") FP = b.fingerprint || FP;
|
||||
return { r, b };
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
"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"},
|
||||
"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"}
|
||||
@@ -24,7 +25,8 @@
|
||||
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
|
||||
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
|
||||
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
|
||||
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."}
|
||||
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."},
|
||||
"periods": {"name": "Time windows and prices", "description": "One price per kWh for each time window, all in, as on your bill."}
|
||||
},
|
||||
"choices": {
|
||||
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
|
||||
@@ -34,7 +36,8 @@
|
||||
"meter_phases": {"1": "1 phase", "3": "3 phases"},
|
||||
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
|
||||
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file"},
|
||||
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"}
|
||||
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
|
||||
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
|
||||
},
|
||||
"applies": {
|
||||
"restart": {"name": "Applies after a restart"},
|
||||
@@ -79,8 +82,24 @@
|
||||
},
|
||||
"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": {"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": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
|
||||
"setup_only": {"name": "Setup: nothing is controlled yet", "description": "Nothing is sent to any inverter until setup is saved and the add-on restarts.", "control": "Control starts after setup.", "open": "Open Setup"},
|
||||
"batteries": {"name": "Batteries", "description": "To choose a mode for each battery, turn on battery modes in the settings.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings",
|
||||
"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": {
|
||||
@@ -97,7 +116,13 @@
|
||||
},
|
||||
"settings": {
|
||||
"name": "Settings",
|
||||
"description": "Every setting, grouped by task, and the simple view follows in a later update.",
|
||||
"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."}, "forecast": {"description": "With a forecast, a battery buys only what the sun will not give."}},
|
||||
"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."},
|
||||
@@ -127,6 +152,7 @@
|
||||
"remove": "Remove",
|
||||
"add_row": "Add a row",
|
||||
"add_solar": "Add a solar inverter",
|
||||
"periods": {"window": "Window", "from": "From", "to": "To", "days": "Days", "price": "Price", "add": "Add a time window"},
|
||||
"reversed": "Reads negative",
|
||||
"phase": "Phase",
|
||||
"raw_roster": {"description": "This inverter list can only be changed in the Configuration tab."},
|
||||
@@ -156,6 +182,13 @@
|
||||
"no_source": {"description": "Choose where the dynamic prices come from."},
|
||||
"custom_profile": {"description": "Other time windows can only be changed in the Configuration tab."},
|
||||
"no_price": {"description": "Enter a price per kWh."},
|
||||
"no_periods": {"description": "Add at least one time window."},
|
||||
"period_time": {"description": "Window {row}: write each time as hours and minutes, like 07:00 or 24:00."},
|
||||
"period_zero": {"description": "Window {row} starts and ends at the same time, so change one of them."},
|
||||
"period_price": {"description": "Window {row}: enter a price per kWh."},
|
||||
"period_price_range": {"description": "Window {row}: enter the price in euro per kWh, like 0.34."},
|
||||
"period_overlap": {"description": "Windows {a} and {b} both cover {day} {from} to {to}, so change one of them."},
|
||||
"period_gap": {"description": "No window covers {day} {from} to {to}, so add one or widen one."},
|
||||
"unknown": {"description": "The page cannot store this choice, so reload the page."}
|
||||
},
|
||||
"setup": {
|
||||
@@ -176,7 +209,7 @@
|
||||
"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": "Choosing a mode for each battery comes in a later update."},
|
||||
"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.",
|
||||
@@ -205,7 +238,6 @@
|
||||
"again": "Check again",
|
||||
"blocked": {"description": "Fix the items marked in red, then check again."}
|
||||
},
|
||||
"shadow": {"name": "Start in shadow mode", "description": "Recommended, because every inverter then stays at 0 W until you turn shadow mode off under Settings."},
|
||||
"dialog": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and shadow mode keeps them at 0 W after that.", "yes": "Save and restart"},
|
||||
"dialog_live": {"name": "Save and restart?", "description": "Every inverter is set to 0 W while the add-on restarts, and then the new settings apply.", "yes": "Save and restart"},
|
||||
"dialog_setup": {"name": "Save and restart?", "description": "The add-on restarts with these settings, and shadow mode keeps every inverter at 0 W.", "yes": "Save and restart"},
|
||||
@@ -215,5 +247,209 @@
|
||||
"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.",
|
||||
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
|
||||
"tops_up": "Too little sun is forecast, so it charges some from the grid.",
|
||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||
},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"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,378 @@
|
||||
{
|
||||
"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."}, "forecast": {"description": "Met een prognose koopt een batterij alleen wat de zon niet geeft."}},
|
||||
"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.",
|
||||
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
|
||||
"tops_up": "Te weinig zon voorspeld, dus ze laadt wat van het net.",
|
||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||
},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,9 @@ CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = (
|
||||
"choice", "entity", "entity_list", "toggle", "segmented", "slider")
|
||||
NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = (
|
||||
"number", "text", "select", "locked", "inverter_list", "solar_list")
|
||||
PERIOD_LIST = "period_list" # P5: time windows, each with its own price
|
||||
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
|
||||
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST)
|
||||
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST, PERIOD_LIST)
|
||||
# What an entity picker offers first. `power_w` is a power sensor the add-on
|
||||
# reads as watts with no unit conversion, so validate.py warns on a kW sensor.
|
||||
ENTITY_KINDS = ("power_w", "power", "energy", "pct", "number", "any")
|
||||
@@ -45,6 +46,7 @@ class Field:
|
||||
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:
|
||||
@@ -125,6 +127,16 @@ GROUPS = (
|
||||
# pv.py reads each entity's own unit, so a kW sensor is fine here.
|
||||
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
|
||||
wizard=3),)),
|
||||
# OPT-11: the switch that turns battery modes on. Each battery's mode
|
||||
# and on/off switch are runtime state on the Batteries screen.
|
||||
Part("modes", (
|
||||
Field("battery_modes_enabled", TOGGLE),
|
||||
# OPT-11 P4: the solar forecast; today's also on Settings, Simple.
|
||||
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
|
||||
note="forecast"),
|
||||
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
|
||||
Field("solar_trust_pct", SLIDER, "%", step=5),
|
||||
)),
|
||||
)),
|
||||
Group("tariff", (
|
||||
Part("", (
|
||||
@@ -154,6 +166,8 @@ GROUPS = (
|
||||
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
||||
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "daynight"),), wizard=4),
|
||||
Field("ui.periods", PERIOD_LIST, "€/kWh", step=0.01,
|
||||
show=(("ui.fallback", "==", "custom"),), wizard=4),
|
||||
)),
|
||||
Part("selling", (
|
||||
# OPT-11: derived from the batteries' selling switches once battery
|
||||
@@ -174,12 +188,14 @@ GROUPS = (
|
||||
Group("safety", (
|
||||
Part("peak", (
|
||||
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
|
||||
simple=True, wizard=5),
|
||||
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),
|
||||
Field("shadow_mode", SEGMENTED, simple=True, wizard=6,
|
||||
note="shadow"),
|
||||
Field("auto_start", TOGGLE),
|
||||
Field("estop_fitted", TOGGLE),
|
||||
Field("stale_input_s", SLIDER, "s"),
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
`options_to_ui()` turns the stored blob into what the page edits: always a list
|
||||
of inverter cards, solar rows with a "reads negative" toggle, a contract and
|
||||
simple prices instead of `static_profile` JSON, and a region preset instead of
|
||||
the levies figure. `ui_to_options()` turns it back, so the validator and the
|
||||
simple prices or time windows (P5) instead of `static_profile` JSON, and a
|
||||
region preset instead of the levies figure. `ui_to_options()` turns it back, so the validator and the
|
||||
Supervisor always see the real blob.
|
||||
|
||||
The rule that makes the round trip exact: the model carries the stored values
|
||||
@@ -16,12 +16,14 @@ See DECISIONS/UX-13-one-layout-table.md.
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
|
||||
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
|
||||
from .static_tariff import ProfileInvalid, parse_static_profile
|
||||
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH, MAX_PLAUSIBLE_EUR_KWH
|
||||
from .static_tariff import (DAY_NAMES, MINUTES_PER_DAY, Period, ProfileInvalid,
|
||||
parse_static_profile)
|
||||
|
||||
__all__ = ["options_to_ui", "ui_to_options", "ui_to_options_partial",
|
||||
"REGION_PRESETS",
|
||||
"REGION_PRESETS", "PERIOD_DAYS",
|
||||
"arbitrage_from_batteries", "DeriveError", "COUNT", "GROWN"]
|
||||
|
||||
|
||||
@@ -30,9 +32,10 @@ class DeriveError(ValueError):
|
||||
the sentence the page shows (app/ui_text `derive.<code>`), and `field` the
|
||||
page field it is shown beside."""
|
||||
|
||||
def __init__(self, text, code="", field=""):
|
||||
def __init__(self, text, code="", field="", **values):
|
||||
super().__init__(text)
|
||||
self.code, self.field = code, field
|
||||
self.values = values # the sentence's {placeholders} (P5)
|
||||
self.keys = () # the option keys the failed derivation writes
|
||||
|
||||
|
||||
@@ -213,14 +216,125 @@ def _profile_decode(raw):
|
||||
return "custom", None, None, None
|
||||
|
||||
|
||||
# --- 3b. the time-window editor (UX-13 P5) ---------------------------------- #
|
||||
# Each row is {"from", "to", "days", "price"} and, when read from a stored
|
||||
# profile, its "name". `days` is one of these; a profile with any other day set
|
||||
# is edited in the Configuration tab only (`ui.periods` is None).
|
||||
PERIOD_DAYS = ("mon-sun", "mon-fri", "sat,sun") + DAY_NAMES
|
||||
_DAYSETS = {d: frozenset(range(7)) if d == "mon-sun" else
|
||||
frozenset(range(5)) if d == "mon-fri" else
|
||||
frozenset((5, 6)) if d == "sat,sun" else
|
||||
frozenset((DAY_NAMES.index(d),)) for d in PERIOD_DAYS}
|
||||
_TIME = re.compile(r"^\s*(\d{1,2}):(\d{2})\s*$")
|
||||
|
||||
|
||||
def _hhmm(minute) -> str:
|
||||
return f"{minute // 60:02d}:{minute % 60:02d}"
|
||||
|
||||
|
||||
def _periods_decode(raw):
|
||||
"""The rows for a stored profile: [] when unset, None when it cannot be
|
||||
shown as rows (unparsable, or days the editor has no choice for)."""
|
||||
try:
|
||||
prof = parse_static_profile(raw) if isinstance(raw, str) else None
|
||||
except ProfileInvalid:
|
||||
return None
|
||||
if prof is None:
|
||||
return [] if isinstance(raw, str) else None
|
||||
rows = []
|
||||
for p in prof.periods:
|
||||
days = next((d for d, s in _DAYSETS.items() if s == p.days), None)
|
||||
if days is None:
|
||||
return None
|
||||
rows.append({"from": _hhmm(p.start_min), "to": _hhmm(p.end_min),
|
||||
"days": days, "price": p.eur_per_kwh, "name": p.name})
|
||||
return rows
|
||||
|
||||
|
||||
def _minute(text, *, end):
|
||||
""""07:00" -> 420, "24:00" -> 1440 as an end, else None."""
|
||||
m = _TIME.match(str(text or ""))
|
||||
if not m:
|
||||
return None
|
||||
h, mi = int(m.group(1)), int(m.group(2))
|
||||
if mi > 59 or h > 24 or (h == 24 and (mi or not end)):
|
||||
return None
|
||||
return h * 60 + mi
|
||||
|
||||
|
||||
def _periods_json(rows) -> str:
|
||||
"""The static_profile for the editor's rows, or DeriveError naming the
|
||||
first problem: a bad time, an empty price, an overlap or a gap."""
|
||||
if not rows:
|
||||
raise DeriveError("add at least one time window", "no_periods",
|
||||
"ui.periods")
|
||||
periods = []
|
||||
for i, row in enumerate(rows, start=1):
|
||||
row = row if isinstance(row, dict) else {}
|
||||
start = _minute(row.get("from"), end=False)
|
||||
end = _minute(row.get("to"), end=True)
|
||||
if start is None or end is None:
|
||||
raise DeriveError(f"window {i} has a bad time", "period_time",
|
||||
"ui.periods", row=i)
|
||||
end = end or MINUTES_PER_DAY # "00:00" as an end
|
||||
if start == end:
|
||||
raise DeriveError(f"window {i} is empty", "period_zero",
|
||||
"ui.periods", row=i)
|
||||
price = _num(row.get("price"))
|
||||
if price is None:
|
||||
raise DeriveError(f"window {i} needs a price", "period_price",
|
||||
"ui.periods", row=i)
|
||||
if abs(price) > MAX_PLAUSIBLE_EUR_KWH:
|
||||
raise DeriveError(f"window {i}'s price is out of range",
|
||||
"period_price_range", "ui.periods", row=i)
|
||||
if row.get("days") not in _DAYSETS:
|
||||
raise DeriveError(f"window {i} has no days", "unknown",
|
||||
"ui.periods")
|
||||
name = str(row.get("name") or "").strip() or f"window{i}"
|
||||
periods.append(Period(name=name, start_min=start, end_min=end,
|
||||
days=_DAYSETS[row["days"]],
|
||||
eur_per_kwh=price))
|
||||
_tiling(periods)
|
||||
return json.dumps({"periods": [
|
||||
{"name": p.name, "from": _hhmm(p.start_min),
|
||||
"to": _hhmm(p.end_min), "days": row["days"],
|
||||
"eur_per_kwh": p.eur_per_kwh}
|
||||
for p, row in zip(periods, rows)]})
|
||||
|
||||
|
||||
def _tiling(periods):
|
||||
"""Each minute of the week in exactly one window, as static_tariff.py
|
||||
requires. The first overlap or gap, by day and time, is the error."""
|
||||
for day in range(7):
|
||||
start, kind, rows = None, None, None
|
||||
for minute in range(MINUTES_PER_DAY + 1):
|
||||
here = [i for i, p in enumerate(periods, start=1)
|
||||
if minute < MINUTES_PER_DAY and p.covers(day, minute)]
|
||||
now = ("gap" if not here else "overlap" if len(here) > 1
|
||||
else None) if minute < MINUTES_PER_DAY else "end"
|
||||
if kind is not None and (now != kind or (kind == "overlap"
|
||||
and here != rows)):
|
||||
values = {"day": DAY_NAMES[day], "from": _hhmm(start),
|
||||
"to": _hhmm(minute)}
|
||||
if kind == "gap":
|
||||
raise DeriveError("a time has no window", "period_gap",
|
||||
"ui.periods", **values)
|
||||
raise DeriveError("two windows cover one time",
|
||||
"period_overlap", "ui.periods",
|
||||
a=rows[0], b=rows[1], **values)
|
||||
if now in ("gap", "overlap") and kind is None:
|
||||
start, kind, rows = minute, now, here
|
||||
|
||||
|
||||
def _tariff_decode(o):
|
||||
kind, fixed, day, night = _profile_decode(o.get("static_profile", ""))
|
||||
raw = o.get("static_profile", "")
|
||||
kind, fixed, day, night = _profile_decode(raw)
|
||||
provider = o.get("price_provider")
|
||||
dynamic = not _blank(provider) and provider != "off"
|
||||
return {"ui.contract": "dynamic" if dynamic else kind,
|
||||
"price_provider": provider, "ui.fallback": kind,
|
||||
"ui.fixed_price": fixed, "ui.day_price": day,
|
||||
"ui.night_price": night}
|
||||
"ui.night_price": night, "ui.periods": _periods_decode(raw)}
|
||||
|
||||
|
||||
def _tariff_encode(m, keep):
|
||||
@@ -243,11 +357,16 @@ def _tariff_encode(m, keep):
|
||||
out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"),
|
||||
_price_of(m, "ui.night_price"))
|
||||
elif kind == "custom":
|
||||
if "static_profile" not in keep:
|
||||
rows = m.get("ui.periods")
|
||||
if rows is not None:
|
||||
out["static_profile"] = _periods_json(rows)
|
||||
elif "static_profile" not in keep:
|
||||
raise DeriveError("odd tariff windows are edited in the "
|
||||
"Configuration tab", "custom_profile",
|
||||
"ui.contract")
|
||||
out["static_profile"] = keep["static_profile"]
|
||||
else:
|
||||
# Days the editor cannot show: kept as stored.
|
||||
out["static_profile"] = keep["static_profile"]
|
||||
else:
|
||||
raise DeriveError(f"unknown contract {kind!r}", "unknown",
|
||||
"ui.contract")
|
||||
|
||||
@@ -9,26 +9,23 @@ are (DECISIONS/UX-13-one-layout-table.md).
|
||||
"""
|
||||
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from . import uilayout as L
|
||||
from . import uitext
|
||||
from . import uimodel as U
|
||||
from .configui import help_of, label_of, problems_by_field, split_group
|
||||
from .validate import power_request_entities
|
||||
|
||||
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
|
||||
"en.json")
|
||||
# A choice the page cannot make, only keep: odd tariff windows (P5 editor).
|
||||
ONLY_IF_HELD = ("custom",)
|
||||
# A choice the page cannot make, only keep. None since P5's period editor.
|
||||
ONLY_IF_HELD = ()
|
||||
_TIERS = ("bad", "warn", "info")
|
||||
|
||||
|
||||
def ui_text() -> dict:
|
||||
with io.open(UI_TEXT, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
def ui_text(lang="en") -> dict:
|
||||
"""The page words in `lang`, English where Dutch has no key (P5)."""
|
||||
return copy.deepcopy(uitext.words(lang))
|
||||
|
||||
|
||||
def _roster():
|
||||
@@ -102,7 +99,7 @@ def _field(fld, schema_ui, translations, lang, T):
|
||||
"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,
|
||||
"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()
|
||||
@@ -122,6 +119,10 @@ def _field(fld, schema_ui, translations, lang, T):
|
||||
if hints:
|
||||
out["hints"] = {v: e.get("description", "")
|
||||
for v, e in hints.items()}
|
||||
if fld.control == L.PERIOD_LIST:
|
||||
# P5: the day choices of one time window, in the editor's order.
|
||||
names = T["choices"]["period_days"]
|
||||
out["days"] = [[d, names.get(d, d)] for d in U.PERIOD_DAYS]
|
||||
if fld.mirror:
|
||||
out["mirror"] = fld.mirror
|
||||
if fld.fills:
|
||||
@@ -145,7 +146,7 @@ def _field(fld, schema_ui, translations, lang, T):
|
||||
def layout(schema_ui, translations, lang="en") -> dict:
|
||||
"""Every group, part and field of the Installer view, as the page draws
|
||||
it. Built from the table only: a field the table places is on the page."""
|
||||
T = ui_text()
|
||||
T = ui_text(lang)
|
||||
groups = []
|
||||
for g in L.GROUPS:
|
||||
parts = []
|
||||
@@ -220,14 +221,19 @@ def _settle(out: dict, stored: dict | None) -> dict:
|
||||
| {k: v for k, v in out.items() if k not in stored})
|
||||
|
||||
|
||||
def derive_message(err: U.DeriveError) -> dict:
|
||||
words = ui_text()["settings"]["derive"]
|
||||
def derive_message(err: U.DeriveError, lang="en") -> dict:
|
||||
T = uitext.words(lang)
|
||||
words = T["settings"]["derive"]
|
||||
text = words.get(err.code, words["unknown"])["description"]
|
||||
args = dict(getattr(err, "values", None) or {})
|
||||
# A time window's day is shown by name (P5 period editor).
|
||||
if "day" in args:
|
||||
args["day"] = T["choices"]["period_days"].get(args["day"], args["day"])
|
||||
return {"field": err.field or None, "card": None, "tier": "bad",
|
||||
"text": text}
|
||||
"text": uitext.say(text, **args)}
|
||||
|
||||
|
||||
def derive_messages(errors, problems, notes, model, keys):
|
||||
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
|
||||
@@ -238,7 +244,7 @@ def derive_messages(errors, problems, notes, model, keys):
|
||||
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) for err in errors]
|
||||
return ([derive_message(err, lang) for err in errors]
|
||||
+ anchors(kept, notes, model, keys))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""UX-13 P5: the page's words, app/ui_text/<lang>.json, in English or Dutch.
|
||||
|
||||
One file per language with the same keys. A key missing from a language falls
|
||||
back to English, key by key, the way the Supervisor's translations do.
|
||||
test_ui_text.py holds both files to STYLE.md and to each other.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text")
|
||||
LANGS = ("en", "nl")
|
||||
_CACHE: dict = {}
|
||||
|
||||
|
||||
def lang_of(value) -> str:
|
||||
""""nl", "nl-BE" or "nl_NL" -> "nl"; anything else -> "en"."""
|
||||
head = str(value or "").strip().lower().replace("_", "-").split("-")[0]
|
||||
return head if head in LANGS else "en"
|
||||
|
||||
|
||||
def raw(lang: str) -> dict:
|
||||
"""One language file as written, without the English fallback."""
|
||||
if lang not in _CACHE:
|
||||
with io.open(os.path.join(DIR, f"{lang}.json"), encoding="utf-8") as f:
|
||||
_CACHE[lang] = json.load(f)
|
||||
return _CACHE[lang]
|
||||
|
||||
|
||||
def _merge(base, over):
|
||||
if not (isinstance(base, dict) and isinstance(over, dict)):
|
||||
return copy.deepcopy(over)
|
||||
out = copy.deepcopy(base)
|
||||
for k, v in over.items():
|
||||
out[k] = _merge(base.get(k), v) if k in base else copy.deepcopy(v)
|
||||
return out
|
||||
|
||||
|
||||
def words(lang="en") -> dict:
|
||||
"""Every word in `lang`, English where a key is missing."""
|
||||
lang = lang_of(lang)
|
||||
return raw("en") if lang == "en" else _merge(raw("en"), raw(lang))
|
||||
|
||||
|
||||
def say(template: str, **values) -> str:
|
||||
"""Fill `{name}` placeholders, as settings.js `say()` does."""
|
||||
for k, v in values.items():
|
||||
template = template.replace("{" + k + "}", str(v))
|
||||
return template
|
||||
@@ -243,6 +243,13 @@ 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.
|
||||
|
||||
13. OPT-11 P4: `solar_trust_pct` outside 30-100 or not whole, or a solar
|
||||
forecast entity that is not a `sensor.` id. A trust of 0 or 500 % would
|
||||
make the day plan buy everything or nothing, which no one means.
|
||||
|
||||
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,9 +328,10 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
|
||||
from . import messages, pv
|
||||
from . import dayplan, messages, pv, solarfc
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
@@ -333,7 +341,7 @@ from .inverter import roster_entries
|
||||
from .maintenance import MAINT_OPTION_DEFAULTS
|
||||
from .p1 import (
|
||||
SOURCE_HA, SOURCE_HA_SIGNED, is_enabled, validate_meter_options)
|
||||
from .peakguard import PEAK_OPTION_DEFAULTS
|
||||
from .peakguard import PEAK_MARGIN_RANGE_W, PEAK_OPTION_DEFAULTS
|
||||
from .price import (
|
||||
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
|
||||
SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
@@ -1194,6 +1202,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
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)
|
||||
problems += _forecast_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)
|
||||
@@ -1745,6 +1757,8 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
||||
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))
|
||||
for key in FORECAST_KEYS:
|
||||
add(key, opts.get(key))
|
||||
return out
|
||||
|
||||
|
||||
@@ -1784,6 +1798,19 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} reads in {unit}, but {S(key, where)} is "
|
||||
"taken as watts, so pick a sensor in W.")
|
||||
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} does not read in kWh, so pick a solar "
|
||||
"forecast sensor in kWh.")
|
||||
if any(opts.get(k) for k in FORECAST_KEYS) and \
|
||||
opts.get("battery_modes_enabled") is not True:
|
||||
_advise(notes, "info", ("pv_forecast_entity",),
|
||||
"The solar forecast is used only with battery modes on.")
|
||||
if opts.get("pv_forecast_tomorrow_entity") and \
|
||||
not opts.get("pv_forecast_entity"):
|
||||
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
||||
"Tomorrow's forecast is used only with today's, so set "
|
||||
f"{S('pv_forecast_entity')} too.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -2022,6 +2049,50 @@ def _price_check(notes, opts, running, prices_known) -> None:
|
||||
"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 []
|
||||
|
||||
|
||||
TRUST_RANGE_PCT = (30, 100)
|
||||
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
|
||||
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
|
||||
FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
|
||||
|
||||
|
||||
def _forecast_problems(opts: dict) -> list[str]:
|
||||
"""Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and
|
||||
each forecast entity set is a sensor."""
|
||||
out = []
|
||||
raw = opts.get("solar_trust_pct")
|
||||
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
|
||||
lo, hi = TRUST_RANGE_PCT
|
||||
if trust is None or trust != int(trust) or not lo <= trust <= hi:
|
||||
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
|
||||
f"to {hi} %. It is {messages.shown(raw)}.")
|
||||
for key in FORECAST_KEYS:
|
||||
e = opts.get(key)
|
||||
if e is None or e == "":
|
||||
continue
|
||||
if not isinstance(e, str) or not _SENSOR_ID.fullmatch(e):
|
||||
out.append(f"{S(key)} must be a sensor, like "
|
||||
f"sensor.energy_production_today_remaining. It is "
|
||||
f"{messages.shown(e)}.")
|
||||
return out
|
||||
|
||||
|
||||
def _number(opts: dict, key, default):
|
||||
raw = opts.get(key, default)
|
||||
if raw is None or raw == "":
|
||||
|
||||
@@ -33,6 +33,11 @@ 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
|
||||
@@ -43,7 +48,7 @@ from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import messages, selfconfig, uipage
|
||||
from . import messages, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import region_of
|
||||
from .validate import config_notes, setup_checks
|
||||
@@ -53,14 +58,16 @@ from .uimodel import DeriveError, options_to_ui
|
||||
|
||||
_LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
# OPT-11: the per-battery actions, which name a battery and a value.
|
||||
PACK_ACTIONS = ("pack_use", "pack_mode", "pack_sell")
|
||||
|
||||
# UX-13 P1: the page lives in app/static/ as three files - index.html,
|
||||
# app.css, app.js - plus the page words from app/ui_text/en.json. They are
|
||||
# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English
|
||||
# and, since P5, Dutch; the page picks by the browser's language). They are
|
||||
# joined into ONE response here, once, so the page needs no second request
|
||||
# and no route of its own: the route list below is unchanged, and every URL
|
||||
# the page uses stays relative (ingress). No build step, no external file.
|
||||
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
|
||||
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
|
||||
"en.json")
|
||||
|
||||
|
||||
def _read(path):
|
||||
@@ -71,7 +78,8 @@ def _read(path):
|
||||
def page() -> str:
|
||||
"""The assembled index page. `</` is escaped inside the two inline
|
||||
scripts, so no text in them can close the tag early."""
|
||||
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
|
||||
ui_text = json.dumps({lang: uitext.raw(lang) for lang in uitext.LANGS},
|
||||
ensure_ascii=False)
|
||||
scripts = {}
|
||||
for name in ("app.js", "settings.js"):
|
||||
scripts[name] = _read(os.path.join(STATIC, name))
|
||||
@@ -129,8 +137,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
async def index(_request):
|
||||
return web.Response(text=index_page, content_type="text/html")
|
||||
|
||||
async def status(_request):
|
||||
return web.json_response(controller.status_payload())
|
||||
async def status(request):
|
||||
# UX-13 P5: Home's sentences in the page's language, English or Dutch.
|
||||
lang = uitext.lang_of(request.query.get("lang"))
|
||||
if lang == "en":
|
||||
return web.json_response(controller.status_payload())
|
||||
return web.json_response(controller.status_payload(lang=lang))
|
||||
|
||||
async def action(request):
|
||||
try:
|
||||
@@ -142,7 +154,17 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
# UX-15: nothing to start or stop until setup is saved.
|
||||
return web.json_response(
|
||||
{"ok": False, "error": messages.row_setup_only()}, status=409)
|
||||
result = await controller.handle_action(what)
|
||||
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})
|
||||
@@ -264,7 +286,9 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
if model is not None:
|
||||
payload["messages"] = (
|
||||
uipage.derive_messages(errors, problems, notes, model,
|
||||
_keys_of(options)) if errors
|
||||
_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"):
|
||||
@@ -316,7 +340,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
except DeriveError:
|
||||
model = posted["model"]
|
||||
_opts, errors = uipage.to_options_partial(model, stored_now)
|
||||
messages_ = [uipage.derive_message(e) for e in errors]
|
||||
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_,
|
||||
|
||||
@@ -146,6 +146,18 @@ 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
|
||||
# OPT-11 P4: a Home Assistant solar forecast (kWh left today; tomorrow's is
|
||||
# optional) and how much of it to count on. Read only with battery modes
|
||||
# on; empty means no day plan (P3 option A). app/solarfc.py lists the
|
||||
# integrations whose sensors fit.
|
||||
pv_forecast_entity: ""
|
||||
pv_forecast_tomorrow_entity: ""
|
||||
solar_trust_pct: 70
|
||||
|
||||
# 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 +297,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 +957,10 @@ schema:
|
||||
capacity_kwh: float(0.1,200)?
|
||||
|
||||
estop_fitted: bool
|
||||
battery_modes_enabled: bool
|
||||
pv_forecast_entity: str?
|
||||
pv_forecast_tomorrow_entity: str?
|
||||
solar_trust_pct: int(30,100)
|
||||
# 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 +1031,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
|
||||
|
||||
@@ -1041,6 +1041,133 @@ check("...and the three that are NOT caught are reported as such, with the "
|
||||
all(b is False for _n, b, _w in MUT[-3:]))
|
||||
|
||||
|
||||
|
||||
print()
|
||||
print("13. OPT-11: the mode layer - applied at or below 35, released above it")
|
||||
# A mode is a SOFT unit limit beside the hard ones (UnitBounds.mode_*). The
|
||||
# allocator applies it only when the site target was decided at or below the
|
||||
# mode's own priority (P_MODE, 35), drops it when its range leaves out 0 W,
|
||||
# and never lets it narrow a hard limit into nothing.
|
||||
from app.allocator import (MODE_FAULT, MODE_OUTRANKED, MODE_RELEASED, # noqa: E402
|
||||
loop_bound, unit_interval)
|
||||
from app.arbiter import P_MODE # noqa: E402
|
||||
|
||||
|
||||
def M(lo=-RATING, hi=RATING, mlo=-inf, mhi=inf, prio=P_MODE, **kw):
|
||||
"""A unit's bounds with a mode range beside its hard limits."""
|
||||
b = B(lo, hi, **kw)
|
||||
return UnitBounds(lo=b.lo, hi=b.hi, gate=b.gate, pinned_w=b.pinned_w,
|
||||
pinned_by=b.pinned_by, contradiction=b.contradiction,
|
||||
mode_lo=mlo, mode_hi=mhi, mode_priority=prio)
|
||||
|
||||
|
||||
_off = {A: M(), Bee: M(mlo=0.0, mhi=0.0)}
|
||||
_at35 = plan(3000, _off, deciding_priority=P_MODE)
|
||||
_at50 = plan(3000, _off, deciding_priority=P_PEAK)
|
||||
_at10 = plan(3000, _off, deciding_priority=P_LOOP)
|
||||
print(f" B off, 3000 W: decided at 10 {_at10.explain()} | at 35 "
|
||||
f"{_at35.explain()} | at 50 {_at50.explain()}")
|
||||
check("⭐ RELEASE AT 35: a target decided at P_MODE itself keeps the mode - "
|
||||
"B (Off) is held at 0 W and A carries all 3000 W",
|
||||
_at35.shares == {A: 3000, Bee: 0} and _at35.unmet_w == 0)
|
||||
check("...and below it too (the loop, 10): the same split",
|
||||
_at10.shares == _at35.shares)
|
||||
check("⭐ RELEASE AT 50: a target decided by the peak floor releases the mode "
|
||||
"and B takes its weighted half",
|
||||
_at50.shares == {A: 1500, Bee: 1500} and _at50.unmet_w == 0)
|
||||
check("...and the boundary is exactly 35: at 36 the mode is released",
|
||||
plan(3000, _off, deciding_priority=P_MODE + 1).shares
|
||||
== {A: 1500, Bee: 1500})
|
||||
check("...and no deciding priority named means the mode applies (the safe "
|
||||
"reading for a direct caller)",
|
||||
plan(3000, _off).shares == {A: 3000, Bee: 0})
|
||||
check("unit_interval() reports why: None when applied, MODE_RELEASED above "
|
||||
"35, and the hard limits always",
|
||||
unit_interval(_off[Bee], P_MODE) == (0.0, 0.0, None)
|
||||
and unit_interval(_off[Bee], P_PEAK) == (-RATING, RATING, MODE_RELEASED))
|
||||
|
||||
# CONTAINS 0: a mode that would FORCE a direction is a software fault.
|
||||
_force = {A: M(mlo=500.0, mhi=RATING), Bee: M()}
|
||||
_f = plan(-1000, _force, deciding_priority=P_LOOP)
|
||||
print(f" A's mode is [500, 5000] (leaves out 0 W), site charges 1000 W: "
|
||||
f"{_f.explain()}, faults {_f.mode_faults}")
|
||||
check("⭐ CONTAINS 0: a mode range that leaves out 0 W is dropped and named "
|
||||
"as a fault, so REFUSE_SIGN stays unreachable: both units charge and "
|
||||
"the split is allowed",
|
||||
_f.ok and _f.mode_faults == ((A, MODE_FAULT),)
|
||||
and all(v <= 0 for v in _f.shares.values())
|
||||
and sum(_f.shares.values()) == -1000)
|
||||
_f_obeyed = plan(-1000, {A: B(500.0, RATING), Bee: B(-RATING, RATING)})
|
||||
check("...and that is not a hypothetical: the same range obeyed as a hard "
|
||||
"limit forces A to give 500 W while B charges 1500 W, which the "
|
||||
"allocator REFUSES as circulating power",
|
||||
not _f_obeyed.ok and REFUSE_SIGN in _f_obeyed.refused)
|
||||
check("...and a mode whose own lo is above its own hi is the same fault",
|
||||
unit_interval(M(mlo=300.0, mhi=-300.0))[2] == MODE_FAULT)
|
||||
_out = unit_interval(M(lo=-2000.0, hi=-500.0, mlo=0.0, mhi=0.0))
|
||||
check("a hard limit that leaves the mode no room wins outright "
|
||||
"(MODE_OUTRANKED): the unit keeps its hard interval",
|
||||
_out == (-2000.0, -500.0, MODE_OUTRANKED))
|
||||
check("a unit with no mode (every unit with battery modes off) gets exactly "
|
||||
"its hard limits, and plan() is the same record as before the mode "
|
||||
"fields existed",
|
||||
unit_interval(B(-700.0, 900.0)) == (-700.0, 900.0, None)
|
||||
and plan(1234, TWO, deciding_priority=P_PEAK) == plan(1234, TWO))
|
||||
|
||||
# The sweep: sum and sign kept whatever the modes, the target and the
|
||||
# deciding priority; every share inside its effective interval.
|
||||
_rng = random.Random(1135)
|
||||
_bad, _nm = [], 0
|
||||
for _ in range(20000):
|
||||
_units = {}
|
||||
for u in (A, Bee, "inverter-c")[:_rng.choice((2, 3))]:
|
||||
r = _rng.choice((2000.0, 3600.0, 5000.0))
|
||||
mlo = _rng.choice((-r, -700.0, 0.0))
|
||||
mhi = _rng.choice((0.0, 450.0, r))
|
||||
_units[u] = M(-r, r, mlo, mhi)
|
||||
_d = _rng.choice((P_LOOP, P_ARBITRAGE, P_MODE, P_MODE + 1, P_PEAK, None))
|
||||
_t = _rng.randint(-15000, 15000)
|
||||
_p = plan(_t, _units, deciding_priority=_d)
|
||||
_nm += 1
|
||||
_eff = {u: unit_interval(b, _d)[:2] for u, b in _units.items()}
|
||||
_sh = _p.shares
|
||||
if (not _p.ok or sum(_sh.values()) + _p.unmet_w != _p.sum_w
|
||||
or (any(v > 0 for v in _sh.values())
|
||||
and any(v < 0 for v in _sh.values()))
|
||||
or any(not (_eff[u][0] <= _sh[u] <= _eff[u][1]) for u in _sh)
|
||||
or (_d is not None and _d > P_MODE
|
||||
and any(_eff[u] != (b.lo, b.hi) for u, b in _units.items()))):
|
||||
_bad.append((_t, _d, _p.explain()))
|
||||
for x in _bad[:5]:
|
||||
print(f" BAD {x}")
|
||||
check(f"over {_nm} random ticks (2-3 units, mode ranges holding 0 W, targets "
|
||||
f"+-15 kW, deciding at 10/30/35/36/50/None) the shares sum to S with "
|
||||
f"`unmet`, never split the sign, and each stays inside its interval - "
|
||||
f"the mode's above 35 released, applied at or below",
|
||||
not _bad and _nm == 20000)
|
||||
|
||||
# loop_bound(): what the loop may ask when it decides.
|
||||
_lb = loop_bound({A: M(mlo=-3000.0, mhi=0.0), Bee: M(mlo=0.0, mhi=0.0)})
|
||||
check("loop_bound() sums each unit's interval with its mode applied: A may "
|
||||
"only charge 3000 W, B is off, so the loop may ask [-3000, 0]",
|
||||
_lb == (-3000.0, 0.0))
|
||||
check("...a unit that is not live adds nothing",
|
||||
loop_bound({A: M(), Bee: M()}, live=(A,)) == (-RATING, RATING))
|
||||
check("...a PINNED unit adds the span from 0 to its pin, so a 200 W probe "
|
||||
"(SAFETY-73: the closed limit becomes a pin) stays reachable",
|
||||
loop_bound({A: M(gate=P_SAFETY, pin=200.0),
|
||||
Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 200.0))
|
||||
check("...and the pin is clamped into the hard limits first, as plan() "
|
||||
"clamps it",
|
||||
loop_bound({A: M(lo=-RATING, hi=0.0, gate=P_SAFETY, pin=200.0),
|
||||
Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 0.0))
|
||||
check("...a mode that leaves out 0 W adds the hard interval (dropped, as in "
|
||||
"plan()), so every term holds 0 and so does the sum",
|
||||
loop_bound({A: M(mlo=500.0, mhi=900.0), Bee: M(mlo=0.0, mhi=0.0)})
|
||||
== (-RATING, RATING))
|
||||
check("...and a unit contradicting itself adds nothing",
|
||||
loop_bound({A: M(contra=True), Bee: M(mlo=0.0, mhi=0.0)}) == (0.0, 0.0))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
|
||||
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
|
||||
|
||||
print()
|
||||
print("ARB-03 2. the split: twelve sources, each classified where it is BUILT")
|
||||
print("ARB-03 2. the split: 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",
|
||||
@@ -193,16 +197,20 @@ UNIT_SOURCES = {
|
||||
"refused": "THIS battery's own reading gave nothing against THIS unit's "
|
||||
"own standing command, so that direction is closed on THIS "
|
||||
"unit and its share goes to the others. Filed at N>1 only",
|
||||
# OPT-11, added on purpose (test_peakguard.py part 10f says why).
|
||||
"mode": "THIS battery's mode, chosen by its owner. A soft unit limit "
|
||||
"that stage 1 keeps apart from the hard ones and stage 3 "
|
||||
"releases when a claim above P_MODE decides the site target",
|
||||
}
|
||||
for _src, _why in sorted(SITE_SOURCES.items()):
|
||||
print(f" site {_src:<11} {_why}")
|
||||
for _src, _why in sorted(UNIT_SOURCES.items()):
|
||||
print(f" unit {_src:<11} {_why}")
|
||||
check("all TWELVE claim sources are classified, and only those twelve",
|
||||
check("all 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 six that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
check("the seven that are about a PACK, a DEVICE or one write are per-inverter",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
|
||||
check("a source nobody classified cannot be routed at all - actuator_for() "
|
||||
"raises rather than defaulting to the site, because defaulting to the "
|
||||
@@ -220,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 six "
|
||||
"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}})
|
||||
@@ -540,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)
|
||||
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
"""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"),
|
||||
# OPT-11 P4: the day plan's word, while holding only.
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", has_at=True,
|
||||
day="sun_fills"), "sun_fills"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="tops_up"),
|
||||
"tops_up"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="no_forecast"),
|
||||
"no_forecast"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="charge", day="tops_up"),
|
||||
"grid_charge"),
|
||||
# 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"),
|
||||
(dict(other_cycle="A", mode=packmode.LEFTOVER_SOLAR, role="hold",
|
||||
day="tops_up"), "other_cycle"),
|
||||
]
|
||||
_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")
|
||||
@@ -1156,6 +1156,142 @@ check("...while the GAIN does own it: 0.6 -> 0.3 -> 0.2 falls monotonically, at
|
||||
_R["gain03"]["over"] < _R["rail"]["over"]
|
||||
and _R["gain02"]["over"] < _R["gain03"]["over"])
|
||||
|
||||
print()
|
||||
print("OPT-11: the loop is told what the battery modes allow")
|
||||
# compute(bound=(lo, hi)) is the site output bound: the sum over live units of
|
||||
# (hard limits with the mode applied), allocator.loop_bound(). Without it the
|
||||
# loop asks for watts no battery may give, the allocator reports them unmet,
|
||||
# and the saturation freeze parks the target on them (SAFETY-73 section 1).
|
||||
import random as _random # noqa: E402
|
||||
from app.control import MODE_BOUND # noqa: E402
|
||||
from named_absence import present # noqa: E402
|
||||
|
||||
TB = Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0,
|
||||
step_w=10, target_grid_w=-10.0)
|
||||
|
||||
|
||||
def closed_loop(cycles, load, *, bound=None, deliver=None, change=None):
|
||||
"""A plant that follows 94 % of the command each cycle, capped at
|
||||
`deliver` (what the allocator can place). `change(i)` may return a new
|
||||
bound. Rows: (command, actual, sat_count, frozen, reason, i_w)."""
|
||||
prev, actual, sat, i_w, rows = 0.0, 0.0, 0, None, []
|
||||
for i in range(cycles):
|
||||
if change is not None:
|
||||
bound = change(i, bound)
|
||||
d = compute(prev, load - actual, actual, TB, sat, i_w, bound=bound)
|
||||
prev, sat, i_w = d.target_w, d.sat_count, d.i_w
|
||||
cmd = prev if deliver is None else max(deliver[0],
|
||||
min(deliver[1], prev))
|
||||
actual = actual + 0.94 * (cmd - actual)
|
||||
rows.append((prev, actual, sat, d.frozen, d.reason, d.i_w))
|
||||
return rows
|
||||
|
||||
|
||||
# (a) One battery may give 800 W (the other is Off), the house takes 3000 W.
|
||||
_with = closed_loop(40, 3000.0, bound=(-800.0, 800.0))
|
||||
_without = closed_loop(40, 3000.0, deliver=(-800.0, 800.0))
|
||||
print(f" house 3000 W, modes allow 800 W: with the bound the command ends "
|
||||
f"at {nth(_with, -1)[0]:.0f} W (sat {max(r[2] for r in _with)}, reason "
|
||||
f"{nth(_with, -1)[4]}); without it {nth(_without, -1)[0]:.0f} W with "
|
||||
f"{sum(r[3] for r in _without)} frozen cycles")
|
||||
check("⭐ THE LOOP BOUND HOLDS: every command and every integrator value "
|
||||
"stays inside [-800, 800], and the command settles at the bound",
|
||||
all(-800.0 <= r[0] <= 800.0 and -800.0 <= r[5] <= 800.0 for r in _with)
|
||||
and present(nth(_with, -1)) and nth(_with, -1)[0] == 800.0
|
||||
and nth(_with, -1)[4] == MODE_BOUND)
|
||||
_big = []
|
||||
_pv, _iw, _sat = 0.0, None, 0
|
||||
for _i in range(30):
|
||||
_d = compute(_pv, 12000.0, _pv, TB, _sat, _iw, bound=(-800.0, 800.0))
|
||||
_pv, _iw, _sat = _d.target_w, _d.i_w, _d.sat_count
|
||||
_big.append(_d)
|
||||
check("...and a house far beyond what the modes allow (12 kW against 800 W) "
|
||||
"does not read as the loop winding into its rail: SAFETY-03's flags "
|
||||
"stay false on every cycle",
|
||||
_big and not any(d.i_clamped or d.clamped for d in _big)
|
||||
and all(d.target_w <= 800.0 for d in _big))
|
||||
check("⭐ NO SATURATION FREEZE from unmet watts: sat_count never reaches the "
|
||||
"freeze and no cycle is frozen",
|
||||
not any(r[3] for r in _with)
|
||||
and max(r[2] for r in _with) < TB.saturation_cycles)
|
||||
check("...and the same site WITHOUT the bound is the failure it removes: the "
|
||||
"loop asks past 800 W, the plant cannot follow and the freeze engages",
|
||||
any(r[3] for r in _without) and max(r[0] for r in _without) > 800.0)
|
||||
|
||||
# (b) Every battery switched off: the bound is [0, 0].
|
||||
_off = closed_loop(20, 2500.0, bound=(0.0, 0.0))
|
||||
check("all batteries off: the loop asks 0 W on every cycle, with the "
|
||||
"integrator at 0, and never freezes",
|
||||
all(r[0] == 0.0 and r[5] == 0.0 and not r[3] for r in _off))
|
||||
|
||||
# (c) A mode closes while the battery gives 2000 W: the ask drops at once.
|
||||
_close = closed_loop(30, 2000.0, bound=(-5000.0, 5000.0),
|
||||
change=lambda i, b: (0.0, 0.0) if i >= 20 else b)
|
||||
check("a mode that closes mid-run drops the ask to 0 W on the same cycle "
|
||||
"(no slew steps of unmet watts) and carries no integrator past it",
|
||||
present(nth(_close, 20)) and nth(_close, 19)[0] > 1500.0
|
||||
and nth(_close, 20)[0] == 0.0
|
||||
and nth(_close, 20)[5] == 0.0 and not any(r[3] for r in _close[20:]))
|
||||
|
||||
# (d) Rounding never steps outside the bound.
|
||||
_q = compute(1000.0, 3000.0, 1000.0, TB, 0, 1236.0, bound=(-5000.0, 1236.0))
|
||||
check("quantising to 10 W does not step outside the bound: 1236 W becomes "
|
||||
"1230 W, not 1240 W", _q.target_w == 1230.0)
|
||||
_qn = compute(-1000.0, -3000.0, -1000.0, TB, 0, -1236.0,
|
||||
bound=(-1236.0, 5000.0))
|
||||
check("...on the charge side too: -1236 W becomes -1230 W",
|
||||
_qn.target_w == -1230.0)
|
||||
|
||||
# (e) A bound that leaves out 0 W is a fault and is not obeyed.
|
||||
_rngc = _random.Random(1131)
|
||||
_same, _nb = True, 0
|
||||
for _ in range(4000):
|
||||
args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-4000, 4000),
|
||||
_rngc.uniform(-5000, 5000), TB, _rngc.randint(0, 5),
|
||||
_rngc.choice((None, _rngc.uniform(-6000, 6000))))
|
||||
_nb += 1
|
||||
if (compute(*args, bound=(200.0, 900.0)) != compute(*args)
|
||||
or compute(*args, bound=None) != compute(*args)):
|
||||
_same = False
|
||||
check(f"a bound that leaves out 0 W is ignored, and bound=None is the law as "
|
||||
f"it was: {_nb} random cycles give the identical Decision",
|
||||
_same and _nb == 4000)
|
||||
|
||||
# (f) Inside the bound nothing changes: a bound wider than every command.
|
||||
_wide = True
|
||||
for _ in range(4000):
|
||||
args = (_rngc.uniform(-4000, 4000), _rngc.uniform(-3000, 3000),
|
||||
_rngc.uniform(-4000, 4000), TB, _rngc.randint(0, 5),
|
||||
_rngc.choice((None, _rngc.uniform(-4000, 4000))))
|
||||
if compute(*args, bound=(-9000.0, 9000.0)) != compute(*args):
|
||||
_wide = False
|
||||
check("a bound wider than the rail changes no Decision (4000 random cycles)",
|
||||
_wide)
|
||||
|
||||
# (g) Review of PR #149: a bound EQUAL to the rail - every battery on, the
|
||||
# default once modes are on - must leave SAFETY-03 exactly as it was. Before
|
||||
# the fix the mode clamp ran first and took `i_clamped` away from the rail.
|
||||
_rail_same, _rail_hits = True, 0
|
||||
for _tb in (TB, Tuning(gain=0.6, max_w=5000.0, slew_w=400.0, deadband_w=15.0,
|
||||
step_w=10, target_grid_w=-10.0,
|
||||
integrator_max_w=7000.0)):
|
||||
_lim = _tb.max_w
|
||||
for _ in range(4000):
|
||||
args = (_rngc.uniform(-5000, 5000), _rngc.uniform(-12000, 12000),
|
||||
_rngc.uniform(-5000, 5000), _tb, _rngc.randint(0, 5),
|
||||
_rngc.choice((None, _rngc.uniform(-8000, 8000))))
|
||||
_d0 = compute(*args)
|
||||
_rail_hits += _d0.i_clamped
|
||||
if compute(*args, bound=(-_lim, _lim)) != _d0:
|
||||
_rail_same = False
|
||||
check(f"a bound equal to the rail changes no Decision, SAFETY-03's i_clamped "
|
||||
f"included ({_rail_hits} of 8000 random cycles wind into the rail), and "
|
||||
f"with integrator_max_w above max_w too", _rail_same and _rail_hits > 100)
|
||||
_one_side = compute(4000.0, 12000.0, 4000.0, TB, 0, 4990.0,
|
||||
bound=(0.0, 5000.0))
|
||||
check("...and a bound that closes only the other side still lets the rail "
|
||||
"flag a loop winding into it", _one_side.i_clamped)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -546,12 +546,13 @@ def resolve(path):
|
||||
return node
|
||||
|
||||
|
||||
check("the page is assembled: no placeholder is left and the words parse",
|
||||
_WORDS = json.loads(PAGE.split('id="ui-text">', 1)[1]
|
||||
.split("</script>", 1)[0].replace("<\\/", "</"))
|
||||
check("the page is assembled: no placeholder is left and the words parse, "
|
||||
"English and Dutch (UX-13 P5)",
|
||||
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
|
||||
and "/*UI_TEXT*/" not in PAGE
|
||||
and json.loads(PAGE.split('id="ui-text">', 1)[1]
|
||||
.split("</script>", 1)[0].replace("<\\/", "</"))["home"]
|
||||
== UI)
|
||||
and _WORDS["en"]["home"] == UI and set(_WORDS) == {"en", "nl"})
|
||||
check("four tabs with the four hash routes, in a keyboard tablist",
|
||||
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
|
||||
"settings", "setup"]
|
||||
@@ -563,7 +564,7 @@ check(f"no external file and no web font: no URL leaves the add-on "
|
||||
and "<link" not in PAGE and "src=" not in PAGE)
|
||||
_abs = re.findall(r"""(?:fetch\(|href=)["'](/[^"']*)["']""", PAGE)
|
||||
check(f"every fetch and link is relative, for ingress ({_abs})",
|
||||
not _abs and 'fetch("status"' in JS and 'fetch("action"' in JS)
|
||||
not _abs and 'fetch("status?lang=' in JS and 'fetch("action"' in JS)
|
||||
_keys = set(re.findall(r'data-t(?:-label)?="([\w.]+)"', PAGE)) | set(
|
||||
re.findall(r'''\bt\("([\w.]+)"\)''', JS))
|
||||
_missing = sorted(k for k in _keys if not isinstance(resolve(k), str))
|
||||
@@ -610,6 +611,8 @@ async def _serve():
|
||||
out["page"] = (r.status, r.content_type, await r.text())
|
||||
async with s.get(f"http://127.0.0.1:{port}/status") as r:
|
||||
out["status"] = await r.json()
|
||||
async with s.get(f"http://127.0.0.1:{port}/status?lang=nl-BE") as r:
|
||||
out["status_nl"] = await r.json()
|
||||
async with s.get(f"http://127.0.0.1:{port}/config/model") as r:
|
||||
out["model"] = await r.json()
|
||||
finally:
|
||||
@@ -618,6 +621,14 @@ async def _serve():
|
||||
|
||||
|
||||
E = asyncio.run(_serve())
|
||||
_nl_words = json.load(io.open(os.path.join(HERE, "app", "ui_text", "nl.json"),
|
||||
encoding="utf-8"))["home_words"]
|
||||
check("UX-13 P5: /status?lang=nl-BE builds Home in Dutch, and plain /status "
|
||||
"stays English",
|
||||
E["status_nl"]["state"]["pill"] in _nl_words["pill"].values()
|
||||
and E["status_nl"]["control"]["text"] in _nl_words["control"].values()
|
||||
and E["status"]["state"]["pill"] not in _nl_words["pill"].values()
|
||||
and set(E["status_nl"]) == set(E["status"]))
|
||||
check("GET / serves the assembled page as HTML", E["page"][0] == 200
|
||||
and E["page"][1] == "text/html" and E["page"][2] == PAGE)
|
||||
check("GET status over HTTP carries both halves",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -47,57 +47,10 @@ def check(name, cond):
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# --- the rule (STYLE.md sections 1 and 4) --------------------------------------
|
||||
BANNED = re.compile(
|
||||
r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|"
|
||||
r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|"
|
||||
r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|"
|
||||
r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|"
|
||||
r"capaciteitstarief|journalctl|ssh|grep|"
|
||||
r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I)
|
||||
ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)"
|
||||
r"\.[a-z0-9_*]+")
|
||||
SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b")
|
||||
SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b")
|
||||
PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]")
|
||||
|
||||
|
||||
def words(text):
|
||||
return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text))
|
||||
if re.search(r"[A-Za-z0-9]", w)]
|
||||
|
||||
|
||||
def sentences(text):
|
||||
"""Sentence count: a stop followed by a space and a capital, or the end."""
|
||||
t = PLACEHOLDER.sub("X", text.strip())
|
||||
t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t)
|
||||
return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()])
|
||||
|
||||
|
||||
def title_problems(title):
|
||||
out = []
|
||||
if len(words(title)) > 5:
|
||||
out.append(f"title over 5 words ({len(words(title))})")
|
||||
if title.lower().startswith("goodwe"):
|
||||
out.append("title carries a GoodWe prefix")
|
||||
if title.rstrip().endswith("."):
|
||||
out.append("title ends in a full stop")
|
||||
return out + text_problems(title, keys=False, entities=False)
|
||||
|
||||
|
||||
def text_problems(text, *, keys, entities):
|
||||
t = PLACEHOLDER.sub("X", text)
|
||||
out = []
|
||||
hit = sorted({m.group(0) for m in BANNED.finditer(t)})
|
||||
if hit:
|
||||
out.append("banned: " + ", ".join(hit[:4]))
|
||||
if not entities and ENTITY.search(t):
|
||||
out.append("entity id: " + ENTITY.search(t).group(0))
|
||||
if not keys and SNAKE.search(ENTITY.sub("", t)):
|
||||
out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0))
|
||||
if SHOUT.search(t):
|
||||
out.append("capitals: " + SHOUT.search(t).group(0))
|
||||
return out
|
||||
# --- the rule (STYLE.md sections 1 and 4): tools/style_rules.py, shared with
|
||||
# test_ui_text.py since UX-13 P5.
|
||||
from style_rules import (BANNED, ENTITY, PLACEHOLDER, SHOUT, SNAKE, # noqa: E402,F401
|
||||
sentences, text_problems, title_problems, words)
|
||||
|
||||
|
||||
def body_problems(body, severity):
|
||||
@@ -483,10 +436,13 @@ def collect():
|
||||
if isinstance(v.get("fields"), dict):
|
||||
walk(v["fields"], f"{path}{key}.")
|
||||
walk(tr.get("configuration"), "")
|
||||
# UX-13: the new page's own words, held to the option texts' rule.
|
||||
# UX-13: the new page's own words, held to the option texts' rule. Home's
|
||||
# sentences (`home_words`) are templates, checked rendered in part 8.
|
||||
for f in sorted((APP / "ui_text").glob("*.json")):
|
||||
def walk_ui(node, path):
|
||||
for key, v in node.items():
|
||||
if not path and key == "home_words":
|
||||
continue
|
||||
if isinstance(v, dict):
|
||||
walk_ui(v, f"{path}{key}.")
|
||||
elif isinstance(v, str):
|
||||
@@ -889,7 +845,8 @@ DATA = {
|
||||
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
|
||||
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
|
||||
"main.py:err", "main.py:', '.join(sorted(stranded))",
|
||||
"main.py:sorted(on_roster)", "main.py:inv.name",
|
||||
"main.py:sorted(on_roster)", "main.py:inv.name", "main.py:topic_s",
|
||||
"main.py:more",
|
||||
"main.py:inv.batt_fresh.bound.release_s", "main.py:inv.batt_fresh.bound.move_w",
|
||||
"main.py:inv.batt_fresh.bound.unchanged_s", "main.py:inv.batt_fresh.held",
|
||||
"main.py:value", "main.py:float(value)",
|
||||
@@ -1008,7 +965,7 @@ def _peak_texts():
|
||||
proj = Projection(projected_w=4338.0, elapsed_w=4338.0, elapsed_s=799.0,
|
||||
covered_frac=1.0, complete=False, block_start=_T0,
|
||||
held_w=0.0)
|
||||
kw = dict(cap_w=3500.0, reading=None, soc_floor=30.0, max_hold_s=30.0)
|
||||
kw = dict(cap_w=3500.0, margin_w=0.0, reading=None, soc_floor=30.0, max_hold_s=30.0)
|
||||
sat = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=80.0, **kw)
|
||||
low = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=20.0, **kw)
|
||||
unread = _pg.decide(proj, batt_w=1500.0, achievable_w=5000.0, soc=None, **kw)
|
||||
@@ -1102,6 +1059,8 @@ TEXT = {
|
||||
"main.py:rep.explain()": _migration_texts,
|
||||
"store.py:rep.explain()": _migration_texts,
|
||||
"main.py:plan.words": _tariff_texts,
|
||||
# OPT-11 P2: why an MQTT command was ignored.
|
||||
"main.py:why": lambda: list(messages.PACK_REFUSED.values()),
|
||||
# Families: every builder of the text, as a template.
|
||||
"main.py:refusal": lambda: (_static(("main.py", "return", "write_refusal"))
|
||||
+ _static(("allocator.py", "return", "authorises"))),
|
||||
@@ -1852,12 +1811,15 @@ def home_problems(kind, text):
|
||||
return out
|
||||
|
||||
|
||||
_home = homeview.text_samples()
|
||||
_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)] if p]
|
||||
for k, t, p in _home_bad:
|
||||
print(f" {k}: {t!r}: {p}")
|
||||
check(f"every Home sentence obeys its STYLE limit ({len(_home)} rendered, "
|
||||
f"{len(_home_bad)} over)", len(_home) > 80 and not _home_bad)
|
||||
for _lang in ("en", "nl"): # UX-13 P5: Home in English and Dutch
|
||||
_home = homeview.text_samples(_lang)
|
||||
_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)]
|
||||
if p]
|
||||
for k, t, p in _home_bad:
|
||||
print(f" {_lang} {k}: {t!r}: {p}")
|
||||
check(f"every Home sentence in {_lang} obeys its STYLE limit "
|
||||
f"({len(_home)} rendered, {len(_home_bad)} over)",
|
||||
len(_home) > 80 and not _home_bad)
|
||||
_shadowed = [t for k, t in homeview.text_samples() if "ould" in t]
|
||||
check("in shadow mode the Home plan says what the add-on WOULD do: every "
|
||||
"shadow variant of a plan text carries 'would'",
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Runnable check for OPT-11 P1, battery modes. `python3 test_modes.py`
|
||||
|
||||
Each battery gets an on/off switch and a mode (Solar first, Leftover solar),
|
||||
kept in the store and filed as a SOFT unit limit at P_MODE (35). Drives the
|
||||
REAL `Controller` (read_inputs + cycle) through tools/opt11_identity.py's
|
||||
harness, in simulated time. DECISIONS/OPT-11-battery-modes.md has the record.
|
||||
|
||||
1. With battery modes off, every command is byte-identical to release/1.0
|
||||
a5dddad, at one inverter and at two (digests measured on that tree).
|
||||
2. Solar order: surplus solar fills the Solar first battery, then spills to the
|
||||
Leftover solar one; a full Solar first battery hands all of it on.
|
||||
3. Off: the battery is asked for 0 W, the others carry the site, the loop is
|
||||
bounded and never freezes on watts no battery may give; all off at N=1 and
|
||||
N=2 asks 0 W.
|
||||
4. Off stops and skips maintenance, with a log line; a battery in maintenance
|
||||
gets no mode claim.
|
||||
5. The actions: `pack_use` and `pack_mode`, what they store, log and refuse.
|
||||
6. A mode range without 0 W is dropped as a software fault, logged once.
|
||||
7. The Batteries screen's data.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file and the named
|
||||
tests) are listed in the pull request and DECISIONS/OPT-11-battery-modes.md.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import homeview, messages, packmode # noqa: E402
|
||||
from app.control import MODE_BOUND # noqa: E402
|
||||
from app.maintenance import DRAIN, IDLE # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
ON = {"battery_modes_enabled": True}
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
|
||||
|
||||
def store_with(**modes):
|
||||
"""A store where each named battery has (use, mode)."""
|
||||
s = rig._Store()
|
||||
for name, (use, mode) in modes.items():
|
||||
s.data[unit_key(name, packmode.USE_KEY)] = use
|
||||
s.data[unit_key(name, packmode.MODE_KEY)] = mode
|
||||
return s
|
||||
|
||||
|
||||
def spy_claims():
|
||||
"""Record every claim list main.py's own `resolve` is handed."""
|
||||
seen, real = [], app_main.resolve
|
||||
|
||||
def _spy(claims, **kw):
|
||||
seen.append(list(claims))
|
||||
return real(claims, **kw)
|
||||
app_main.resolve = _spy
|
||||
return seen, lambda: setattr(app_main, "resolve", real)
|
||||
|
||||
|
||||
def spy_bounds():
|
||||
"""Record the `bound` main.py hands `compute()` on every cycle."""
|
||||
seen, real = [], app_main.compute
|
||||
|
||||
def _spy(*a, **kw):
|
||||
seen.append(kw.get("bound", "absent"))
|
||||
return real(*a, **kw)
|
||||
app_main.compute = _spy
|
||||
return seen, lambda: setattr(app_main, "compute", real)
|
||||
|
||||
|
||||
def flat(t):
|
||||
return 0.0
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. battery modes off: byte-identical to release/1.0 a5dddad")
|
||||
# The digests below were printed by `python3 tools/opt11_identity.py` run on
|
||||
# the a5dddad tree (the same file copied in, nothing else changed), in the CI
|
||||
# image, on 2026-09-28. Each hashes 540 cycles: site target, every inverter's
|
||||
# written value, the loop's reason, integrator and saturation count, the
|
||||
# arbiter's sentence and the allocation record.
|
||||
A5DDDAD = {
|
||||
"n1": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262",
|
||||
"n2": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
|
||||
"n1_off": "fc3bca170c46920f08d86baee62ea883954a89ea6222d6bbf7d6652877226262",
|
||||
"n2_off": "a60003b1930fa3cba21f3220670d9172b9ff438347d77ba9bc3383fca1ceb15a",
|
||||
# Added in review (PR #149), printed on a5dddad the same way: both
|
||||
# batteries due for maintenance, and the arbiter alone on 6000 seeded
|
||||
# claim lists with the peak floor, trades, maintenance and refusals.
|
||||
"n1_maint": "5a21d43ec7cbf95a224bbb9bca13a8e62a00161101b586ae9fa68ab565992977",
|
||||
"n2_maint": "48a1ad96121592e207a4664fb2da9f5208a49b88e5802a358873a5d30cb8c18e",
|
||||
"resolve": "43a874216711936e14b9185bcc8d26c58fe4f3cfc3c1c8eaf89bffd95d6e81ef",
|
||||
}
|
||||
_claims, _undo_c = spy_claims()
|
||||
_bounds, _undo_b = spy_bounds()
|
||||
try:
|
||||
_now = rig.digests()
|
||||
finally:
|
||||
_undo_c()
|
||||
_undo_b()
|
||||
# SAFETY-78 changes n2_maint ON PURPOSE: on a5dddad and 5a4cdc2 its N=2 drain
|
||||
# was refused on 392 of 540 cycles (site at 0 W); now none is. Printed on the
|
||||
# SAFETY-78 tree the same way. Every other digest is unchanged.
|
||||
SAFETY78_N2_MAINT = \
|
||||
"cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1"
|
||||
for k in A5DDDAD:
|
||||
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at N=1 and N=2, with the option absent and with it false, every "
|
||||
"command, reason and allocation record over 540 cycles each is "
|
||||
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
|
||||
"at its cut-out level, at N=2 a refusal with probes, both batteries "
|
||||
"due for maintenance; n2_maint apart, below), and so is the arbiter "
|
||||
"on 6000 claim lists",
|
||||
{k: v for k, v in _now.items() if k != "n2_maint"}
|
||||
== {k: v for k, v in A5DDDAD.items() if k != "n2_maint"})
|
||||
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
|
||||
"refused on 392 of 540 cycles now runs (test_safety78.py)",
|
||||
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
|
||||
check("...and no cycle in those runs filed a `mode` claim or handed "
|
||||
"`compute()` a bound",
|
||||
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
|
||||
and _bounds and set(_bounds) == {None})
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the solar order: Solar first fills, the rest spills to Leftover")
|
||||
_surplus = (lambda t: -3200.0) # 3200 W more solar than the house uses
|
||||
_full_a = (lambda u, t: 100.0 if (u == "a" and t >= 300) else 50.0)
|
||||
_rows, _c = rig.run(2, extra=ON, dur=600.0, load=_surplus, soc=_full_a,
|
||||
cut=lambda u, t: False,
|
||||
store=store_with(**{A: (True, packmode.SOLAR_FIRST),
|
||||
B: (True, packmode.LEFTOVER_SOLAR)}))
|
||||
_room = [r for r in _rows if 200 <= r[0] < 300]
|
||||
_full = [r for r in _rows if r[0] >= 500]
|
||||
|
||||
|
||||
def _cmd(r):
|
||||
"""One row's written values, by battery; ABSENTs if the row is missing."""
|
||||
return (dict(r[2]) if present(r)
|
||||
else {"a": nth((), 0, "a"), "b": nth((), 0, "b")})
|
||||
|
||||
|
||||
print(f" A has room: A {_cmd(nth(_room, -1))['a']:+.0f} W, B "
|
||||
f"{_cmd(nth(_room, -1))['b']:+.0f} W | A full: A {_cmd(nth(_full, -1))['a']:+.0f} "
|
||||
f"W, B {_cmd(nth(_full, -1))['b']:+.0f} W")
|
||||
check("⭐ while A (Solar first) has room it takes all it can, 2500 W, and B "
|
||||
"(Leftover solar) gets only the spill, about 700 W",
|
||||
present(nth(_room, -1))
|
||||
and all(_cmd(r)["a"] == -2500.0 and -800.0 <= _cmd(r)["b"] <= -600.0
|
||||
for r in _room))
|
||||
check("⭐ once A is full, the surplus spills to B, up to B's rating",
|
||||
present(nth(_full, -1))
|
||||
and all(_cmd(r)["a"] == 0.0 and _cmd(r)["b"] == -2500.0 for r in _full))
|
||||
_rows_off, _ = rig.run(2, dur=300.0, load=_surplus,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False)
|
||||
check("...and the same site with battery modes off splits 50/50, which is "
|
||||
"what says the order above comes from the modes",
|
||||
_cmd(_rows_off[-1])["a"] == _cmd(_rows_off[-1])["b"] < -1500.0)
|
||||
_give = rig.run(2, extra=ON, dur=300.0, load=lambda t: 3000.0,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||
store=store_with(**{A: (True, packmode.SOLAR_FIRST),
|
||||
B: (True, packmode.LEFTOVER_SOLAR)}))[0]
|
||||
check("while giving power the modes keep OPT-09's weights: A Solar first and "
|
||||
"B Leftover solar, both half full, share a 3000 W house equally",
|
||||
_cmd(_give[-1])["a"] == _cmd(_give[-1])["b"] > 1400.0)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. Off: 0 W on that battery, the loop bounded, no freeze")
|
||||
_rows, _c = rig.run(2, extra=ON, dur=600.0, load=lambda t: 4000.0,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||
store=store_with(**{B: (False, packmode.SOLAR_FIRST)}))
|
||||
_tail = _rows[40:]
|
||||
print(f" house 4000 W, B off: A {_cmd(_tail[-1])['a']:+.0f} W, B "
|
||||
f"{_cmd(_tail[-1])['b']:+.0f} W, site {_tail[-1][1]:+.0f} W, reason "
|
||||
f"{_tail[-1][3]}, {_tail[-1][7]}")
|
||||
check("⭐ B (Off) is asked for 0 W on every cycle and A carries the house up "
|
||||
"to its rating",
|
||||
all(_cmd(r)["b"] == 0.0 for r in _rows)
|
||||
and all(_cmd(r)["a"] == 2500.0 for r in _tail))
|
||||
check("⭐ the loop stays inside what the modes allow (2500 W) and never "
|
||||
"freezes: no saturated-freeze reason, and the saturation count stays "
|
||||
"below the freeze",
|
||||
all(r[1] <= 2500.0 for r in _rows)
|
||||
and not any(r[3] == "saturated-freeze" for r in _rows)
|
||||
and max(r[5] for r in _rows) < _c.tuning.saturation_cycles
|
||||
and _tail[-1][3] == MODE_BOUND)
|
||||
check("...and nothing is left unmet: the allocation places the whole target",
|
||||
all("UNMET" not in (r[7] or "") for r in _tail))
|
||||
_bounds, _undo_b = spy_bounds()
|
||||
try:
|
||||
rig.run(2, extra=ON, dur=100.0, load=lambda t: 4000.0,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||
store=store_with(**{B: (False, packmode.SOLAR_FIRST)}))
|
||||
finally:
|
||||
_undo_b()
|
||||
check("...because main.py hands compute() the loop bound, B's 0 W included: "
|
||||
"[-2500, 2500]", _bounds and set(_bounds) == {(-2500.0, 2500.0)})
|
||||
# Review (PR #149): a unit the allocator drops (stale link) must not count in
|
||||
# the loop bound either, or the loop asks for watts nobody live can place.
|
||||
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||
cut=lambda u, t: False)
|
||||
_both = _c.loop_bound([app_main.Claim.limit(
|
||||
"supervised", app_main.P_SAFETY, -2500.0, 2500.0,
|
||||
actuator=_c.actuator_of("supervised", i)) for i in _c.inverters])
|
||||
_c.live_units = lambda: (A,)
|
||||
_one_live = _c.loop_bound([app_main.Claim.limit(
|
||||
"supervised", app_main.P_SAFETY, -2500.0, 2500.0,
|
||||
actuator=_c.actuator_of("supervised", i)) for i in _c.inverters])
|
||||
check("the loop bound counts only live units: both live [-5000, 5000], B's "
|
||||
"link stale [-2500, 2500]",
|
||||
_both == (-5000.0, 5000.0) and _one_live == (-2500.0, 2500.0))
|
||||
|
||||
for _n in (1, 2):
|
||||
_st = store_with(**{A: (False, packmode.SOLAR_FIRST),
|
||||
B: (False, packmode.LEFTOVER_SOLAR)})
|
||||
if _n == 1:
|
||||
_st = rig._Store()
|
||||
_st.data[unit_key(app_main.UNIT_A, packmode.USE_KEY)] = False
|
||||
_rows, _c = rig.run(_n, extra=ON, dur=300.0, load=lambda t: 3000.0,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||
store=_st)
|
||||
check(f"every battery off at N={_n}: every command is 0 W, the site "
|
||||
f"target is 0 W, the integrator stays at 0 and nothing freezes",
|
||||
all(all(v == 0.0 for _u, v in r[2]) and r[1] == 0.0 and r[4] == 0.0
|
||||
and r[3] != "saturated-freeze" for r in _rows))
|
||||
_rows1, _ = rig.run(1, extra=ON, dur=300.0, load=lambda t: 1800.0,
|
||||
soc=lambda u, t: 50.0)
|
||||
_rows1o, _ = rig.run(1, dur=300.0, load=lambda t: 1800.0,
|
||||
soc=lambda u, t: 50.0)
|
||||
check("at N=1 a battery that is on (Solar first by default) follows the "
|
||||
"house exactly as with battery modes off",
|
||||
[r[:6] for r in _rows1] == [r[:6] for r in _rows1o]
|
||||
and _cmd(_rows1[-1])["a"] > 1700.0)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. Off stops and skips maintenance; maintenance outranks the mode")
|
||||
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
||||
_st = store_with(**{A: (False, packmode.SOLAR_FIRST)})
|
||||
_rows, _c = rig.run(2, extra={**ON, **MAINT}, dur=60.0,
|
||||
load=lambda t: 500.0, soc=lambda u, t: 60.0,
|
||||
cut=lambda u, t: False, store=_st)
|
||||
print(f" both due at 11:00, A off: A {_c.maints[0].phase}, "
|
||||
f"B {_c.maints[1].phase}")
|
||||
check("⭐ a battery that is off starts no cycle, and does not hold the slot: "
|
||||
"both are due, A (first in the roster) is off, so B starts",
|
||||
_c.maints[0].phase == IDLE and _c.maints[1].phase == DRAIN)
|
||||
_st2 = store_with()
|
||||
_seen, _undo_c = spy_claims()
|
||||
_msgs = []
|
||||
|
||||
|
||||
async def _switch_b_off(c, t):
|
||||
if t == 30.0:
|
||||
_msgs.append(await c.handle_action("pack_use", unit=B, value=False))
|
||||
|
||||
|
||||
try:
|
||||
_rows, _c = rig.run(2, extra={**ON, **MAINT}, dur=60.0,
|
||||
load=lambda t: 500.0, soc=lambda u, t: 60.0,
|
||||
cut=lambda u, t: False, store=_st2,
|
||||
before=_switch_b_off)
|
||||
finally:
|
||||
_undo_c()
|
||||
_during = [cl for cl in _seen[:6]]
|
||||
print(f" B off at 30 s while A drains: A {_c.maints[0].phase}, "
|
||||
f"B {_c.maints[1].phase}; events: "
|
||||
f"{[e for e in _c.events if 'switched' in str(e) or 'ABORT' in str(e)]}")
|
||||
check("while A runs its cycle it gets no mode claim (maintenance at 60/40 "
|
||||
"outranks the mode) and B, idle, gets one",
|
||||
all({c.actuator for c in cl if c.source == "mode"} == {B}
|
||||
for cl in _during))
|
||||
_st3 = store_with()
|
||||
_st3.data[unit_key(B, "phase")] = DRAIN
|
||||
_st3.data["maint_schema_version"] = 1
|
||||
|
||||
|
||||
async def _b_off_now(c, t):
|
||||
if t == 10.0:
|
||||
_msgs.append(await c.handle_action("pack_use", unit=B, value=False))
|
||||
|
||||
|
||||
_rows, _c = rig.run(2, extra=ON, dur=30.0, load=lambda t: 500.0,
|
||||
soc=lambda u, t: 60.0, cut=lambda u, t: False,
|
||||
store=_st3, before=_b_off_now)
|
||||
_ev = [str(e) for e in _c.events]
|
||||
print(f" B mid-drain, switched off at 10 s: B {_c.maints[1].phase}; "
|
||||
f"{[e for e in _ev if 'switched off' in e or 'ABORT' in e]}")
|
||||
check("⭐ switching off a battery that is mid-cycle stops its cycle at once, "
|
||||
"with a log line saying why",
|
||||
_c.maints[1].phase == IDLE
|
||||
and any("battery switched off" in e for e in _ev)
|
||||
and _cmd(_rows[-1])["b"] == 0.0)
|
||||
_st4 = store_with(**{B: (False, packmode.SOLAR_FIRST)})
|
||||
_st4.data[unit_key(B, "phase")] = DRAIN
|
||||
_st4.data["maint_schema_version"] = 1
|
||||
_rows, _c = rig.run(2, extra=ON, dur=20.0, load=lambda t: 500.0,
|
||||
soc=lambda u, t: 60.0, cut=lambda u, t: False, store=_st4)
|
||||
check("...and a store that says Off for a battery mid-cycle (a restart) stops "
|
||||
"it on the first cycle, and it is never asked to drain",
|
||||
_c.maints[1].phase == IDLE
|
||||
and all(_cmd(r)["b"] == 0.0 for r in _rows))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the actions: pack_use and pack_mode")
|
||||
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False)
|
||||
_r1 = asyncio.run(_c.handle_action("pack_mode", unit=B,
|
||||
value=packmode.LEFTOVER_SOLAR))
|
||||
_r2 = asyncio.run(_c.handle_action("pack_use", unit=A, value=False))
|
||||
print(f" {_r1} {_r2}")
|
||||
check("pack_mode stores the mode under the battery's own key and says what "
|
||||
"it now is",
|
||||
_r1 == {"unit": B, "use": True, "mode": packmode.LEFTOVER_SOLAR}
|
||||
and _c.store.data[unit_key(B, packmode.MODE_KEY)]
|
||||
== packmode.LEFTOVER_SOLAR)
|
||||
check("pack_use stores the switch, and both are written to the action log",
|
||||
_r2 == {"unit": A, "use": False, "mode": packmode.SOLAR_FIRST}
|
||||
and _c.store.data[unit_key(A, packmode.USE_KEY)] is False
|
||||
and any("inverter-a switched off by operator" in str(e)
|
||||
for e in _c.events)
|
||||
and any("inverter-b mode set to leftover solar" in str(e)
|
||||
for e in _c.events))
|
||||
_bad = [asyncio.run(_c.handle_action("pack_use", unit="inverter-z",
|
||||
value=True)),
|
||||
asyncio.run(_c.handle_action("pack_use", unit=A, value="yes")),
|
||||
# 0 == False in Python: a stored 0 would read back as ON.
|
||||
asyncio.run(_c.handle_action("pack_use", unit=A, value=0)),
|
||||
asyncio.run(_c.handle_action("pack_use", unit=A, value=1)),
|
||||
asyncio.run(_c.handle_action("pack_mode", unit=A,
|
||||
value="sell_only")),
|
||||
asyncio.run(_c.handle_action("pack_mode", unit=A, value=None))]
|
||||
check("an unknown battery, a switch that is not true or false (0 and 1 "
|
||||
"included), and a mode "
|
||||
"that does not exist are refused, "
|
||||
"with a plain reason, and store nothing",
|
||||
all(isinstance(x, dict) and set(x) == {"error"} for x in _bad)
|
||||
and unit_key(A, packmode.MODE_KEY) not in _c.store.data
|
||||
and _c.store.data[unit_key(A, packmode.USE_KEY)] is False)
|
||||
_rows, _c0 = rig.run(2, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||
cut=lambda u, t: False)
|
||||
_r0 = asyncio.run(_c0.handle_action("pack_use", unit=A, value=False))
|
||||
check("with battery modes off the actions are refused and store nothing",
|
||||
set(_r0) == {"error"}
|
||||
and unit_key(A, packmode.USE_KEY) not in _c0.store.data)
|
||||
check("the old actions still take no battery and answer as before",
|
||||
asyncio.run(_c0.handle_action("nonsense")) is None)
|
||||
check("a store value from a later version (an unknown mode) reads as the "
|
||||
"default, Solar first and on, never as Off",
|
||||
packmode.read({unit_key(A, packmode.MODE_KEY): "grid_only",
|
||||
unit_key(A, packmode.USE_KEY): "maybe"}, A)
|
||||
== packmode.PackMode(True, packmode.SOLAR_FIRST))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. a mode range without 0 W is a software fault, never obeyed")
|
||||
_real_range = packmode.mode_range
|
||||
packmode.mode_range = lambda pm, r: (100.0, 900.0)
|
||||
_log = []
|
||||
|
||||
|
||||
class _H(logging.Handler):
|
||||
def emit(self, rec):
|
||||
_log.append((rec.levelno, rec.getMessage()))
|
||||
|
||||
|
||||
_h = _H()
|
||||
logging.getLogger("goodwe").addHandler(_h)
|
||||
try:
|
||||
_rows, _c = rig.run(2, extra=ON, dur=60.0, load=lambda t: -2000.0,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False)
|
||||
finally:
|
||||
packmode.mode_range = _real_range
|
||||
logging.getLogger("goodwe").removeHandler(_h)
|
||||
_errs = [msg for lv, msg in _log if lv >= logging.ERROR and "MODE_RANGE" in msg]
|
||||
print(f" {_errs}")
|
||||
check("⭐ both batteries still charge from the surplus (the forced range is "
|
||||
"dropped, so no battery is made to give power) and no split is refused",
|
||||
all(v <= 0.0 for _u, v in _rows[-1][2])
|
||||
and _cmd(_rows[-1])["a"] < 0.0
|
||||
and not any("REFUSED" in (r[7] or "") for r in _rows))
|
||||
check("...and it is logged once per battery, as an ERROR naming the fault "
|
||||
"code, not once per cycle",
|
||||
len(_errs) == 2 and any(A in e for e in _errs) and any(B in e for e in _errs))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. the Batteries screen's data")
|
||||
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||
cut=lambda u, t: False,
|
||||
store=store_with(**{B: (False, packmode.LEFTOVER_SOLAR)}))
|
||||
_bat = homeview.battery_rows(_c, False, True)
|
||||
_bat_nl = homeview.build(_c, level="ok", checks=[], lang="nl")["batteries"]
|
||||
print(f" {[(b['name'], b['use'], b['mode_key'], b['mode']) for b in _bat]}")
|
||||
check("with battery modes on each row carries its switch, its state and its "
|
||||
"stored mode, in the page's words",
|
||||
[(b["use"], b["mode_key"], b["mode_choice"], b["mode"]) for b in _bat]
|
||||
== [(True, "solar_first", "solar_first", "Solar first"),
|
||||
(False, "off", "leftover_solar", "Off")]
|
||||
and _bat_nl[1]["mode"] == "Uit")
|
||||
_rows, _c0 = rig.run(2, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||
cut=lambda u, t: False)
|
||||
check("...and with them off the rows are what they were: no new keys, the "
|
||||
"mode reads as before",
|
||||
all("mode_key" not in b and b["mode"] == homeview.mode_house()
|
||||
for b in homeview.battery_rows(_c0, False, True)))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n8. the page's route: /action and /status over the real web app")
|
||||
import socket # noqa: E402
|
||||
|
||||
import aiohttp # noqa: E402
|
||||
from aiohttp import web as aioweb # noqa: E402
|
||||
|
||||
from app import web as app_web # noqa: E402
|
||||
|
||||
_rows, _c = rig.run(2, extra=ON, dur=10.0, load=flat, soc=lambda u, t: 50.0,
|
||||
cut=lambda u, t: False)
|
||||
|
||||
|
||||
async def _page():
|
||||
# The configuration routes are not used here; a stand-in keeps the app
|
||||
# from building a Supervisor client.
|
||||
app = app_web.build_app(_c, selfcfg=object())
|
||||
runner = aioweb.AppRunner(app)
|
||||
await runner.setup()
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
|
||||
out = {}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
root = f"http://127.0.0.1:{port}/"
|
||||
for key, body in (
|
||||
("mode", {"action": "pack_mode", "unit": B,
|
||||
"value": packmode.LEFTOVER_SOLAR}),
|
||||
("off", {"action": "pack_use", "unit": A,
|
||||
"value": False}),
|
||||
("bad", {"action": "pack_mode", "unit": A,
|
||||
"value": "grid_only"})):
|
||||
async with s.post(root + "action", json=body) as r:
|
||||
out[key] = (r.status, await r.json(content_type=None))
|
||||
async with s.get(root + "status") as r:
|
||||
out["status"] = (r.status, await r.json(content_type=None))
|
||||
async with s.get(root) as r:
|
||||
out["page"] = (r.status, await r.text())
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
_web = asyncio.run(_page())
|
||||
print(f" {_web['mode']} {_web['off']} {_web['bad']}")
|
||||
check("POST /action with pack_mode and pack_use names a battery and a value, "
|
||||
"and answers what it stored",
|
||||
_web["mode"] == (200, {"ok": True, "result": {
|
||||
"unit": B, "use": True, "mode": packmode.LEFTOVER_SOLAR}})
|
||||
and _web["off"][0] == 200 and _web["off"][1]["result"]["use"] is False)
|
||||
check("...a value this release does not offer is a 400 with the plain reason "
|
||||
"the page shows",
|
||||
_web["bad"][0] == 400 and _web["bad"][1]["ok"] is False
|
||||
and _web["bad"][1]["error"] == messages.pack_refused("value"))
|
||||
_sb = {b["name"]: b for b in _web["status"][1].get("batteries", [])}
|
||||
check("...and /status then shows both batteries' switch and mode for the "
|
||||
"Batteries screen",
|
||||
_web["status"][0] == 200
|
||||
and (_sb.get(A, {}).get("mode_key"), _sb.get(B, {}).get("mode_key"))
|
||||
== ("off", packmode.LEFTOVER_SOLAR))
|
||||
check("the page carries the mode names from app/ui_text for its cards",
|
||||
_web["page"][0] == 200 and "Leftover solar" in _web["page"][1]
|
||||
and "Overschot zonne-energie" in _web["page"][1])
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,722 @@
|
||||
"""Runnable check for OPT-11 P3, price runs per battery. `python3 test_modes_p3.py`
|
||||
|
||||
Drives the pure modules (priceruns, modetrade) and the REAL Controller
|
||||
(tools/opt11p3_replay.py: read_inputs + cycle on a P1 meter, the arbiter, the
|
||||
allocator and the peak guard, with two lagged model batteries), in simulated
|
||||
time. DECISIONS/OPT-11-battery-modes.md has the record.
|
||||
|
||||
1. With battery modes off, every command and trade is byte-identical to
|
||||
release/1.0 7a00f12 on the trade path P3 changed (digests measured there),
|
||||
and the harness would see a change there.
|
||||
2. Price runs: consecutive, firm, sized by energy; give runs clear the spread;
|
||||
the sell run sits in a give run with no slot below 0; a started run is kept.
|
||||
3. Roles: a fuzz of modetrade.decide() and size() against the invariants.
|
||||
4. The real controller over a day: no opposite-direction split, no grid
|
||||
charge on Solar first, the quarter never above the aim while charging, no
|
||||
sell below the floor or the spread, holding outside the expensive hours.
|
||||
5. A refusal wins over a run.
|
||||
6. No prices or too small a gap: the price modes follow the house; Solar first
|
||||
alone never trades.
|
||||
7. A run edge reseeds the loop (handover measured with and without).
|
||||
8. The site Why row: a limit that pushes power up does not "hold it back" (F2).
|
||||
9. Selling is off (owner, 2026-09-29): `pack_sell` refused, read as false.
|
||||
10. The Batteries screen: Why and Today lines from the roles and runs.
|
||||
11. Review additions: modes on ignore the site trade switch, a battery in
|
||||
maintenance takes no part, the headroom stop latches for the period.
|
||||
|
||||
12. Option A: no sale in a day; Leftover solar never grid-charges (P4).
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file and the named
|
||||
tests) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import opt11p3_identity as ident # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import homeview, messages, modetrade, packmode, priceruns # noqa
|
||||
from app import arbitrage # noqa: E402
|
||||
from app.arbiter import Claim, P_ARBITRAGE, P_LOOP, P_PEAK, Resolution # noqa
|
||||
from app.main import Controller, why_raised # noqa: E402
|
||||
from app.price import PricePoint, brussels # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. modes off: byte-identical to 7a00f12 on the trade path")
|
||||
# Printed by tools/opt11p3_identity.py on release/1.0 7a00f12 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each; the trade CHARGEs and DISCHARGEs.
|
||||
AT_7A00F12 = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
_rows = {k: ident.run(**v)[0] for k, v in ident.SCENARIOS.items()}
|
||||
_got = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
for k in AT_7A00F12:
|
||||
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
|
||||
f"{_got[k][:16]}")
|
||||
check("with battery modes off (option absent or false), every command, "
|
||||
"integrator, allocation and trade sentence equals 7a00f12's, at one "
|
||||
"inverter and at two", _got == AT_7A00F12)
|
||||
_acts = {r[-1][0] for r in _rows["n2"] if r[-1]}
|
||||
check("...and the runs reach the trade P3 now skips with modes on: a charge "
|
||||
"and a sale", {arbitrage.CHARGE, arbitrage.DISCHARGE} <= _acts)
|
||||
|
||||
|
||||
def _moved(scn, patch):
|
||||
"""Does `patch` (applied before each cycle) move scenario `scn`?"""
|
||||
real = ident._feed
|
||||
|
||||
def feed(c, t):
|
||||
patch(c)
|
||||
real(c, t)
|
||||
ident._feed = feed
|
||||
try:
|
||||
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= AT_7A00F12[scn]
|
||||
finally:
|
||||
ident._feed = real
|
||||
|
||||
|
||||
def _as_if_trade_ended(c):
|
||||
c._mode_trade_was = True # the run-edge reseed, leaking to modes off
|
||||
|
||||
|
||||
def _moved_headroom(scn):
|
||||
"""Does an unmeasured headroom (the expression P3 hoisted) move it?"""
|
||||
from app import peakguard
|
||||
real = peakguard.charge_headroom_w
|
||||
peakguard.charge_headroom_w = lambda g: None
|
||||
try:
|
||||
return ident.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= AT_7A00F12[scn]
|
||||
finally:
|
||||
peakguard.charge_headroom_w = real
|
||||
|
||||
|
||||
check("non-vacuous: a run-edge reseed leaking into modes off moves n2, and so "
|
||||
"does the headroom P3 hoisted, if it went unmeasured",
|
||||
_moved("n2", _as_if_trade_ended) and _moved_headroom("n1"))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. price runs")
|
||||
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
|
||||
|
||||
|
||||
def pts(prices, start=D0, minutes=15, provisional=()):
|
||||
return [PricePoint(start=brussels(start + timedelta(minutes=minutes * i)),
|
||||
eur_per_kwh=p, provisional=i in provisional)
|
||||
for i, p in enumerate(prices)]
|
||||
|
||||
|
||||
# 24 h of quarters: cheap 02:00-04:00 (and one cheaper single quarter at
|
||||
# 05:00), dear 18:00-20:00 with a peak quarter at 19:00, flat otherwise.
|
||||
DAY = [0.30] * 96
|
||||
for i in range(8, 16):
|
||||
DAY[i] = 0.10
|
||||
DAY[20] = 0.05
|
||||
for i in range(72, 80):
|
||||
DAY[i] = 0.50
|
||||
DAY[76] = 0.70
|
||||
P = pts(DAY)
|
||||
NOW = D0 + timedelta(minutes=5)
|
||||
r1 = priceruns.plan(P, NOW, required_eur_kwh=0.25, charge_kwh=5.0,
|
||||
charge_kw=2.5, sell_kwh=0.0, sell_kw=0.0)
|
||||
r2 = priceruns.plan(P, NOW, required_eur_kwh=0.25, charge_kwh=0.0,
|
||||
charge_kw=0.0, sell_kwh=2.5, sell_kw=2.5)
|
||||
print(f" 2 h need: {r1.cheap}; give {r1.give}")
|
||||
check("the cheap run lasts what the energy needs at the rating (5 kWh at "
|
||||
"2.5 kW = 2 h) and is the cheapest such consecutive window, not the "
|
||||
"cheapest single quarter",
|
||||
r1.cheap.start == D0 + timedelta(hours=2)
|
||||
and r1.cheap.end == D0 + timedelta(hours=4)
|
||||
and abs(r1.cheap.mean_eur_kwh - 0.10) < 1e-9)
|
||||
check("...with nothing to charge, one quarter: the cheapest one",
|
||||
r2.cheap.start == D0 + timedelta(hours=5)
|
||||
and r2.cheap.end == D0 + timedelta(hours=5, minutes=15))
|
||||
check("give runs are the consecutive quarters at least the required spread "
|
||||
"above the cheap run (0.10 + 0.25): 18:00-20:00, one run",
|
||||
r1.on and len(r1.give) == 1
|
||||
and r1.give[0].start == D0 + timedelta(hours=18)
|
||||
and r1.give[0].end == D0 + timedelta(hours=20))
|
||||
check("the sell run is the dearest window inside a give run, sized by the "
|
||||
"sellers' energy (2.5 kWh at 2.5 kW = 1 h)",
|
||||
r2.sell.end - r2.sell.start == timedelta(hours=1)
|
||||
and r1.give[0].start <= r2.sell.start < r2.sell.end <= r1.give[0].end
|
||||
and r2.sell.start <= D0 + timedelta(hours=19) < r2.sell.end)
|
||||
_flat = priceruns.plan(pts([0.3] * 96), NOW, required_eur_kwh=0.1,
|
||||
charge_kwh=5, charge_kw=2.5, sell_kwh=0, sell_kw=0)
|
||||
_thr0 = priceruns.plan(pts([0.3] * 96), NOW, required_eur_kwh=0.0,
|
||||
charge_kwh=5, charge_kw=2.5, sell_kwh=0, sell_kw=0)
|
||||
check("a flat day has no plan (`not_worth`), even with a threshold of 0: the "
|
||||
"cheap run never counts as its own expensive run",
|
||||
_flat.reason == priceruns.NOT_WORTH and not _flat.give
|
||||
and _thr0.reason == priceruns.NOT_WORTH)
|
||||
_prov = priceruns.plan(pts(DAY, provisional=set(range(74, 96))), NOW,
|
||||
required_eur_kwh=0.25, charge_kwh=5, charge_kw=2.5,
|
||||
sell_kwh=2.5, sell_kw=2.5)
|
||||
check("a provisional (carried) price is never part of a run (section 8.3)",
|
||||
all(g.end <= D0 + timedelta(hours=18, minutes=30) for g in _prov.give)
|
||||
and (_prov.sell is None
|
||||
or _prov.sell.end <= D0 + timedelta(hours=18, minutes=30)))
|
||||
_neg = list(DAY)
|
||||
_neg[76] = -0.05
|
||||
_rn = priceruns.plan(pts(_neg), NOW, required_eur_kwh=0.25, charge_kwh=0,
|
||||
charge_kw=0, sell_kwh=10.0, sell_kw=2.5)
|
||||
check("the sell run never spans a quarter priced below 0 (section 17)",
|
||||
_rn.sell is not None and not (_rn.sell.start <= D0 + timedelta(
|
||||
hours=19) < _rn.sell.end))
|
||||
# All prices below 0 but one: the give run holds negative quarters (they
|
||||
# clear the spread over an even lower cheap run), and the sale must skip them.
|
||||
_allneg = [-0.30] * 96
|
||||
for i in range(8, 16):
|
||||
_allneg[i] = -0.40
|
||||
for i in range(72, 80):
|
||||
_allneg[i] = -0.05
|
||||
_allneg[76] = 0.10
|
||||
_rneg = priceruns.plan(pts(_allneg), NOW, required_eur_kwh=0.15,
|
||||
charge_kwh=0, charge_kw=0, sell_kwh=2.5, sell_kw=2.5)
|
||||
check("...even when the whole give run is below 0 but one quarter: the sale "
|
||||
"is that quarter only",
|
||||
_rneg.on and _rneg.sell is not None
|
||||
and _rneg.sell.start == D0 + timedelta(hours=19)
|
||||
and _rneg.sell.end == D0 + timedelta(hours=19, minutes=15))
|
||||
# A spike inside the cheap run clears the spread over the run's own mean:
|
||||
# it must not also be a give run (charge and give would overlap).
|
||||
_sp = [0.50] * 96
|
||||
for i in range(8, 16):
|
||||
_sp[i] = 0.02
|
||||
_sp[15] = 0.20
|
||||
_rsp = priceruns.plan(pts(_sp), NOW, required_eur_kwh=0.05, charge_kwh=5.0,
|
||||
charge_kw=2.5, sell_kwh=0, sell_kw=0)
|
||||
check("no give run overlaps the cheap run, even a quarter inside it that "
|
||||
"clears the spread over the run's mean",
|
||||
_rsp.cheap.start == D0 + timedelta(hours=2)
|
||||
and all(g.end <= _rsp.cheap.start or g.start >= _rsp.cheap.end
|
||||
for g in _rsp.give))
|
||||
_few = priceruns.plan(pts([0.1]), NOW, required_eur_kwh=0.1, charge_kwh=1,
|
||||
charge_kw=1, sell_kwh=0, sell_kw=0)
|
||||
check("fewer than two firm slots: no plan (`no_prices`)",
|
||||
_few.reason == priceruns.NO_PRICES)
|
||||
_mid = D0 + timedelta(hours=3)
|
||||
_new = priceruns.plan(P, _mid, required_eur_kwh=0.25, charge_kwh=0.0,
|
||||
charge_kw=0.0, sell_kwh=0.0, sell_kw=0.0)
|
||||
_kept = priceruns.keep_started(r1, _new, _mid)
|
||||
check("a run under way is kept to its end when a new plan would move it "
|
||||
"(no flapping), and a run not yet started is not",
|
||||
_new.cheap != r1.cheap and _kept.cheap == r1.cheap
|
||||
and priceruns.keep_started(r1, _new, NOW).cheap == _new.cheap)
|
||||
_late = priceruns.plan(P, D0 + timedelta(hours=20, minutes=5),
|
||||
required_eur_kwh=0.25, charge_kwh=5, charge_kw=2.5,
|
||||
sell_kwh=0, sell_kw=0)
|
||||
check("a run that has ended is not planned again, and nothing beyond the "
|
||||
"firm prices is invented",
|
||||
all(g.start >= D0 + timedelta(hours=20) for g in _late.give)
|
||||
and not _late.on)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. roles: modetrade.decide() fuzz")
|
||||
rnd = random.Random(311)
|
||||
_bad = []
|
||||
_seen = set()
|
||||
for _ in range(20000):
|
||||
n = rnd.choice((1, 2, 3))
|
||||
packs = []
|
||||
for k in range(n):
|
||||
packs.append(modetrade.Pack(
|
||||
f"inv-{k}", rnd.random() > 0.15,
|
||||
rnd.choice(packmode.MODES), rnd.random() > 0.5,
|
||||
rnd.choice((1000.0, 2500.0, 5000.0)),
|
||||
rnd.choice((None, 5.0, 19.0, 20.0, 21.0, 60.0, 95.0, 99.0)),
|
||||
rnd.choice((None, 5.0, 10.0)), live=rnd.random() > 0.1,
|
||||
idle=rnd.random() > 0.1, refused=rnd.choice((0, 0, 0, 1, -1))))
|
||||
t = D0 + timedelta(minutes=rnd.randrange(0, 24 * 60))
|
||||
runs = rnd.choice((None, r1, r2, _flat))
|
||||
ok_s = rnd.random() > 0.3
|
||||
ok_c = ok_s and rnd.random() > 0.3
|
||||
hr = rnd.choice((None, 0.0, 100.0, 499.0, 501.0, 3000.0))
|
||||
price = rnd.choice((None, -0.1, 0.0, 0.5))
|
||||
house = rnd.choice((None, -500.0, 800.0))
|
||||
was = rnd.random() > 0.7
|
||||
blocked = rnd.random() > 0.8
|
||||
tk = modetrade.decide(runs, t, packs, charge_ok=ok_c, sell_ok=ok_s,
|
||||
trade_why="x", price_now=price, headroom_w=hr,
|
||||
floor_pct=20.0, ceiling_pct=95.0, house_w=house,
|
||||
charging_now=was, blocked=blocked)
|
||||
_seen.add(tk.action)
|
||||
by = {p.name: p for p in packs}
|
||||
for name, (lo, hi) in tk.ranges.items():
|
||||
if not lo <= 0.0 <= hi:
|
||||
_bad.append(("range without 0", name, lo, hi))
|
||||
for name, role in tk.roles.items():
|
||||
p = by[name]
|
||||
if role == modetrade.CHARGE and (p.mode != packmode.CHEAP_HOURS
|
||||
or p.refused < 0 or not p.use
|
||||
or p.soc is None or p.soc >= 95.0):
|
||||
_bad.append(("bad charger", p))
|
||||
if role == modetrade.SELL and (not p.sell or p.refused > 0
|
||||
or p.mode == packmode.SOLAR_FIRST
|
||||
or p.soc is None or p.soc <= 20.0):
|
||||
_bad.append(("bad seller", p))
|
||||
if not p.use and tk.ranges.get(name, (0.0, 0.0)) != (0.0, 0.0):
|
||||
_bad.append(("off not 0", p))
|
||||
if tk.action == modetrade.CHARGE:
|
||||
need = 0.0 if was else modetrade.START_W
|
||||
if not (ok_c and hr is not None and hr > need and not blocked
|
||||
and runs is not None and runs.cheap.holds(t)):
|
||||
_bad.append(("charge without leave", ok_c, hr, blocked))
|
||||
for name, (lo, hi) in tk.ranges.items():
|
||||
if tk.roles[name] != modetrade.CHARGE and lo < 0.0:
|
||||
_bad.append(("grid charge on a non-charger", by[name]))
|
||||
s = modetrade.size(tk, (-7000.0, 7000.0), hr)
|
||||
if not (s < 0 and -s <= min(-tk.ask_w, hr) + 1e-9):
|
||||
_bad.append(("charge size", s, tk.ask_w, hr))
|
||||
if modetrade.size(tk, (-300.0, 300.0), hr) < -300.0:
|
||||
_bad.append(("charge over the bound",))
|
||||
if tk.action == modetrade.SELL:
|
||||
if not (ok_s and price is not None and price >= 0.0 and runs
|
||||
is not None and runs.sell.holds(t) and house is not None):
|
||||
_bad.append(("sell without leave", ok_s, price))
|
||||
s = modetrade.size(tk, (-7000.0, 2000.0), hr)
|
||||
if not 0.0 < s <= 2000.0:
|
||||
_bad.append(("sell size", s))
|
||||
if tk.action is None and modetrade.size(tk, (-1e4, 1e4), hr) != 0.0:
|
||||
_bad.append(("no action but a size",))
|
||||
print(f" actions seen {sorted(map(str, _seen))}; {len(_bad)} bad: {_bad[:3]}")
|
||||
check("20000 random ticks: every range holds 0 W; Off is [0, 0]; no Solar "
|
||||
"first, refusing, full or unknown battery charges; no Solar first, "
|
||||
"refusing or at-floor battery sells; while one charges no other may "
|
||||
"charge; a charge needs the guard, the headroom (500 W to start) and "
|
||||
"no stop this period, and is sized under the headroom; a sale needs a "
|
||||
"price >= 0 in the sell run", not _bad
|
||||
and {modetrade.CHARGE, modetrade.SELL, None} <= _seen)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the real controller over a day (A Solar first, B Cheap hours + sell)")
|
||||
# Wholesale; all-in (x + 0.12) x 1.06: cheap 0.148, flat 0.339, dear 0.551.
|
||||
# With a cycle cost of 0.20 (0.30 widened) only 19:00-20:00 clears it.
|
||||
DAYW = [0.20] * 96 * 2
|
||||
for i in list(range(4, 12)) + list(range(100, 108)):
|
||||
DAYW[i] = 0.02 # 01:00-03:00 wholesale
|
||||
for i in list(range(76, 80)) + list(range(172, 176)):
|
||||
DAYW[i] = 0.40 # 19:00-20:00
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(DAYW)]
|
||||
|
||||
|
||||
def house_flat(w):
|
||||
return [(UTC0.timestamp() - 10, float(w))]
|
||||
|
||||
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
|
||||
|
||||
def run_day(variant, *, house_w=2600.0, start_h=0.75, hours=21.0,
|
||||
soc0=None, choice=None, refuse=(), n=2, check_cb=None,
|
||||
patch=None, house=None, limits=None):
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None and \
|
||||
c.allocation.shares else {}
|
||||
rows.append(dict(
|
||||
now=now, grid=h.grid(), shares=sh,
|
||||
action=getattr(c.arb, "action", None),
|
||||
winner=c.reason.split(" ->")[0],
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
soc={u: p.soc for u, p in h.plants.items()},
|
||||
p={u: p.p for u, p in h.plants.items()},
|
||||
runs=c.mode_runs, price=pr, why=c.why_words,
|
||||
unmet=getattr(c.allocation, "unmet_w", 0) or 0))
|
||||
if check_cb:
|
||||
check_cb(c, h, now, pr)
|
||||
st, c = rp.run(variant, house or house_flat(house_w), WPTS,
|
||||
UTC0 + timedelta(hours=start_h), 3600 * hours, check=cb,
|
||||
soc0=soc0, choice=choice, refuse=refuse, n=n, patch=patch,
|
||||
extra={"cycle_cost_threshold_eur": 0.20}, limits=limits)
|
||||
return rows, st, c
|
||||
|
||||
|
||||
# Selling is off (owner, 2026-09-29, option A). Its code is kept behind
|
||||
# packmode.SELLING_ENABLED, and these checks turn it on to hold that code to
|
||||
# its invariants; part 12 checks it is unreachable with the flag as shipped.
|
||||
_SELL_SHIPPED = packmode.SELLING_ENABLED
|
||||
packmode.SELLING_ENABLED = True
|
||||
ROWS, ST, _c4 = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0})
|
||||
charge = [r for r in ROWS if r["action"] == arbitrage.CHARGE]
|
||||
sell = [r for r in ROWS if r["action"] == arbitrage.DISCHARGE]
|
||||
print(f" {len(ROWS)} cycles, {len(charge)} charging, {len(sell)} selling; "
|
||||
f"{rp.summary(ST)}")
|
||||
check("B charged from the grid in the cheap run and sold in the dear one, "
|
||||
"and A never took part in either",
|
||||
charge and sell
|
||||
and all(r["roles"][A] in (modetrade.REST, modetrade.HOUSE)
|
||||
for r in charge + sell)
|
||||
and all(r["now"].astimezone(L).hour in (0, 1, 2, 3) for r in charge)
|
||||
and all(r["runs"].cheap.holds(r["now"]) for r in charge)
|
||||
and all(r["now"].astimezone(L).hour == 19 for r in sell))
|
||||
_mixed = [r for r in ROWS if any(v > 0 for v in r["shares"].values())
|
||||
and any(v < 0 for v in r["shares"].values())]
|
||||
check("no opposite-direction split on any cycle (every share one sign)",
|
||||
not _mixed)
|
||||
check("no grid charge on Solar first: while the site charges from the grid, "
|
||||
"A's share is never below 0 W",
|
||||
all(r["shares"].get(A, 0) >= 0 for r in charge))
|
||||
# The quarters the add-on itself banked (the P1 blocks, as the bill reads).
|
||||
_chq = {int(r["now"].timestamp() // 900) for r in charge}
|
||||
_qc = [b.offtake_avg_w for b in _c4.p1.blocks
|
||||
if int(b.start.timestamp() // 900) in _chq]
|
||||
print(f" banked quarters with a grid charge: {len(_qc)}, highest "
|
||||
f"{max(_qc):.2f} W, aim {AIM} W")
|
||||
check("never above the SAFETY-79 aim while grid charging: every banked "
|
||||
"15-minute period with a grid charge is at most cap - margin",
|
||||
_qc and max(_qc) <= AIM + 0.5)
|
||||
check("...and the charge is sized to the headroom, not the rating: under a "
|
||||
"2.6 kW house B charges well under its 2.5 kW",
|
||||
max(-r["shares"].get(B, 0) for r in charge) < 1200)
|
||||
_sold = [r for r in sell]
|
||||
check("no sale below the floor or the spread: every sale cycle has B above "
|
||||
"arbitrage_soc_floor (20 %) and a price at least cheap + required",
|
||||
all(r["soc"]["b"] > 19.9 for r in _sold)
|
||||
and all(r["price"] is not None and rp.all_in(r["price"])
|
||||
>= r["runs"].cheap.mean_eur_kwh + 0.30 - 1e-9 for r in _sold))
|
||||
_hold = [r for r in ROWS if r["roles"].get(B) == modetrade.HOLD]
|
||||
check("outside the expensive hours B holds its power: never gives while "
|
||||
"holding", _hold and all(r["shares"].get(B, 0) <= 0 for r in _hold))
|
||||
_give = [r for r in ROWS if r["roles"].get(B) in (modetrade.GIVE,
|
||||
modetrade.SELL)]
|
||||
check("...and gives only in the expensive hours (19:00-20:00)",
|
||||
all(r["now"].astimezone(L).hour == 19 for r in _give))
|
||||
|
||||
|
||||
def starts(rows, kind):
|
||||
return sum(1 for a, b in zip(rows, rows[1:])
|
||||
if a["action"] != kind and b["action"] == kind)
|
||||
|
||||
|
||||
check("the sale starts once and runs to its end: no on-off while the two "
|
||||
"batteries' 5 kW discharge makes the peak guard read 'engaged'",
|
||||
starts(ROWS, arbitrage.DISCHARGE) == 1)
|
||||
|
||||
# B's inverter can only take 1000 W: the trade asks for that, not its rating.
|
||||
ROWS_U, _, _ = run_day("p3_ch_sell", hours=1.0, house_w=800.0,
|
||||
soc0={"a": 60.0, "b": 30.0},
|
||||
limits={"b": (-1000.0, 1000.0)})
|
||||
_cu = [r for r in ROWS_U if r["action"] == arbitrage.CHARGE]
|
||||
print(f" B limited to 1 kW: charge {min(r['shares'].get(B, 0) for r in _cu)}"
|
||||
f" W, unmet {max(abs(r['unmet']) for r in _cu)} W")
|
||||
check("SAFETY-45 sizing inside the modes: the charge is sized to what the "
|
||||
"batteries may place, so none of it is left unmet (P1's gap)",
|
||||
_cu and all(abs(r["unmet"]) < 1 for r in _cu)
|
||||
and min(r["shares"].get(B, 0) for r in _cu) >= -1000)
|
||||
|
||||
# A house that swings over the limit every minute during the cheap run.
|
||||
_sw = [(UTC0.timestamp() + 60 * k, 2000.0 if k % 2 else 3900.0)
|
||||
for k in range(0, 200)]
|
||||
ROWS_W, _, _ = run_day("p3_ch_sell", hours=1.5, house=_sw,
|
||||
soc0={"a": 60.0, "b": 30.0})
|
||||
_per_q = {}
|
||||
for a_, b_ in zip(ROWS_W, ROWS_W[1:]):
|
||||
if a_["action"] != arbitrage.CHARGE and b_["action"] == arbitrage.CHARGE:
|
||||
q = int(b_["now"].timestamp() // 900)
|
||||
_per_q[q] = _per_q.get(q, 0) + 1
|
||||
print(f" house swinging 2.0/3.9 kW each minute: charge starts per period "
|
||||
f"{sorted(_per_q.values())}")
|
||||
check("a charge the peak guard or the headroom stopped stays off for the "
|
||||
"rest of that 15-minute period (at most one start per period)",
|
||||
_per_q and max(_per_q.values()) == 1)
|
||||
|
||||
# At the floor: B starts the sale just above it.
|
||||
ROWS_F, _, _ = run_day("p3_ch_sell", start_h=18.75, hours=1.5,
|
||||
soc0={"a": 60.0, "b": 24.0})
|
||||
_sf = [r for r in ROWS_F if r["action"] == arbitrage.DISCHARGE]
|
||||
_after = [r for r in ROWS_F if r["now"].astimezone(L).hour == 19
|
||||
and r["soc"]["b"] <= 20.0]
|
||||
_lastf = nth(ROWS_F, -1, "the last cycle")
|
||||
print(f" from 24 %: sold {len(_sf)} cycles, B ends at "
|
||||
f"{_lastf['soc']['b'] if present(_lastf) else _lastf} %")
|
||||
check("the sale stops at arbitrage_soc_floor and does not re-arm in the run",
|
||||
_sf and _after and all(r["action"] != arbitrage.DISCHARGE
|
||||
for r in _after))
|
||||
packmode.SELLING_ENABLED = _SELL_SHIPPED
|
||||
|
||||
# N=1: one Cheap hours battery.
|
||||
ROWS1, _, _ = run_day("p3_ch_sell", n=1, soc0={"a": 30.0},
|
||||
choice={"a": (True, "cheap_hours", False)},
|
||||
hours=3.0)
|
||||
_c1 = [r for r in ROWS1 if r["action"] == arbitrage.CHARGE]
|
||||
check("at one inverter the same rules: it charges in the cheap run, sized "
|
||||
"under the aim", _c1 and max(r["grid"] for r in _c1) < AIM + 400)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. a refusal wins over a run")
|
||||
ROWS_R, _, _ = run_day("p3_ch_sell", hours=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
refuse=("b",))
|
||||
_first = nth(ROWS_R, 0, "the first cycle")
|
||||
_ref = [r for r in ROWS_R if present(_first)
|
||||
and r["now"] > _first["now"] + timedelta(minutes=3)]
|
||||
print(f" B refuses to charge; after 3 min: trades "
|
||||
f"{sorted({str(r['action']) for r in _ref})}, B roles "
|
||||
f"{sorted({r['roles'].get(B) for r in _ref})}")
|
||||
check("B refusing to charge: no grid trade is filed after the refusal is "
|
||||
"learned, B's role is no longer 'charge', and A covers the house",
|
||||
_ref and all(r["action"] is None for r in _ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE for r in _ref)
|
||||
and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. no prices, no gap, Solar first alone")
|
||||
|
||||
|
||||
def _no_plan(c):
|
||||
c.tariff_plan = None
|
||||
c.fresh_tariff_plan = lambda now: None
|
||||
|
||||
|
||||
ROWS_N, _, _ = run_day("p3_ch_sell", hours=1.0, patch=_no_plan,
|
||||
soc0={"a": 60.0, "b": 60.0}, house_w=1500.0)
|
||||
check("no prices: no trade, and Leftover solar / Cheap hours follow the "
|
||||
"house (they give power, not held forever)",
|
||||
all(r["action"] is None for r in ROWS_N)
|
||||
and all(r["roles"].get(B) == modetrade.HOUSE for r in ROWS_N)
|
||||
and any(r["shares"].get(B, 0) > 0 for r in ROWS_N))
|
||||
ROWS_S, _, _ = run_day("p3_ch_sell", hours=21.0,
|
||||
choice={"a": (True, "solar_first", False),
|
||||
"b": (True, "solar_first", True)})
|
||||
check("both batteries Solar first: never a grid charge or a sale all day, "
|
||||
"even with selling switched on",
|
||||
all(r["action"] is None for r in ROWS_S))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. a run edge reseeds the loop")
|
||||
|
||||
|
||||
def _no_reseed(c):
|
||||
c._reseed_run_edge = lambda claims: None
|
||||
|
||||
|
||||
def edge(rows, kind):
|
||||
for i in range(1, len(rows)):
|
||||
if rows[i - 1]["action"] == kind and rows[i]["action"] != kind:
|
||||
w = [r["grid"] for r in rows[i:i + 36]]
|
||||
return min(w), max(w)
|
||||
return None
|
||||
|
||||
|
||||
_e_on = edge(run_day("p3_ch_sell", hours=3.0, house_w=800.0,
|
||||
soc0={"a": 60.0, "b": 70.0})[0], arbitrage.CHARGE)
|
||||
_e_off = edge(run_day("p3_ch_sell", hours=3.0, house_w=800.0,
|
||||
soc0={"a": 60.0, "b": 70.0}, patch=_no_reseed)[0],
|
||||
arbitrage.CHARGE)
|
||||
print(f" grid in the 3 min after a 2.5 kW charge ends, 800 W house: "
|
||||
f"reseeded {_e_on}, not reseeded {_e_off}")
|
||||
check("reseeded, the loop takes over without the import spike the trade's "
|
||||
"watts leave behind (and exports no more than without)",
|
||||
_e_on and _e_off and _e_on[1] < 1000 and _e_off[1] > 2000
|
||||
and _e_on[0] > _e_off[0] - 100)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n8. the site Why row (OPT-11 P1 test, F2)")
|
||||
_cl = [Claim.set("loop", P_LOOP, 1200.0), Claim.limit("peak", P_PEAK,
|
||||
lo=2500.0)]
|
||||
_up = Resolution(2500.0, "loop", bound_by="peak(quarter peak)")
|
||||
_dn = Resolution(800.0, "loop", bound_by="peak(quarter peak)")
|
||||
check("a peak floor that raised the power reads 'raised for the peak limit', "
|
||||
"never 'held back'",
|
||||
why_raised(_up, _cl) is True and "held back" not in messages.why(
|
||||
"loop", 2500.0, "peak(quarter peak)", raised=True)
|
||||
and "raised for the peak limit" in messages.why(
|
||||
"loop", 2500.0, "peak(quarter peak)", raised=True))
|
||||
check("...a limit that lowered it still reads 'held back'",
|
||||
why_raised(_dn, _cl) is False
|
||||
and "held back by" in messages.why("loop", 800.0, "pack"))
|
||||
check("...and a trade the peak floor raised reads the same way",
|
||||
why_raised(Resolution(2000.0, "arbitrage", bound_by="peak"),
|
||||
[Claim.set("arbitrage", P_ARBITRAGE, -1000.0)]) is True)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n9. the selling switch is off (owner decision 2026-09-29, option A)")
|
||||
_st = rp._Store()
|
||||
_st.data[unit_key(B, packmode.SELL_KEY)] = True # an old stored "on"
|
||||
_st.data[unit_key(B, packmode.MODE_KEY)] = packmode.CHEAP_HOURS
|
||||
_ctl = Controller(dict(rp.options(True, False)), rp.Hass(("a", "b"),
|
||||
lambda t: 0.0), _st, rp._Pub(), stop=asyncio.Event())
|
||||
_r9 = [asyncio.run(_ctl.handle_action("pack_sell", unit=B, value=v))
|
||||
for v in (True, False, "yes")]
|
||||
check("pack_sell is refused with the plain reason 'Selling is off for now.' "
|
||||
"and stores nothing",
|
||||
all(x == {"error": messages.pack_refused("sell_off")} for x in _r9)
|
||||
and _st.data[unit_key(B, packmode.SELL_KEY)] is True
|
||||
and not any("selling switched" in str(e) for e in _ctl.events))
|
||||
check("...a stored 'on' reads as false: no battery sells or can, whatever "
|
||||
"the store holds", packmode.SELLING_ENABLED is False
|
||||
and packmode.read(_st.data, B).sell is False
|
||||
and packmode.read(_st.data, B).can_sell is False
|
||||
and all(p.sell is False for p in _ctl._mode_packs()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n10. the Batteries screen")
|
||||
_keys = set(homeview.ROLE_WHY.values()) | {"saves_later", "no_prices",
|
||||
"no_spread"}
|
||||
_en, _nl = homeview.uitext.words("en"), homeview.uitext.words("nl")
|
||||
check("every role's Why line has words in English and Dutch, and they differ",
|
||||
all(k in _en["home_words"]["why"] and k in _nl["home_words"]["why"]
|
||||
and _en["home_words"]["why"][k] != _nl["home_words"]["why"][k]
|
||||
for k in _keys))
|
||||
packmode.SELLING_ENABLED = True # the kept sell code, as in part 4
|
||||
_c10 = run_day("p3_ch_sell", start_h=18.0, hours=0.5,
|
||||
soc0={"a": 60.0, "b": 80.0})[2]
|
||||
_now10 = D0 + timedelta(hours=18, minutes=30)
|
||||
_plan = homeview.mode_runs(_c10, *homeview.local_day(_now10.astimezone(
|
||||
timezone.utc)), _now10.astimezone(timezone.utc), False)
|
||||
packmode.SELLING_ENABLED = _SELL_SHIPPED
|
||||
print(f" plan blocks for B at 18:30: "
|
||||
f"{[(r['from'], r['to'], r['kind'], r['what']) for r in _plan[0]]}")
|
||||
check("the plan draws B's expensive run, the sale inside it, and nothing "
|
||||
"for Solar first A", _plan[0]
|
||||
and {r["battery"] for r in _plan[0]} == {1}
|
||||
and any(r["kind"] == "give" for r in _plan[0]))
|
||||
_sale = [r for r in _plan[0] if r["what"].startswith("Sells")]
|
||||
check("a sale block shows no money: the price shown is what the house pays, "
|
||||
"and a sale earns the lower injection price",
|
||||
_sale and all(r["amount"] is None and r["eur"] is None for r in _sale))
|
||||
_why = homeview.battery_why(
|
||||
modes_on=True, mode=packmode.CHEAP_HOURS, off=False, reading=True,
|
||||
phase="idle", shadow=False, running=True, held=False, refused=0,
|
||||
peak=False, sf_ahead=True, role="hold", has_at=True)
|
||||
check("a holding battery's Why names when it gives power",
|
||||
_why == "saves" and "18:00 - 19:00" in homeview.why_words(
|
||||
_why, at="18:00 - 19:00"))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n11. review additions (PR #153): gaps four mutants survived")
|
||||
import types # noqa: E402
|
||||
import app.main as _main # noqa: E402
|
||||
|
||||
# Modes on with the site switch on: only the modes trade (R13).
|
||||
ROWS_X, _, _ = run_day("p2_arb", hours=3.0, soc0={"a": 30.0, "b": 30.0},
|
||||
choice={"a": (True, "solar_first", False),
|
||||
"b": (True, "solar_first", False)})
|
||||
check("modes on, 'Trade on the price spread' on, both batteries Solar first: "
|
||||
"no trade all through the cheap hours (the site trade is not run)",
|
||||
ROWS_X and all(r["action"] is None for r in ROWS_X)
|
||||
and all(v >= 0 for r in ROWS_X for v in r["shares"].values())
|
||||
and not any(r["winner"].startswith("arbitrage") for r in ROWS_X))
|
||||
|
||||
# A battery in a maintenance phase takes no part in a run (R10).
|
||||
ROWS_M, _, _cm = run_day("p3_ch_sell", hours=0.6, house_w=800.0,
|
||||
soc0={"a": 60.0, "b": 30.0})
|
||||
_lm = nth(ROWS_M, -1, "the last cycle")
|
||||
_nm = _lm["now"] if present(_lm) else None
|
||||
_tm = _cm._mode_roles(_nm, [types.SimpleNamespace(phase=_main.IDLE),
|
||||
types.SimpleNamespace(phase="CHARGE")]) \
|
||||
if _nm is not None else None
|
||||
check("a battery in its monthly cycle is not a charger: no range, no charge "
|
||||
"role, and no grid charge when it is the only price battery",
|
||||
_tm is not None and B not in _tm.ranges
|
||||
and _tm.roles.get(B) != modetrade.CHARGE and _tm.action is None)
|
||||
|
||||
# The headroom stopping a charge latches it off for the period (R8).
|
||||
_cm2 = run_day("p3_ch_sell", hours=0.6, house_w=800.0,
|
||||
soc0={"a": 60.0, "b": 30.0})[2]
|
||||
_ok8 = False
|
||||
if _nm is not None and _cm2.mode_tick is not None \
|
||||
and _cm2.mode_tick.action == modetrade.CHARGE:
|
||||
_q = int(_nm.timestamp() // 900)
|
||||
_cm2.arb_headroom_w = 0.0
|
||||
_t1 = _cm2._mode_roles(_nm, None)
|
||||
_cm2.arb_headroom_w = 3000.0
|
||||
_t2 = _cm2._mode_roles(_nm + timedelta(seconds=5), None)
|
||||
_nq = datetime.fromtimestamp((_q + 1) * 900 + 5, timezone.utc)
|
||||
_cm2.tariff_at = _nq
|
||||
_t3 = _cm2._mode_roles(_nq, None)
|
||||
_ok8 = (_t1.why == "headroom" and _cm2._charge_blocked_q in (_q, _q + 1)
|
||||
and _t2.action is None and _t3.action == modetrade.CHARGE)
|
||||
check("a charge the headroom stopped stays off for the rest of the period, "
|
||||
"even when the headroom comes back, and may start in the next one", _ok8)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n12. option A: no sale, and Leftover solar takes solar only (P4)")
|
||||
ROWS_A, _, _ = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0},
|
||||
choice={"a": (True, "solar_first", False),
|
||||
"b": (True, "cheap_hours", True)})
|
||||
print(f" Cheap hours B with a stored sell 'on': "
|
||||
f"{sum(r['action'] == arbitrage.CHARGE for r in ROWS_A)} charging, "
|
||||
f"{sum(r['action'] == arbitrage.DISCHARGE for r in ROWS_A)} selling")
|
||||
check("with selling on in the store, a whole day has no sale and no sell "
|
||||
"role, while Cheap hours only still charges from the grid",
|
||||
all(r["action"] != arbitrage.DISCHARGE for r in ROWS_A)
|
||||
and all(modetrade.SELL not in r["roles"].values() for r in ROWS_A)
|
||||
and any(r["action"] == arbitrage.CHARGE for r in ROWS_A))
|
||||
ROWS_L, _, _c12 = run_day("p3_ch_sell", soc0={"a": 60.0, "b": 30.0},
|
||||
choice={"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", True)})
|
||||
_lh = [r for r in ROWS_L if r["roles"].get(B) == modetrade.HOLD]
|
||||
_lg = [r for r in ROWS_L if r["roles"].get(B) == modetrade.GIVE]
|
||||
print(f" Leftover solar B: {len(_lh)} holding, {len(_lg)} giving, "
|
||||
f"grid trades {sorted({str(r['action']) for r in ROWS_L})}")
|
||||
check("Leftover solar never charges from the grid before the forecast: no "
|
||||
"grid trade and no charge role all day, in the same cheap run",
|
||||
all(r["action"] is None for r in ROWS_L)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE for r in ROWS_L))
|
||||
check("...it still saves its power and gives it in the expensive hours",
|
||||
_lh and _lg and all(r["now"].astimezone(L).hour == 19 for r in _lg))
|
||||
_pk = [modetrade.Pack("x", True, m, False, 2500.0, 30.0, 10.0)
|
||||
for m in packmode.MODES]
|
||||
check("...only Cheap hours only is a grid charger (packmode.GRID_CHARGE_MODES)",
|
||||
[p.mode for p in modetrade.chargers(_pk, 95.0)]
|
||||
== [packmode.CHEAP_HOURS])
|
||||
_c12b = run_day("p3_ch_sell", start_h=0.75, hours=0.25,
|
||||
soc0={"a": 60.0, "b": 30.0},
|
||||
choice={"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", True)})[2]
|
||||
_n12 = UTC0 + timedelta(hours=1)
|
||||
_plan12 = homeview.mode_runs(_c12b, *homeview.local_day(_n12), _n12,
|
||||
False)[0]
|
||||
print(f" Leftover solar plan at 01:00: "
|
||||
f"{[(r['from'], r['to'], r['kind']) for r in _plan12]}")
|
||||
check("...and the plan draws no grid block for it, only its expensive run",
|
||||
_plan12 and all(r["kind"] != "grid" for r in _plan12))
|
||||
_enw, _nlw = homeview.uitext.words("en"), homeview.uitext.words("nl")
|
||||
check("...and its help text promises no grid charging (EN and NL)",
|
||||
all("cheap" not in t.lower() and "goedkop" not in t.lower()
|
||||
for t in (_enw["home"]["batteries"]["choice"]["leftover_solar"]
|
||||
["description"],
|
||||
_nlw["home"]["batteries"]["choice"]["leftover_solar"]
|
||||
["description"],
|
||||
_enw["home"]["batteries"]["how"]["leftover_solar"]
|
||||
["description"],
|
||||
_nlw["home"]["batteries"]["how"]["leftover_solar"]
|
||||
["description"])))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,800 @@
|
||||
"""Runnable check for OPT-11 P4, the solar forecast and the day plan.
|
||||
`python3 test_modes_p4.py`
|
||||
|
||||
Drives the pure modules (solarfc, dayplan, modetrade) and the REAL Controller
|
||||
(tools/opt11p3_replay.py's engine, with a forecast sensor in the fake Home
|
||||
Assistant), in simulated time. DECISIONS/OPT-11-battery-modes.md has the
|
||||
record.
|
||||
|
||||
1. No usable forecast (none set, or set and unavailable): every command,
|
||||
trade and role is byte-identical to release/1.0 5a4cdc2 (digests measured
|
||||
there), and the harness sees a change there.
|
||||
2. The forecast sensors: the three supported shapes, units, stale, missing.
|
||||
3. The house profile: hours, days kept, the night floor, ready.
|
||||
4. The day plan: sunny, partly, dark; Solar first never gets a level; a
|
||||
Leftover solar level is the shortfall; hysteresis.
|
||||
5. modetrade with a plan: a fuzz against the invariants, and no plan = P3.
|
||||
6. The real controller over a night and a day: sunny (no grid charge),
|
||||
dark (Leftover solar buys the shortfall only, and stops there), Cheap
|
||||
hours skips the part the sun covers, a forecast that goes away mid-run,
|
||||
and every P1-P3 and SAFETY-79 invariant on every cycle.
|
||||
7. The Batteries screen and the Simple view: the forecast line and Why.
|
||||
8. Options: validation of the trust and the entities.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa
|
||||
from app import priceruns, solarfc, validate # noqa: E402
|
||||
from app.tariff import BILLING_TZ # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
D0 = datetime(2026, 9, 21, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
||||
# Printed by tools/opt11p4_identity.py on release/1.0 5a4cdc2 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each, battery modes on.
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"n2_ls_ls":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch":
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"n2_ls_ls_fc_down":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch_fc_down":
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
}
|
||||
import opt11p4_identity as ident # noqa: E402
|
||||
|
||||
got = ident.digests()
|
||||
for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: same digest as 5a4cdc2", got.get(k) == v)
|
||||
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||
got[k] == got[k[:-len('_fc_down')]])
|
||||
|
||||
|
||||
def _moved(scn, patch, undo):
|
||||
patch()
|
||||
try:
|
||||
return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0]) \
|
||||
!= BASE_DIGESTS[scn]
|
||||
finally:
|
||||
undo()
|
||||
|
||||
|
||||
_gcm = modetrade.GRID_CHARGE_MODES
|
||||
check("the harness bites: Leftover solar grid-charging with no plan moves "
|
||||
"n2_sf_ls",
|
||||
_moved("n2_sf_ls",
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES",
|
||||
(packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)),
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm)))
|
||||
_read = solarfc.read
|
||||
|
||||
|
||||
def _as_dark(today, tomorrow, now, **kw):
|
||||
return _read({"state": "0", "attributes": {"unit_of_measurement":
|
||||
"kWh"},
|
||||
"last_updated": now.isoformat()}, None, now)
|
||||
|
||||
|
||||
check("...and an unavailable forecast read as a dark one moves "
|
||||
"n2_sf_ls_fc_down (Leftover solar would buy its shortfall)",
|
||||
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
|
||||
lambda: setattr(solarfc, "read", _read)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. the forecast sensors")
|
||||
NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(state, unit="kWh", at=NOW, **attrs):
|
||||
a = dict(attrs)
|
||||
if unit is not None:
|
||||
a["unit_of_measurement"] = unit
|
||||
return {"state": state, "attributes": a,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
fc, why = solarfc.read(st("12.5"), None, NOW)
|
||||
check("Forecast.Solar shape (state only, kWh): read", fc is not None
|
||||
and abs(fc.today.kwh - 12.5) < 1e-9 and fc.today.periods == ())
|
||||
check("...the rest of the day adds up to the state",
|
||||
abs(fc.between(NOW, UTC0 + timedelta(days=1)) - 12.5) < 1e-6)
|
||||
check("...and nothing comes after sunset (20:00 local)",
|
||||
fc.between(datetime(2026, 9, 21, 20, tzinfo=L), UTC0
|
||||
+ timedelta(days=1)) < 1e-9)
|
||||
fc2, _ = solarfc.read(st("12500", unit="Wh"), None, NOW)
|
||||
fc3, _ = solarfc.read(st("0.0125", unit="MWh"), None, NOW)
|
||||
check("Wh and MWh are converted, never guessed",
|
||||
abs(fc2.today.kwh - 12.5) < 1e-9 and abs(fc3.today.kwh - 12.5) < 1e-9)
|
||||
check("a sensor with no unit, or in W, is refused (unit)",
|
||||
solarfc.read(st("12.5", unit=None), None, NOW) == (None, solarfc.UNIT)
|
||||
and solarfc.read(st("12.5", unit="W"), None, NOW)[1] == solarfc.UNIT)
|
||||
check("unavailable, unknown and a missing entity are no forecast",
|
||||
solarfc.read(st("unavailable"), None, NOW)[1] == solarfc.MISSING
|
||||
and solarfc.read(st("unknown"), None, NOW)[1] == solarfc.MISSING
|
||||
and solarfc.read(None, None, NOW)[1] == solarfc.MISSING)
|
||||
check("a negative or non-numeric state is no forecast",
|
||||
solarfc.read(st("-1"), None, NOW)[1] == solarfc.BAD
|
||||
and solarfc.read(st("lots"), None, NOW)[1] == solarfc.BAD)
|
||||
old = NOW - timedelta(seconds=solarfc.FRESH_S + 1)
|
||||
check("older than 3 h is stale, and stale is no forecast",
|
||||
solarfc.read(st("12.5", at=old), None, NOW) == (None, solarfc.STALE)
|
||||
and solarfc.read(st("12.5", at=NOW - timedelta(hours=2, minutes=59)),
|
||||
None, NOW)[0] is not None)
|
||||
check("a report from the future is stale too",
|
||||
solarfc.read(st("12.5", at=NOW + timedelta(hours=1)), None,
|
||||
NOW)[1] == solarfc.STALE)
|
||||
check("not configured says so", solarfc.read(None, None, NOW,
|
||||
configured=False)[1]
|
||||
== solarfc.NOT_SET)
|
||||
# Solcast: detailedForecast, kW per 30 min, the whole day.
|
||||
sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(),
|
||||
"pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)]
|
||||
fc4, _ = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)
|
||||
# 10:00-18:00 local at 2 kW = 16 kWh in all; from 09:00 all 16 is to come,
|
||||
# scaled to the state's 8.
|
||||
check("Solcast shape: periods read and scaled to the state (kWh left)",
|
||||
fc4.today.periods and abs(fc4.between(NOW, UTC0 + timedelta(days=1))
|
||||
- 8.0) < 1e-6)
|
||||
check("...the split is used: 10:00-12:00 is a quarter of the day's sun",
|
||||
abs(fc4.between(datetime(2026, 9, 21, 10, tzinfo=L),
|
||||
datetime(2026, 9, 21, 12, tzinfo=L)) - 2.0) < 1e-6)
|
||||
# Open-Meteo: wh_period, Wh per hour.
|
||||
wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16
|
||||
else 0.0)
|
||||
for h in range(24)}
|
||||
fc5, _ = solarfc.read(st("7.0", wh_period=wh), None, NOW)
|
||||
check("Open-Meteo shape: wh_period read (8 x 1 kWh, 7 left at 09:00)",
|
||||
abs(fc5.between(NOW, UTC0 + timedelta(days=1)) - 7.0) < 1e-6
|
||||
and abs(fc5.between(datetime(2026, 9, 21, 14, tzinfo=L),
|
||||
datetime(2026, 9, 21, 16, tzinfo=L)) - 2.0) < 1e-6)
|
||||
TOM = UTC0 + timedelta(days=1)
|
||||
check("without tomorrow's sensor, a span into tomorrow's sun is unknown",
|
||||
fc.between(NOW, TOM + timedelta(hours=9)) is None
|
||||
and fc.between(NOW, TOM + timedelta(hours=5)) is not None)
|
||||
fct, _ = solarfc.read(st("12.5"), st("20.0"), NOW)
|
||||
check("with it, tomorrow's sun is counted",
|
||||
abs(fct.between(TOM, TOM + timedelta(days=1)) - 20.0) < 1e-6)
|
||||
fcs, _ = solarfc.read(st("12.5"), st("20.0", at=old), NOW)
|
||||
check("a stale tomorrow is dropped, today is kept",
|
||||
fcs is not None and fcs.tomorrow is None)
|
||||
# Review (PR #156): just after local midnight the integration may not have
|
||||
# refreshed yet, so "left today" is still yesterday's 0 and "tomorrow" is
|
||||
# really today. That is stale, not a dark day.
|
||||
MID = datetime(2026, 9, 22, 0, 10, tzinfo=L).astimezone(timezone.utc)
|
||||
EVE = datetime(2026, 9, 21, 21, 30, tzinfo=L).astimezone(timezone.utc)
|
||||
check("a today reading from before local midnight is stale (not 0 kWh)",
|
||||
solarfc.read(st("0.0", at=EVE), st("15", at=EVE), MID)
|
||||
== (None, solarfc.STALE))
|
||||
fcm, _ = solarfc.read(st("15", at=MID - timedelta(minutes=2)),
|
||||
st("18", at=EVE), MID)
|
||||
check("...and a tomorrow reading from before midnight is dropped",
|
||||
fcm is not None and fcm.tomorrow is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. the house profile")
|
||||
|
||||
|
||||
def profile(day_w=None, days=1, evening=None, start=UTC0 - timedelta(days=1)):
|
||||
hp = dayplan.HouseProfile()
|
||||
t = start
|
||||
end = start + timedelta(days=days)
|
||||
while t < end:
|
||||
h = t.astimezone(L).hour
|
||||
w = (evening if evening is not None and 17 <= h < 22 else
|
||||
day_w if day_w is not None else 600.0)
|
||||
hp.add(t, w)
|
||||
t += timedelta(seconds=5)
|
||||
hp.add(t, None)
|
||||
return hp
|
||||
|
||||
|
||||
hp = profile(day_w=600.0, evening=3000.0)
|
||||
check("a full day recorded is ready, with 24 hours",
|
||||
hp.ready() and sum(len(r) for r in hp.days.values()) == 24)
|
||||
check("an hour's figure is its mean", abs(hp.hour_w(18) - 3000.0) < 1e-6
|
||||
and abs(hp.hour_w(3) - 600.0) < 1e-6)
|
||||
hp_half = dayplan.HouseProfile()
|
||||
for i in range(12 * 60 * 10):
|
||||
hp_half.add(UTC0 + timedelta(seconds=5 * i), 500.0)
|
||||
check("ten hours are not ready (MIN_HOURS)", not hp_half.ready())
|
||||
neg = profile(day_w=-2000.0)
|
||||
f, base = neg.estimator()
|
||||
check("a sunny net use (negative) is lifted to the night floor for the "
|
||||
"solar sum", f(12) == -2000.0 and base == -2000.0 or base >= 0.0)
|
||||
hp9 = profile(days=9)
|
||||
check("only the last 7 days are kept", len(hp9.days) <= 8)
|
||||
hp_rt = dayplan.HouseProfile(hp.dump())
|
||||
check("dump and load give the same figures", hp_rt.hour_w(18) == round(
|
||||
hp.hour_w(18), 1) and hp_rt.ready())
|
||||
bogus = dayplan.HouseProfile({"2099-01-01": {str(h): 9e5 for h in range(24)},
|
||||
"zz": {str(h): 9e5 for h in range(24)}})
|
||||
check("a stored day that is not a date is dropped on load",
|
||||
"zz" not in bogus.days)
|
||||
bogus.add(UTC0, 500.0)
|
||||
bogus.add(UTC0 + timedelta(hours=1), 500.0)
|
||||
check("a stored day after today (a wrong clock) is pruned, not kept forever",
|
||||
"2099-01-01" not in bogus.days)
|
||||
check("a bad stored profile is ignored, not fatal",
|
||||
not dayplan.HouseProfile({"x": "y", "2026-09-20": {"99": 1,
|
||||
"a": "b"}}).ready())
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. the day plan")
|
||||
P = modetrade.Pack
|
||||
|
||||
|
||||
def pk(name, mode, soc, cap=10.0, **kw):
|
||||
return P(name, True, mode, False, 2500.0, soc, cap, **kw)
|
||||
|
||||
|
||||
# Prices: cheap 01:00-03:00, dear 19:00-20:00 local (as in test_modes_p3).
|
||||
DAYW = [0.20] * 96 * 2
|
||||
for i in list(range(4, 12)) + list(range(100, 108)):
|
||||
DAYW[i] = 0.02
|
||||
for i in list(range(76, 80)) + list(range(172, 176)):
|
||||
DAYW[i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(DAYW)]
|
||||
from app.price import PricePoint # noqa: E402
|
||||
|
||||
|
||||
def runs_at(now):
|
||||
pts = [PricePoint(start=s, eur_per_kwh=rp.all_in(p), provisional=False)
|
||||
for s, _n, p in WPTS]
|
||||
return priceruns.plan(pts, now, required_eur_kwh=0.30, charge_kwh=6.0,
|
||||
charge_kw=2.5, sell_kwh=0.0, sell_kw=0.0)
|
||||
|
||||
|
||||
T0 = datetime(2026, 9, 21, 0, 30, tzinfo=L).astimezone(timezone.utc)
|
||||
R0 = runs_at(T0)
|
||||
check("the price plan has a cheap run at night and a dear one at 19:00",
|
||||
R0.on and R0.cheap.holds(datetime(2026, 9, 21, 1, 30, tzinfo=L))
|
||||
and any(g.start.astimezone(L).hour == 19 for g in R0.give))
|
||||
|
||||
|
||||
def fc_of(kwh, now=T0):
|
||||
wh = {(UTC0 + timedelta(hours=h)).isoformat():
|
||||
(kwh * 1000.0 / 10 if 8 <= h < 18 else 0.0) for h in range(24)}
|
||||
return solarfc.read(st(f"{kwh}", wh_period=wh, at=now), None, now)[0]
|
||||
|
||||
|
||||
EST = profile(day_w=600.0, evening=3000.0).estimator()
|
||||
PACKS = [pk(A, packmode.SOLAR_FIRST, 60.0), pk(B, packmode.LEFTOVER_SOLAR,
|
||||
30.0)]
|
||||
|
||||
|
||||
def dplan(kwh, packs=PACKS, trust=70, est=EST):
|
||||
return dayplan.plan(fc_of(kwh), est, R0, T0, packs, trust_pct=trust,
|
||||
floor_pct=20.0, ceiling_pct=95.0)
|
||||
|
||||
|
||||
sunny, partly, dark = dplan(40.0), dplan(12.0), dplan(1.0)
|
||||
print(f" sunny {sunny.verdict} {sunny.targets}; partly {partly.verdict} "
|
||||
f"{partly.targets}; dark {dark.verdict} {dark.targets}")
|
||||
check("40 kWh forecast: sunny, and B needs nothing from the grid",
|
||||
sunny.verdict == dayplan.SUNNY and sunny.need_kwh[B] == 0.0)
|
||||
check("1 kWh forecast: dark (not even A fills from the sun)",
|
||||
dark.verdict == dayplan.DARK)
|
||||
check("Solar first never gets a level (never charges from the grid)",
|
||||
all(A not in p.targets for p in (sunny, partly, dark)))
|
||||
_use = 3000.0 / 1000.0 # the 19:00-20:00 run at 3 kW
|
||||
_want = 20.0 + _use / dayplan.EFF / 10.0 * 100.0
|
||||
check("dark: B's level is what the house draws in the dear run (floor + "
|
||||
f"use / eff = {_want:.1f} %), not the 95 % ceiling",
|
||||
abs(dark.targets[B] - _want) < 0.1 and dark.targets[B] < 95.0)
|
||||
check("...so it buys only the shortfall", abs(dark.need_kwh[B] - (
|
||||
_want - 30.0) / 10.0) < 0.01)
|
||||
check("the level falls as the forecast rises (partly between dark and "
|
||||
"sunny)", sunny.targets[B] <= partly.targets[B] <= dark.targets[B])
|
||||
ch = dplan(12.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0)])
|
||||
ch_dark = dplan(1.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0)])
|
||||
check("...and on a partly sunny day too: Cheap hours ignores the plan "
|
||||
"(design section 1; review of PR #156)", B not in ch.targets
|
||||
and B not in ch_dark.targets
|
||||
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, 30.0), 95.0,
|
||||
{B: 40.0}) == 95.0)
|
||||
# Review of PR #156: four mutants that survived.
|
||||
_hi = {h: (3000.0 if 17 <= h < 22 else 2000.0 if 8 <= h < 17 else 600.0)
|
||||
for h in range(24)}
|
||||
check("a house that uses more by day leaves less sun: a higher level",
|
||||
dplan(20.0, est=(lambda h: _hi[h], 600.0)).targets[B]
|
||||
> dplan(20.0).targets[B] + 1.0)
|
||||
_two = dplan(40.0, packs=[pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
pk(B, packmode.LEFTOVER_SOLAR, 30.0),
|
||||
pk("inverter-c", packmode.LEFTOVER_SOLAR, 80.0)])
|
||||
check("two Leftover solar batteries share the sun by their room (65:15)",
|
||||
abs(_two.share_kwh[B] / _two.share_kwh["inverter-c"] - 6.5 / 1.5)
|
||||
< 0.01)
|
||||
_h = lambda hh: datetime(2026, 9, 21, hh, tzinfo=L).astimezone(timezone.utc)
|
||||
_rp = priceruns.RunPlan(priceruns.Run(_h(13), _h(14), 0.02),
|
||||
(priceruns.Run(_h(8), _h(9), 0.4),
|
||||
priceruns.Run(_h(19), _h(20), 0.4)),
|
||||
None, 0.3, None, True)
|
||||
_pl = dayplan.plan(fc_of(12.0), EST, _rp, T0, PACKS, trust_pct=70,
|
||||
floor_pct=20.0, ceiling_pct=95.0)
|
||||
check("the plan is for the dear run after the cheap run, not an earlier one",
|
||||
_pl is not None and _pl.until == _h(19))
|
||||
_hp = dayplan.HouseProfile()
|
||||
for i in range(30):
|
||||
_hp.add(_h(10) + timedelta(seconds=5 * i), 500.0)
|
||||
_hp.add(_h(11), 500.0)
|
||||
check("an hour with under 5 minutes of readings is not kept",
|
||||
not any(10 in r for r in _hp.days.values()))
|
||||
check("trust 100 % counts more sun than 70 %: a lower level",
|
||||
dplan(12.0, trust=100).targets[B] < partly.targets[B])
|
||||
check("...and the sun counted scales with the trust exactly",
|
||||
abs(dplan(12.0, trust=100).sun_kwh * 0.7 - partly.sun_kwh) < 0.01)
|
||||
_room_a = (95.0 - 60.0) / 10.0 * 10.0 / 10.0 # A: 60 % -> 95 % of 10 kWh
|
||||
check("Solar first takes the sun first: B's share is what is left after "
|
||||
f"A's {_room_a:.1f} kWh of room",
|
||||
abs(partly.share_kwh[B] - max(0.0, partly.sun_kwh - _room_a)) < 0.01
|
||||
and partly.share_kwh[B] < partly.sun_kwh)
|
||||
_flat = (lambda h: 600.0, 600.0)
|
||||
_sunny_net = (lambda h: -2000.0 if 9 <= h < 17 else 600.0, 600.0)
|
||||
check("the house's net use in sunny hours (negative) is lifted to the night "
|
||||
"floor: the same sun as a flat 600 W house",
|
||||
abs(dplan(12.0, est=_flat).sun_kwh - dplan(12.0, est=_sunny_net)
|
||||
.sun_kwh) < 1e-6)
|
||||
check("no forecast, no house profile or no price plan: no plan",
|
||||
dayplan.plan(None, EST, R0, T0, PACKS, trust_pct=70, floor_pct=20,
|
||||
ceiling_pct=95) is None
|
||||
and dplan(12.0, est=None) is None
|
||||
and dayplan.plan(fc_of(12.0), EST, None, T0, PACKS, trust_pct=70,
|
||||
floor_pct=20, ceiling_pct=95) is None)
|
||||
# A plan for a run beyond today's forecast (after 20:00, cheap run tomorrow
|
||||
# night, give run tomorrow evening) with no tomorrow sensor: no plan.
|
||||
T_EVE = datetime(2026, 9, 21, 21, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
check("the sun before the dear run is unknown (no tomorrow sensor): no plan",
|
||||
dayplan.plan(fc_of(12.0, T_EVE), EST, runs_at(T_EVE), T_EVE, PACKS,
|
||||
trust_pct=70, floor_pct=20, ceiling_pct=95) is None)
|
||||
off = [pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
P(B, False, packmode.LEFTOVER_SOLAR, False, 2500.0, 30.0, 10.0)]
|
||||
check("an Off battery gets no level", B not in dplan(1.0, packs=off).targets)
|
||||
maint = [pk(A, packmode.SOLAR_FIRST, 60.0),
|
||||
pk(B, packmode.LEFTOVER_SOLAR, 30.0, idle=False)]
|
||||
check("a battery in maintenance gets no level",
|
||||
B not in dplan(1.0, packs=maint).targets)
|
||||
# Hysteresis.
|
||||
cap = {B: 10.0}
|
||||
check("hysteresis: a 0.3 kWh move on a 40 % level is ignored",
|
||||
dayplan.hold({B: 40.0}, {B: 43.0}, cap) == {B: 40.0})
|
||||
check("...a 1 kWh move is taken", dayplan.hold({B: 40.0}, {B: 50.0}, cap)
|
||||
== {B: 50.0})
|
||||
check("...10 % of a small level is enough (0.3 kWh on a 25 % level)",
|
||||
dayplan.hold({B: 25.0}, {B: 28.0}, cap) == {B: 28.0})
|
||||
check("...a battery new to the plan takes its level, one gone is dropped",
|
||||
dayplan.hold({A: 50.0}, {B: 33.0}, cap) == {B: 33.0})
|
||||
_flips = 0
|
||||
_kept = None
|
||||
rnd = random.Random(4)
|
||||
for i in range(400):
|
||||
lvl = 40.0 + rnd.uniform(-2.0, 2.0) # a noisy forecast
|
||||
new = dayplan.hold(_kept, {B: lvl}, cap)
|
||||
if _kept is not None and new != _kept:
|
||||
_flips += 1
|
||||
_kept = new
|
||||
check(f"a forecast wobbling by +-0.2 kWh moves the level rarely "
|
||||
f"({_flips} of 400)", _flips <= 8)
|
||||
|
||||
# ============================================================================ #
|
||||
print("5. modetrade with a plan")
|
||||
|
||||
|
||||
def p3_chargers(packs, ceiling, done=()):
|
||||
"""P3 option A's rule, as 5a4cdc2 wrote it."""
|
||||
return [p for p in packs if p.use and p.idle and p.live
|
||||
and p.mode in packmode.GRID_CHARGE_MODES and p.soc is not None
|
||||
and p.soc < ceiling and p.refused >= 0 and p.name not in done]
|
||||
|
||||
|
||||
rnd = random.Random(11)
|
||||
bad = []
|
||||
same = 0
|
||||
for i in range(20000):
|
||||
packs = [P(n, rnd.random() > 0.1, rnd.choice(packmode.MODES), False,
|
||||
2500.0, rnd.choice([None, rnd.uniform(0, 100)]),
|
||||
rnd.choice([None, 10.0, 5.0]), live=rnd.random() > 0.05,
|
||||
idle=rnd.random() > 0.1, refused=rnd.choice([0, 0, 0, 1, -1]))
|
||||
for n in (A, B, "c")]
|
||||
ceiling = rnd.choice([90.0, 95.0, 100.0])
|
||||
if [p.name for p in modetrade.chargers(packs, ceiling)] == \
|
||||
[p.name for p in p3_chargers(packs, ceiling)]:
|
||||
same += 1
|
||||
targets = {p.name: rnd.uniform(0, 120) for p in packs
|
||||
if rnd.random() > 0.3}
|
||||
ch = modetrade.chargers(packs, ceiling, targets=targets)
|
||||
for p in ch:
|
||||
to = modetrade.charge_to(p, ceiling, targets)
|
||||
if p.mode == packmode.SOLAR_FIRST or to is None or to > ceiling \
|
||||
or p.soc >= to or p.refused < 0 or not (p.use and p.idle
|
||||
and p.live):
|
||||
bad.append((p, targets, ceiling))
|
||||
if p.mode == packmode.LEFTOVER_SOLAR and p.name not in targets:
|
||||
bad.append(("LS without a level", p))
|
||||
check("no plan: chargers() is P3 option A's rule on 20 000 random rosters",
|
||||
same == 20000)
|
||||
check("with a plan: never Solar first, never above the ceiling or the "
|
||||
"level, never refusing, Off, not live or in maintenance, and Leftover "
|
||||
f"solar only with a level ({len(bad)} bad)", not bad)
|
||||
# decide() with a plan, in the cheap run: every range holds 0, only chargers
|
||||
# charge, SF never charges.
|
||||
now_c = R0.cheap.start + timedelta(minutes=5)
|
||||
bad = []
|
||||
for i in range(5000):
|
||||
packs = [P(n, rnd.random() > 0.1, rnd.choice(packmode.MODES), False,
|
||||
2500.0, rnd.uniform(0, 100), 10.0, live=rnd.random() > 0.05,
|
||||
idle=rnd.random() > 0.1, refused=rnd.choice([0, 0, 1, -1]))
|
||||
for n in (A, B)]
|
||||
targets = {p.name: rnd.uniform(0, 100) for p in packs}
|
||||
t = modetrade.decide(R0, now_c, packs, charge_ok=True, sell_ok=True,
|
||||
trade_why=None, price_now=0.1, headroom_w=3000.0,
|
||||
floor_pct=20.0, ceiling_pct=95.0, house_w=500.0,
|
||||
targets=targets)
|
||||
for p in packs:
|
||||
lo, hi = t.ranges.get(p.name, (0.0, 0.0))
|
||||
if not lo <= 0.0 <= hi:
|
||||
bad.append(("range without 0", p, lo, hi))
|
||||
if t.roles[p.name] == modetrade.CHARGE and (
|
||||
p.mode == packmode.SOLAR_FIRST
|
||||
or p.soc >= (95.0 if p.mode == packmode.CHEAP_HOURS
|
||||
else min(95.0, targets[p.name]))):
|
||||
bad.append(("charges past its level", p, targets))
|
||||
check(f"decide() with a plan on 5000 random cases: ranges hold 0, nothing "
|
||||
f"charges at or past its level, Solar first never ({len(bad)} bad)",
|
||||
not bad)
|
||||
t_ls = modetrade.decide(R0, now_c, [pk(A, packmode.SOLAR_FIRST, 50.0),
|
||||
pk(B, packmode.LEFTOVER_SOLAR, 30.0)],
|
||||
charge_ok=True, sell_ok=True, trade_why=None,
|
||||
price_now=0.1, headroom_w=3000.0, floor_pct=20.0,
|
||||
ceiling_pct=95.0, house_w=500.0,
|
||||
targets={B: 50.0})
|
||||
check("a Leftover solar battery below its level charges in the cheap run, "
|
||||
"Solar first rests", t_ls.roles == {A: modetrade.REST,
|
||||
B: modetrade.CHARGE})
|
||||
e = modetrade.energy([pk(B, packmode.LEFTOVER_SOLAR, 30.0)], 20.0, 95.0,
|
||||
targets={B: 45.0})
|
||||
check("the cheap run is sized by the shortfall (1.5 kWh), not the room to "
|
||||
"95 % (6.5 kWh)", abs(e[0] - 1.5) < 1e-9)
|
||||
|
||||
# ============================================================================ #
|
||||
print("6. the real controller over a night and a day")
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
FC_KWH = [None] # today's forecast in the fake sensor (None: down)
|
||||
|
||||
|
||||
async def _sensor(self, e):
|
||||
now = rp_start[0] + timedelta(seconds=self.t)
|
||||
kwh = FC_KWH[0](now) if callable(FC_KWH[0]) else FC_KWH[0]
|
||||
if kwh is None:
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": now.isoformat()}
|
||||
if e.endswith("tomorrow"):
|
||||
return None
|
||||
loc = now.astimezone(L)
|
||||
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
|
||||
(kwh * 100.0 if 8 <= h < 18 else 0.0) for h in range(24)}
|
||||
left = sum(v for k, v in wh.items()
|
||||
if datetime.fromisoformat(k) >= now) / 1000.0
|
||||
return {"state": f"{left:.3f}",
|
||||
"attributes": {"unit_of_measurement": "kWh", "wh_period": wh},
|
||||
"last_updated": now.isoformat(), "last_reported": now.isoformat()}
|
||||
|
||||
|
||||
rp.Hass.state = _sensor
|
||||
rp_start = [UTC0]
|
||||
FC_OPTS = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"cycle_cost_threshold_eur": 0.20}
|
||||
|
||||
|
||||
def seed_profile(c):
|
||||
if c.house_profile is not None:
|
||||
c.house_profile = profile(day_w=600.0, evening=3000.0)
|
||||
|
||||
|
||||
def house_flat(w):
|
||||
return [(UTC0.timestamp() - 10, float(w))]
|
||||
|
||||
|
||||
def run_day(choice, *, fc, soc0, hours=21.0, start_h=0.75, house_w=800.0,
|
||||
refuse=(), extra=None, patch=seed_profile):
|
||||
FC_KWH[0] = fc
|
||||
start = UTC0 + timedelta(hours=start_h)
|
||||
rp_start[0] = start
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None and \
|
||||
c.allocation.shares else {}
|
||||
rows.append(dict(
|
||||
now=now, grid=h.grid(), shares=sh,
|
||||
action=getattr(c.arb, "action", None),
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
soc={u: p.soc for u, p in h.plants.items()},
|
||||
targets=c.plan_targets(), plan=c.day_plan, runs=c.mode_runs))
|
||||
st_, c = rp.run("p3_ls", house_flat(house_w), WPTS, start, 3600 * hours,
|
||||
check=cb, soc0=soc0, choice=choice, refuse=refuse,
|
||||
extra=dict(FC_OPTS, **(extra or {})), patch=patch)
|
||||
return rows, st_, c
|
||||
|
||||
|
||||
SF_LS = {"a": (True, "solar_first", False), "b": (True, "leftover_solar",
|
||||
False)}
|
||||
SF_CH = {"a": (True, "solar_first", False), "b": (True, "cheap_hours",
|
||||
False)}
|
||||
|
||||
|
||||
def invariants(tag, rows, c):
|
||||
charge = [r for r in rows if r["action"] == arbitrage.CHARGE]
|
||||
mixed = [r for r in rows if any(v > 0 for v in r["shares"].values())
|
||||
and any(v < 0 for v in r["shares"].values())]
|
||||
check(f"{tag}: no opposite-direction split on any cycle", not mixed)
|
||||
check(f"{tag}: Solar first never charges from the grid (A's share >= 0 "
|
||||
"while the site charges)",
|
||||
all(r["shares"].get(A, 0) >= 0 for r in charge))
|
||||
q = {int(r["now"].timestamp() // 900) for r in charge}
|
||||
banked = [b.offtake_avg_w for b in c.p1.blocks
|
||||
if int(b.start.timestamp() // 900) in q]
|
||||
check(f"{tag}: every banked quarter with a grid charge <= cap - margin "
|
||||
f"(max {max(banked, default=0):.1f} W, aim {AIM})",
|
||||
not banked or max(banked) <= AIM + 0.5)
|
||||
return charge
|
||||
|
||||
|
||||
# Sunny: 40 kWh forecast. B (Leftover solar) at 30 % needs nothing.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("sunny", rows, c)
|
||||
_last = nth(rows, -1, "a cycle")
|
||||
print(f" sunny: verdict {present(_last) and _last['plan'] and _last['plan'].verdict}, "
|
||||
f"{len(charge)} charge cycles")
|
||||
check("sunny: a day plan is made, and says sunny",
|
||||
any(r["plan"] is not None and r["plan"].verdict == dayplan.SUNNY
|
||||
for r in rows))
|
||||
check("sunny: no grid charge at all", not charge)
|
||||
|
||||
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("dark", rows, c)
|
||||
tgt = nth([r["targets"][B] for r in rows if r["targets"]], 0, "a level")
|
||||
top = max(r["soc"]["b"] for r in rows)
|
||||
print(f" dark: B level {tgt:.1f} %, B reached {top:.1f} %, "
|
||||
f"{len(charge)} charge cycles")
|
||||
check("dark: Leftover solar charges from the grid in the cheap run",
|
||||
charge and all(r["runs"].cheap.holds(r["now"]) for r in charge)
|
||||
and all(r["roles"][B] == modetrade.CHARGE for r in charge))
|
||||
check("dark: ...only up to its level (the shortfall), not 95 %",
|
||||
present(tgt) and tgt < 60.0 and top <= tgt + 2.0)
|
||||
check("dark: A rests while B charges", all(r["roles"][A] == modetrade.REST
|
||||
for r in charge))
|
||||
|
||||
# Cheap hours on a partly sunny day: ignores the plan, fills as in P3.
|
||||
rows, st_, c = run_day(SF_CH, fc=14.0, soc0={"a": 60.0, "b": 30.0})
|
||||
charge = invariants("cheap hours, partly", rows, c)
|
||||
top = max(r["soc"]["b"] for r in rows)
|
||||
print(f" cheap hours partly: B reached {top:.1f} %")
|
||||
check("Cheap hours gets no planned level",
|
||||
not any(r["targets"] and B in r["targets"] for r in rows))
|
||||
rows_nf, _s, c_nf = run_day(SF_CH, fc=None, soc0={"a": 60.0, "b": 30.0})
|
||||
check("Cheap hours with a sun forecast charges as with none (P3)",
|
||||
charge and abs(max(r["soc"]["b"] for r in rows_nf) - top) < 1.0
|
||||
and top > 80.0)
|
||||
|
||||
# The forecast goes away mid-run: Leftover solar stops at once (P3 option A).
|
||||
DROP = UTC0 + timedelta(hours=1, minutes=30)
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: 1.0 if now < DROP else None,
|
||||
soc0={"a": 60.0, "b": 10.0})
|
||||
invariants("forecast lost mid-run", rows, c)
|
||||
before = [r for r in rows if r["now"] < DROP and r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
late = [r for r in rows if r["now"] > DROP + timedelta(
|
||||
seconds=2 * 60 + 10) and r["action"] == arbitrage.CHARGE]
|
||||
check("forecast lost mid-run: Leftover solar was charging, and stops within "
|
||||
"a forecast read (60 s) and a cycle", before and not late)
|
||||
|
||||
# The forecast is far too high (40 kWh, and there is no sun at all): no grid
|
||||
# charge, so B covers less of the evening - a money risk, never a safety one.
|
||||
rows, st_, c = run_day(SF_LS, fc=40.0, soc0={"a": 30.0, "b": 30.0})
|
||||
invariants("forecast too high", rows, c)
|
||||
check("forecast too high: no grid charge, and B still keeps its power for "
|
||||
"the dear run", not [r for r in rows if r["action"]
|
||||
== arbitrage.CHARGE]
|
||||
and all(r["roles"].get(B) != modetrade.GIVE for r in rows
|
||||
if r["now"].astimezone(L).hour not in (19,)))
|
||||
|
||||
# A refusal wins over the plan.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
refuse=("b",))
|
||||
invariants("refusal", rows, c)
|
||||
check("refusal: B refuses to charge, so the plan's charge ends and B is "
|
||||
"never a charger after it", rows and not [
|
||||
r for r in rows[len(rows) // 3:]
|
||||
if r["roles"].get(B) == modetrade.CHARGE])
|
||||
|
||||
# Off wins over the plan.
|
||||
rows, st_, c = run_day({"a": (True, "solar_first", False),
|
||||
"b": (False, "leftover_solar", False)},
|
||||
fc=1.0, soc0={"a": 60.0, "b": 30.0})
|
||||
check("Off: an Off battery never charges from the grid, whatever the plan",
|
||||
not [r for r in rows if r["action"] == arbitrage.CHARGE]
|
||||
and all(abs(r["shares"].get(B, 0)) < 1e-6 for r in rows))
|
||||
|
||||
# No house profile yet (first day): no plan, P3 option A.
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
patch=None)
|
||||
check("no house profile yet: no plan, and Leftover solar takes solar only",
|
||||
all(r["plan"] is None for r in rows)
|
||||
and not [r for r in rows if r["action"] == arbitrage.CHARGE])
|
||||
check("...while the profile learns and the store keeps each closed hour",
|
||||
c.house_profile is not None and len(
|
||||
c.store.data.get("house_profile") or {}) >= 1)
|
||||
check("selling stays off (option A)", packmode.SELLING_ENABLED is False)
|
||||
|
||||
# A forecast arriving mid-period makes the plan at once, not at the next
|
||||
# 15-minute period.
|
||||
COME = datetime(2026, 9, 21, 1, 3, tzinfo=L).astimezone(timezone.utc)
|
||||
rows, st_, c = run_day(SF_LS, fc=lambda now: None if now < COME else 1.0,
|
||||
soc0={"a": 60.0, "b": 30.0}, hours=0.5)
|
||||
first = next((r["now"] for r in rows if r["targets"]), None)
|
||||
check("a forecast that arrives at 01:03 makes the plan before 01:15",
|
||||
first is not None and first < COME + timedelta(minutes=3))
|
||||
|
||||
# Battery modes off: the forecast is never read.
|
||||
READS = [0]
|
||||
_real_sensor = rp.Hass.state
|
||||
|
||||
|
||||
async def _counted(self, e):
|
||||
READS[0] += 1
|
||||
return await _real_sensor(self, e)
|
||||
rp.Hass.state = _counted
|
||||
FC_KWH[0] = 1.0
|
||||
rp.run("modes_off", house_flat(800.0), WPTS, UTC0 + timedelta(hours=1),
|
||||
600.0, extra=dict(FC_OPTS))
|
||||
rp.Hass.state = _real_sensor
|
||||
check("battery modes off: the forecast sensor is never read", READS[0] == 0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("7. the Batteries screen and the Simple view")
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
hours=0.6)
|
||||
rp.m.datetime = datetime
|
||||
sb = homeview.solar_block(c, c.solar_fc.at)
|
||||
check("the forecast line names the forecast, what is counted and when",
|
||||
sb["line"].startswith("Solar still to come today:")
|
||||
and "we count on" in sb["line"] and sb["state"] == "plan")
|
||||
why = homeview.day_key(c, B)
|
||||
check("B's Why key (plan, dark) is tops_up", why == "tops_up")
|
||||
check("...and says so in plain words", homeview.why_words(why)
|
||||
== "Too little sun is forecast, so it charges some from the grid.")
|
||||
_d0, _d1 = homeview.local_day(c.solar_fc.at)
|
||||
_blocks, _ = homeview.mode_runs(c, _d0, _d1, c.solar_fc.at, False)
|
||||
_grid = [b for b in _blocks if b["kind"] == "grid" and b["battery"] == 1]
|
||||
_need = homeview.plan_need(c, B)
|
||||
check(f"Today: B's grid block is the planned shortfall ({_need:.2f} kWh), "
|
||||
"not its room to 95 %", _grid and abs(_grid[0]["kwh"] - _need) < 0.01
|
||||
and _need < 3.0)
|
||||
_run = c.mode_runs.cheap
|
||||
_packs = c._mode_packs()
|
||||
_m1 = c._run_mark("charge", _run, _packs, lambda p: True, ((B, 40.0),))
|
||||
_m2 = c._run_mark("charge", _run, _packs, lambda p: False, ((B, 50.0),))
|
||||
check("a level the hysteresis moved opens the run to that battery again",
|
||||
_m1 and not _m2)
|
||||
c.solar_fc = None
|
||||
check("the levels are dropped as soon as the forecast goes, even before "
|
||||
"the plan is rebuilt", c._plan_targets and c.plan_targets() is None)
|
||||
check("a lost forecast says so: no forecast, Leftover solar solar only",
|
||||
homeview.solar_block(c, datetime.now(timezone.utc))["state"]
|
||||
== "no_forecast" and homeview.day_key(c, B) == "no_forecast")
|
||||
check("the Why line wins over 'saves its power' only while holding",
|
||||
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
|
||||
reading=True, phase="idle", shadow=False,
|
||||
running=True, held=False, refused=0, peak=False,
|
||||
sf_ahead=True, role="hold", has_at=True,
|
||||
day="sun_fills") == "sun_fills"
|
||||
and homeview.battery_why(modes_on=True, mode="leftover_solar",
|
||||
off=False, reading=True, phase="idle",
|
||||
shadow=False, running=True, held=False,
|
||||
refused=0, peak=False, sf_ahead=True,
|
||||
role="charge", has_at=True, day="tops_up")
|
||||
== "grid_charge")
|
||||
for lang in ("en", "nl"):
|
||||
homeview._LANG[0] = lang
|
||||
t = [homeview.why_words(k) for k in ("sun_fills", "tops_up",
|
||||
"no_forecast")]
|
||||
t += [homeview._t("solar.no_forecast"), homeview._t("solar.learning"),
|
||||
homeview.line_solar(9.2, 6.4, "10:15")]
|
||||
check(f"{lang}: every P4 sentence is there and short (<= 2 x 12 words)",
|
||||
all(t) and all(len(s.split()) <= 24 for s in t))
|
||||
homeview._LANG[0] = "en"
|
||||
from app import uilayout # noqa: E402
|
||||
simple = [f.key for _g, _p, f in uilayout.fields() if f.simple]
|
||||
check("the Simple view has the solar forecast card",
|
||||
"pv_forecast_entity" in simple)
|
||||
|
||||
# ============================================================================ #
|
||||
print("8. options")
|
||||
BASE_OPTS = {"setpoint_entity": "number.x", "max_w": 2500}
|
||||
for v, ok in ((70, True), (30, True), (100, True), (29, False), (101, False),
|
||||
(70.5, False), ("x", False)):
|
||||
probs = validate._forecast_problems(dict(solar_trust_pct=v))
|
||||
check(f"solar_trust_pct {v!r}: {'accepted' if ok else 'refused'}",
|
||||
(not probs) == ok)
|
||||
check("a forecast entity that is not a sensor is refused, in plain words",
|
||||
validate._forecast_problems({"pv_forecast_entity": "number.x"})
|
||||
and "must be a sensor" in validate._forecast_problems(
|
||||
{"pv_forecast_entity": "number.x"})[0])
|
||||
check("an entity id with a path or odd characters is refused",
|
||||
all(validate._forecast_problems({"pv_forecast_entity": e}) for e in
|
||||
("sensor.x/../config", "sensor.x?y=1", "sensor.X", "sensor.",
|
||||
" sensor.x", "sensor.a b"))
|
||||
and not validate._forecast_problems(
|
||||
{"pv_forecast_entity": "sensor.energy_production_today_remaining"}))
|
||||
check("empty entities are fine", not validate._forecast_problems(
|
||||
{"pv_forecast_entity": "", "pv_forecast_tomorrow_entity": ""}))
|
||||
notes = []
|
||||
validate._entity_notes(notes, {"pv_forecast_entity": "sensor.f",
|
||||
"battery_modes_enabled": True},
|
||||
{"sensor.f": {"unit": "W"}})
|
||||
check("a forecast sensor not in kWh gets a warning",
|
||||
any("kWh" in n.text for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, {"pv_forecast_entity": "sensor.f"},
|
||||
{"sensor.f": {"unit": "kWh"}})
|
||||
check("a forecast with battery modes off gets a note that it is unused",
|
||||
any("battery modes on" in n.text for n in notes))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -42,6 +42,7 @@ part is a diff against it, reported as `N topics, M missing, C changed, A added`
|
||||
re-executed from mutated source, each one named with the property it
|
||||
breaks. A property nothing breaks is a property nothing is watching.
|
||||
Part 9 What these checks CANNOT see, enumerated and sized.
|
||||
Part 10 OPT-11 P2: each battery's select and switch, commands, removal.
|
||||
|
||||
⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC,
|
||||
DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose
|
||||
@@ -821,8 +822,8 @@ MUTATIONS = [
|
||||
("per-unit entities announced at a roster of ONE",
|
||||
[("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]),
|
||||
("the unit is dropped from the unique_id",
|
||||
[('unique_id = f"{BASE}_unit_{slug}_{key}"',
|
||||
'unique_id = f"{BASE}_unit_{key}"')]),
|
||||
[('return f"{BASE}_unit_{slug}_{key}"',
|
||||
'return f"{BASE}_unit_{key}"')]),
|
||||
("the unit is dropped from the state topic",
|
||||
[('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]),
|
||||
("the slug de-collision is removed (rule 9(c) does not cover this)",
|
||||
@@ -944,6 +945,540 @@ for line in [
|
||||
]:
|
||||
print(" " + line)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print("\n10. OPT-11 P2: each battery's switch and mode as HA entities")
|
||||
# ---------------------------------------------------------------------------
|
||||
import logging # noqa: E402
|
||||
import os # noqa: E402
|
||||
import tempfile # noqa: E402
|
||||
|
||||
from app import messages, packmode # noqa: E402
|
||||
from app.store import Store, unit_key # noqa: E402
|
||||
|
||||
|
||||
def announce_p2(units=(), omit=(), include=(), modes=(), retract=(),
|
||||
module=mqtt):
|
||||
"""What `_on_connect()` sends: the announcement, then any subscriptions."""
|
||||
pub = module.MqttPublisher.__new__(module.MqttPublisher)
|
||||
pub.omit, pub.units, pub.include = set(omit), [str(u) for u in units], \
|
||||
[str(k) for k in include]
|
||||
pub.modes, pub.retract = tuple(modes), list(retract)
|
||||
pub.enabled, pub.client = True, FakeClient()
|
||||
subs = []
|
||||
pub.client.subscribe = lambda topic, *a: subs.append(topic)
|
||||
pub._on_connect()
|
||||
return pub.client.msgs, subs
|
||||
|
||||
|
||||
# --- 10a. modes off: byte-identical to ed739d4 --------------------------------
|
||||
# The digest below is GENERATED from release/1.0 at ed739d4 (the base of this
|
||||
# PR): the same matrix run through that commit's `_announce()`. Reproduce with
|
||||
# `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
|
||||
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
|
||||
["inverter-b", "Inverter B", "x", "y"], ["café"]]
|
||||
ED739D4_OFF = "501a7d93782a260b"
|
||||
_h, _n, _subs_off = hashlib.sha256(), 0, []
|
||||
for _r in ROSTERS:
|
||||
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
|
||||
for _inc in [(), ("pv",)]:
|
||||
_m, _s = announce_p2(_r, _om, _inc)
|
||||
_h.update(repr(_m).encode())
|
||||
_subs_off += _s
|
||||
_n += 1
|
||||
print(f" modes off, {_n} configurations: digest {_h.hexdigest()[:16]} "
|
||||
f"(ed739d4: {ED739D4_OFF})")
|
||||
check("modes off: every configuration announces byte-for-byte what ed739d4 "
|
||||
"announces, in the same order, and subscribes to nothing",
|
||||
_h.hexdigest()[:16] == ED739D4_OFF and not _subs_off)
|
||||
|
||||
|
||||
# --- 10b. modes on: the ordered payload diff ----------------------------------
|
||||
MODES = packmode.MODES
|
||||
ON1, SUB1 = announce_p2(["inverter-a"], modes=MODES)
|
||||
m, c, a = report("roster of one, modes on", ON1)
|
||||
# P2 announced the first two; OPT-11 P3 appended "Cheap hours only".
|
||||
_opt = '["Solar first", "Leftover solar", "Cheap hours only"]'
|
||||
MODE_N1 = [
|
||||
("homeassistant/select/goodwe_ctl_unit_inverter_a_mode/config",
|
||||
'{"name": "Inverter A Mode", "object_id": "goodwe_inverter_a_mode", '
|
||||
'"unique_id": "goodwe_ctl_unit_inverter_a_mode", '
|
||||
'"state_topic": "goodwe_ctl/unit/inverter_a/mode", '
|
||||
'"command_topic": "goodwe_ctl/unit/inverter_a/mode/set", '
|
||||
'"options": ' + _opt + ', "retain": false, '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:battery-sync"}', True),
|
||||
("homeassistant/switch/goodwe_ctl_unit_inverter_a_use/config",
|
||||
'{"name": "Inverter A Use", "object_id": "goodwe_inverter_a_use", '
|
||||
'"unique_id": "goodwe_ctl_unit_inverter_a_use", '
|
||||
'"state_topic": "goodwe_ctl/unit/inverter_a/use", '
|
||||
'"command_topic": "goodwe_ctl/unit/inverter_a/use/set", '
|
||||
'"payload_on": "ON", "payload_off": "OFF", "retain": false, '
|
||||
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
|
||||
'"icon": "mdi:power"}', True),
|
||||
]
|
||||
check("modes on at a roster of one: every existing message byte-identical and "
|
||||
"in place, then exactly the select and the switch, then availability",
|
||||
ON1 == SITE_NOW[:-1] + MODE_N1 + SITE_NOW[-1:])
|
||||
check("...and it subscribes to the two command topics, and nothing else",
|
||||
SUB1 == ["goodwe_ctl/unit/+/mode/set", "goodwe_ctl/unit/+/use/set"])
|
||||
|
||||
ON2, _ = announce_p2(NAMES2, modes=MODES)
|
||||
m, c, a = report("roster of two, modes on", ON2, TWO)
|
||||
check("modes on at a roster of two: nothing missing or changed, four added, "
|
||||
"and the modes-off list is an unchanged prefix",
|
||||
(m, c) == ([], []) and len(a) == 4 and ON2[:len(TWO) - 1] == TWO[:-1]
|
||||
and ON2[-1] == TWO[-1])
|
||||
_mc = cfgs(ON2)[-4:]
|
||||
check("...A's two stay on the site device with its name in front; B's two are "
|
||||
"on B's own device, as TEL-07's rule has it",
|
||||
[c_["name"] for c_ in _mc] == ["Inverter A Mode", "Inverter A Use",
|
||||
"Mode", "Use"]
|
||||
and _mc[0]["device"] == mqtt.DEVICE == _mc[1]["device"]
|
||||
and _mc[2]["device"] == _mc[3]["device"]
|
||||
== mqtt.unit_device("inverter-b", "inverter_b"))
|
||||
check("...unique_ids are stable per battery and distinct from every other id",
|
||||
[c_["unique_id"] for c_ in _mc]
|
||||
== ["goodwe_ctl_unit_inverter_a_mode", "goodwe_ctl_unit_inverter_a_use",
|
||||
"goodwe_ctl_unit_inverter_b_mode", "goodwe_ctl_unit_inverter_b_use"]
|
||||
and len({c_["unique_id"] for c_ in cfgs(ON2)}) == len(cfgs(ON2)))
|
||||
_ids = [compose(c_["device"]["name"], c_["name"]).split(".", 1)[1]
|
||||
for c_ in _mc]
|
||||
print(f" composed ids: {_ids}")
|
||||
check("...and HA composes the ids the design names: "
|
||||
"goodwe_inverter_<x>_mode and goodwe_inverter_<x>_use",
|
||||
_ids == ["goodwe_inverter_a_mode", "goodwe_inverter_a_use",
|
||||
"goodwe_inverter_b_mode", "goodwe_inverter_b_use"])
|
||||
check("every mode has a plain label, in MODES order, and a later mode only "
|
||||
"appends an option: no existing option or id changes",
|
||||
all(k in mqtt.MODE_LABELS for k in MODES)
|
||||
and len(set(mqtt.MODE_LABELS.values())) == len(mqtt.MODE_LABELS)
|
||||
and json.loads(MODE_N1[0][1])["options"]
|
||||
== [mqtt.MODE_LABELS[k] for k in MODES])
|
||||
# Review of #152: the HA options are what automations send, so they are kept
|
||||
# apart from the page's words on purpose. They start equal; if the page's
|
||||
# English changes, this goes red and the author decides: keep the HA option
|
||||
# (and change this pin) or rename it (a breaking change, in DECISIONS).
|
||||
_en = json.loads((HERE / "app" / "ui_text" / "en.json").read_text())
|
||||
check("each HA mode option is the page's English name for that mode, in "
|
||||
"both places the page names it",
|
||||
all(mqtt.MODE_LABELS[k] == _en["home_words"]["modes"][k]
|
||||
== _en["home"]["batteries"]["choice"][k]["name"] for k in MODES))
|
||||
# OPT-11 P3 added `cheap_hours`: against P2's two modes (7a00f12), only the
|
||||
# options list changes, by one entry at its end; no topic, id or label moves.
|
||||
_p3 = mqtt.mode_payloads(NAMES2, MODES)
|
||||
_orig = mqtt.MODE_LABELS
|
||||
mqtt.MODE_LABELS = {k: v for k, v in _orig.items() if k != "cheap_hours"}
|
||||
try:
|
||||
_p2 = mqtt.mode_payloads(NAMES2, MODES[:2])
|
||||
finally:
|
||||
mqtt.MODE_LABELS = _orig
|
||||
check("...P3 appended `cheap_hours`: against P2's list only the options "
|
||||
"change, by one entry at the end, and P2's two labels are unchanged",
|
||||
MODES[:2] == ("solar_first", "leftover_solar")
|
||||
and _p2[0][1]["options"] == ["Solar first", "Leftover solar"] and
|
||||
[t for t, _ in _p3] == [t for t, _ in _p2]
|
||||
and all({k: v for k, v in x.items() if k != "options"}
|
||||
== {k: v for k, v in y.items() if k != "options"}
|
||||
for (_, x), (_, y) in zip(_p3, _p2))
|
||||
and _p3[0][1]["options"] == _p2[0][1]["options"] + ["Cheap hours only"])
|
||||
|
||||
|
||||
# --- 10c. modes turned off: removal ------------------------------------------
|
||||
_cur2 = [t for t, _ in mqtt.mode_payloads(NAMES2, MODES)]
|
||||
_ret = mqtt.mode_retractions(_cur2, [])
|
||||
OFF2, _subs = announce_p2(NAMES2, retract=_ret)
|
||||
check("modes turned off: the modes-off list, then an EMPTY retained config "
|
||||
"for each of the four, then availability, and no subscription",
|
||||
OFF2 == TWO[:-1] + [(t, "", True) for t in _cur2] + TWO[-1:]
|
||||
and not _subs)
|
||||
check("...the store keeps what was announced, so a roster that shrinks removes "
|
||||
"only the battery that left",
|
||||
mqtt.mode_retractions(_cur2, [t for t, _ in
|
||||
mqtt.mode_payloads(["inverter-a"], MODES)])
|
||||
== _cur2[2:])
|
||||
check("...and nothing from the store is removed that is not one of our mode "
|
||||
"configs: a non-list, a number, a site sensor, another add-on's select",
|
||||
mqtt.mode_retractions("homeassistant/select/x/config", []) == []
|
||||
and mqtt.mode_retractions(
|
||||
[3, None, "homeassistant/sensor/goodwe_ctl_grid/config",
|
||||
"homeassistant/select/other_mode/config",
|
||||
"homeassistant/select/goodwe_ctl_unit_a_mode/config/x",
|
||||
"homeassistant/select/goodwe_ctl_unit_a_mode/config"], [])
|
||||
== ["homeassistant/select/goodwe_ctl_unit_a_mode/config"])
|
||||
|
||||
|
||||
_on1 = [t for t, _ in mqtt.mode_payloads(["inverter-a"], MODES)]
|
||||
_k, _r = mqtt.mode_configs(["inverter-a"], MODES, _cur2)
|
||||
check("start-up (`mode_configs`): a battery that left is removed, and stays "
|
||||
"in the store, so a start with no broker does not forget it",
|
||||
_r == _cur2[2:] and _k == _on1 + _cur2[2:])
|
||||
_k, _r = mqtt.mode_configs(NAMES2, (), _cur2 + _cur2[:1])
|
||||
check("...modes turned off: all four are removed, once each, and kept",
|
||||
_r == _cur2 and _k == _cur2)
|
||||
check("...never turned on: nothing to store and nothing to remove, so the "
|
||||
"store is not written",
|
||||
mqtt.mode_configs(NAMES2, (), None) == ([], []))
|
||||
_k, _r = mqtt.mode_configs(NAMES2, MODES, _cur2)
|
||||
check("...turned on again: nothing removed, and the list does not grow",
|
||||
_r == [] and _k == _cur2)
|
||||
|
||||
|
||||
from app.main import mode_announcement # noqa: E402
|
||||
_st = Store(os.path.join(tempfile.mkdtemp(), "ann.json"))
|
||||
_OPTS1M = dict(OPTS2, battery_modes_enabled=True,
|
||||
inverters=OPTS2["inverters"][:1])
|
||||
_seen = [mode_announcement(dict(OPTS2), _st),
|
||||
dict(_st.data)]
|
||||
_seen += [mode_announcement(dict(OPTS2, battery_modes_enabled=True), _st),
|
||||
list(_st.data.get(mqtt.MODE_CONFIGS_KEY) or [])]
|
||||
_seen += [mode_announcement(_OPTS1M, _st),
|
||||
list(_st.data.get(mqtt.MODE_CONFIGS_KEY) or [])]
|
||||
_seen += [mode_announcement(dict(OPTS2, battery_modes_enabled=False), _st),
|
||||
list(_st.data.get(mqtt.MODE_CONFIGS_KEY) or [])]
|
||||
check("main's start-up, through a real Store: never on announces and stores "
|
||||
"nothing; on stores the four; a roster of one removes B's two and keeps "
|
||||
"them; off removes all four and the store keeps them",
|
||||
_seen[0] == ((), []) and mqtt.MODE_CONFIGS_KEY not in _seen[1]
|
||||
and _seen[2] == (MODES, []) and _seen[3] == _cur2
|
||||
and _seen[4] == (MODES, _cur2[2:]) and sorted(_seen[5]) == sorted(_cur2)
|
||||
and _seen[6] == ((), _seen[5])
|
||||
and sorted(_seen[7]) == sorted(_cur2))
|
||||
_st = Store(os.path.join(tempfile.mkdtemp(), "ren.json"))
|
||||
mode_announcement(dict(OPTS2, battery_modes_enabled=True), _st)
|
||||
_ren = dict(OPTS2, battery_modes_enabled=True, inverters=[
|
||||
OPTS2["inverters"][0], dict(OPTS2["inverters"][1], name="inverter-c")])
|
||||
_r = mode_announcement(_ren, _st)[1]
|
||||
_kept = list(_st.data.get(mqtt.MODE_CONFIGS_KEY) or [])
|
||||
check("...B renamed C while on: B's two are removed and stay stored beside "
|
||||
"A's and C's, so a start with no broker does not forget them",
|
||||
_r == _cur2[2:] and _kept[-2:] == _cur2[2:] and len(_kept) == 6
|
||||
and mode_announcement(_ren, _st)[1] == _cur2[2:])
|
||||
check("battery modes are on only for exactly `true`: not \"true\", 1 or "
|
||||
"\"false\"",
|
||||
[packmode.enabled({"battery_modes_enabled": v})
|
||||
for v in (True, "true", 1, "false", None)]
|
||||
== [True, False, False, False, False])
|
||||
|
||||
|
||||
# --- 10d. state topics, from the store ----------------------------------------
|
||||
_tmp = tempfile.mkdtemp()
|
||||
OPTS2M = dict(OPTS2, battery_modes_enabled=True)
|
||||
|
||||
|
||||
def ctl_for(opts, path):
|
||||
pub = _Pub()
|
||||
pub.sent = []
|
||||
pub.publish = lambda values, _p=pub: (_p.sent.append(dict(values)),
|
||||
setattr(_p, "last", dict(values)))
|
||||
return Controller(dict(opts), None, Store(path), pub,
|
||||
stop=asyncio.Event()), pub
|
||||
|
||||
|
||||
_path = os.path.join(_tmp, "state.json")
|
||||
ctlm, pubm = ctl_for(OPTS2M, _path)
|
||||
ctlm.publish()
|
||||
_fed = {f"{mqtt.BASE}/{k}" for k in pubm.last
|
||||
if k.endswith("/mode") or k.endswith("/use")}
|
||||
check("every announced mode entity is fed, and nothing else is: the state "
|
||||
"topics published are exactly the ones announced",
|
||||
_fed == {c_["state_topic"] for c_ in _mc})
|
||||
check("...defaults with nothing in the store: both On, both Solar first",
|
||||
[pubm.last[k] for k in ("unit/inverter_a/mode", "unit/inverter_a/use",
|
||||
"unit/inverter_b/mode", "unit/inverter_b/use")]
|
||||
== ["Solar first", "ON", "Solar first", "ON"])
|
||||
_c_off, _p_off = ctl_for(dict(OPTS2, battery_modes_enabled=False),
|
||||
os.path.join(_tmp, "off2.json"))
|
||||
_c_off.publish()
|
||||
_c_abs, _p_abs = ctl_for(OPTS2, os.path.join(_tmp, "off3.json"))
|
||||
_c_abs.publish()
|
||||
check("modes off (false or absent): the controller publishes the same keys as "
|
||||
"before, with no mode or switch topic",
|
||||
set(_p_off.last) == set(_p_abs.last) == set(vals2)
|
||||
and not [k for k in _p_off.last if k.endswith(("/mode", "/use"))])
|
||||
|
||||
|
||||
# --- 10e. commands: the /action path, validated -------------------------------
|
||||
class _Logs(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__(logging.DEBUG)
|
||||
self.rows = []
|
||||
|
||||
def emit(self, record):
|
||||
self.rows.append((record.levelno, record.getMessage()))
|
||||
|
||||
|
||||
_logs = _Logs()
|
||||
logging.getLogger("goodwe").addHandler(_logs)
|
||||
logging.getLogger("goodwe").setLevel(logging.DEBUG)
|
||||
_calls = []
|
||||
_real_handle = ctlm.handle_action
|
||||
|
||||
|
||||
async def _spy(what, **kw):
|
||||
_calls.append((what, kw))
|
||||
return await _real_handle(what, **kw)
|
||||
|
||||
|
||||
ctlm.handle_action = _spy
|
||||
|
||||
|
||||
def cmd(topic, payload, retained=False, ctl=None):
|
||||
ctl = ctl or ctlm
|
||||
asyncio.run(ctl.mqtt_command(topic, payload, retained))
|
||||
|
||||
|
||||
def stored(ctl=None):
|
||||
d = (ctl or ctlm).store.data
|
||||
return {k: d.get(k) for k in (unit_key("inverter-a", "pack_use"),
|
||||
unit_key("inverter-a", "pack_mode"),
|
||||
unit_key("inverter-b", "pack_use"),
|
||||
unit_key("inverter-b", "pack_mode"))}
|
||||
|
||||
|
||||
T_A = "goodwe_ctl/unit/inverter_a"
|
||||
T_B = "goodwe_ctl/unit/inverter_b"
|
||||
pubm.sent.clear()
|
||||
cmd(f"{T_B}/use/set", b"OFF")
|
||||
cmd(f"{T_A}/mode/set", b"Leftover solar")
|
||||
check("good commands go through handle_action() with the /action arguments",
|
||||
_calls == [("pack_use", {"unit": "inverter-b", "value": False}),
|
||||
("pack_mode", {"unit": "inverter-a",
|
||||
"value": "leftover_solar"})])
|
||||
check("...and reach the store under P1's keys, and nowhere else",
|
||||
stored() == {unit_key("inverter-a", "pack_use"): None,
|
||||
unit_key("inverter-a", "pack_mode"): "leftover_solar",
|
||||
unit_key("inverter-b", "pack_use"): False,
|
||||
unit_key("inverter-b", "pack_mode"): None})
|
||||
check("...and the state topics say so at once, from the store",
|
||||
pubm.sent and pubm.sent[-1] == {
|
||||
"unit/inverter_a/mode": "Leftover solar", "unit/inverter_a/use": "ON",
|
||||
"unit/inverter_b/mode": "Solar first", "unit/inverter_b/use": "OFF"})
|
||||
check("...and the action log records each change",
|
||||
any("inverter-b switched off" in e for e in ctlm.events)
|
||||
and any("inverter-a mode set to leftover solar" in e
|
||||
for e in ctlm.events))
|
||||
|
||||
_before = stored()
|
||||
_n_calls = len(_calls)
|
||||
BAD = [
|
||||
(f"{T_A}/use/set", b"on"), (f"{T_A}/use/set", b"ON\n"),
|
||||
(f"{T_A}/use/set", b"true"), (f"{T_A}/use/set", b"1"),
|
||||
(f"{T_A}/use/set", b""),
|
||||
(f"{T_A}/mode/set", b"solar_first"), # a key, not the option
|
||||
(f"{T_A}/mode/set", b"Sell only"), # not an offered mode
|
||||
(f"{T_A}/mode/set", b"solar first"),
|
||||
(f"{T_A}/mode/set", "Sοlar first".encode()), # Greek omicron
|
||||
(f"{T_A}/mode/set", b"Solar first\x00"),
|
||||
(f"{T_A}/mode/set", b'{"action": "pack_use", "value": false}'),
|
||||
(f"{T_A}/mode/set", b"Solar first; rm -rf /"),
|
||||
(f"{T_A}/mode/set", b"' OR 1=1 --"),
|
||||
(f"{T_A}/use/set", b"\xff\xfe"), # not UTF-8
|
||||
(f"{T_A}/mode/set", b"Solar first" * 10), # too long
|
||||
("goodwe_ctl/unit/inverter_z/use/set", b"OFF"), # no such battery
|
||||
("goodwe_ctl/unit/../use/set", b"OFF"),
|
||||
("goodwe_ctl/unit/inverter_a/soc/set", b"OFF"),
|
||||
("goodwe_ctl/unit/inverter_a/use/set/x", b"OFF"),
|
||||
("goodwe_ctl/unit/Inverter_A/use/set", b"OFF"),
|
||||
(f"{T_A}/use/set", None),
|
||||
]
|
||||
for _t, _p in BAD:
|
||||
cmd(_t, _p)
|
||||
check(f"all {len(BAD)} bad commands are refused: the store is unchanged",
|
||||
stored() == _before)
|
||||
_passed = [c_ for c_ in _calls[_n_calls:]]
|
||||
check("...those that reach handle_action() carry a value /action also refuses "
|
||||
"(None) or no known battery; nothing is coerced into a valid choice",
|
||||
all(kw["value"] is None or kw["unit"] is None for _, kw in _passed))
|
||||
|
||||
# Log once per topic and reason.
|
||||
_logs.rows.clear()
|
||||
for _ in range(5):
|
||||
cmd(f"{T_B}/use/set", b"maybe")
|
||||
_warn = [r for r in _logs.rows if r[0] >= logging.WARNING]
|
||||
check("a refused command repeated 5 times is logged once at WARNING, in plain "
|
||||
"words, and at DEBUG the other times",
|
||||
len(_warn) == 1 and messages.pack_refused("value") in _warn[0][1]
|
||||
and len([r for r in _logs.rows if r[0] == logging.DEBUG]) == 5)
|
||||
_logs.rows.clear()
|
||||
_flood, _ = ctl_for(OPTS2M, os.path.join(_tmp, "flood.json"))
|
||||
for _i in range(100):
|
||||
cmd(f"goodwe_ctl/unit/x{_i}/use/set", b"OFF", ctl=_flood)
|
||||
_w = [r[1] for r in _logs.rows if r[0] >= logging.WARNING]
|
||||
_per = [w for w in _w if "still ignored" not in w]
|
||||
_more = [w for w in _w if "still ignored" in w]
|
||||
print(f" flood: {len(_per)} per-topic WARNINGs, then {_more}")
|
||||
check("...and a flood of 100 different bad topics logs 32 WARNINGs, then the "
|
||||
"rest only as a count, at 1 and 10 more (review of #152)",
|
||||
len(_per) == 32 and len(_more) == 2
|
||||
and "1 more so far" in _more[0] and "10 more so far" in _more[1])
|
||||
_logs.rows.clear()
|
||||
for _ in range(100):
|
||||
cmd(f"{T_B}/use/set", b"maybe", ctl=_flood)
|
||||
_more = [r[1] for r in _logs.rows
|
||||
if r[0] >= logging.WARNING and "still ignored" in r[1]]
|
||||
check("...so a repeated attack on ONE topic still shows it goes on: the count "
|
||||
"reaches 100 in 3 lines, not 100",
|
||||
len(_more) == 1 and "100 more so far" in _more[0])
|
||||
_logs.rows.clear()
|
||||
_inj, _ = ctl_for(OPTS2M, os.path.join(_tmp, "inj.json"))
|
||||
cmd("goodwe_ctl/unit/a\nERROR forged line\x1b[31m/use/set", b"OFF", ctl=_inj)
|
||||
_w = [r[1] for r in _logs.rows if r[0] >= logging.WARNING]
|
||||
check("...and a topic with a newline or an escape code cannot forge a log "
|
||||
"line: it is logged printable only",
|
||||
len(_w) == 1 and "\n" not in _w[0] and "\x1b" not in _w[0]
|
||||
and "a?ERROR forged line?[31m" in _w[0])
|
||||
|
||||
# Modes off: refused on the same path, even if a message arrives.
|
||||
cmd(f"{T_A}/use/set", b"OFF", ctl=_c_off)
|
||||
check("with modes off a command is refused by the same check /action makes",
|
||||
stored(_c_off)[unit_key("inverter-a", "pack_use")] is None)
|
||||
|
||||
|
||||
# --- 10f. a retained command after a restart ----------------------------------
|
||||
_logs.rows.clear()
|
||||
_before = stored()
|
||||
_n_calls = len(_calls)
|
||||
cmd(f"{T_A}/use/set", b"OFF", retained=True)
|
||||
cmd(f"{T_B}/use/set", b"ON", retained=True)
|
||||
check("a RETAINED command (the broker replays it on subscribe, e.g. after a "
|
||||
"restart) is never applied, and never reaches handle_action()",
|
||||
stored() == _before and len(_calls) == _n_calls)
|
||||
check("...and it is logged once, in plain words",
|
||||
any(messages.pack_refused("retained") in r[1]
|
||||
for r in _logs.rows if r[0] >= logging.WARNING))
|
||||
ctl_r, pub_r = ctl_for(OPTS2M, _path) # a restart: same state file
|
||||
ctl_r.publish()
|
||||
check("after a restart the state topics come from the store: A Leftover solar "
|
||||
"and on, B off",
|
||||
[pub_r.last[k] for k in ("unit/inverter_a/mode", "unit/inverter_a/use",
|
||||
"unit/inverter_b/use")]
|
||||
== ["Leftover solar", "ON", "OFF"])
|
||||
logging.getLogger("goodwe").removeHandler(_logs)
|
||||
|
||||
|
||||
# --- 10g. the paho side ----------------------------------------------------------
|
||||
class _Msg:
|
||||
def __init__(self, topic, payload, retain):
|
||||
self.topic, self.payload, self.retain = topic, payload, retain
|
||||
|
||||
|
||||
_got = []
|
||||
|
||||
|
||||
async def _handler(*args):
|
||||
_got.append(args)
|
||||
|
||||
|
||||
async def _drive():
|
||||
pub = mqtt.MqttPublisher.__new__(mqtt.MqttPublisher)
|
||||
pub._on_message(None, None, _Msg("t", b"x", 0)) # not bound: dropped
|
||||
pub.bind(asyncio.get_running_loop(), _handler)
|
||||
pub._on_message(None, None, _Msg(f"{T_A}/use/set", b"OFF", 1))
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
asyncio.run(_drive())
|
||||
check("paho's thread hands (topic, bytes, retained) to the controller's loop, "
|
||||
"and drops a message that arrives before the controller exists",
|
||||
_got == [(f"{T_A}/use/set", b"OFF", True)])
|
||||
|
||||
_kws = {k.arg: ast.unparse(k.value) for c_ in _call for k in c_.keywords}
|
||||
_main_src = (HERE / "app" / "main.py").read_text()
|
||||
check("(source read) main() passes modes and retractions to the publisher, "
|
||||
"and binds its commands to the controller",
|
||||
_kws.get("modes") == "_modes" and _kws.get("retract") == "_retract"
|
||||
and "pub.bind(asyncio.get_running_loop(), controller.mqtt_command)"
|
||||
in _main_src
|
||||
and "_modes, _retract = mode_announcement(opts, store)" in _main_src)
|
||||
|
||||
|
||||
# --- 10h. the guards, broken one at a time --------------------------------------
|
||||
def p2_properties(mod):
|
||||
out = {}
|
||||
try:
|
||||
off, s_off = announce_p2(NAMES2, module=mod)
|
||||
on1, s_on = announce_p2(["inverter-a"], modes=MODES, module=mod)
|
||||
on2, _ = announce_p2(NAMES2, modes=MODES, module=mod)
|
||||
out["off identical"] = off == TWO and not s_off
|
||||
out["on payloads"] = on1 == ON1 and on2 == ON2
|
||||
out["subscribes when on"] = s_on == SUB1
|
||||
cur = [t for t, _ in mod.mode_payloads(NAMES2, MODES)]
|
||||
out["retract filters store"] = mod.mode_retractions(
|
||||
[3, "homeassistant/sensor/goodwe_ctl_grid/config",
|
||||
"homeassistant/select/other/config"] + cur, cur) == []
|
||||
out["retract keeps current"] = mod.mode_retractions(cur, cur) == []
|
||||
out["retract published"] = announce_p2(
|
||||
NAMES2, retract=cur[:1], module=mod)[0] == \
|
||||
TWO[:-1] + [(cur[0], "", True)] + TWO[-1:]
|
||||
pc = mod.parse_command
|
||||
out["retained dropped"] = pc(f"{T_A}/use/set", b"OFF", True,
|
||||
MODES)[3] == "retained"
|
||||
out["size capped"] = pc(f"{T_A}/mode/set", b"Solar first" * 10,
|
||||
False, MODES)[3] == "size"
|
||||
out["utf-8 only"] = pc(f"{T_A}/use/set", b"\xff", False,
|
||||
MODES)[3] == "encoding"
|
||||
out["switch exact"] = (
|
||||
pc(f"{T_A}/use/set", b"on", False, MODES)[2] is None
|
||||
and pc(f"{T_A}/use/set", b"OFF", False, MODES)[2] is False)
|
||||
out["select labels only"] = (
|
||||
pc(f"{T_A}/mode/set", b"solar_first", False, MODES)[2] is None
|
||||
and pc(f"{T_A}/mode/set", b"Leftover solar", False,
|
||||
MODES)[2] == "leftover_solar")
|
||||
out["select offered only"] = pc(
|
||||
f"{T_A}/mode/set", b"Solar first", False, ("leftover_solar",))[2] \
|
||||
is None
|
||||
out["topic exact"] = all(
|
||||
pc(t, b"OFF", False, MODES)[3] == "topic"
|
||||
for t in ("goodwe_ctl/unit/../use/set", "goodwe_ctl/unit/a/b/use/set",
|
||||
"goodwe_ctl/unit/a/use/set/x", "x/goodwe_ctl/unit/a/use/set"))
|
||||
except Exception as err: # noqa: BLE001
|
||||
out["did not raise"] = False
|
||||
out["__error__"] = repr(err)[:70]
|
||||
return out
|
||||
|
||||
|
||||
P2_SHIPPED = p2_properties(mqtt)
|
||||
check("the shipped module has every P2 property (so a red below is the "
|
||||
"mutation)", all(v is True for v in P2_SHIPPED.values()))
|
||||
P2_MUTATIONS = [
|
||||
("mode entities announced with modes off",
|
||||
[(" if not modes:\n return []\n", " modes = modes or ('solar_first', 'leftover_solar')\n")]),
|
||||
("subscribed with modes off", [(" if self.modes:\n", " if True:\n")]),
|
||||
("retained commands applied", [(" if retained:\n", " if False:\n")]),
|
||||
("no payload size cap", [("or len(payload) > MAX_COMMAND_BYTES", "or False")]),
|
||||
("bad UTF-8 replaced, not refused",
|
||||
[('.decode("utf-8")', '.decode("utf-8", "replace")')]),
|
||||
("switch payload case-folded",
|
||||
[("{USE_ON: True, USE_OFF: False}.get(text)",
|
||||
"{USE_ON: True, USE_OFF: False}.get(text.upper())")]),
|
||||
("select accepts mode keys too",
|
||||
[("if MODE_LABELS.get(k) == text", "if text in (k, MODE_LABELS.get(k))")]),
|
||||
("select accepts any labelled mode, offered or not",
|
||||
[("(k for k in modes if MODE_LABELS.get(k) == text)",
|
||||
"(k for k in MODE_LABELS if MODE_LABELS.get(k) == text)")]),
|
||||
("command topic loosened", [('/([a-z0-9_]+)/(mode|use)/set")', '/(.+)/(mode|use)/set")')]),
|
||||
("command topic matched as a prefix",
|
||||
[("_COMMAND_RE.fullmatch(topic)", "_COMMAND_RE.search(topic)")]),
|
||||
("store list not filtered",
|
||||
[("and _MODE_CONFIG_RE.fullmatch(t)\n", "and True\n")]),
|
||||
("current configs also removed", [("and t not in keep))", "))")]),
|
||||
("commands published retained by HA", [('cfg["retain"] = False', 'cfg["retain"] = True')]),
|
||||
("retractions not published",
|
||||
[(' self.client.publish(topic, "", retain=True)\n', " pass\n")]),
|
||||
]
|
||||
_p2_surv = []
|
||||
for label, pairs in P2_MUTATIONS:
|
||||
got = properties(load(pairs))
|
||||
got.update(p2_properties(load(pairs)))
|
||||
broke = sorted(k for k, v in {**SHIPPED, **P2_SHIPPED}.items()
|
||||
if v is True and got.get(k) is not True)
|
||||
print(f" {'RED ' if broke else 'GREEN'} {label}"
|
||||
+ (f" (breaks: {', '.join(broke)})" if broke else ""))
|
||||
if not broke:
|
||||
_p2_surv.append(label)
|
||||
check(f"all {len(P2_MUTATIONS)} P2 mutations of app/mqtt.py are red",
|
||||
not _p2_surv)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Runnable check for SAFETY-79, the margin under the peak limit.
|
||||
`python3 test_peak_margin.py`
|
||||
|
||||
The peak guard aims at `peak_cap_w - peak_margin_w` (default 50 W); the bill
|
||||
and every report keep the real `peak_cap_w`. DECISIONS/SAFETY-79-peak-margin.md
|
||||
has the record. Drives the REAL Controller through tools/safety79_identity.py.
|
||||
|
||||
1. `peak_margin_w: 0` is byte-identical to release/1.0 ed739d4.
|
||||
2. The guard aims at cap - margin at N=1 and N=2, and with OPT-11 modes on an
|
||||
Off battery covers the shortfall to cap - margin.
|
||||
3. The bill and the reports still use the real cap.
|
||||
4. Each call site in main.py passes the value its side needs.
|
||||
5. The setting: default, range, refusals in plain words.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import safety79_identity as rig # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
from style_rules import text_problems # noqa: E402
|
||||
from app import messages, peakguard # noqa: E402
|
||||
from app.control import maintenance_charge_floor # noqa: E402
|
||||
from app.peakguard import (PEAK_MARGIN_RANGE_W, # noqa: E402
|
||||
PEAK_OPTION_DEFAULTS, quarter_target_w)
|
||||
from app.validate import config_notes, validate_startup_config # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CAP = float(rig.CAP_W)
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. peak_margin_w 0: byte-identical to release/1.0 ed739d4")
|
||||
# Printed by `python3 tools/safety79_identity.py 0` on the ed739d4 tree (the
|
||||
# tool copied in, nothing else changed; that tree ignores the key), in the CI
|
||||
# image; re-printed 2026-09-29 (review) after adding the charge headroom, the
|
||||
# shaping suspension and the two maintenance scenarios. 540 cycles each.
|
||||
ED739D4 = {
|
||||
"n1": "82316f7d4362fae4c035a1bed5e7907cf362faba994c634d0b1c356c501f53e5",
|
||||
"n2": "0dc24e16a8390ec42edacf735eaf20273847b33b9c67d7cdc536df0ca739e421",
|
||||
"n1_off": "eed44f058f3fcc6e7651c5c84c6fa6313e7520035ad7cac282012cf6909668a0",
|
||||
"n2_off": "69796c107b9f78892e04d282b754f889590acf9fe1b96f09525f5ea2005e9c0a",
|
||||
"n1_maint":
|
||||
"bd4a88a10aa840a360ac781ac04e30b040a5aac5426fabba1fb259d71dfeba67",
|
||||
"n2_maint":
|
||||
"0656bc5a4a2fa3a7455822fdd7da5f68f57fd9593f374e869a0d30ba7e429275",
|
||||
}
|
||||
_zero = rig.digests(0)
|
||||
_default = rig.digests()
|
||||
for k in ED739D4:
|
||||
print(f" {k:7} margin 0 {'same' if _zero[k] == ED739D4[k] else 'DIFFERS'}"
|
||||
f" {_zero[k][:16]}, default {_default[k][:16]}")
|
||||
check("⭐ at N=1 and N=2, with and without battery modes and with a "
|
||||
"maintenance charge, margin 0 is "
|
||||
"byte-identical to ed739d4", _zero == ED739D4)
|
||||
check("...and the default (key absent) changes every scenario, so the "
|
||||
"scenarios do reach the margin", all(_default[k] != ED739D4[k]
|
||||
for k in ED739D4))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the guard aims at cap - margin")
|
||||
_runs = {}
|
||||
for name, spec in rig.SCENARIOS.items():
|
||||
for m in (None, 0, 120):
|
||||
_runs[name, m] = rig.run(**spec, margin=m, dur=3600.0)
|
||||
for name in rig.SCENARIOS:
|
||||
g = _runs[name, None][1].peak_guard
|
||||
print(f" {name}: margin {_runs[name, None][1].peak_margin_w:.0f} W, "
|
||||
f"aims at {g.target_w:.0f} W, limit {g.ceiling_w:.0f} W")
|
||||
check("test_target_is_cap_minus_margin: with the key absent every scenario "
|
||||
"(N=1, N=2, modes on and off) aims at cap - 50 and reports the cap",
|
||||
all(_runs[n, None][1].peak_margin_w == 50.0
|
||||
and _runs[n, None][1].peak_guard.target_w == CAP - 50
|
||||
and _runs[n, None][1].peak_guard.ceiling_w == CAP
|
||||
for n in rig.SCENARIOS))
|
||||
check("...and a margin of 120 moves the aim to cap - 120, a margin of 0 to "
|
||||
"the cap", all(_runs[n, 120][1].peak_guard.target_w == CAP - 120
|
||||
and _runs[n, 0][1].peak_guard.target_w == CAP
|
||||
for n in rig.SCENARIOS))
|
||||
|
||||
|
||||
def banked(ctl):
|
||||
return {b.start.strftime("%H:%M"): round(b.offtake_avg_w, 2)
|
||||
for b in ctl.p1.blocks}
|
||||
|
||||
|
||||
_b = {m: banked(_runs["n1_off", m][1]) for m in (0, None, 120)}
|
||||
print(f" N=1, the battery Off: quarters banked {_b}")
|
||||
check("test_off_battery_n1: at N=1 with the battery Off, the guard makes it "
|
||||
"give just enough to bank the 11:30 quarter at cap - margin (2500, "
|
||||
"2450, 2380 W for margins 0, 50, 120)",
|
||||
[_b[m]["11:30"] for m in (0, None, 120)]
|
||||
== [CAP, CAP - 50, CAP - 120])
|
||||
|
||||
|
||||
def meter_at(rows, t):
|
||||
row = next((r for r in rows if r[0] == t), None)
|
||||
if row is None:
|
||||
return None, {}
|
||||
return rig.load(t) - sum(w for _u, w in row[2]), dict(row[2])
|
||||
|
||||
|
||||
_g0, _c0 = meter_at(_runs["n2_off", 0][0], 1800.0)
|
||||
_g50, _c50 = meter_at(_runs["n2_off", None][0], 1800.0)
|
||||
print(f" N=2, B Off, 6000 W house: margin 0 -> {_c0}, meter {_g0} W; "
|
||||
f"margin 50 -> {_c50}, meter {_g50} W")
|
||||
check("test_off_battery_n2: at N=2 with B Off and A at its rating, B covers "
|
||||
"the shortfall to cap - margin, not to the cap",
|
||||
_c0["b"] == 1000.0 and _g0 == CAP
|
||||
and _c50["b"] == 1050.0 and _g50 == CAP - 50)
|
||||
|
||||
def _peak(w, observed=True):
|
||||
return type("R", (), {"is_observed": observed, "peak_w": w})()
|
||||
|
||||
|
||||
# Review (SAFETY-79 point 2): the margin applies under a month peak already
|
||||
# banked, too. Aiming AT it can bank a few W over it, which raises the bill;
|
||||
# and a banked peak between cap - margin and the cap (a quarter the old guard
|
||||
# held at the cap, say) was aimed at exactly, so one overshoot crossed the cap.
|
||||
check("test_margin_under_banked_peak: the aim is the margin under the limit "
|
||||
"that holds - the cap, or a month peak already banked above it",
|
||||
quarter_target_w(CAP, 50, _peak(2499.0)) == CAP - 50
|
||||
and quarter_target_w(CAP, 50, _peak(3000.0)) == 2950.0
|
||||
and quarter_target_w(CAP, 50, _peak(3000.0, observed=False)) == CAP - 50
|
||||
and quarter_target_w(CAP, 50, None) == CAP - 50
|
||||
and quarter_target_w(CAP, 0, _peak(3000.0)) == 3000.0)
|
||||
|
||||
|
||||
def _decide(banked_w, projected_w):
|
||||
"""Half a quarter at `projected_w`, battery idle, a month peak banked."""
|
||||
from datetime import datetime, timezone
|
||||
from app.tariff import Projection
|
||||
pr = Projection(projected_w, projected_w, 450.0, 1.0, False,
|
||||
datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc),
|
||||
projected_w)
|
||||
return peakguard.decide(pr, batt_w=0.0, cap_w=CAP, margin_w=50,
|
||||
reading=_peak(banked_w), achievable_w=5000.0,
|
||||
soc=60.0, soc_floor=20.0)
|
||||
|
||||
|
||||
_d1, _d2 = _decide(2499.0, 2600.0), _decide(3000.0, 3100.0)
|
||||
print(f" banked 2499 W: floor {_d1.floor_w}, limit {_d1.ceiling_w}; "
|
||||
f"banked 3000 W: floor {_d2.floor_w}, limit {_d2.ceiling_w}")
|
||||
check("...and decide() sizes the floor to land the quarter 50 W under it "
|
||||
"(2449 and 2950 W), while the reported limit stays the cap or the peak",
|
||||
_d1.floor_w == 2 * (2600.0 - 2450.0) and _d1.ceiling_w == CAP
|
||||
and _d2.floor_w == 2 * (3100.0 - 2950.0) and _d2.ceiling_w == 3000.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the bill and the reports keep the real cap")
|
||||
_c = _runs["n1_off", None][1]
|
||||
_pk = _c.peak.peak().peak_w
|
||||
print(f" month peak {_pk:.0f} W, tracker cap {_c.peak.cap_w:.0f} W, "
|
||||
f"warning {_c.peak.warning_frac():.3f}, headroom "
|
||||
f"{_c.peak.headroom_w():.0f} W")
|
||||
check("test_billing_uses_cap: the month-peak tracker (bill, headroom, 80 % "
|
||||
"warning) is built on the real cap, not cap - margin",
|
||||
_c.peak.cap_w == CAP and _c.peak.warning_frac() == _pk / CAP
|
||||
and _c.peak.headroom_w() == CAP - _pk)
|
||||
_eng = [g for r in _runs["n1_off", None][0] for g in (r[-1][1],)
|
||||
if g and g[0] and g[1]]
|
||||
print(f" {len(_eng)} engaged cycles; one: {_eng[0][4] if _eng else None}")
|
||||
check("test_reports_use_cap: every engaged sentence names the real limit and "
|
||||
"the aim beside it", _eng and all(
|
||||
g[2] == CAP and "against a 2500 W limit (peak_cap_w), aiming at "
|
||||
"2450 W" in g[4] for g in _eng))
|
||||
check("...and the settings note compares the month peak with the real cap: "
|
||||
"a 2480 W peak under a 2500 W cap is no warning, 2520 W is",
|
||||
config_notes({"peak_cap_w": 2500, "peak_margin_w": 50},
|
||||
month_peak_w=2480) == []
|
||||
and [n.keys for n in config_notes({"peak_cap_w": 2500,
|
||||
"peak_margin_w": 50},
|
||||
month_peak_w=2520)]
|
||||
== [("peak_cap_w",)])
|
||||
_lim = _eng[0][2] if _eng else 0.0
|
||||
_notice = messages.peak_saturated(_lim)
|
||||
check("...and the user-facing peak notice built from the decision's limit "
|
||||
"says the cap", messages.watts(CAP) in _notice.body
|
||||
and messages.watts(CAP - 50) not in _notice.body)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. each call site in main.py")
|
||||
with open(os.path.join(HERE, "app", "main.py"), encoding="utf-8") as f:
|
||||
_src = f.read()
|
||||
_tree = ast.parse(_src)
|
||||
|
||||
|
||||
def calls(name):
|
||||
return [n for n in ast.walk(_tree) if isinstance(n, ast.Call)
|
||||
and isinstance(n.func, (ast.Attribute, ast.Name))
|
||||
and getattr(n.func, "attr", getattr(n.func, "id", "")) == name]
|
||||
|
||||
|
||||
def kw(call, key):
|
||||
if not present(call):
|
||||
return "absent"
|
||||
return next((ast.get_source_segment(_src, k.value) for k in call.keywords
|
||||
if k.arg == key), "absent")
|
||||
|
||||
|
||||
def arg(call, i):
|
||||
if not present(call) or len(call.args) <= i:
|
||||
return "absent"
|
||||
return ast.get_source_segment(_src, call.args[i])
|
||||
|
||||
|
||||
_dec = [c for c in calls("decide")
|
||||
if getattr(c.func, "value", None) is not None
|
||||
and getattr(c.func.value, "id", "") == "peakguard"]
|
||||
_shape = calls("shaping_suspended")
|
||||
_maint = calls("maintenance_charge_floor")
|
||||
_tracker = calls("PeakTracker")
|
||||
_d, _s, _m, _t = (nth(_dec, 0, "peakguard.decide()"),
|
||||
nth(_shape, 0, "shaping_suspended()"),
|
||||
nth(_maint, 0, "maintenance_charge_floor()"),
|
||||
nth(_tracker, 0, "PeakTracker()"))
|
||||
print(f" decide(cap_w={kw(_d, 'cap_w')}, margin_w={kw(_d, 'margin_w')})")
|
||||
print(f" shaping_suspended(..., {arg(_s, 2)})")
|
||||
print(f" maintenance_charge_floor(..., {arg(_m, 2)})")
|
||||
print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})")
|
||||
check("test_call_sites: the guard gets the real cap AND the margin; the "
|
||||
"forecast checks get cap - margin; the month-peak tracker gets the "
|
||||
"real cap",
|
||||
len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w")
|
||||
and kw(_d, "margin_w") == "self.peak_margin_w"
|
||||
and len(_shape) == 1 and len(_maint) == 1
|
||||
and arg(_s, 2) == "self.peak_aim_w()"
|
||||
and arg(_m, 2) == "self.peak_aim_w()"
|
||||
and len(_tracker) == 1
|
||||
and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))')
|
||||
check("test_aim_w: `peak_aim_w()` is cap - margin, and the forecast sizing "
|
||||
"gives a maintenance charge only what is left under it",
|
||||
_c.peak_aim_w() == CAP - 50
|
||||
and maintenance_charge_floor(2000.0, 2400.0, _c.peak_aim_w()) == 50.0
|
||||
and peakguard.shaping_suspended(None, 2470.0, _c.peak_aim_w())
|
||||
and not peakguard.shaping_suspended(None, 2470.0, CAP))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the setting")
|
||||
|
||||
|
||||
with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as f:
|
||||
_hits = re.findall(r"^ peak_margin_w:\s*(\S+)", f.read(), re.M)
|
||||
print(f" config.yaml: {_hits}")
|
||||
check("config.yaml ships 50, int(0,500), and the in-code mirror matches",
|
||||
_hits == ["50", "int(0,500)"]
|
||||
and PEAK_OPTION_DEFAULTS["peak_margin_w"] == 50
|
||||
and PEAK_MARGIN_RANGE_W == (0, 500))
|
||||
|
||||
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"max_w": 2000, "peak_cap_w": 3500}
|
||||
_base_problems = validate_startup_config(BASE)
|
||||
|
||||
|
||||
def refused(value, **more):
|
||||
opts = dict(BASE, peak_margin_w=value, **more)
|
||||
return [p for p in validate_startup_config(opts)
|
||||
if p not in _base_problems]
|
||||
|
||||
|
||||
_ok = {v: refused(v) for v in (0, 50, 500)}
|
||||
_bad = {v: refused(v) for v in (-1, 501, 12.5, "abc")}
|
||||
for v, p in _bad.items():
|
||||
print(f" {v!r}: {p}")
|
||||
check("test_margin_range: 0, 50 and 500 are accepted, and so is the key "
|
||||
"absent", not any(_ok.values()) and _base_problems == [])
|
||||
check("...and below 0, above 500, not whole, or not a number is refused, "
|
||||
"once, naming the setting and the range",
|
||||
all(len(p) == 1 and "“Margin below the peak limit” (peak_margin_w)"
|
||||
in p[0] and "from 0 to 500 W" in p[0] for p in _bad.values()))
|
||||
_high = refused(500, peak_cap_w=500)
|
||||
print(f" 500 under a 500 W limit: {_high}")
|
||||
check("...and a margin that leaves nothing under the limit is refused",
|
||||
len(_high) == 1 and "not below" in _high[0])
|
||||
_style = [text_problems(p[0], keys=("peak_margin_w", "peak_cap_w"),
|
||||
entities=False) for p in list(_bad.values()) + [_high]]
|
||||
check(f"...in plain words (STYLE.md): {_style}", not any(_style))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -368,7 +368,7 @@ check("...and it grows monotonically through the quarter, so the guard is at "
|
||||
BATT_NOW = 400.0
|
||||
a_edge = averager_at(CEIL, 450.0)
|
||||
p_edge = PeakTracker.project(a_edge, B + timedelta(seconds=450))
|
||||
d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_edge = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
e_edge = excess_w(p_edge, CEIL)
|
||||
print(f" held exactly AT the ceiling for 450 s: projected "
|
||||
@@ -383,7 +383,7 @@ check("at the engagement boundary the excess is zero, so the floor is exactly "
|
||||
# than on the excess. Re-derived here rather than described: a guard that
|
||||
# released as soon as `excess <= 0` hands the actuator back at the exact instant
|
||||
# it is working, and the load springs straight back.
|
||||
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
released = decide(p_edge, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
would_release_on_excess = excess_w(p_edge, CEIL) <= 0.0
|
||||
# ⚠️ `floor_w` IS FORMATTED DEFENSIVELY, AND MUTATION.md's OWN LEDGER IS WHY.
|
||||
@@ -404,7 +404,7 @@ check("...and the claim is held there, not released. `excess` falls to zero as "
|
||||
"arithmetic read as a level", would_release_on_excess and released.engaged)
|
||||
d_gone = decide(PeakTracker.project(averager_at(500.0, 450.0),
|
||||
B + timedelta(seconds=450)),
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
check("...and it DOES release once the load falls under what the quarter can "
|
||||
"afford, so 'threshold the floor' is not 'never let go'",
|
||||
@@ -413,7 +413,7 @@ check("...and it DOES release once the load falls under what the quarter can "
|
||||
# A hair over, and the floor is the battery plus the excess. Not a constant.
|
||||
a_over = averager_at(CEIL + 200.0, 450.0)
|
||||
p_over = PeakTracker.project(a_over, B + timedelta(seconds=450))
|
||||
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_over = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" 200 W over for 450 s: {d_over.explain()}")
|
||||
check("a quarter projected over the ceiling engages, and the floor is the "
|
||||
@@ -422,7 +422,7 @@ check("a quarter projected over the ceiling engages, and the floor is the "
|
||||
and near(d_over.floor_w, BATT_NOW + excess_w(p_over, CEIL), 1e-6))
|
||||
check("...and the floor moves with the battery measurement, so it is a claim "
|
||||
"about delivered power rather than a fixed setpoint",
|
||||
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, reading=None,
|
||||
not near(decide(p_over, batt_w=BATT_NOW + 250.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR).floor_w,
|
||||
d_over.floor_w, 1.0))
|
||||
|
||||
@@ -433,7 +433,7 @@ intent = 0.0
|
||||
delivered_floor = []
|
||||
intent_floor = []
|
||||
for _ in range(5):
|
||||
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, reading=None,
|
||||
d_del = decide(p_over, batt_w=stuck_batt, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
delivered_floor.append(d_del.floor_w)
|
||||
intent = intent + excess_w(p_over, CEIL) # what the intent form would do
|
||||
@@ -516,7 +516,7 @@ check(f"a loop already discharging harder ({BIG:.0f} W) than the floor is not "
|
||||
absurd = averager_at(LIVE_MAX_W * 4, 870.0)
|
||||
p_absurd = PeakTracker.project(absurd, B + timedelta(seconds=870))
|
||||
DEV_MAX = LIVE_MAX_W - 500.0 # a device rating tighter than max_w
|
||||
d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, reading=None,
|
||||
d_absurd = decide(p_absurd, batt_w=1000.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=min(LIVE_MAX_W, DEV_MAX), max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
r6 = resolve([Claim.limit("device", P_SAFETY, -DEV_MAX, DEV_MAX, "rating"),
|
||||
Claim.limit("supervised", P_SAFETY, -LIVE_MAX_W, LIVE_MAX_W, "max_w"),
|
||||
@@ -599,7 +599,7 @@ print(f" a real PeakStore on a missing volume: state={rd_broken.state}, "
|
||||
f"headroom_w()={tr_broken.headroom_w(B)}")
|
||||
check("the real store answers NOT_OBSERVED rather than a peak of 0 W",
|
||||
rd_broken.state == NOT_OBSERVED and rd_broken.peak_w is None)
|
||||
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, reading=rd_broken,
|
||||
d_broken = decide(p_over, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=rd_broken,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" {d_broken.explain()}")
|
||||
check("AND THE GUARD STILL ENGAGES ON IT. An unreadable peak must not disarm "
|
||||
@@ -627,7 +627,7 @@ print("4. blindness: three ways a projection is not evidence")
|
||||
# (a) NO OPEN BLOCK. A fresh averager projects 0 W - under every ceiling.
|
||||
fresh = QuarterAverager(phases=1, max_hold_s=MAX_HOLD_S)
|
||||
p_fresh = PeakTracker.project(fresh, B)
|
||||
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||
d_fresh = decide(p_fresh, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" fresh averager: projected {p_fresh.projected_w:.0f} W, "
|
||||
f"block_start={p_fresh.block_start}; {d_fresh.explain()}")
|
||||
@@ -655,7 +655,7 @@ p_frozen = PeakTracker.project(frozen_av, B + timedelta(seconds=600))
|
||||
RAILED = LIVE_MAX_W # the loop has wound to the rail
|
||||
is_frozen = looks_frozen(FREEZE_S + 1.0, -RAILED, LIVE_MAX_W,
|
||||
FreezeWatchdogConfig(hold_s=FREEZE_S))
|
||||
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||
d_frozen = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
|
||||
frozen=is_frozen)
|
||||
print(f" meter stuck at -4000 W with the command at {-RAILED:+.0f} W: "
|
||||
@@ -680,7 +680,7 @@ check("...and the same command NOT frozen is left alone, so the blind branch is
|
||||
"not simply always on",
|
||||
near(resolve(SAFE + [Claim.set("loop", P_LOOP, -RAILED, "grid"),
|
||||
peakguard.claim(decide(
|
||||
p_frozen, batt_w=-RAILED, cap_w=CAP_W,
|
||||
p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0,
|
||||
reading=None, achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR, frozen=False))
|
||||
or Claim.limit("noop", P_PEAK)]).target_w,
|
||||
@@ -692,7 +692,7 @@ gapped = averager_at(4200.0, 600.0, gap_at=200.0, gap_s=GAP)
|
||||
p_gap = PeakTracker.project(gapped, B + timedelta(seconds=600))
|
||||
clean = averager_at(4200.0, 600.0)
|
||||
p_clean = PeakTracker.project(clean, B + timedelta(seconds=600))
|
||||
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||
d_gap = decide(p_gap, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" a {GAP:.0f} s hole at 4200 W: uncovered {uncovered_s(p_gap):.0f} s, "
|
||||
f"projected {p_gap.projected_w:.0f} W vs {p_clean.projected_w:.0f} W with "
|
||||
@@ -756,7 +756,7 @@ check("a renamed held value now raises AttributeError on the first cycle. The "
|
||||
OLD = (p_held.partial_ws if hasattr(p_held, "partial_ws")
|
||||
else HELD * 300.0) # acc, which the old form would still have had
|
||||
old_projected = (HELD * 300.0 + 0.0 * (QUARTER_S - 300.0)) / QUARTER_S
|
||||
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||
d_true = decide(p_held, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
print(f" same fixture: shipped projection {p_held.projected_w:.0f} W "
|
||||
f"(engaged={d_true.engaged}), the defaulted form's "
|
||||
@@ -832,7 +832,7 @@ def replay(path, *, guarded: bool, cap_w=CAP_W, max_w=LIVE_MAX_W, pack=None,
|
||||
next_cycle = t + timedelta(seconds=CYCLE_S)
|
||||
cycles += 1
|
||||
d = decide(PeakTracker.project(ing.averager, t),
|
||||
batt_w=batt, cap_w=cap_w, reading=tracker.peak(t),
|
||||
batt_w=batt, cap_w=cap_w, margin_w=0.0, reading=tracker.peak(t),
|
||||
achievable_w=max_w, max_hold_s=MAX_HOLD_S, soc=soc,
|
||||
soc_floor=floor_pct, frozen=False)
|
||||
if d.floor_w is not None and d.floor_w < 0.0:
|
||||
@@ -1175,7 +1175,9 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
|
||||
int(now.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc)
|
||||
span = int((now - blk).total_seconds())
|
||||
remaining = max(1.0, QUARTER_S - (now - blk).total_seconds())
|
||||
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
|
||||
# SAFETY-79: sized against what the guard aims at, cap - margin.
|
||||
net_w = (CAP_W - ctl.peak_margin_w
|
||||
+ want_floor_w * remaining / QUARTER_S)
|
||||
for i in range(0, span + 1, 5):
|
||||
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
|
||||
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
|
||||
@@ -1257,6 +1259,13 @@ check(f"the saturating fixture ({NET2:.0f} W) is a load the real ingest "
|
||||
f"accepts, not one it rejects as implausible", NET2 < PLAUSIBLE_MAX_W)
|
||||
print(f" saturated case: {c2.peak_guard.explain()}")
|
||||
check("a load larger than the inverter saturates the guard", c2.peak_guard.saturated)
|
||||
# SAFETY-81 review: the alarm must hold ALARM_HOLD_S first. The fixture runs on
|
||||
# the wall clock, so the start of the hold is moved back instead of waited out.
|
||||
check("...but not on its first cycle: it must hold peakguard.ALARM_HOLD_S "
|
||||
"(SAFETY-81)", hass2.notifications.count("goodwe_peak_saturated") == 0
|
||||
and c2._peak_high_since is not None)
|
||||
c2._peak_high_since -= timedelta(seconds=peakguard.ALARM_HOLD_S)
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c2.cycle())
|
||||
check("...and THAT raises exactly one notification, with its own id - the one "
|
||||
"state where the money will be spent and a human is the only remaining "
|
||||
"actuator", hass2.notifications.count("goodwe_peak_saturated") == 1)
|
||||
@@ -1417,7 +1426,8 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
|
||||
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
|
||||
# tolerances that used to absorb the wall clock are gone.
|
||||
_proj3 = _seen["projection"]
|
||||
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
|
||||
_kw = dict(cap_w=CAP_W, margin_w=b3.peak_margin_w, reading=b3.peak.peak(),
|
||||
achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
|
||||
soc_floor=SOC_FLOOR)
|
||||
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
|
||||
@@ -1554,7 +1564,8 @@ check("SAFETY-24 adds exactly the two keys it names and no more: §14's kW/W "
|
||||
"trap `capacity_peak_limit_kw` stays declined, and no speculative "
|
||||
"`peak_guard`/`peak_shave` block appeared beside it",
|
||||
_peak_keys == []
|
||||
and sorted(PEAK_OPTION_DEFAULTS) == ["bms_soc_floor", "peak_soc_floor"])
|
||||
and sorted(PEAK_OPTION_DEFAULTS) == ["bms_soc_floor", "peak_margin_w",
|
||||
"peak_soc_floor"]) # + SAFETY-79
|
||||
# Graded on the parsed call syntax, not on the text: the module docstring names
|
||||
# `peak_cap_w` while arguing that it does not read it, so a grep would be
|
||||
# satisfied by deleting the explanation and would fail on writing one.
|
||||
@@ -1634,13 +1645,13 @@ check("...and the defaulting form that was ACTUALLY shipped once in this "
|
||||
discharge_permitted(float(None or 100), SOC_FLOOR) is None)
|
||||
|
||||
# --- the decision -----------------------------------------------------------
|
||||
d_ok = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_ok = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK,
|
||||
soc_floor=SOC_FLOOR)
|
||||
d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_lim = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_FLOOR, soc_floor=SOC_FLOOR)
|
||||
d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
d_unread = decide(p_hungry, batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=None,
|
||||
soc_floor=SOC_FLOOR)
|
||||
print(f" with battery: {d_ok.explain()}")
|
||||
@@ -1682,7 +1693,7 @@ check("floor-limited and saturated are MUTUALLY EXCLUSIVE, by construction: the
|
||||
# notification that matters.
|
||||
d_quiet = decide(PeakTracker.project(averager_at(500.0, 450.0),
|
||||
B + timedelta(seconds=450)),
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR)
|
||||
print(f" an EMPTY pack on a quiet quarter: {d_quiet.explain()}")
|
||||
@@ -1694,7 +1705,7 @@ check("an empty pack on a quarter that does not need shaving is IDLE, not "
|
||||
and d_quiet.floor_w is None)
|
||||
# And the BLIND branch is untouched: it never forces a discharge, so there is
|
||||
# nothing for an energy floor to bound there.
|
||||
d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||
d_blind_low = decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_FLOOR - 5.0, soc_floor=SOC_FLOOR, frozen=True)
|
||||
check("...and an empty pack does not disturb the BLIND branch either: SAFETY-"
|
||||
@@ -1751,7 +1762,7 @@ for load in (0.0, CEIL - 500.0, CEIL, CEIL + 300.0, CEIL + 3000.0,
|
||||
for bw in (-2000.0, 0.0, 400.0, 4000.0):
|
||||
for sc in (None, 0.0, BMS_FLOOR, SOC_FLOOR, SOC_FLOOR + 0.5, 100.0):
|
||||
for fl in (SOC_FLOOR, BMS_FLOOR + 1.0, 55.0):
|
||||
_d = decide(_pr, batt_w=bw, cap_w=CAP_W, reading=None,
|
||||
_d = decide(_pr, batt_w=bw, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W,
|
||||
max_hold_s=MAX_HOLD_S, soc=sc, soc_floor=fl)
|
||||
SWEEP_N += 1
|
||||
@@ -1798,8 +1809,8 @@ check("`soc` and `soc_floor` are REQUIRED keyword-only arguments of decide() "
|
||||
"with no default. A default on either is TEST-14's exact shape and would "
|
||||
"be the quiet fallback this card exists to remove - and a default on "
|
||||
"`soc_floor` would additionally be CFG-03's fourth defaults site",
|
||||
_required_kw == {"batt_w", "cap_w", "reading", "achievable_w", "soc",
|
||||
"soc_floor"})
|
||||
_required_kw == {"batt_w", "cap_w", "margin_w", "reading",
|
||||
"achievable_w", "soc", "soc_floor"})
|
||||
_req_fields = tuple(f.name for f in dataclasses.fields(PeakDecision)
|
||||
if f.default is dataclasses.MISSING
|
||||
and f.default_factory is dataclasses.MISSING)
|
||||
@@ -1948,7 +1959,7 @@ check("...and it reaches the action log too", any("FLOOR_LIMITED" in e
|
||||
for e in c24.events))
|
||||
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
|
||||
# at 0 W in that state, so the guard's own answer is what is graded here.
|
||||
d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, reading=None,
|
||||
d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=None, soc_floor=SOC_FLOOR)
|
||||
check("decide() handed soc=None answers floor-limited rather than a full "
|
||||
@@ -2226,7 +2237,7 @@ def replay_loop(window, *, start_soc, soc_floor, usable_wh=REF_PACK_WH):
|
||||
next_cycle = t + timedelta(seconds=CYCLE_S)
|
||||
cycles += 1
|
||||
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
|
||||
cap_w=CAP_W, reading=tracker.peak(t),
|
||||
cap_w=CAP_W, margin_w=0.0, reading=tracker.peak(t),
|
||||
achievable_w=LOOP_MAX_W, max_hold_s=MAX_HOLD_S, soc=soc,
|
||||
soc_floor=soc_floor, frozen=False)
|
||||
dec = compute(prev_w, w - batt, batt, LOOP_TUNING, sat_count=sat,
|
||||
@@ -2653,9 +2664,32 @@ check("...and so does the persistent notification, which outlives the page and "
|
||||
#
|
||||
# If you are here because you added a TWELFTH, that argument is what this line
|
||||
# wants from you, not an edit.
|
||||
# ⚠️ SAFETY-73 ADDED `refused` (a P_SAFETY unit limit on a measured refusal).
|
||||
# ⚠️ OPT-11 ADDED `mode`, ON PURPOSE, AND IT IS NOT THE DEFERRED RESERVE EITHER.
|
||||
# `mode` is `Claim.limit("mode", P_MODE, lo, hi)` on ONE inverter's actuator:
|
||||
# the owner's per-battery choice (on/off, Solar first, Leftover solar), filed
|
||||
# only with `battery_modes_enabled` on (default off). It is not conditioned on
|
||||
# a state of charge, and it cannot bound the site against the peak floor:
|
||||
# 1. stage 1 keeps it APART from the hard limits (UnitBounds.mode_*), so it
|
||||
# never enters a `lo > hi` test, not even the unit's own;
|
||||
# 2. stage 3 applies it only while the site target was decided at or below
|
||||
# P_MODE (35), so the guard's floor (P_PEAK, 50) RELEASES it, and at N=1
|
||||
# `resolve()` applies it to the site by the same rule, after stage 2;
|
||||
# 3. its range always holds 0 W, so it can forbid a direction and never
|
||||
# force one (a range without 0 is dropped as a software fault).
|
||||
# test_arbiter.py part 9 grades the no-contradiction claim at N=2, over 625
|
||||
# cycles with the floor, and part 10 at N=1. The sweeps below never reach it:
|
||||
# their fixtures leave battery modes off, so they still see {device,
|
||||
# supervised, loop}. DECISIONS/OPT-11-battery-modes.md.
|
||||
# ⚠️ SAFETY-78 ADDED `drain`, AND IT IS NOT THE DEFERRED RESERVE. It is a
|
||||
# site `lo` at P_MAINTENANCE, filed only at N>=2 while a pack's maintenance
|
||||
# drain is pinned, equal to what that pin places. It is not conditioned on a
|
||||
# state of charge, it never closes discharge (a `lo`, not a `hi`), and it is
|
||||
# never filed at N=1, so the sweeps below still see {device, supervised,
|
||||
# loop}. DECISIONS/SAFETY-78-drain-at-two.md.
|
||||
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
"arbitrage", "maintenance", "loop", "circulation",
|
||||
"sign_site", "pack", "refused"}
|
||||
"sign_site", "pack", "refused", "mode", "drain"}
|
||||
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
|
||||
# that a THIRD such indirection reads as an unrecognised append rather than as a
|
||||
# silent gap in the scan.
|
||||
@@ -2697,7 +2731,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
|
||||
"- a claim filed through a shape it cannot parse has to fail here rather "
|
||||
"than be counted as nothing, which is how a scan like this goes quietly "
|
||||
"vacuous", len(_appends) >= 10 and None not in _filed)
|
||||
check("...and the sources that cycle can file are EXACTLY these TWELVE. THIS "
|
||||
check("...and the sources that cycle can file are EXACTLY these FOURTEEN. THIS "
|
||||
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
|
||||
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
|
||||
"reserve is a new source. Filing one takes this line red on the line it "
|
||||
@@ -2837,7 +2871,7 @@ print(" site: " + ", ".join(sorted(s for s, v in CLAIM_SCOPE.items()
|
||||
+ " | per-inverter: "
|
||||
+ ", ".join(sorted(s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT)))
|
||||
check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
"SAFETY-30's and OPT-09's additions: the six about the GRID CONNECTION "
|
||||
"SAFETY-30's, OPT-09's and SAFETY-78's additions: the seven about the GRID CONNECTION "
|
||||
"are site claims and the five about a pack, a device or one write are "
|
||||
"per-inverter. `circulation` is a site claim because its quantity is "
|
||||
"defined over the roster and is identically zero for any one unit; "
|
||||
@@ -2846,10 +2880,11 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
|
||||
"per-inverter because since SAFETY-30 it grades one unit's command "
|
||||
"against that unit's OWN measured power",
|
||||
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
|
||||
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
|
||||
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site",
|
||||
"drain"}
|
||||
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
||||
== {"device", "supervised", "failsafe", "maintenance", "pack",
|
||||
"refused"})
|
||||
"refused", "mode"})
|
||||
|
||||
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
|
||||
# real cycle files is a site claim. Observed on the claims main.py's own resolve
|
||||
@@ -2947,9 +2982,11 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
_routed == {"device": "inverter-a", "supervised": "inverter-a",
|
||||
"failsafe": "inverter-a", "maintenance": "inverter-a",
|
||||
"pack": "inverter-a", "refused": "inverter-a",
|
||||
"mode": "inverter-a",
|
||||
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
|
||||
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
|
||||
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
|
||||
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE,
|
||||
"drain": ACTUATOR_SITE})
|
||||
check("...and the roster it was restored to is the shipped one, so nothing "
|
||||
"below inherits a two-inverter Controller",
|
||||
c_src.units == (app_main.UNIT_A,) and len(c_src.inverters) == 1)
|
||||
@@ -3028,13 +3065,13 @@ _unit_sources_unrouted = [
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all THIRTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
check("all FIFTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
|
||||
"of EVERY one of them. An append that computed its actuator, or omitted "
|
||||
"the argument, or hand-wrote a string, fails HERE rather than being "
|
||||
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
||||
"and 10f is in this file because that already happened once",
|
||||
len(_direct) == 13
|
||||
len(_direct) == 15
|
||||
and all(a is not None and a.startswith("self.actuator_of('")
|
||||
for _s, a, _ln in _routing))
|
||||
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
@@ -3051,15 +3088,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
|
||||
"site-scoped sources and is the whole defect for a per-inverter one: a "
|
||||
"`device` limit built inside the roster loop and routed without `inv` "
|
||||
"files inverter B's rating on inverter A, where it binds the wrong "
|
||||
"share. Eight of the thirteen appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and "
|
||||
"SAFETY-73's two `refused` - and all eight pass one. "
|
||||
"share. Nine of the fifteen appends are SCOPE_UNIT - `device`, "
|
||||
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack`, "
|
||||
"SAFETY-73's two `refused` and OPT-11's `mode` - and all nine pass one. "
|
||||
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
|
||||
"the site, beside `circulation`, because the number that tripped it is "
|
||||
"the site target",
|
||||
_unit_sources_unrouted == []
|
||||
and len([r for r in _routing
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 8)
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 9)
|
||||
check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
|
||||
"`actuator=` at the call site to mis-type. Their routing is pinned where "
|
||||
@@ -3068,7 +3105,8 @@ check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"arbitrage.claim() by test_arbitrage.py",
|
||||
sorted(_indirect) == sorted(CLAIM_BUILDERS)
|
||||
and len(_direct) + len(_indirect) == len(_appends))
|
||||
check("...and between them the fifteen appends account for exactly the twelve "
|
||||
check("...and between them the seventeen appends account for exactly the "
|
||||
"fourteen "
|
||||
"sources 10f pins, so the routing pin and the source set are one scan "
|
||||
"and neither can drift from the other",
|
||||
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
|
||||
@@ -3129,7 +3167,7 @@ check("...and naming those three is not an accident of this fixture but the "
|
||||
{c.source for c in _cl2}
|
||||
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
|
||||
"arbitrage", "circulation", "sign_site",
|
||||
"refused"})
|
||||
"refused", "mode", "drain"})
|
||||
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
|
||||
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
|
||||
# WHOSE NUMBERS the bound is built from - and those are two different defects
|
||||
@@ -5443,7 +5481,7 @@ MUT.append(("blindness() returns None - the degenerate 0 W projection is graded"
|
||||
|
||||
# 2. the frozen term dropped from the guard's inputs.
|
||||
MUT.append(("frozen=False - SAFETY-18's term dropped",
|
||||
not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, reading=None,
|
||||
not decide(p_frozen, batt_w=-RAILED, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, soc=SOC_OK, soc_floor=SOC_FLOOR,
|
||||
frozen=False).engaged and d_frozen.engaged,
|
||||
"part 4: 'THE BACKSTOP: SAFETY-18's runaway ... cut to 0 W'"))
|
||||
@@ -5514,7 +5552,7 @@ _early = sum(1 for _t in (500.0, 1000.0, 2000.0)
|
||||
if discharge_permitted(SOC_FLOOR - 1.0, SOC_FLOOR) is not None
|
||||
and not decide(PeakTracker.project(
|
||||
averager_at(_t, 450.0), B + timedelta(seconds=450)),
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, reading=None,
|
||||
batt_w=BATT_NOW, cap_w=CAP_W, margin_w=0.0, reading=None,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_FLOOR - 1.0, soc_floor=SOC_FLOOR).engaged)
|
||||
MUT.append((f"the SoC gate moved above the `wanted > 0` test ({_early} quiet "
|
||||
@@ -5944,7 +5982,7 @@ print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()")
|
||||
_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0),
|
||||
B + timedelta(seconds=450))
|
||||
_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
|
||||
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
|
||||
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, margin_w=0.0, reading=obs_hi,
|
||||
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||
soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||
# UX-08 2d: the row is in plain words now, and the pair is still ordered.
|
||||
@@ -6023,7 +6061,7 @@ def _proj_at(net_w, elapsed):
|
||||
|
||||
def _decide_on(projection, *, batt_w, soc=SOC_OK, cap_w=CAP_W,
|
||||
achievable_w=LIVE_MAX_W, reading=None, frozen=False):
|
||||
return decide(projection, batt_w=batt_w, cap_w=cap_w, reading=reading,
|
||||
return decide(projection, batt_w=batt_w, cap_w=cap_w, margin_w=0.0, reading=reading,
|
||||
achievable_w=achievable_w, soc=soc, soc_floor=SOC_FLOOR,
|
||||
max_hold_s=MAX_HOLD_S, frozen=frozen)
|
||||
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
"""Runnable check for SAFETY-78: a maintenance drain at N=2 no longer stalls.
|
||||
`python3 test_safety78.py`
|
||||
|
||||
At two inverters the drain pins its battery at the drain rate and the site
|
||||
target is split around the pin. Below the pin the other battery had to charge,
|
||||
the allocator refused the opposite directions, the site sat at 0 W and the
|
||||
drain never moved (OPT-11 P1 F1, UX-13 P3 check 5, OPT-11 P3 F4). Now a site
|
||||
`lo` (`drain`, P_MAINTENANCE) keeps the site target at or above what the pin
|
||||
places. Drives the REAL Controller (tools/opt11_identity.py's harness).
|
||||
DECISIONS/SAFETY-78-drain-at-two.md has the record.
|
||||
|
||||
1. N=2, house below, at and above the drain rate, a solar surplus: the drain
|
||||
runs at its rate, no opposite split, no refusal, no log spam, and it ends.
|
||||
2. A drain that cannot give power waits with ONE log line.
|
||||
3. Battery modes on, a Cheap hours battery in a cheap run during the drain:
|
||||
no grid charge, and its card says why.
|
||||
4. The peak guard binds during a drain, and the arbiter's order holds.
|
||||
5. N=1 is byte-identical to release/1.0 5a4cdc2, and the harness bites.
|
||||
6. A seeded sweep of the floor against the loop, trades, the peak floor, a
|
||||
safety stop, refusals and an Off battery.
|
||||
|
||||
Mutations (MUTATION.md, purged copies, graded over this file and the named
|
||||
tests) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_identity as p3 # noqa: E402
|
||||
import safety78_identity as s78 # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app import allocator, arbitrage, homeview, packmode # noqa: E402
|
||||
from app.arbiter import (Claim, P_ARBITRAGE, P_LOOP, P_MAINTENANCE, # noqa
|
||||
P_MODE, P_PEAK, P_SAFETY, pre_resolve, resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import CHARGE, DRAIN # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
M = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def refused(rows):
|
||||
return sum(1 for r in rows if r[7] and "REFUSED" in r[7])
|
||||
|
||||
|
||||
def opposite(rows):
|
||||
return sum(1 for r in rows if cmd(r)["a"] * cmd(r)["b"] < 0)
|
||||
|
||||
|
||||
def drain_run(house, dur=600.0, soc=lambda u, t: 50.0, cut=None, **kw):
|
||||
return rig.run(2, extra=dict(M, **kw.pop("extra", {})), dur=dur,
|
||||
load=lambda t: house(t) if callable(house) else house,
|
||||
soc=soc, cut=cut or (lambda u, t: False), **kw)
|
||||
|
||||
|
||||
def with_peak_db(fn):
|
||||
"""Run `fn()` with the Controller's peak store in a temporary file."""
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s78t-"), "tariff.db")
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig.Controller = real
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. N=2: the drain runs whatever the house uses")
|
||||
RUNS = {h: drain_run(h) for h in (1200.0, 2000.0, 3000.0, -1500.0)}
|
||||
for h, (rows, c) in RUNS.items():
|
||||
print(f" house {h:+.0f} W: last A {cmd(rows[-1])['a']:+.0f} W, B "
|
||||
f"{cmd(rows[-1])['b']:+.0f} W, grid {h - sum(cmd(rows[-1]).values()):+.0f}"
|
||||
f" W; {rows[-1][6]}; refusals {refused(rows)}")
|
||||
check("⭐ below, at and above the drain rate and under a solar surplus, "
|
||||
"battery A is commanded its 2000 W drain on EVERY cycle, so the drain "
|
||||
"moves (it used to sit at 0 W from the first cycle)",
|
||||
all(cmd(r)["a"] == 2000.0 for rows, _c in RUNS.values() for r in rows)
|
||||
and all(c.maints[0].phase == DRAIN for _r, c in RUNS.values()))
|
||||
check("⭐ no cycle splits the site both ways and the allocator refuses "
|
||||
"nothing: battery B never charges while A drains",
|
||||
all(opposite(rows) == 0 and refused(rows) == 0
|
||||
and all(cmd(r)["b"] >= 0.0 for r in rows)
|
||||
for rows, _c in RUNS.values()))
|
||||
check("...and nothing is logged per cycle: no ARBITER line in the action log",
|
||||
not any("ARBITER" in e for _r, c in RUNS.values() for e in c.events))
|
||||
_r3 = RUNS[3000.0][0]
|
||||
check("above the drain rate, B covers the rest of the house in the SAME "
|
||||
"direction and the grid settles at the loop's aim",
|
||||
990.0 <= cmd(_r3[-1])["b"] <= 1030.0
|
||||
and abs(3000.0 - sum(cmd(_r3[-1]).values()) + 10.0) <= 30.0)
|
||||
check("at or below the drain rate, B is held at 0 W and A exports the rest, "
|
||||
"as the drain does at one inverter ('exports the surplus')",
|
||||
all(cmd(RUNS[h][0][-1])["b"] == 0.0 for h in (1200.0, 2000.0, -1500.0))
|
||||
and all(RUNS[h][0][-1][6].startswith("loop -> 2000 W")
|
||||
for h in (1200.0, 2000.0, -1500.0)))
|
||||
check("...and where the loop alone would ask less, the Why names the drain",
|
||||
"limited by drain" in RUNS[1200.0][0][-1][6])
|
||||
|
||||
# The drain ends: A's level falls to the floor, and the cycle moves on.
|
||||
_end_rows, _end = drain_run(1200.0, dur=500.0,
|
||||
soc=lambda u, t: max(5.0, 50.0 - t / 10.0)
|
||||
if u == "a" else 50.0)
|
||||
check("⭐ and the drain ENDS: A reaches the 11 % floor and the cycle moves "
|
||||
"to its charge phase, with no refusal on the way",
|
||||
_end.maints[0].phase == CHARGE and refused(_end_rows) == 0)
|
||||
|
||||
# The check bites: without the floor, the same site stalls as it did.
|
||||
_real = Controller._drain_floor
|
||||
Controller._drain_floor = lambda self, claims, results: None
|
||||
try:
|
||||
_bite, _bc = drain_run(1200.0, dur=120.0)
|
||||
finally:
|
||||
Controller._drain_floor = _real
|
||||
print(f" without the drain floor: refusals {refused(_bite)}/{len(_bite)}, "
|
||||
f"last A {cmd(_bite[-1])['a']:+.0f} W")
|
||||
check("non-vacuous: with the floor taken out, the same fixture refuses every "
|
||||
"cycle and parks A at 0 W (the reported defect)",
|
||||
refused(_bite) == len(_bite) and cmd(_bite[-1])["a"] == 0.0
|
||||
and any("ARBITER" in e for e in _bc.events))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a drain that cannot give power waits, with one log line")
|
||||
_w_rows, _w = drain_run(1500.0, cut=lambda u, t: u == "a")
|
||||
_waits = [e for e in _w.events if "cannot give power now" in e]
|
||||
print(f" A refuses to give: {_waits}; last {_w_rows[-1][2]}")
|
||||
check("⭐ A refuses to give power (SAFETY-73): the cycle waits, the line is "
|
||||
"logged ONCE over 120 cycles, and nothing is refused",
|
||||
len(_waits) == 1 and _w.inverters[0].refused == 1
|
||||
and refused(_w_rows) == 0 and _w.maints[0].phase == DRAIN)
|
||||
check("...and B covers the house on its own meanwhile",
|
||||
1400.0 <= cmd(_w_rows[-1])["b"] <= 1600.0)
|
||||
|
||||
# PR #154 review: one line per episode, across a probe and a second episode.
|
||||
_pr_rows, _pr = drain_run(1500.0, dur=1300.0, cut=lambda u, t: u == "a")
|
||||
check("the SAFETY-73 probe (A pinned at 200 W, not by maintenance) does not "
|
||||
"end the wait: still ONE line over 1300 s with a probe in it",
|
||||
any(cmd(r)["a"] == 200.0 for r in _pr_rows)
|
||||
and sum("cannot give power now" in e for e in _pr.events) == 1)
|
||||
_e2_rows, _e2 = drain_run(1500.0, dur=2600.0, cut=lambda u, t: u == "a" and (
|
||||
t < 600.0 or t >= 1400.0))
|
||||
check("...and a second episode, after the probe ends the first, logs again",
|
||||
sum("cannot give power now" in e for e in _e2.events) == 2)
|
||||
_cw_rows, _cw = drain_run(-1500.0, dur=300.0, cut=lambda u, t: u == "a")
|
||||
_cw_cards = homeview.battery_rows(_cw, False, True)
|
||||
check("while A waits, B may take the solar surplus, so its card does not "
|
||||
"say 'This one does not charge'",
|
||||
cmd(_cw_rows[-1])["b"] < 0.0 and _cw_cards[1]["why_key"] != "other_cycle")
|
||||
|
||||
|
||||
def reject_a(c, t):
|
||||
"""Every write to A is rejected, so A's link goes stale (not live)."""
|
||||
if not getattr(c.hass, "_rej", False):
|
||||
real = c.hass.set_number
|
||||
|
||||
async def set_number(e, v):
|
||||
return False if e == "number.sp_a" else await real(e, v)
|
||||
c.hass.set_number, c.hass._rej = set_number, True
|
||||
|
||||
|
||||
_l_rows, _l = drain_run(1500.0, dur=400.0, before=reject_a)
|
||||
print(f" A's writes rejected: {_l_rows[-1][2]}, {_l_rows[-1][7][-40:]}")
|
||||
check("⭐ a draining battery that is dropped (stale link) adds no floor: B "
|
||||
"covers the 1500 W house, not the 2000 W drain rate, and the wait is "
|
||||
"logged once",
|
||||
"dropped inverter-a" in _l_rows[-1][7]
|
||||
and cmd(_l_rows[-1])["b"] == 1500.0
|
||||
and sum("cannot give power now" in e for e in _l.events) == 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. battery modes on: no grid charge during the drain")
|
||||
|
||||
|
||||
def cheap_b():
|
||||
s = rig._Store()
|
||||
s.data[unit_key(A, packmode.MODE_KEY)] = packmode.SOLAR_FIRST
|
||||
s.data[unit_key(B, packmode.MODE_KEY)] = packmode.CHEAP_HOURS
|
||||
return s
|
||||
|
||||
|
||||
def modes_run(maint):
|
||||
extra = dict(s79.P1, **p3.ARB, battery_modes_enabled=True,
|
||||
**(M if maint else {}))
|
||||
ticks = []
|
||||
|
||||
def on_cycle(c, t):
|
||||
ticks.append(c.mode_tick)
|
||||
rows, c = with_peak_db(lambda: rig.run(
|
||||
2, extra=extra, dur=1800.0, load=lambda t: 800.0,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False, before=p3._feed,
|
||||
on_cycle=on_cycle, store=cheap_b()))
|
||||
cheap = [(r, k) for r, k in zip(rows, ticks) if 900.0 <= r[0] < 1800.0]
|
||||
return rows, c, cheap
|
||||
|
||||
|
||||
_base_rows, _base_c, _base_cheap = modes_run(False)
|
||||
_m_rows, _m_c, _m_cheap = modes_run(True)
|
||||
check("baseline: with no drain, B (Cheap hours only) charges from the grid "
|
||||
"in the cheap run, so the fixture reaches the charge",
|
||||
any(cmd(r)["b"] < 0.0 for r, _k in _base_cheap)
|
||||
and all(k.roles[B] == "charge" for _r, k in _base_cheap))
|
||||
check("⭐ with A draining, B never charges in that run: its role is not "
|
||||
"'charge', no trade is filed, and the modes say why ('cycle')",
|
||||
all(cmd(r)["b"] >= 0.0 and k.roles[B] != "charge" and k.action is None
|
||||
and k.why == "cycle" for r, k in _m_cheap)
|
||||
and _m_c.maints[0].phase == DRAIN)
|
||||
check("...and the drain runs through the run: A at 2000 W, nothing refused",
|
||||
all(cmd(r)["a"] == 2000.0 for r in _m_rows) and refused(_m_rows) == 0)
|
||||
|
||||
# Battery modes off: the site trade (`arbitrage.decide()`) buys in the cheap
|
||||
# period. It is a site SET at P_ARBITRAGE, below the drain floor.
|
||||
_t_trades = []
|
||||
_t_rows, _t_c = with_peak_db(lambda: rig.run(
|
||||
2, extra=dict(s79.P1, **p3.ARB, **M), dur=1800.0, load=lambda t: 800.0,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False, before=p3._feed,
|
||||
on_cycle=lambda c, t: _t_trades.append(c.arb and c.arb.action)))
|
||||
print(f" modes off, trades seen: {sorted({str(a) for a in _t_trades})}")
|
||||
check("⭐ battery modes off: the site trade still asks to buy in the cheap "
|
||||
"period, and the drain floor keeps it from charging B against the drain: "
|
||||
"A drains at 2000 W, B never charges, nothing is refused",
|
||||
arbitrage.CHARGE in _t_trades
|
||||
and all(cmd(r)["a"] == 2000.0 and cmd(r)["b"] >= 0.0 for r in _t_rows)
|
||||
and refused(_t_rows) == 0)
|
||||
_cards = homeview.battery_rows(_m_c, False, True)
|
||||
print(f" cards: {[(b['name'], b['now'], b['why']) for b in _cards]}")
|
||||
check("⭐ B's card tells the truth: it names the draining battery and says "
|
||||
"it does not charge (it said 'charges from the grid' while resting)",
|
||||
_cards[1]["why_key"] == "other_cycle" and A in _cards[1]["why"]
|
||||
and _cards[0]["why_key"] == "cycle_mode")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the peak guard during a drain, and the order of the claims")
|
||||
|
||||
|
||||
def off_b():
|
||||
s = rig._Store()
|
||||
s.data[unit_key(B, packmode.USE_KEY)] = False
|
||||
return s
|
||||
|
||||
|
||||
_p_rows, _p = with_peak_db(lambda: rig.run(
|
||||
2, extra=dict(s79.P1, battery_modes_enabled=True, **M), dur=600.0,
|
||||
load=lambda t: 6000.0, soc=lambda u, t: 60.0, cut=lambda u, t: False,
|
||||
before=s79._feed, store=off_b()))
|
||||
print(f" house 6000 W, limit {s79.CAP_W} W, B Off: {_p_rows[-1][2]}, "
|
||||
f"{_p_rows[-1][6]}")
|
||||
check("⭐ the peak guard binds during the drain: the site goes above the "
|
||||
"drain to the guard's floor, A keeps draining at 2000 W and B (Off) "
|
||||
"gives only the shortfall, in the same direction",
|
||||
_p.peak_guard.engaged and _p_rows[-1][1] >= _p.peak_guard.floor_w > 2000.0
|
||||
and all(cmd(r)["a"] == 2000.0 and cmd(r)["b"] >= 0.0 for r in _p_rows)
|
||||
and "limited by peak" in _p_rows[-1][6] and refused(_p_rows) == 0)
|
||||
|
||||
|
||||
|
||||
def blind_at(t0):
|
||||
"""The site meter stops answering at `t0`: a safety SET at P_SAFETY."""
|
||||
def before(c, t):
|
||||
if t >= t0 and not getattr(c.hass, "_blind", False):
|
||||
real = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
return None if e == "sensor.grid" else await real(e, invert)
|
||||
c.hass.number, c.hass._blind = number, True
|
||||
return before
|
||||
|
||||
|
||||
_s_rows, _s = drain_run(3000.0, dur=300.0, before=blind_at(150.0))
|
||||
print(f" meter lost at 150 s: {_s_rows[-1][2]}, {_s_rows[-1][6]}")
|
||||
check("⭐ a safety stop outranks the drain at N=2: with the meter gone the "
|
||||
"safety SET parks both batteries at 0 W, and the drain ran before",
|
||||
cmd(_s_rows[20])["a"] == 2000.0
|
||||
and all(cmd(r) == {"a": 0.0, "b": 0.0} for r in _s_rows if r[0] >= 175.0)
|
||||
and _s_rows[-1][6].startswith("safety -> 0 W"))
|
||||
|
||||
UNITS = (A, B)
|
||||
_drain = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, "drain", actuator=A),
|
||||
Claim.limit("drain", P_MAINTENANCE, lo=2000.0, reason="drain")]
|
||||
_order = {
|
||||
"loop under the drain": ([Claim.set("loop", P_LOOP, 500.0)], 2000.0),
|
||||
"a grid charge (trade)": ([Claim.set("arbitrage", P_ARBITRAGE, -2500.0)],
|
||||
2000.0),
|
||||
"a sale bigger than the drain": ([Claim.set("arbitrage", P_ARBITRAGE,
|
||||
3000.0)], 3000.0),
|
||||
"a peak floor above the drain": ([Claim.set("loop", P_LOOP, 500.0),
|
||||
Claim.limit("peak", P_PEAK, lo=3500.0)],
|
||||
3500.0),
|
||||
"a safety stop": ([Claim.set("loop", P_LOOP, 500.0),
|
||||
Claim.set("safety", P_SAFETY, 0.0, "stopped")], 0.0),
|
||||
}
|
||||
_got = {}
|
||||
for k, (extra, want) in _order.items():
|
||||
r = resolve(_drain + extra, units=UNITS)
|
||||
_got[k] = (r.target_w, dict(r.shares), r.allocated)
|
||||
print(f" {k}: target {r.target_w:+.0f} W, shares {dict(r.shares)}")
|
||||
check("⭐ the order holds: a safety stop parks everything; the peak floor "
|
||||
"above the drain wins; a sale above it runs; a grid charge and the "
|
||||
"loop below it are lifted to it; never an opposite split",
|
||||
all(_got[k][0] == want and _got[k][2]
|
||||
and not (min(_got[k][1].values()) < 0 < max(_got[k][1].values()))
|
||||
for k, (_e, want) in _order.items())
|
||||
and _got["a safety stop"][1] == {A: 0, B: 0}
|
||||
and _got["a grid charge (trade)"][1] == {A: 2000, B: 0})
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. N=1: byte-identical to release/1.0 5a4cdc2")
|
||||
# Printed by `python3 tools/safety78_identity.py` on release/1.0 5a4cdc2 (the
|
||||
# same file copied in, nothing else changed), goodwe-addon-checks:1,
|
||||
# 2026-09-29. 480 cycles each, draining throughout, every claim and the
|
||||
# newest action-log line hashed.
|
||||
AT_5A4CDC2 = {
|
||||
"n1_drain":
|
||||
"55fcfc4a0b9251746f315cfdea607a33640d3a81faf6604c4718a04bbc7dd776",
|
||||
"n1_drain_modes":
|
||||
"93867eb4ee4132b47c030668bd7f07587a353bced2461524e09421125c3c4edf",
|
||||
}
|
||||
_now = s78.digests()
|
||||
for k in AT_5A4CDC2:
|
||||
print(f" {k} {'same' if _now[k] == AT_5A4CDC2[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at one inverter a drain under a house below, at and above its "
|
||||
"rate, a surplus and a load over the peak limit, with battery modes "
|
||||
"off and on, is byte-identical to 5a4cdc2: commands, reasons, roles, "
|
||||
"trades and every claim",
|
||||
_now == AT_5A4CDC2)
|
||||
Controller._drain_floor = lambda self, claims, results: (
|
||||
2000.0 if any(r.phase == DRAIN for r in results) else None)
|
||||
try:
|
||||
_leak = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
|
||||
_same = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
|
||||
finally:
|
||||
Controller._drain_floor = _real
|
||||
_ref = s78.digest(s78.run(**s78.SCENARIOS["n1_drain"], dur=60.0)[0])
|
||||
check("non-vacuous: filing the drain floor at N=1 (which moves no watt "
|
||||
"there) moves the digest, because the harness hashes the claims",
|
||||
_leak != _ref and _leak == _same)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the floor against every other claim (PR #154 review, seeded sweep)")
|
||||
_rnd = random.Random(78)
|
||||
_bad, _peak_above, _off_gave = [], 0, 0
|
||||
for _i in range(20000):
|
||||
ma, mb = _rnd.choice((2500., 3000., 5000.)), _rnd.choice((1500., 2500.))
|
||||
ua = [Claim.limit("supervised", P_SAFETY, -ma, ma, actuator=A),
|
||||
Claim.set("maintenance", P_MAINTENANCE,
|
||||
_rnd.choice((500., 2000., 6000.)), "drain", actuator=A)]
|
||||
if _rnd.random() < .2:
|
||||
ua.append(Claim.limit("pack", P_SAFETY, -ma,
|
||||
_rnd.choice((0., 800.)), actuator=A))
|
||||
ub = [Claim.limit("supervised", P_SAFETY, -mb, mb, actuator=B)]
|
||||
_offb = _rnd.random() < .4
|
||||
if _offb:
|
||||
ub.append(Claim.limit("mode", P_MODE, 0., 0., "off", actuator=B))
|
||||
if _rnd.random() < .15:
|
||||
ub.append(Claim.limit("refused", P_SAFETY, -mb, 0., actuator=B))
|
||||
pin = allocator.pin_w(pre_resolve(ua))
|
||||
site = [Claim.set("loop", P_LOOP, _rnd.uniform(-6000., 8000.))]
|
||||
if pin is not None and pin > 0.0:
|
||||
site.append(Claim.limit("drain", P_MAINTENANCE, lo=pin))
|
||||
if _rnd.random() < .3:
|
||||
site.append(Claim.set("arbitrage", P_ARBITRAGE,
|
||||
_rnd.choice((-3000., 1000., 4000.))))
|
||||
pk = _rnd.uniform(0., 8000.) if _rnd.random() < .4 else None
|
||||
if pk is not None:
|
||||
site.append(Claim.limit("peak", P_PEAK, lo=pk))
|
||||
stop = _rnd.random() < .05
|
||||
if stop:
|
||||
site.append(Claim.set("safety", P_SAFETY, 0.0, "stopped"))
|
||||
r = resolve(ua + ub + site, units=UNITS)
|
||||
s = dict(r.shares)
|
||||
why = []
|
||||
if min(s.values()) < 0 < max(s.values()) or r.contradiction:
|
||||
why.append("opposite or contradiction")
|
||||
if stop and (r.target_w != 0.0 or any(s.values())):
|
||||
why.append("stop not parked")
|
||||
if not stop and pin is not None and pin > 0.0:
|
||||
if r.target_w < pin or s[B] < 0:
|
||||
why.append("below the floor, or B charges")
|
||||
if pk is not None and pk > pin:
|
||||
_peak_above += 1
|
||||
if r.allocated and r.target_w < min(pk, pin + mb):
|
||||
why.append("peak floor not honoured")
|
||||
if _offb and s[B] > 0:
|
||||
_off_gave += 1
|
||||
if not (pk is not None and pk > pin):
|
||||
why.append("Off B used for the drain")
|
||||
if why:
|
||||
_bad.append((_i, why, r.target_w, s, pin, pk))
|
||||
print(f" {_bad[:3]}; peak above the drain {_peak_above}, Off B gave for "
|
||||
f"the peak {_off_gave}")
|
||||
check("⭐ over 20000 seeded claim lists: never an opposite split or a "
|
||||
"contradiction; a safety stop parks both; the site never goes below "
|
||||
"the pin and B never charges; a peak floor above the drain is met; an "
|
||||
"Off battery gives only for the peak floor, never for the drain",
|
||||
not _bad and _peak_above > 1000 and _off_gave > 100)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Runnable check for SAFETY-81, no false "Load too high for battery" alarms.
|
||||
`python3 test_safety81.py`
|
||||
|
||||
Drives the REAL Controller through tools/safety81_sim.py (a 1 s cycle on a
|
||||
plant shaped like the SAFETY-79 rig, one sample in ten late).
|
||||
DECISIONS/SAFETY-81-load-too-high.md has the record.
|
||||
|
||||
1. Every command is byte-identical to release/1.0 5a4cdc2.
|
||||
2. No alarm on the quarter-edge spike, the last seconds, noise (margin 50
|
||||
and 0) or a burst the quarter absorbs.
|
||||
3. Real overloads still alarm, ALARM_HOLD_S after 5a4cdc2.
|
||||
4. peakguard.load_too_high() on its own.
|
||||
5. The hold across a cycle with no battery reading (review).
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import safety81_sim as sim # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.peakguard import load_too_high # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# Printed by `python3 tools/safety81_sim.py` on release/1.0 5a4cdc2 (the tool
|
||||
# copied in, nothing else changed), in the CI image: (alarm times, digest).
|
||||
BASE = {
|
||||
"const4500": ([2699.0], "6ba6b8634f0df9526adc04f03c82db7d083973268deda0d1f07c5519bc2e5e9d"),
|
||||
"const4500_m0": ([2699.0], "2bede28f3ea6ba9294be134c9b9733e43d22a7c24deac3bf8210bbbbe09b2039"),
|
||||
"sine4500": ([2699.0], "bf70c2c23a0eede40c34478d1354b7daa1858e0a0f920693e2dd15fb5046120f"),
|
||||
"edge4600": ([2.0, 902.0, 1802.0, 2699.0], "3cc0c6fec0c70f138530955babc2b416fe7307373037c1b2e927b6ccdaad3a4d"),
|
||||
"edge4600_m0": ([2.0, 902.0, 1802.0, 2699.0], "ba9ba2c042ee42b043315b996ece38b3478dc9866aaf833256c9c29c02f0fe9f"),
|
||||
"burst_end": ([881.0, 1781.0, 2681.0], "5f70e1eb4244820f1d44b494cb80e51f1a4486dee03357c9a8e8f7d371c5d9af"),
|
||||
"noise5000_m50": ([292.0, 2551.0, 2698.0], "48b446e1a78e6d6032b31cb68a4eadb0f74e3aec4cc1e184162e9c65449bfdc5"),
|
||||
"noise5000_m0": ([292.0, 2552.0, 2698.0], "1a0527dee6c2b03dc95a90b4682858c7bf5edf7205c2aced249112f04328c960"),
|
||||
"step6500": ([1201.0, 1801.0], "95e5c90e4a6814526cb4f3bea76286063b449d86a420f888c014531fb6e913a3"),
|
||||
"miss5560": ([1201.0, 1801.0], "cd991cfe1685750598236701a5a2f2f0342d66645609c536d42ab7ba07216e6c"),
|
||||
"ramp6500": ([1296.0, 1801.0], "8db7675535f1a01abf38bd6bfb996a6fe57711d3ecca53541bb1c0870748c46b"),
|
||||
"step7500_reserve": ([], "21eb9c07a4582f47f347084d1c75a28a5d767bc068bc3f74f32433d3239e9ccb"),
|
||||
# Added in review, printed the same way.
|
||||
"sine4500_fast": (["112, about one per 30 s"], "710008fd8f2d33a0e2cbb9a626bedafd6d87bc23712bbc5b74a4af9b3ec21897"),
|
||||
"twice7000": ([1001.0, 1502.0, 2699.0], "1cddda8cf35859efb2ad503f1683f744cb812a6325a390c4fa3df19d717af043"),
|
||||
"step6500_gap": ([1201.0, 1801.0], "95e5c90e4a6814526cb4f3bea76286063b449d86a420f888c014531fb6e913a3"),
|
||||
}
|
||||
OUT = {k: sim.run(**v) for k, v in sim.SCENARIOS.items()}
|
||||
RUN = {k: v[:2] for k, v in OUT.items()}
|
||||
ERR = {k: v[4] for k, v in OUT.items()}
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. every command byte-identical to release/1.0 5a4cdc2")
|
||||
check("the same scenarios as 5a4cdc2", set(RUN) == set(BASE))
|
||||
for k, (alarms, digest) in RUN.items():
|
||||
check(f"{k}: digest {digest[:12]}", digest == BASE[k][1])
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. no false alarm (5a4cdc2's are listed)")
|
||||
for k in ("edge4600", "edge4600_m0", "const4500", "const4500_m0", "sine4500",
|
||||
"noise5000_m50", "noise5000_m0", "burst_end", "sine4500_fast"):
|
||||
check(f"{k}: none (5a4cdc2: {BASE[k][0]})", RUN[k][0] == [])
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. real overloads still alarm, ALARM_HOLD_S after 5a4cdc2")
|
||||
# ALARM_HOLD_S (10 s) plus up to 2 s of late meter samples; at 1800 s the
|
||||
# quarter-edge tick restarts the hold.
|
||||
for k, want in (("step6500", [1211.0, 1813.0]),
|
||||
("miss5560", [1213.0, 1813.0])):
|
||||
print(f" {k}: load steps up at 1200 s; alarms at {RUN[k][0]}, "
|
||||
f"5a4cdc2 {BASE[k][0]}")
|
||||
check(f"{k}: after the hold, and again only at the next quarter",
|
||||
RUN[k][0] == want)
|
||||
check("step6500_gap: cycles stopped by a lost battery reading do not "
|
||||
"restart the hold",
|
||||
RUN["step6500_gap"][0] == [1215.0, 1813.0])
|
||||
check("twice7000: notified once in the quarter (5a4cdc2: "
|
||||
f"{BASE['twice7000'][0]})", RUN["twice7000"][0] == [1011.0])
|
||||
check("twice7000: the ERROR line for both overloads",
|
||||
ERR["twice7000"] == [1011.0, 1512.0])
|
||||
# By design: until 1306 s (then the hold) this quarter would end at most
|
||||
# ALARM_MISS_W over the real 3500 W cap; 5a4cdc2 alarmed at the aim (cap - 50).
|
||||
print(f" ramp6500: alarms at {RUN['ramp6500'][0]}, "
|
||||
f"5a4cdc2 {BASE['ramp6500'][0]}")
|
||||
check("ramp6500: alarms once the quarter will end over the real cap by "
|
||||
"more than ALARM_MISS_W",
|
||||
RUN["ramp6500"][0] == [1316.0, 1813.0])
|
||||
check("step7500 under the peak reserve: not this alarm (the guard is not "
|
||||
"allowed to discharge)", RUN["step7500_reserve"][0] == [])
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. load_too_high()")
|
||||
|
||||
|
||||
def proj(elapsed, done_avg, held):
|
||||
"""A projection `elapsed` s in, averaging `done_avg` so far."""
|
||||
r = 900.0 - elapsed
|
||||
return SimpleNamespace(elapsed_s=elapsed, held_w=held,
|
||||
projected_w=(done_avg * elapsed + max(held, 0) * r)
|
||||
/ 900.0)
|
||||
|
||||
|
||||
def ltl(p, batt, cap=3500.0, rail=2000.0):
|
||||
return load_too_high(p, batt_w=batt, ceiling_w=cap, achievable_w=rail)
|
||||
|
||||
|
||||
check("load 6000 W vs 3500 + 2000: yes", ltl(proj(300, 3450, 4000), 2000))
|
||||
check("already over, the battery can carry the load but not catch up: yes",
|
||||
ltl(proj(450, 7200, 7200), 0, rail=5000))
|
||||
check("the last second, a 7 W miss (under ALARM_MISS_W): no",
|
||||
not ltl(proj(899, 3509, 2500), 1000))
|
||||
check("a miss of exactly ALARM_MISS_W: no",
|
||||
not ltl(proj(450, 3520, 3500), 2000))
|
||||
check("...and 1 W more: yes", ltl(proj(450, 3522, 3500), 2000))
|
||||
check("over cap + battery, but the quarter lands under the cap: no",
|
||||
not ltl(proj(880, 3450, 4000), 2000))
|
||||
check("...and when it does not: yes", ltl(proj(700, 3450, 4000), 2000))
|
||||
check("no battery reading: no", not ltl(proj(300, 3450, 5000), None))
|
||||
check("no seconds left: no", not ltl(proj(900, 4000, 5000), 2000))
|
||||
check("a quarter already over is not won back by export: yes",
|
||||
ltl(proj(800, 3950, 1000), 0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("5. the hold across a cycle with no battery reading")
|
||||
# A reading the stale detector drops (STALE_READING) reaches the alarm as None
|
||||
# while the cycle still runs. Unknown is not "fine": it must not restart the
|
||||
# hold. Driven on Controller.check_peak_saturated() itself, with stubs.
|
||||
Q0 = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
|
||||
notes = []
|
||||
|
||||
|
||||
async def _call(domain, service, data):
|
||||
notes.append(data.get("notification_id"))
|
||||
|
||||
|
||||
stub = SimpleNamespace(
|
||||
peak_guard=SimpleNamespace(saturated=True, ceiling_w=3500.0,
|
||||
reason="PEAK_GUARD: fixture"),
|
||||
site_rating_w=2000.0, shadow=False, events=[],
|
||||
_peak_batt_prev=None, _peak_alarm_block=None, _peak_high_since=None,
|
||||
_peak_saturated=False, hass=SimpleNamespace(call=_call),
|
||||
log_event=lambda msg, hint=None: None)
|
||||
first = None
|
||||
for sec in range(0, 30):
|
||||
stub.site_batt_w = None if 4 <= sec < 8 else 2000.0
|
||||
p = proj(300 + sec, 3450, 6000)
|
||||
p.block_start = Q0
|
||||
stub._peak_proj = p
|
||||
asyncio.run(Controller.check_peak_saturated(stub))
|
||||
if notes and first is None:
|
||||
first = sec
|
||||
check(f"a 4 s gap in the battery reading: alarm at {first} s, not 18 s",
|
||||
first == 10)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)} check(s)")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -76,8 +76,8 @@ check("the mockup's six steps: meter, inverters, solar, prices, peak limit, "
|
||||
and [WS["steps"][str(n)]["name"] for n in range(1, 7)]
|
||||
== ["Your meter", "Inverters and batteries", "Solar", "Prices",
|
||||
"Peak limit", "Check and start"])
|
||||
check("battery modes are not a step yet (OPT-11): step 5 says they come later",
|
||||
"later update" in WS["modes_later"]["description"]
|
||||
check("battery modes are not a wizard step (OPT-11): step 5 points to the Batteries screen",
|
||||
"Batteries screen" in WS["modes_later"]["description"]
|
||||
and not any("mode" in k for k in BY_STEP[5]))
|
||||
SPEC = uipage.setup_spec()
|
||||
check("the spec the page reads is the table's",
|
||||
@@ -631,6 +631,12 @@ print(f" {len(_all)} blobs from {len(B.starts())} starts; "
|
||||
check(f"every plan from a new install is accepted by the shipped validator "
|
||||
f"({len(_new)} plans)", len(_new) >= 25 and not [n for n, r in _new
|
||||
if r])
|
||||
_windows = [(n, b) for n, b, r in _all if "periods" in n and not r]
|
||||
check(f"UX-13 P5: the time-window plans are among them and write the "
|
||||
f"windows as typed ({len(_windows)} blobs)",
|
||||
len(_windows) >= 12 and all(
|
||||
len(json.loads(b["static_profile"])["periods"]) in (1, 2, 5)
|
||||
for _n, b in _windows))
|
||||
_empty = [(n, r) for n, _b, r in _all if n.startswith("empty-")]
|
||||
check(f"every plan from a new install as shipped (no power request entity, "
|
||||
f"setup-only mode) is accepted ({len(_empty)} plans)",
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""UX-13 P5: the page's words, English and Dutch, follow STYLE.md. `python3 test_ui_text.py`
|
||||
|
||||
app/ui_text/en.json and nl.json hold every word the Home, Batteries, Settings
|
||||
and Setup screens show that is not an option label (those are
|
||||
translations/*.yaml, test_translations.py) or a status row (app/messages.py,
|
||||
test_messages.py). This check fails CI and names the offending string when:
|
||||
|
||||
1. the two files differ in keys, or a string's {placeholders} differ;
|
||||
2. a string breaks STYLE.md: a name or label over 6 words, a description over
|
||||
1 sentence of 25 words, any other line over 2 sentences, a semicolon, a
|
||||
banned or jargon word (English and Dutch lists), an option key or entity id;
|
||||
3. a string carries its own details (a link, a section number, a file name)
|
||||
instead of leaving them to the page's DOCS link;
|
||||
4. Home's sentences, rendered by app/homeview.py in each language, break
|
||||
the Home rules of test_messages.py part 8 (checked there too);
|
||||
5. a Dutch string is left in English, outside the short list of words that
|
||||
are the same in both;
|
||||
6. the English words that mirror app/messages.py drift from it.
|
||||
Each rule is shown to catch a spoiled input (habit 1 of MUTATION.md).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
from style_rules import (PLACEHOLDER, sentences, text_problems, # noqa: E402
|
||||
title_problems, words)
|
||||
from app import homeview, messages, uitext # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def load(lang):
|
||||
with open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
|
||||
encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
EN, NL = load("en"), load("nl")
|
||||
|
||||
|
||||
def leaves(node, path=()):
|
||||
"""(path, text) for every string, lists included (the month names)."""
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
yield from leaves(v, path + (k,))
|
||||
elif isinstance(node, list):
|
||||
for i, v in enumerate(node):
|
||||
yield from leaves(v, path + (i,))
|
||||
else:
|
||||
yield path, node
|
||||
|
||||
|
||||
def where(lang, path):
|
||||
return f"{lang}.json:" + ".".join(str(p) for p in path)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. English and Dutch have the same keys and placeholders")
|
||||
_en, _nl = dict(leaves(EN)), dict(leaves(NL))
|
||||
|
||||
|
||||
def missing(en, nl):
|
||||
"""The keys only one language has."""
|
||||
return (sorted(where("en", p) for p in en if p not in nl),
|
||||
sorted(where("nl", p) for p in nl if p not in en))
|
||||
|
||||
|
||||
def holes(en, nl):
|
||||
"""The Dutch strings whose {placeholders} differ from the English."""
|
||||
return sorted(
|
||||
f"{where('nl', p)}: {sorted(PLACEHOLDER.findall(str(en[p])))} vs "
|
||||
f"{sorted(PLACEHOLDER.findall(str(t)))}"
|
||||
for p, t in nl.items() if p in en
|
||||
and sorted(PLACEHOLDER.findall(str(t)))
|
||||
!= sorted(PLACEHOLDER.findall(str(en[p]))))
|
||||
|
||||
|
||||
_only_en, _only_nl = missing(_en, _nl)
|
||||
for x in _only_en + _only_nl:
|
||||
print(f" MISSING in the other language: {x}")
|
||||
check(f"every one of the {len(_en)} English strings has a Dutch one, and no "
|
||||
"Dutch string is extra", not _only_en and not _only_nl and len(_en) > 300)
|
||||
_holes = holes(_en, _nl)
|
||||
for x in _holes:
|
||||
print(f" PLACEHOLDERS differ: {x}")
|
||||
check("every Dutch string fills the same {placeholders} as its English one",
|
||||
not _holes)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. every string follows STYLE.md, in both languages")
|
||||
# STYLE.md section 4, the "Not" column, plus the Dutch words for the same
|
||||
# things (translations/nl.yaml keeps "setpoint" in two option names, which the
|
||||
# page never shows). Matched as whole words, case-insensitive.
|
||||
JARGON = {
|
||||
"en": r"controllers?|control loop|setpoints?|SoC|state of charge|BMS|PV|"
|
||||
r"failsafe|trip(?:ped)?|parked|discharg\w*|P1 feed|quarter-hour|"
|
||||
r"payload|endpoint|JSON|API|timeout|boolean|config|commands?|"
|
||||
r"charge level|ceiling|billed quarter|quarters?|banked|soft limit|"
|
||||
r"open block|actuator|ESP32|master|meter source|circulating|"
|
||||
r"sign disagreement|rail",
|
||||
"nl": r"setpoints?|setpunt\w*|SoC|laadtoestand|BMS|PV|failsafe|regelaar|"
|
||||
r"regellus|controller|latch|ontlad\w*|payload|endpoint|JSON|API|"
|
||||
r"time-?out|boolean|config|kwartuur\w*|commando\w*|geparkeerd|"
|
||||
r"ESP32|master",
|
||||
}
|
||||
JARGON = {k: re.compile(r"\b(?:" + v + r")\b", re.I) for k, v in JARGON.items()}
|
||||
# Details belong behind the page's DOCS link, never in the string itself.
|
||||
DETAILS = re.compile(r"https?:|www\.|DOCS|documentation, section|§|"
|
||||
r"\bsee section\b|\bzie sectie\b", re.I)
|
||||
NAME_WORDS, DESC_WORDS, LINE_SENTENCES = 6, 25, 2
|
||||
|
||||
|
||||
def kind_of(path):
|
||||
if path[0] == "home_words":
|
||||
return "template" # part 4 renders and checks these
|
||||
return "description" if path[-1] == "description" else "name"
|
||||
|
||||
|
||||
def problems(lang, path, text):
|
||||
kind = kind_of(path)
|
||||
out = []
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return ["empty"]
|
||||
if kind == "name" and len(words(text)) > NAME_WORDS:
|
||||
out.append(f"name over {NAME_WORDS} words ({len(words(text))})")
|
||||
if kind == "name" and sentences(text) > LINE_SENTENCES:
|
||||
out.append(f"over {LINE_SENTENCES} sentences ({sentences(text)})")
|
||||
if kind == "description":
|
||||
if sentences(text) > 1:
|
||||
out.append(f"description over 1 sentence ({sentences(text)})")
|
||||
if len(words(text)) > DESC_WORDS:
|
||||
out.append(f"description over {DESC_WORDS} words "
|
||||
f"({len(words(text))})")
|
||||
if ";" in text:
|
||||
out.append("semicolon")
|
||||
hit = JARGON[lang].search(PLACEHOLDER.sub("X", text))
|
||||
if hit:
|
||||
out.append(f"jargon: {hit.group(0)!r}")
|
||||
if DETAILS.search(text):
|
||||
out.append(f"details belong behind the DOCS link: "
|
||||
f"{DETAILS.search(text).group(0)!r}")
|
||||
# The picker's own help names "entity id": the one place it is the word.
|
||||
ok_entity = path[:2] == ("settings", "picker")
|
||||
return out + [p for p in text_problems(text, keys=False, entities=False)
|
||||
if not (ok_entity and p.startswith("entity id"))]
|
||||
|
||||
|
||||
_bad = []
|
||||
for lang, data in (("en", EN), ("nl", NL)):
|
||||
for path, text in leaves(data):
|
||||
for p in problems(lang, path, text):
|
||||
_bad.append(f"{where(lang, path)}: {p} :: {text!r}")
|
||||
for x in _bad:
|
||||
print(f" STYLE {x}")
|
||||
check(f"every string in en.json and nl.json follows STYLE.md "
|
||||
f"({len(_en) + len(_nl)} strings, {len(_bad)} breaking it)", not _bad)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the rule catches a spoiled input")
|
||||
check("a 7-word name is caught",
|
||||
problems("en", ("fields", "x", "name"),
|
||||
"One two three four five six seven"))
|
||||
check("a 2-sentence description is caught",
|
||||
problems("nl", ("fields", "x", "description"), "Eén zin. Twee zinnen."))
|
||||
check("jargon is caught in English and in Dutch",
|
||||
problems("en", ("x",), "Setpoint sent") and
|
||||
problems("nl", ("x",), "De SoC is laag"))
|
||||
check("a link or section in a string is caught",
|
||||
problems("en", ("x", "description"),
|
||||
"See https://example.org for the rest."))
|
||||
check("a snake_case option key is caught",
|
||||
problems("nl", ("x",), "Zet peak_cap_w hoger"))
|
||||
check("a semicolon is caught",
|
||||
problems("en", ("x",), "Saved; restart to apply"))
|
||||
check("a 26-word description is caught, and 25 words pass",
|
||||
problems("en", ("x", "description"), " ".join(["word"] * 26) + ".")
|
||||
and not problems("en", ("x", "description"),
|
||||
" ".join(["word"] * 25) + "."))
|
||||
check("a 3-sentence line is caught",
|
||||
problems("en", ("x",), "One. Two. Three."))
|
||||
check("a key only one language has is caught, both ways",
|
||||
missing({("a",): "x", ("b",): "y"}, {("a",): "x"}) == (["en.json:b"], [])
|
||||
and missing({("a",): "x"}, {("a",): "x", ("c",): "z"})
|
||||
== ([], ["nl.json:c"]))
|
||||
check("a placeholder dropped or renamed in Dutch is caught",
|
||||
holes({("a",): "Window {row}"}, {("a",): "Tijdvak"})
|
||||
and holes({("a",): "{w} now"}, {("a",): "nu {d}"})
|
||||
and not holes({("a",): "{a} {b}"}, {("a",): "{b} {a}"}))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. Home's sentences, rendered in each language (as test_messages 8)")
|
||||
PLAN_WORDS, PLAN_SENTENCES = 12, 2
|
||||
|
||||
|
||||
def home_problems(kind, text):
|
||||
if "{" in text or "}" in text:
|
||||
return ["a placeholder was not filled"]
|
||||
if kind == "title":
|
||||
return title_problems(text)
|
||||
out = text_problems(text, keys=False, entities=False)
|
||||
if kind == "line":
|
||||
if sentences(text) > 1 or len(words(text)) > 25:
|
||||
out.append("line over 1 sentence of 25 words")
|
||||
return out
|
||||
if sentences(text) > PLAN_SENTENCES:
|
||||
out.append(f"plan line over {PLAN_SENTENCES} sentences")
|
||||
out += [f"plan sentence over {PLAN_WORDS} words: {x!r}"
|
||||
for x in re.split(r"(?<=[.!?])\s+", text)
|
||||
if len(words(x)) > PLAN_WORDS]
|
||||
return out
|
||||
|
||||
|
||||
for lang in ("en", "nl"):
|
||||
_samples = homeview.text_samples(lang)
|
||||
_hb = [f"{lang} {k}: {p} :: {t!r}" for k, t in _samples
|
||||
for p in home_problems(k, t)]
|
||||
_hb += [f"{lang}: jargon {JARGON[lang].search(t).group(0)!r} :: {t!r}"
|
||||
for _k, t in _samples if JARGON[lang].search(t)]
|
||||
for x in _hb:
|
||||
print(f" HOME {x}")
|
||||
check(f"{lang}: every Home sentence follows its rule "
|
||||
f"({len(_samples)} rendered, {len(_hb)} breaking it)",
|
||||
len(_samples) > 80 and not _hb)
|
||||
_en_s = [t for _k, t in homeview.text_samples("en")]
|
||||
_nl_s = [t for _k, t in homeview.text_samples("nl")]
|
||||
check("Home in Dutch is not Home in English: most sentences differ, and "
|
||||
"numbers use a decimal comma",
|
||||
sum(a != b for a, b in zip(_en_s, _nl_s)) > 0.9 * len(_en_s)
|
||||
and "2,5 kW" in homeview.text_samples("nl").__repr__()
|
||||
and "2.5 kW" in _en_s.__repr__())
|
||||
check("the language resets after a Dutch build, so English callers stay "
|
||||
"English", homeview.pill("running") == EN["home_words"]["pill"]["running"])
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. no Dutch string is left in English")
|
||||
# Words that are the same in both, or names. Anything else equal is untranslated.
|
||||
SAME = {"Meter", "Timing", "Support", "Details", "Alarm", "Info", "per kWh",
|
||||
"{hour}:00 UTC", "Flanders, Fluvius Antwerpen", "Modus", "Status"}
|
||||
|
||||
|
||||
def english(en, nl):
|
||||
return sorted(f"{where('nl', p)} = {t!r}" for p, t in nl.items()
|
||||
if p in en and t == en[p] and t not in SAME
|
||||
and PLACEHOLDER.sub("", t).strip(" .:,"))
|
||||
|
||||
|
||||
_same = english(_en, _nl)
|
||||
for x in _same:
|
||||
print(f" ENGLISH {x}")
|
||||
check(f"every Dutch string is Dutch ({len(_same)} still English)", not _same)
|
||||
_spoiled = dict(_nl)
|
||||
_spoiled[("settings", "savebar", "save")] = _en[("settings", "savebar",
|
||||
"save")]
|
||||
check("mutation: an English string left in nl.json is caught",
|
||||
english(_en, _spoiled) == ["nl.json:settings.savebar.save = 'Save'"])
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the words that mirror app/messages.py")
|
||||
check("the setup-only banner on Home is messages.banner_setup_only()",
|
||||
EN["home"]["setup_only"]["name"] == messages.banner_setup_only())
|
||||
_mirror = {k: (EN["home_words"]["attn"][k], v)
|
||||
for k, v in homeview.ATTN_FROM_MESSAGES.items()}
|
||||
_drift = {k: v for k, v in _mirror.items() if v[0] != v[1]}
|
||||
check(f"five Home titles are the notification's own title ({_drift})",
|
||||
len(_mirror) == 5 and not _drift)
|
||||
check("app/uitext.py falls back to English per key and reads nl-BE as nl",
|
||||
uitext.lang_of("nl-BE") == "nl" and uitext.lang_of("fr") == "en"
|
||||
and uitext.words("nl")["home"]["tabs"]["home"]
|
||||
== NL["home"]["tabs"]["home"]
|
||||
and uitext._merge({"a": {"b": 1, "c": 2}}, {"a": {"b": 3}})
|
||||
== {"a": {"b": 3, "c": 2}})
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -217,6 +217,116 @@ check("the hook answers None while battery modes do not exist",
|
||||
check("so the toggle is stored as the page has it",
|
||||
edit(DEFAULTS, arbitrage_enabled=True)["arbitrage_enabled"] is True)
|
||||
|
||||
print("\n7. the time-window editor (UX-13 P5)")
|
||||
|
||||
|
||||
def rows(*spec):
|
||||
return [{"from": a, "to": b, "days": d, "price": p} for a, b, d, p in spec]
|
||||
|
||||
|
||||
def periods(r, base=SITE):
|
||||
return edit(base, ui__contract="custom", ui__fallback="custom",
|
||||
ui__periods=r)
|
||||
|
||||
|
||||
def refused(r):
|
||||
"""(code, values) of the DeriveError the rows give, or None."""
|
||||
try:
|
||||
periods(r)
|
||||
except U.DeriveError as err:
|
||||
return err.code, err.values
|
||||
return None
|
||||
|
||||
|
||||
# Five windows the day/night preset cannot say: a weekday peak, a weekday
|
||||
# night, Saturday split at noon, and Sunday.
|
||||
_five = rows(("06:30", "21:15", "mon-fri", 0.31), ("21:15", "06:30", "mon-fri", 0.19),
|
||||
("00:00", "12:00", "sat", 0.2), ("12:00", "00:00", "sat", 0.25),
|
||||
("00:00", "24:00", "sun", 0.18))
|
||||
_p5 = periods(_five)
|
||||
_prof5 = parse_static_profile(_p5["static_profile"])
|
||||
check("five odd windows write a profile the tariff parser reads, with every "
|
||||
"price in place", [p.eur_per_kwh for p in _prof5.periods]
|
||||
== [0.31, 0.19, 0.2, 0.25, 0.18] and _prof5.periods[3].end_min == 1440)
|
||||
check("...that the shipped start checks accept, and that reads back as the "
|
||||
"same rows", not validate_startup_config(_p5)
|
||||
and U.options_to_ui(_p5)["ui.contract"] == "custom"
|
||||
and [(r["from"], r["to"], r["days"], r["price"])
|
||||
for r in U.options_to_ui(_p5)["ui.periods"]]
|
||||
== [("06:30", "21:15", "mon-fri", 0.31), ("21:15", "06:30", "mon-fri", 0.19),
|
||||
("00:00", "12:00", "sat", 0.2), ("12:00", "24:00", "sat", 0.25),
|
||||
("00:00", "24:00", "sun", 0.18)])
|
||||
_named = dict(SITE, static_profile=json.dumps({"periods": [
|
||||
{"name": "peak", "from": "08:00", "to": "20:00", "days": "mon-sun",
|
||||
"eur_per_kwh": 0.33},
|
||||
{"name": "offpeak", "from": "20:00", "to": "08:00", "days": "mon-sun",
|
||||
"eur_per_kwh": 0.21}]}))
|
||||
_rows = U.options_to_ui(_named)["ui.periods"]
|
||||
_rows[1]["price"] = 0.2
|
||||
check("a stored profile's window names are kept when a price changes",
|
||||
[p.name for p in parse_static_profile(periods(_rows, base=_named)
|
||||
["static_profile"]).periods]
|
||||
== ["peak", "offpeak"])
|
||||
check("the day and night preset shows as three rows, so 'Other time windows' "
|
||||
"starts from what is stored",
|
||||
[r["days"] for r in U.options_to_ui(
|
||||
edit(SITE, ui__contract="daynight", ui__day_price=0.3,
|
||||
ui__night_price=0.2))["ui.periods"]]
|
||||
== ["mon-fri", "mon-fri", "sat,sun"])
|
||||
check("an overlap is named: the two windows, the day and the times",
|
||||
refused(rows(("07:00", "23:00", "mon-fri", 0.3),
|
||||
("22:00", "07:00", "mon-fri", 0.2),
|
||||
("00:00", "24:00", "sat,sun", 0.2)))
|
||||
== ("period_overlap", {"day": "mon", "from": "22:00", "to": "23:00",
|
||||
"a": 1, "b": 2}))
|
||||
check("a gap is named: the day and the times",
|
||||
refused(rows(("07:00", "22:00", "mon-sun", 0.3),
|
||||
("22:00", "24:00", "mon-sun", 0.2)))
|
||||
== ("period_gap", {"day": "mon", "from": "00:00", "to": "07:00"}))
|
||||
check("an overlap that changes partners is named piece by piece: windows 1 "
|
||||
"and 2 first, over their own times only",
|
||||
refused(rows(("00:00", "24:00", "mon-sun", 0.3),
|
||||
("06:00", "08:00", "mon-sun", 0.2),
|
||||
("08:00", "10:00", "mon-sun", 0.2)))
|
||||
== ("period_overlap", {"day": "mon", "from": "06:00", "to": "08:00",
|
||||
"a": 1, "b": 2}))
|
||||
check("a gap on one day only is found on that day",
|
||||
refused(rows(("00:00", "24:00", "mon-fri", 0.3),
|
||||
("00:00", "24:00", "sat", 0.2)))
|
||||
== ("period_gap", {"day": "sun", "from": "00:00", "to": "24:00"}))
|
||||
check("bad times are named by window: '7h', '25:00', '07:60', '24:00' as a "
|
||||
"start", all(refused(rows(("00:00", "24:00", "mon-sun", 0.2), (a, b, "mon", 0.1)))
|
||||
== ("period_time", {"row": 2}) for a, b in
|
||||
(("7h", "08:00"), ("25:00", "08:00"), ("07:60", "08:00"),
|
||||
("24:00", "08:00"), ("07:00", "")))
|
||||
and refused(rows(("7:00", "07:00", "mon-sun", 0.2)))
|
||||
== ("period_zero", {"row": 1}))
|
||||
check("an empty price, and a price in cents, are named by window",
|
||||
refused(rows(("00:00", "24:00", "mon-sun", None)))
|
||||
== ("period_price", {"row": 1})
|
||||
and refused(rows(("00:00", "24:00", "mon-sun", 34)))
|
||||
== ("period_price_range", {"row": 1}))
|
||||
check("no windows at all is refused", refused([]) == ("no_periods", {}))
|
||||
from app import uipage # noqa: E402
|
||||
_msg = uipage.derive_message(U.DeriveError("x", "period_overlap", "ui.periods",
|
||||
day="sat", a=1, b=3, **{
|
||||
"from": "06:00", "to": "07:00"}))
|
||||
_nl = uipage.derive_message(U.DeriveError("x", "period_gap", "ui.periods",
|
||||
day="sun", **{"from": "00:00",
|
||||
"to": "07:00"}), "nl")
|
||||
check(f"the page shows the error beside the editor, with the day by name "
|
||||
f"({_msg['text']!r}, {_nl['text']!r})",
|
||||
_msg["field"] == "ui.periods" and "Windows 1 and 3" in _msg["text"]
|
||||
and "Saturday 06:00 to 07:00" in _msg["text"]
|
||||
and "zondag 00:00 tot 07:00" in _nl["text"])
|
||||
_raw = json.dumps({"periods": [
|
||||
{"from": "00:00", "to": "24:00", "days": "mon,wed", "eur_per_kwh": 0.2},
|
||||
{"from": "00:00", "to": "24:00", "days": "tue,thu-sun", "eur_per_kwh": 0.3}]})
|
||||
check("days the editor has no choice for stay in the Configuration tab, and "
|
||||
"survive a save", U.options_to_ui(dict(SITE, static_profile=_raw))
|
||||
["ui.periods"] is None and edit(dict(SITE, static_profile=_raw), gain=0.5)
|
||||
["static_profile"] == _raw)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
python3 tools/alloc_sim.py --splits # AC 2: split-invariance at the meter
|
||||
python3 tools/alloc_sim.py --sweep # AC 3: gain to 1.0 at every split
|
||||
python3 tools/alloc_sim.py --all
|
||||
python3 tools/alloc_sim.py --spill # OPT-11: surplus solar, by mode
|
||||
|
||||
Exit 0 only if every split-invariance run HOLDS; 1 if one FAILS; 2 if no rig.
|
||||
|
||||
@@ -245,12 +246,98 @@ def sweep(sitesim, control, watchdog, cycles, load_w):
|
||||
return worst
|
||||
|
||||
|
||||
def spill(sitesim, control, arbiter, allocator, packmode, gain=0.6,
|
||||
cycles=30, load_w=1000.0, pv_w=9000.0):
|
||||
"""OPT-11 P1: surplus solar spills from Solar first to Leftover solar.
|
||||
|
||||
The rig's two plants and the add-on's own `compute()`, `resolve()`,
|
||||
`loop_bound()` and `packmode.charge_weights()`, with the claims main.py
|
||||
files for this state: each unit's rating, each unit's mode, the loop, and
|
||||
OPT-09's `pack` limit (`lo = 0`) once battery A is full. A is Solar first
|
||||
and B Leftover solar. Three phases of `cycles` each: A has room; A is full;
|
||||
then B is switched off. The loop is fed the site total (DEV-02).
|
||||
Returns (rows, verdicts).
|
||||
"""
|
||||
from plantsim import INVERTER_A, INVERTER_B, Inverter, Site
|
||||
names = ("inverter-a", "inverter-b")
|
||||
specs = [("A", INVERTER_A), ("B", INVERTER_B)]
|
||||
invs = [Inverter.from_spec(n, dict(sp, **sitesim.INSTANT))
|
||||
for n, sp in specs]
|
||||
site = Site(invs, load=load_w, pv=pv_w)
|
||||
tun = control.Tuning(gain=gain, max_w=sum(sp["rating_w"] for _n, sp in specs),
|
||||
slew_w=sitesim.EXPERIMENT_SLEW_W)
|
||||
C = arbiter.Claim
|
||||
prev, i_w, sat = 0.0, None, 0
|
||||
rows = []
|
||||
phases = (("A has room", False, True), ("A full", True, True),
|
||||
("A full, B off", True, False))
|
||||
for label, a_full, b_on in phases:
|
||||
modes = {names[0]: packmode.PackMode(True, packmode.SOLAR_FIRST),
|
||||
names[1]: packmode.PackMode(b_on, packmode.LEFTOVER_SOLAR)}
|
||||
for _c in range(cycles):
|
||||
claims = []
|
||||
for u, (_n, sp) in zip(names, specs):
|
||||
r = sp["rating_w"]
|
||||
claims.append(C.limit("supervised", arbiter.P_SAFETY, -r, r,
|
||||
actuator=u))
|
||||
lo, hi = packmode.mode_range(modes[u], r)
|
||||
claims.append(C.limit("mode", arbiter.P_MODE, lo, hi,
|
||||
actuator=u))
|
||||
if a_full:
|
||||
claims.append(C.limit("pack", arbiter.P_SAFETY, lo=0.0,
|
||||
actuator=names[0]))
|
||||
bound = allocator.loop_bound(
|
||||
{u: arbiter.pre_resolve([c for c in claims if c.actuator == u])
|
||||
for u in names})
|
||||
d = control.compute(prev, site.grid, site.battery, tun, sat, i_w,
|
||||
bound=bound)
|
||||
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
|
||||
claims.append(C.set("loop", arbiter.P_LOOP, d.target_w))
|
||||
res = arbiter.resolve(
|
||||
claims, units=names,
|
||||
weights=lambda t: packmode.charge_weights(None, modes, t))
|
||||
for inv, u in zip(invs, names):
|
||||
inv.command(float(res.shares[u]))
|
||||
site.settle(sitesim.CYCLE_S, dt=0.05)
|
||||
rows.append(dict(phase=label, target=d.target_w, grid=site.grid,
|
||||
a=res.shares[names[0]], b=res.shares[names[1]],
|
||||
unmet=res.allocation.unmet_w, frozen=d.frozen,
|
||||
reason=d.reason, bound=bound))
|
||||
return rows
|
||||
|
||||
|
||||
def spill_report(rows, cycles):
|
||||
print("=== OPT-11 P1: surplus solar, A Solar first, B Leftover solar ===")
|
||||
ok = True
|
||||
for label in dict.fromkeys(r["phase"] for r in rows):
|
||||
ph = [r for r in rows if r["phase"] == label]
|
||||
last = ph[-1]
|
||||
frozen = sum(r["frozen"] for r in ph)
|
||||
print(f" {label:15} A {last['a']:+6.0f} W B {last['b']:+6.0f} W "
|
||||
f"grid {last['grid']:+7.0f} W loop bound "
|
||||
f"[{last['bound'][0]:.0f}, {last['bound'][1]:.0f}] "
|
||||
f"unmet {last['unmet']} W frozen cycles {frozen} "
|
||||
f"({last['reason']})")
|
||||
ok &= frozen == 0 and all(r["a"] <= 0 and r["b"] <= 0 for r in ph)
|
||||
if label == "A has room":
|
||||
ok &= last["a"] == -5000 and last["b"] < 0
|
||||
elif label == "A full":
|
||||
ok &= last["a"] == 0 and last["b"] == -5000
|
||||
else:
|
||||
ok &= last["a"] == 0 and last["b"] == 0 and last["unmet"] == 0
|
||||
print(f" -> spill {'HOLDS' if ok else 'FAILS'}: A fills first, the rest "
|
||||
f"spills to B, a full A and an Off B send the surplus to the grid, "
|
||||
f"and the loop never freezes on watts no battery may take")
|
||||
return ok
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
ap.add_argument("--table", action="store_true")
|
||||
ap.add_argument("--splits", action="store_true")
|
||||
ap.add_argument("--sweep", action="store_true")
|
||||
ap.add_argument("--all", action="store_true")
|
||||
ap.add_argument("--spill", action="store_true")
|
||||
ap.add_argument("--cycles", type=int, default=40)
|
||||
ap.add_argument("--load", type=float, default=2000.0)
|
||||
ap.add_argument("--gain", type=float, default=1.0)
|
||||
@@ -261,7 +348,7 @@ def main():
|
||||
"or one inverter's power (the PRE-DEV-02 sensing, "
|
||||
"kept because the comparison is the evidence)")
|
||||
a = ap.parse_args()
|
||||
if not (a.table or a.splits or a.sweep):
|
||||
if not (a.table or a.splits or a.sweep or a.spill):
|
||||
a.all = True
|
||||
|
||||
sim = find_sim()
|
||||
@@ -288,6 +375,11 @@ def main():
|
||||
print()
|
||||
if a.sweep or a.all:
|
||||
sweep(sitesim, control, watchdog, a.cycles, a.load)
|
||||
print()
|
||||
if a.spill or a.all:
|
||||
packmode = sitesim.load_addon("app.packmode", "alloc_sim")
|
||||
held &= spill_report(spill(sitesim, control, arbiter, allocator,
|
||||
packmode), 30)
|
||||
return 0 if held else 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT-11: with battery modes off, is every command byte-identical to before?
|
||||
|
||||
python3 tools/opt11_identity.py # prints one digest per scenario
|
||||
|
||||
Drives the REAL `Controller` (read_inputs + cycle: the freshness gate, the
|
||||
arbiter, the allocator, OPT-09's pack claim, SAFETY-73's refusal claims) in
|
||||
simulated time, and hashes, per cycle, everything a command depends on: the
|
||||
site target, each inverter's written value, the loop's reason, integrator and
|
||||
saturation count, and the allocation record. It uses only the Controller API
|
||||
that release/1.0 a5dddad already had, so the SAME file runs on the old tree and
|
||||
the new one. test_modes.py part 1 holds the new tree to the digests this file
|
||||
printed on a5dddad (they are written there, with that provenance).
|
||||
|
||||
Scenarios: N=1 and N=2, a house that steps, a surplus of solar, a battery
|
||||
reaching its cut-out level and, at N=2, a battery that refuses (SAFETY-73) and
|
||||
is probed; the same with both batteries due for maintenance; and the arbiter
|
||||
alone on 6000 seeded claim lists (peak floor, trades, maintenance, refusals).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
TICK = 5.0
|
||||
BASE = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
from app.store import DEFAULTS
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return datetime.fromisoformat(v) if v else None
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.isoformat() if when else None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def house(t):
|
||||
"""A house that steps, then a solar surplus, then an evening load."""
|
||||
if t < 300:
|
||||
return 800.0
|
||||
if t < 900:
|
||||
return 3200.0
|
||||
if t < 1500:
|
||||
return -2600.0 # more solar than the house uses
|
||||
if t < 2100:
|
||||
return 1400.0
|
||||
return 4200.0
|
||||
|
||||
|
||||
class _Hass:
|
||||
"""One meter, `units` batteries that follow their command unless `cut`."""
|
||||
|
||||
def __init__(self, units, soc, cut, load=house):
|
||||
self.units, self.soc, self.cut, self.load = units, soc, cut, load
|
||||
self.cmd = {u: 0.0 for u in units}
|
||||
self.t = 0.0
|
||||
|
||||
def power(self, u):
|
||||
c = self.cmd[u]
|
||||
if self.cut(u, self.t) and c >= -4.0:
|
||||
return -4.0
|
||||
return float(round(c))
|
||||
|
||||
def grid(self):
|
||||
return self.load(self.t) - sum(self.power(u) for u in self.units)
|
||||
|
||||
async def limits(self, e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def call(self, *a):
|
||||
return True
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
if e == "sensor.grid":
|
||||
return self.grid()
|
||||
u = e[-1]
|
||||
if e.startswith("sensor.soc"):
|
||||
return self.soc(u, self.t)
|
||||
if e.startswith("sensor.batt"):
|
||||
return self.power(u)
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.cmd[e[-1]] = float(v)
|
||||
return True
|
||||
|
||||
|
||||
def _options(n, extra):
|
||||
common = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||
"gain": 0.6, "slew_w": 400, "target_grid_w": -10,
|
||||
"deadband_w": 15, "step_w": 10, "heartbeat_s": 10,
|
||||
"stale_input_s": 15, "auto_start": True}
|
||||
if n == 1:
|
||||
common.update({"setpoint_entity": "number.sp_a",
|
||||
"soc_entity": "sensor.soc_a",
|
||||
"batt_entity": "sensor.batt_a", "max_w": 5000,
|
||||
"bms_soc_floor": 10})
|
||||
else:
|
||||
common["inverters"] = [
|
||||
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
|
||||
"max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]]
|
||||
common.update(extra or {})
|
||||
return common
|
||||
|
||||
|
||||
def _soc(u, t):
|
||||
# A empties toward its cut-out level over the run; B stays half full.
|
||||
return max(8.0, 60.0 - t / 40.0) if u == "a" else 50.0
|
||||
|
||||
|
||||
def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
|
||||
soc=_soc, cut=None, before=None):
|
||||
"""Cycle a real Controller; returns (rows, controller). `before(c, t)`
|
||||
runs before each cycle, `on_cycle(c, t)` after it."""
|
||||
units = tuple("ab"[:n])
|
||||
if cut is None:
|
||||
def cut(u, t):
|
||||
return n > 1 and u == "a" and 1600.0 <= t < 2300.0
|
||||
|
||||
async def go():
|
||||
hass = _Hass(units, soc, cut, load)
|
||||
c = Controller(_options(n, extra), hass, store or _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt = m.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
rows = []
|
||||
try:
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
hass.t = clock[0] = t
|
||||
if before is not None:
|
||||
r = before(c, t)
|
||||
if asyncio.iscoroutine(r):
|
||||
await r
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
alloc = getattr(c, "allocation", None)
|
||||
rows.append((t, c.target, tuple(sorted(hass.cmd.items())),
|
||||
c.loop_reason, round(float(c.i_w), 6),
|
||||
c.sat_count, c.reason,
|
||||
alloc.explain() if alloc is not None else None))
|
||||
if on_cycle is not None:
|
||||
on_cycle(c, t)
|
||||
t += TICK
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def digest(rows) -> str:
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
# Both batteries due for their monthly cycle at the run's first cycle, so the
|
||||
# maintenance slot, its pins and its shaping are in every row (PR #149 review:
|
||||
# the scenarios above never reach maintenance).
|
||||
_MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
||||
|
||||
SCENARIOS = {
|
||||
"n1": dict(n=1),
|
||||
"n2": dict(n=2),
|
||||
# The option present and false: what an install that saved the new
|
||||
# option without turning it on runs. The old tree ignores the key.
|
||||
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
||||
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
|
||||
"n1_maint": dict(n=1, extra=_MAINT),
|
||||
"n2_maint": dict(n=2, extra=_MAINT),
|
||||
}
|
||||
|
||||
|
||||
def resolve_rows(cases=6000, seed=1149):
|
||||
"""The arbiter alone, on seeded random claim lists with no mode claim:
|
||||
the peak floor, a trade, the loop, maintenance pins and shaping, SAFETY-73
|
||||
refusal limits and probe pins, packs, ratings and safety stops, at one to
|
||||
three inverters. The peak guard needs a P1 meter the cycle runs above do
|
||||
not have, so its claim is graded here. Uses only a5dddad's API."""
|
||||
import random
|
||||
from math import inf
|
||||
from app import arbiter as a
|
||||
rnd = random.Random(seed)
|
||||
rows = []
|
||||
for _ in range(cases):
|
||||
n = rnd.choice((1, 2, 3))
|
||||
units = ("inverter-a", "inverter-b", "inverter-c")[:n]
|
||||
|
||||
def act(src, u):
|
||||
return a.actuator_for(src, u, units)
|
||||
cl = []
|
||||
for u in units:
|
||||
r = rnd.choice((1000.0, 2500.0, 5000.0))
|
||||
cl.append(a.Claim.limit("supervised", a.P_SAFETY, -r, r, "max_w",
|
||||
actuator=act("supervised", u)))
|
||||
x = rnd.random()
|
||||
if x < 0.15:
|
||||
cl.append(a.Claim.limit("pack", a.P_SAFETY, *rnd.choice(
|
||||
((0.0, inf), (-inf, 0.0))), actuator=act("pack", u)))
|
||||
elif x < 0.25:
|
||||
cl.append(a.Claim.limit("refused", a.P_SAFETY, *rnd.choice(
|
||||
((-inf, 0.0), (0.0, inf))), "refused",
|
||||
actuator=act("refused", u)))
|
||||
elif x < 0.32:
|
||||
cl.append(a.Claim.set("refused", a.P_SAFETY,
|
||||
rnd.choice((200.0, -200.0)), "probing",
|
||||
actuator=act("refused", u)))
|
||||
elif x < 0.42:
|
||||
cl.append(a.Claim.set("maintenance", a.P_MAINTENANCE,
|
||||
rnd.choice((2000.0, -2000.0)), "drain",
|
||||
actuator=act("maintenance", u)))
|
||||
elif x < 0.5:
|
||||
cl.append(a.Claim.limit("maintenance", a.P_MAINT_SHAPE,
|
||||
hi=-rnd.choice((500.0, 2000.0)),
|
||||
actuator=act("maintenance", u)))
|
||||
y = rnd.random()
|
||||
if y < 0.8:
|
||||
cl.append(a.Claim.set("loop", a.P_LOOP, rnd.uniform(-8000, 8000)))
|
||||
if 0.5 < y < 0.9:
|
||||
cl.append(a.Claim.set("arbitrage", a.P_ARBITRAGE,
|
||||
rnd.uniform(-6000, 6000)))
|
||||
if rnd.random() < 0.6:
|
||||
cl.append(a.Claim.limit("peak", a.P_PEAK,
|
||||
lo=rnd.uniform(-4000, 6000)))
|
||||
if rnd.random() < 0.05:
|
||||
cl.append(a.Claim.set("safety", a.P_SAFETY, 0.0, "stopped"))
|
||||
rnd.shuffle(cl)
|
||||
r = a.resolve(cl, units=units if n > 1 else None,
|
||||
weights=(None if rnd.random() < 0.5 else
|
||||
{u: rnd.choice((0.0, 0.5, 1.0)) for u in units}))
|
||||
rows.append((r.target_w, r.winner, r.bound_by, r.contradiction,
|
||||
tuple(sorted((r.shares or {}).items())), r.allocated,
|
||||
r.allocation.explain() if r.allocation else None))
|
||||
return rows
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
out = {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
out["resolve"] = digest(resolve_rows())
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--summary" in sys.argv:
|
||||
for k, v in SCENARIOS.items():
|
||||
rows = run(**v)[0]
|
||||
print(f"{k}: {len(rows)} cycles, loop reasons "
|
||||
f"{sorted({r[3] for r in rows})}, arbiter "
|
||||
f"{sorted({r[6].split(' ->')[0] for r in rows})}")
|
||||
rr = resolve_rows()
|
||||
print(f"resolve: {len(rr)} claim lists, winners "
|
||||
f"{sorted({r[1] for r in rr})}, bound by "
|
||||
f"{sorted({str(r[2]).split('(')[0] for r in rr})}")
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT-11 P3: with battery modes off, is every command byte-identical to
|
||||
release/1.0 7a00f12, on the paths P3 changed?
|
||||
|
||||
python3 tools/opt11p3_identity.py # prints one digest per scenario
|
||||
|
||||
tools/safety79_identity.py's harness (the REAL Controller on a P1 meter, so
|
||||
the peak guard runs) plus a day-ahead price series and `arbitrage_enabled`,
|
||||
so the site trade P3 now skips with modes on (`arbitrage.decide()`, its
|
||||
claim, the sizing to the guard's headroom) runs on every cycle, with a buy
|
||||
period, a sell period and periods in between, and a house that goes over the
|
||||
limit. Each row hashes safety79's fields plus the trade's action, watts and
|
||||
sentence. Uses only the API 7a00f12 has, so the same file runs on both trees;
|
||||
test_modes_p3.py part 1 holds this tree to the digests printed on 7a00f12.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
|
||||
DUR = 5400.0
|
||||
|
||||
|
||||
def prices():
|
||||
"""15-minute firm prices: the cheapest period 15-30 min into the run,
|
||||
the dearest 75-90 min in, and ones in between."""
|
||||
pts = []
|
||||
for i in range(-8, 40):
|
||||
s = rig.BASE + timedelta(minutes=15 * i)
|
||||
p = 0.08 if i == 1 else 0.52 if i == 5 else 0.20 + 0.003 * (i % 7)
|
||||
pts.append(PricePoint(start=brussels(s), eur_per_kwh=p,
|
||||
provisional=False))
|
||||
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", rig.BASE), None,
|
||||
"provider")
|
||||
|
||||
|
||||
PLAN = prices()
|
||||
|
||||
|
||||
def _feed(c, t):
|
||||
c.tariff_plan, c.tariff_at = PLAN, rig.BASE + timedelta(seconds=t)
|
||||
s79._feed(c, t)
|
||||
|
||||
|
||||
def load(t):
|
||||
"""Under the limit, over it, a surplus, a big evening load."""
|
||||
k = int(t // 600) % 5
|
||||
return (900.0, 3200.0, -2200.0, 1500.0, 4200.0)[k]
|
||||
|
||||
|
||||
def soc(u, t):
|
||||
return 55.0 if u == "a" else 45.0
|
||||
|
||||
|
||||
ARB = {"arbitrage_enabled": True, "cycle_cost_threshold_eur": 0.05}
|
||||
SCENARIOS = {
|
||||
"n1": dict(n=1),
|
||||
"n2": dict(n=2),
|
||||
# The option present and false: an install that saved it off.
|
||||
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
||||
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, extra=None, dur=DUR):
|
||||
opts = dict(s79.P1, **ARB, **(extra or {}))
|
||||
trade = []
|
||||
|
||||
def on_cycle(c, t):
|
||||
a = c.arb
|
||||
trade.append(None if a is None else (a.action, a.power_w, a.reason))
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="p3id-"), "tariff.db")
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(n, extra=opts, dur=dur, load=load, soc=soc,
|
||||
cut=lambda u, t: False, before=_feed,
|
||||
on_cycle=on_cycle)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
return [r + (x,) for r, x in zip(rows, trade)], c
|
||||
|
||||
|
||||
def digest(rows) -> str:
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--summary" in sys.argv:
|
||||
for k, v in SCENARIOS.items():
|
||||
rows = run(**v)[0]
|
||||
acts = sorted({str(r[-1][0]) if r[-1] else "None" for r in rows})
|
||||
print(f"{k}: {len(rows)} cycles, trade actions {acts}")
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -0,0 +1,394 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT-11 P3: replay days of recorded house load and real prices through the
|
||||
REAL Controller (read_inputs + cycle, the arbiter, the allocator, the peak
|
||||
guard on a P1 meter), with two modelled batteries, and report energy, cycles
|
||||
and money, plus the P3 invariants on every tick.
|
||||
|
||||
python3 tools/opt11p3_replay.py --days 16 --variant p3_ls
|
||||
python3 tools/opt11p3_replay.py --captured # 2026-09-09/10
|
||||
|
||||
The same file runs on release/1.0 7a00f12 (the P2 baseline) and on this
|
||||
branch: it uses only the Controller API 7a00f12 has, and sets the batteries'
|
||||
choices through the store keys P1 defined (`pack_sell` was reserved there).
|
||||
|
||||
REAL: the house (grid + battery power recorded 2026-09-02..17 on the live
|
||||
install, scenarios/loop-2026-09-02_2026-09-17: the house's own net demand,
|
||||
solar included), the prices (EPEX BE day-ahead, hourly, same weekday one year
|
||||
earlier (364 days), or the captured quarter-hourly energy-charts bodies for
|
||||
2026-09-09/10), the decision code.
|
||||
MODELLED: the batteries (a first-order lag, 3 s, 800 W/s ramp, SoC
|
||||
integrated, 95 % each way, a reading that jitters by a few watts, and
|
||||
optionally a device limit or a refusal to charge), the all-in price (the EPEX file's own stated
|
||||
assumption: (wholesale + 0.02 + 0.10) x 1.06), export paid at the wholesale
|
||||
price, tomorrow's prices known from 13:00 local. The absolute money is
|
||||
therefore an estimate; the comparison between variants is the point.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import gzip
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
LOOP = os.path.join(HERE, "scenarios", "loop-2026-09-02_2026-09-17")
|
||||
EPEX = os.path.join(HERE, "scenarios",
|
||||
"epex-be-dayahead-2025-09-01_2026-09-01.json")
|
||||
QH = os.path.join(HERE, "scenarios",
|
||||
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json")
|
||||
TICK = 5.0
|
||||
CAP_KWH = 10.0
|
||||
RATING = 2500.0
|
||||
PEAK_CAP = 3500
|
||||
|
||||
|
||||
def all_in(wholesale_kwh):
|
||||
return round((wholesale_kwh + 0.02 + 0.10) * 1.06, 6)
|
||||
|
||||
|
||||
def load_house(days):
|
||||
"""[(t_utc_epoch, house_w)] from the recording: grid + battery power."""
|
||||
out = []
|
||||
for d in days:
|
||||
def rd(kind):
|
||||
f = os.path.join(LOOP, f"{d}-{kind}.jsonl.gz")
|
||||
rows = []
|
||||
with gzip.open(f, "rt") as fh:
|
||||
for line in fh:
|
||||
r = json.loads(line)
|
||||
try:
|
||||
v = float(r["v"])
|
||||
except ValueError:
|
||||
continue
|
||||
rows.append((datetime.fromisoformat(r["t"]).timestamp(),
|
||||
v))
|
||||
return rows
|
||||
g, b = rd("grid"), rd("batt")
|
||||
j = 0
|
||||
for t, gw in g:
|
||||
while j + 1 < len(b) and b[j + 1][0] <= t:
|
||||
j += 1
|
||||
bw = b[j][1] if b and b[j][0] <= t else 0.0
|
||||
out.append((t, gw + bw))
|
||||
out.sort()
|
||||
return out
|
||||
|
||||
|
||||
def epex_points(shift_days=364):
|
||||
d = json.load(open(EPEX))["prices"]
|
||||
pts = []
|
||||
for r in d:
|
||||
s = datetime.fromisoformat(r["start"]) + timedelta(days=shift_days)
|
||||
pts.append((s.astimezone(timezone.utc), 3600,
|
||||
float(r["eur_per_kwh"])))
|
||||
return pts
|
||||
|
||||
|
||||
def qh_points():
|
||||
d = json.load(open(QH))
|
||||
return [(datetime.fromtimestamp(t, timezone.utc), 900, p / 1000.0)
|
||||
for t, p in zip(d["unix_seconds"], d["price"])
|
||||
if p is not None]
|
||||
|
||||
|
||||
class Plant:
|
||||
def __init__(self, soc, refuse_charge=False):
|
||||
self.cmd, self.p, self.soc = 0.0, 0.0, float(soc)
|
||||
self.refuse_charge = refuse_charge
|
||||
|
||||
def step(self, dt, floor):
|
||||
c = self.cmd
|
||||
if c > 0 and self.soc <= floor:
|
||||
c = 0.0
|
||||
if c < 0 and (self.soc >= 100.0 or self.refuse_charge):
|
||||
c = 0.0
|
||||
want = self.p + (c - self.p) * (1 - math.exp(-dt / 3.0))
|
||||
dp = max(-800.0 * dt, min(800.0 * dt, want - self.p))
|
||||
self.p += dp
|
||||
e = self.p * dt / 3600.0 / 1000.0 # kWh, + = out
|
||||
eff = 1 / 0.95 if e > 0 else 0.95
|
||||
self.soc -= e * eff / CAP_KWH * 100.0
|
||||
self.soc = max(0.0, min(100.0, self.soc))
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None):
|
||||
self.units = units
|
||||
self.lim = limits or {}
|
||||
self.plants = {u: Plant((soc0 or {}).get(u, 50.0), u in refuse)
|
||||
for u in units}
|
||||
self.house_at = house_at
|
||||
self.t = 0.0
|
||||
|
||||
def grid(self):
|
||||
return self.house_at(self.t) - sum(p.p for p in self.plants.values())
|
||||
|
||||
async def limits(self, e):
|
||||
lo, hi = self.lim.get(e[-1], (-5000.0, 5000.0))
|
||||
return (lo, hi, 1.0)
|
||||
|
||||
async def call(self, *a):
|
||||
return True
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
if e == "sensor.grid":
|
||||
return self.grid()
|
||||
u = e[-1]
|
||||
if e.startswith("sensor.soc"):
|
||||
return round(self.plants[u].soc, 1)
|
||||
if e.startswith("sensor.batt"):
|
||||
# A real reading jitters by a few watts; a dead-flat one is a
|
||||
# stale register to the add-on (SAFETY-37), which is not modelled.
|
||||
j = (ord(u) * 31 + int(self.t)) % 7 - 3
|
||||
return float(round(self.plants[u].p) + j)
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.plants[e[-1]].cmd = float(v)
|
||||
return True
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
from app.store import DEFAULTS
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return datetime.fromisoformat(v) if v else None
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.isoformat() if when else None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
VARIANTS = {
|
||||
# (modes on, arbitrage_enabled, {unit: (use, mode, sell)})
|
||||
"modes_off": (False, False, {}),
|
||||
"arb_only": (False, True, {}),
|
||||
"p2_sf_ls": (True, False, {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}),
|
||||
"p2_arb": (True, True, {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}),
|
||||
"p3_ls": (True, False, {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}),
|
||||
"p3_ch_sell": (True, False, {"a": (True, "solar_first", False),
|
||||
"b": (True, "cheap_hours", True)}),
|
||||
"p3_both_ch": (True, False, {"a": (True, "cheap_hours", False),
|
||||
"b": (True, "cheap_hours", True)}),
|
||||
}
|
||||
|
||||
|
||||
def options(modes, arb, n=2):
|
||||
o = {"meter_entity": "sensor.grid", "meter_source": "ha_signed",
|
||||
"p1_net_entity": "sensor.net", "meter_phases": 1,
|
||||
"peak_cap_w": PEAK_CAP, "gain": 0.6, "slew_w": 400,
|
||||
"target_grid_w": -10, "deadband_w": 15, "step_w": 10,
|
||||
"heartbeat_s": 10, "stale_input_s": 15, "auto_start": True,
|
||||
"battery_modes_enabled": modes, "arbitrage_enabled": arb,
|
||||
"cycle_cost_threshold_eur": 0.10,
|
||||
"inverters": [{"name": f"inverter-{u}",
|
||||
"setpoint_entity": f"number.sp_{u}",
|
||||
"soc_entity": f"sensor.soc_{u}",
|
||||
"batt_entity": f"sensor.batt_{u}",
|
||||
"max_w": int(RATING), "bms_soc_floor": 10,
|
||||
"capacity_kwh": CAP_KWH} for u in "ab"[:n]]}
|
||||
return o
|
||||
|
||||
|
||||
def plan_at(now, pts):
|
||||
"""The firm day-ahead points a site holds at `now`: today, and tomorrow
|
||||
once published (13:00 local)."""
|
||||
loc = brussels(now)
|
||||
day0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end = day0 + timedelta(days=2 if loc.hour >= 13 else 1)
|
||||
sel = [PricePoint(start=brussels(s), eur_per_kwh=all_in(p),
|
||||
provisional=False)
|
||||
for s, span, p in pts if day0 <= s < end]
|
||||
if len(sel) < 2:
|
||||
return None
|
||||
return TariffPlan(PriceRead.series(sel, PriceProduct.DAY_AHEAD,
|
||||
"generic_dayahead", now), None,
|
||||
"provider")
|
||||
|
||||
|
||||
def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
soc0=None, refuse=(), extra=None, choice=None, patch=None,
|
||||
limits=None):
|
||||
modes, arb, var_choice = VARIANTS[variant]
|
||||
choice = var_choice if choice is None else choice
|
||||
units = tuple("ab"[:n])
|
||||
import bisect
|
||||
ts = [h[0] for h in house]
|
||||
|
||||
def house_at(t):
|
||||
i = bisect.bisect_right(ts, start.timestamp() + t) - 1
|
||||
return house[max(0, i)][1]
|
||||
|
||||
wholesale = {s: (span, p) for s, span, p in pts}
|
||||
starts = sorted(wholesale)
|
||||
|
||||
def price_at(now):
|
||||
i = bisect.bisect_right(starts, now) - 1
|
||||
if i < 0:
|
||||
return None
|
||||
s = starts[i]
|
||||
span, p = wholesale[s]
|
||||
return p if now < s + timedelta(seconds=span) else None
|
||||
|
||||
store = _Store()
|
||||
for u, (use, mode, sell) in choice.items():
|
||||
store.data[unit_key(f"inverter-{u}", "pack_use")] = use
|
||||
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
|
||||
store.data[unit_key(f"inverter-{u}", "pack_sell")] = sell
|
||||
stats = dict(imp_kwh=0.0, exp_kwh=0.0, cost=0.0, exp_rev=0.0,
|
||||
out_kwh={u: 0.0 for u in "ab"[:n]},
|
||||
in_kwh={u: 0.0 for u in "ab"[:n]},
|
||||
ticks=0, quarters={}, trade_ticks=0)
|
||||
|
||||
async def go():
|
||||
hass = Hass(units, house_at, soc0, refuse, limits)
|
||||
import tempfile
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="p3r-"), "tariff.db")
|
||||
c = Controller(dict(options(modes, arb, n), **(extra or {})), hass,
|
||||
store, _Pub(), stop=asyncio.Event(), peak_db_path=db)
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = (limits or {}).get(i.name[-1],
|
||||
(-5000.0, 5000.0))
|
||||
if patch is not None:
|
||||
patch(c)
|
||||
c.auto = True
|
||||
real_dt = m.datetime
|
||||
clock = [start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = _DT
|
||||
plan_hour = None
|
||||
try:
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if plan_hour != now.replace(minute=0, second=0,
|
||||
microsecond=0):
|
||||
plan_hour = now.replace(minute=0, second=0,
|
||||
microsecond=0)
|
||||
c.tariff_plan, c.tariff_at = plan_at(now, pts), now
|
||||
else:
|
||||
c.tariff_at = now
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
for p in hass.plants.values():
|
||||
p.step(TICK, 10.0)
|
||||
g = hass.grid()
|
||||
pr = price_at(now)
|
||||
dt_h = TICK / 3600.0
|
||||
if g > 0:
|
||||
stats["imp_kwh"] += g / 1000 * dt_h
|
||||
stats["cost"] += g / 1000 * dt_h * (all_in(pr) if pr
|
||||
is not None else 0)
|
||||
else:
|
||||
stats["exp_kwh"] += -g / 1000 * dt_h
|
||||
stats["exp_rev"] += -g / 1000 * dt_h * (pr or 0.0)
|
||||
for u, p in hass.plants.items():
|
||||
if p.p > 0:
|
||||
stats["out_kwh"][u] += p.p / 1000 * dt_h
|
||||
else:
|
||||
stats["in_kwh"][u] -= p.p / 1000 * dt_h
|
||||
q = int(now.timestamp() // 900)
|
||||
acc = stats["quarters"].setdefault(q, [0.0, 0, False])
|
||||
acc[0] += max(0.0, g)
|
||||
acc[1] += 1
|
||||
if (getattr(c.arb, "action", None) or "").endswith("CHARGE"):
|
||||
acc[2] = True
|
||||
stats["trade_ticks"] += 1
|
||||
stats["ticks"] += 1
|
||||
if check is not None:
|
||||
check(c, hass, now, pr)
|
||||
t += TICK
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return c
|
||||
c = asyncio.run(go())
|
||||
return stats, c
|
||||
|
||||
|
||||
def summary(stats):
|
||||
qs = stats["quarters"]
|
||||
worst = max((a[0] / a[1] for a in qs.values()), default=0.0)
|
||||
worst_trade = max((a[0] / a[1] for a in qs.values() if a[2]),
|
||||
default=0.0)
|
||||
cyc = {u: round(v / CAP_KWH, 2) for u, v in stats["out_kwh"].items()}
|
||||
return {"import_kwh": round(stats["imp_kwh"], 2),
|
||||
"export_kwh": round(stats["exp_kwh"], 2),
|
||||
"import_eur": round(stats["cost"], 2),
|
||||
"export_eur": round(stats["exp_rev"], 2),
|
||||
"net_eur": round(stats["cost"] - stats["exp_rev"], 2),
|
||||
"cycles": cyc, "worst_quarter_w": round(worst, 1),
|
||||
"worst_charge_quarter_w": round(worst_trade, 1),
|
||||
"charge_ticks": stats["trade_ticks"]}
|
||||
|
||||
|
||||
LOOP_DAYS = [f"2026-09-{d:02d}" for d in range(2, 18)]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--variant", default="p3_ls")
|
||||
ap.add_argument("--days", type=int, default=16)
|
||||
ap.add_argument("--captured", action="store_true")
|
||||
a = ap.parse_args()
|
||||
if a.captured:
|
||||
days = ["2026-09-09", "2026-09-10"]
|
||||
pts = qh_points()
|
||||
else:
|
||||
days = LOOP_DAYS[:a.days]
|
||||
pts = epex_points()
|
||||
house = load_house(days)
|
||||
start = datetime.fromisoformat(days[0] + "T00:00:00+02:00") \
|
||||
.astimezone(timezone.utc)
|
||||
dur = 86400.0 * len(days)
|
||||
t0 = time.time()
|
||||
stats, _c = run(a.variant, house, pts, start, dur)
|
||||
out = summary(stats)
|
||||
out["variant"], out["days"] = a.variant, len(days)
|
||||
out["wall_s"] = round(time.time() - t0, 1)
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT-11 P4: with no usable solar forecast, is every command byte-identical
|
||||
to release/1.0 5a4cdc2 (P3, owner option A)?
|
||||
|
||||
python3 tools/opt11p4_identity.py # one digest per scenario
|
||||
|
||||
tools/opt11p3_identity.py's harness (the REAL Controller on a P1 meter, a
|
||||
cheap slot, a dear slot, a house over the limit, `arbitrage_enabled`), now
|
||||
with battery modes ON and each battery's mode set in the store, so P3's run
|
||||
code runs on every cycle. Each row adds the trade and the roles. Scenarios:
|
||||
* `ls`, `ch`, `ls_ls`, `ch_ls`: no `pv_forecast_entity` (the day plan never
|
||||
runs); N=1 and N=2.
|
||||
* `*_fc_down`: `pv_forecast_entity` set but the sensor reads `unavailable`,
|
||||
with a ready house profile in the store (the plan falls back); 5a4cdc2
|
||||
ignores both keys, so the digest must match the scenario without them.
|
||||
Uses only the API 5a4cdc2 has; test_modes_p4.py part 1 holds this tree to the
|
||||
digests printed on 5a4cdc2.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_identity as p3 # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
FC = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
|
||||
|
||||
|
||||
async def _unavailable(self, e):
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": "2026-09-20T11:00:00+00:00"}
|
||||
|
||||
|
||||
rig._Hass.state = _unavailable
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_ls": dict(n=1, modes={"inverter-a": "leftover_solar"}),
|
||||
"n1_ch": dict(n=1, modes={"inverter-a": "cheap_hours"}),
|
||||
"n2_sf_ls": dict(n=2, modes={"inverter-a": "solar_first",
|
||||
"inverter-b": "leftover_solar"}),
|
||||
"n2_ch_ls": dict(n=2, modes={"inverter-a": "cheap_hours",
|
||||
"inverter-b": "leftover_solar"}),
|
||||
"n2_ls_ls": dict(n=2, modes={"inverter-a": "leftover_solar",
|
||||
"inverter-b": "leftover_solar"}),
|
||||
"n2_sf_ch": dict(n=2, modes={"inverter-a": "solar_first",
|
||||
"inverter-b": "cheap_hours"}),
|
||||
}
|
||||
for _k, _v in list(SCENARIOS.items()):
|
||||
SCENARIOS[_k + "_fc_down"] = dict(_v, extra=FC)
|
||||
|
||||
|
||||
def run(n, *, modes, extra=None, dur=p3.DUR):
|
||||
opts = dict(s79.P1, **p3.ARB, battery_modes_enabled=True,
|
||||
**(extra or {}))
|
||||
if n == 2:
|
||||
# A capacity on each battery, so a day plan could size a level;
|
||||
# small, so the cheap run is the one cheap slot.
|
||||
opts["inverters"] = [
|
||||
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
|
||||
"max_w": 2500, "bms_soc_floor": 10, "capacity_kwh": 1.0}
|
||||
for u in "ab"]
|
||||
store = rig._Store()
|
||||
# At N=1 the one battery is named after the site; the key is per name.
|
||||
for name, mode in modes.items():
|
||||
store.data[unit_key(name, "pack_mode")] = mode
|
||||
if extra:
|
||||
# A ready house profile (a day, 600 W, 3 kW 17:00-22:00), so only
|
||||
# the missing forecast stops a plan here. 5a4cdc2 ignores the key.
|
||||
store.data["house_profile"] = {"2026-09-19": {
|
||||
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}
|
||||
trade = []
|
||||
|
||||
def on_cycle(c, t):
|
||||
a, tick = c.arb, getattr(c, "mode_tick", None)
|
||||
trade.append((None if a is None else (a.action, a.power_w, a.reason),
|
||||
None if tick is None else
|
||||
tuple(sorted(tick.roles.items()))))
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="p4id-"), "tariff.db")
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(n, extra=opts, dur=dur, load=p3.load, soc=p3.soc,
|
||||
cut=lambda u, t: False, before=p3._feed,
|
||||
on_cycle=on_cycle, store=store)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
return [r + (x,) for r, x in zip(rows, trade)], c
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
return {k: p3.digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--summary" in sys.argv:
|
||||
for k, v in SCENARIOS.items():
|
||||
rows = run(**v)[0]
|
||||
acts = sorted({str(r[-1][0][0]) if r[-1][0] else "None"
|
||||
for r in rows})
|
||||
roles = sorted({x for r in rows if r[-1][1]
|
||||
for x in (y for _n, y in r[-1][1])})
|
||||
print(f"{k}: {len(rows)} cycles, trade {acts}, roles {roles}")
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OPT-11 P4: does the solar forecast pay? Replays recorded days through the
|
||||
REAL Controller with tools/opt11p3_replay.py's engine (house, batteries,
|
||||
prices, peak guard), plus a solar forecast sensor in Home Assistant.
|
||||
|
||||
python3 tools/opt11p4_replay.py --variant p4_ls --fc recorded
|
||||
python3 tools/opt11p4_replay.py --variant p3_ls --prices real2026
|
||||
|
||||
Runs unchanged on release/1.0 7a00f12 (P2) and 5a4cdc2 (P3): an older tree
|
||||
ignores `pv_forecast_entity` and never reads the sensor.
|
||||
|
||||
HOW THE FORECAST IS MADE (the site's own solar was not recorded for these
|
||||
days; the 2026-09-24 PV recording is only in the live Home Assistant):
|
||||
* The site's solar, hour by hour, is rebuilt from the recording: the house's
|
||||
net use (grid + battery) is house - solar, so solar = max(0, H - net),
|
||||
where H is the median net use of the night hours (22:00-06:00) over all
|
||||
days (`--fc perfect` feeds exactly this).
|
||||
* `recorded`: that solar times the day's error of the real Belgian day-ahead
|
||||
solar forecast (Elia, via energy-charts): forecast / actual, per day.
|
||||
* `national`: the Belgian day-ahead forecast itself, scaled to the site
|
||||
(one factor: the site's total over the national total, all days). It
|
||||
carries local weather differences too, so it is the harder test.
|
||||
* `zero`: a forecast of no sun at all (is the gain the forecast's, or only
|
||||
Leftover solar buying the evening's shortfall?).
|
||||
* `high`: `national` x 1.5. `missing`: the sensor is configured but reads
|
||||
`unavailable`. The sensor has Open-Meteo's shape (state = kWh left today,
|
||||
`wh_period` = Wh per hour); `--shape state` drops `wh_period`
|
||||
(Forecast.Solar's shape).
|
||||
|
||||
MONEY: as opt11p3_replay.py (all-in import (wholesale + 0.12) x 1.06, export
|
||||
at wholesale), plus `stored_eur`: the change in usable energy (above the
|
||||
10 % cut-out) from start to end, x 0.95, at the median all-in import price
|
||||
of the run. `net_after_eur` = net - stored_eur.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
|
||||
EC = os.path.join(HERE, "scenarios",
|
||||
"energy-charts-be-solar-price-2026-09-01_2026-09-17.json")
|
||||
FC_ENTITY = "sensor.energy_production_today_remaining"
|
||||
FC_TOMORROW = "sensor.energy_production_tomorrow"
|
||||
|
||||
rp.VARIANTS.update({
|
||||
"p4_ls": (True, False, {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}),
|
||||
"p4_ch": (True, False, {"a": (True, "solar_first", False),
|
||||
"b": (True, "cheap_hours", False)}),
|
||||
})
|
||||
LOCAL = timezone(timedelta(hours=2)) # CEST for every replayed day
|
||||
|
||||
|
||||
def ec():
|
||||
return json.load(open(EC))
|
||||
|
||||
|
||||
def real2026_points():
|
||||
"""The real day-ahead prices of the replayed days (15-minute)."""
|
||||
d = ec()["price"]
|
||||
return [(datetime.fromtimestamp(t, timezone.utc), 900, p / 1000.0)
|
||||
for t, p in zip(d["unix_seconds"], d["eur_mwh"]) if p is not None]
|
||||
|
||||
|
||||
def site_solar(house):
|
||||
"""{hour start (UTC epoch): site solar kWh}, and H (W)."""
|
||||
by_h = {}
|
||||
for t, w in house:
|
||||
by_h.setdefault(int(t // 3600) * 3600, []).append(w)
|
||||
net = {h: statistics.fmean(v) for h, v in by_h.items() if len(v) > 20}
|
||||
night = [w for h, w in net.items()
|
||||
if datetime.fromtimestamp(h, LOCAL).hour in
|
||||
(22, 23, 0, 1, 2, 3, 4, 5)]
|
||||
base = statistics.median(night)
|
||||
return {h: max(0.0, base - w) / 1000.0 for h, w in net.items()}, base
|
||||
|
||||
|
||||
def national():
|
||||
s = ec()["solar"]
|
||||
act = dict(zip(s["unix_seconds"], s["actual_mw"]))
|
||||
fc = dict(zip(s["dayahead_unix_seconds"], s["dayahead_mw"]))
|
||||
return act, fc
|
||||
|
||||
|
||||
def forecast_table(kind, house, days):
|
||||
"""{hour start epoch: forecast kWh}, and a report of its accuracy."""
|
||||
pv, base = site_solar(house)
|
||||
act, fc = national()
|
||||
day_of = {h: datetime.fromtimestamp(h, LOCAL).date() for h in pv}
|
||||
if kind in ("perfect", "missing"):
|
||||
out = dict(pv)
|
||||
elif kind == "zero":
|
||||
out = {h: 0.0 for h in pv}
|
||||
elif kind == "recorded":
|
||||
ratio = {}
|
||||
for d in set(day_of.values()):
|
||||
hs = [h for h in pv if day_of[h] == d]
|
||||
a = sum(act.get(h) or 0.0 for h in hs)
|
||||
f = sum(fc.get(h) or 0.0 for h in hs)
|
||||
ratio[d] = f / a if a > 0 else 1.0
|
||||
out = {h: pv[h] * ratio[day_of[h]] for h in pv}
|
||||
else:
|
||||
k = sum(pv.values()) / max(1e-9, sum(act.get(h) or 0.0 for h in pv))
|
||||
out = {h: (fc.get(h) or 0.0) * k * (1.5 if kind == "high" else 1.0)
|
||||
for h in pv}
|
||||
rep = []
|
||||
for d in sorted(set(day_of.values())):
|
||||
a = sum(pv[h] for h in pv if day_of[h] == d)
|
||||
f = sum(out[h] for h in out if day_of[h] == d)
|
||||
rep.append((d.isoformat(), round(a, 1), round(f, 1)))
|
||||
return out, base, rep
|
||||
|
||||
|
||||
def install_sensor(table, kind, shape):
|
||||
"""Give opt11p3_replay's Hass a `state()` for the forecast sensors."""
|
||||
hours = sorted(table)
|
||||
t0 = rp_start[0]
|
||||
|
||||
def day_bounds(now):
|
||||
loc = now.astimezone(LOCAL)
|
||||
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return d0, d0 + timedelta(days=1)
|
||||
|
||||
def sensor(now, tomorrow):
|
||||
d0, d1 = day_bounds(now)
|
||||
if tomorrow:
|
||||
d0, d1 = d1, d1 + timedelta(days=1)
|
||||
a, b = d0.timestamp(), d1.timestamp()
|
||||
i, j = bisect.bisect_left(hours, a), bisect.bisect_left(hours, b)
|
||||
rows = [(h, table[h]) for h in hours[i:j]]
|
||||
if not rows:
|
||||
return None
|
||||
n = now.timestamp()
|
||||
left = 0.0
|
||||
for h, kwh in rows:
|
||||
if tomorrow or h >= n:
|
||||
left += kwh
|
||||
elif h + 3600 > n:
|
||||
left += kwh * (h + 3600 - n) / 3600.0
|
||||
attrs = {"unit_of_measurement": "kWh"}
|
||||
if shape == "periods":
|
||||
attrs["wh_period"] = {
|
||||
datetime.fromtimestamp(h, timezone.utc).isoformat():
|
||||
round(kwh * 1000.0, 1) for h, kwh in rows}
|
||||
# The forecast refreshes every hour.
|
||||
rep = datetime.fromtimestamp(int(n // 3600) * 3600, timezone.utc)
|
||||
return {"state": f"{left:.3f}", "attributes": attrs,
|
||||
"last_updated": rep.isoformat(),
|
||||
"last_reported": rep.isoformat()}
|
||||
|
||||
async def state(self, e):
|
||||
now = t0 + timedelta(seconds=self.t)
|
||||
if kind == "missing":
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": now.isoformat()}
|
||||
if e == FC_ENTITY:
|
||||
return sensor(now, False)
|
||||
if e == FC_TOMORROW:
|
||||
return sensor(now, True)
|
||||
return None
|
||||
rp.Hass.state = state
|
||||
|
||||
|
||||
rp_start = [None]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--variant", default="p4_ls")
|
||||
ap.add_argument("--fc", default="recorded",
|
||||
choices=("none", "recorded", "national", "perfect",
|
||||
"high", "missing", "zero"))
|
||||
ap.add_argument("--prices", default="epex2025",
|
||||
choices=("epex2025", "real2026"))
|
||||
ap.add_argument("--shape", default="periods",
|
||||
choices=("periods", "state"))
|
||||
ap.add_argument("--first", type=int, default=2)
|
||||
ap.add_argument("--days", type=int, default=16)
|
||||
ap.add_argument("--trust", type=int, default=None)
|
||||
ap.add_argument("--report", action="store_true",
|
||||
help="print the forecast's accuracy and exit")
|
||||
a = ap.parse_args()
|
||||
days = [f"2026-09-{d:02d}" for d in range(a.first, a.first + a.days)]
|
||||
house = rp.load_house(days)
|
||||
pts = rp.epex_points() if a.prices == "epex2025" else real2026_points()
|
||||
start = datetime.fromisoformat(days[0] + "T00:00:00+02:00") \
|
||||
.astimezone(timezone.utc)
|
||||
rp_start[0] = start
|
||||
extra = {}
|
||||
if a.fc != "none":
|
||||
table, base, rep = forecast_table(a.fc, house, days)
|
||||
if a.report:
|
||||
print(json.dumps({"fc": a.fc, "night_base_w": round(base),
|
||||
"days": rep}))
|
||||
return
|
||||
install_sensor(table, a.fc, a.shape)
|
||||
extra = {"pv_forecast_entity": FC_ENTITY,
|
||||
"pv_forecast_tomorrow_entity": FC_TOMORROW}
|
||||
if a.trust is not None:
|
||||
extra["solar_trust_pct"] = a.trust
|
||||
socs = {}
|
||||
|
||||
def check(c, hass, now, pr):
|
||||
if "start" not in socs:
|
||||
socs["start"] = {u: p.soc for u, p in hass.plants.items()}
|
||||
socs["end"] = {u: p.soc for u, p in hass.plants.items()}
|
||||
dp = getattr(c, "day_plan", None)
|
||||
if dp is not None:
|
||||
socs.setdefault("verdicts", {})[
|
||||
now.astimezone(LOCAL).date().isoformat()] = dp.verdict
|
||||
t0 = time.time()
|
||||
stats, _c = rp.run(a.variant, house, pts, start, 86400.0 * len(days),
|
||||
extra=extra, check=check)
|
||||
out = rp.summary(stats)
|
||||
prices = sorted(rp.all_in(p) for _s, _n, p in pts
|
||||
if start <= _s < start + timedelta(days=len(days)))
|
||||
med = prices[len(prices) // 2]
|
||||
|
||||
def usable(soc):
|
||||
return sum(max(0.0, v - 10.0) / 100.0 * rp.CAP_KWH
|
||||
for v in soc.values())
|
||||
d_kwh = usable(socs["end"]) - usable(socs["start"])
|
||||
out["stored_change_kwh"] = round(d_kwh, 2)
|
||||
out["stored_eur"] = round(d_kwh * 0.95 * med, 2)
|
||||
out["net_after_eur"] = round(out["net_eur"] - out["stored_eur"], 2)
|
||||
out["end_soc"] = {u: round(v, 1) for u, v in socs["end"].items()}
|
||||
out.update(variant=a.variant, fc=a.fc, prices=a.prices, shape=a.shape,
|
||||
first=days[0], days=len(days),
|
||||
verdicts=socs.get("verdicts", {}),
|
||||
wall_s=round(time.time() - t0, 1))
|
||||
print(json.dumps(out))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -176,7 +176,7 @@ def replay(paths, *, guarded: bool, cap_w, max_w, max_hold_s, cycle_s,
|
||||
next_cycle = t + timedelta(seconds=cycle_s)
|
||||
cycles += 1
|
||||
d = decide(PeakTracker.project(ing.averager, t), batt_w=batt,
|
||||
cap_w=cap_w, reading=tracker.peak(t),
|
||||
cap_w=cap_w, margin_w=0.0, reading=tracker.peak(t),
|
||||
achievable_w=max_w, max_hold_s=max_hold_s, soc=soc,
|
||||
soc_floor=soc_floor, frozen=False)
|
||||
if d.engaged:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-78: at one inverter, is a maintenance drain byte-identical to before?
|
||||
|
||||
python3 tools/safety78_identity.py # prints one digest per scenario
|
||||
|
||||
tools/safety79_identity.py's harness (the REAL Controller on a P1 meter, so
|
||||
the peak guard runs) with the battery draining for its monthly cycle from the
|
||||
first cycle, under a house below, at and above the drain rate, a solar
|
||||
surplus and a load over the peak limit. Each row hashes safety79's fields,
|
||||
the modes' roles and the trade, the action log's newest line, and every claim
|
||||
main.py hands `resolve()`, so a claim or a log line added at N=1 that changes
|
||||
no watt still moves the digest. The `_modes` scenarios run battery modes on,
|
||||
Cheap hours only, over opt11p3_identity's prices. Uses only the API release/1.0 5a4cdc2 has, so the same file runs on
|
||||
both trees; test_safety78.py part 5 holds this tree to the digests printed
|
||||
there.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_identity as p3 # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
DUR = 2400.0
|
||||
DRAIN = {"maintenance_enabled": True, "maintenance_start_hour": 11}
|
||||
|
||||
|
||||
def load(t):
|
||||
"""Below the 2000 W drain, at it, above it, a surplus, over the limit."""
|
||||
k = int(t // 480)
|
||||
return (1200.0, 2000.0, 3000.0, -1500.0, 6000.0)[min(k, 4)]
|
||||
|
||||
|
||||
def store_cheap(n):
|
||||
s = rig._Store()
|
||||
for u in ("inverter-a", "inverter-b")[:n]:
|
||||
s.data[unit_key(u, packmode.MODE_KEY)] = packmode.CHEAP_HOURS
|
||||
return s
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_drain": dict(n=1),
|
||||
"n1_drain_modes": dict(n=1, modes=True),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, modes=False, dur=DUR):
|
||||
extra = dict(s79.P1, **DRAIN)
|
||||
if modes:
|
||||
extra.update(p3.ARB, battery_modes_enabled=True)
|
||||
seen, extra_rows = [], []
|
||||
real_resolve = m.resolve
|
||||
|
||||
def spy(claims, **kw):
|
||||
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
|
||||
c.reason, c.actuator) for c in claims))
|
||||
return real_resolve(claims, **kw)
|
||||
|
||||
def on_cycle(c, t):
|
||||
g, tick, a = c.peak_guard, c.mode_tick, c.arb
|
||||
extra_rows.append((
|
||||
seen[-1] if seen else None,
|
||||
None if g is None else (g.engaged, g.floor_w, g.reason),
|
||||
None if tick is None else (tuple(sorted(tick.roles.items())),
|
||||
tick.action, tick.why),
|
||||
None if a is None else (a.action, a.power_w, a.reason),
|
||||
[mm.phase for mm in c.maints],
|
||||
len(c.events), c.events[0] if c.events else None))
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s78-"), "tariff.db")
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
m.resolve = spy
|
||||
try:
|
||||
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=s79.soc,
|
||||
cut=lambda u, t: False,
|
||||
before=p3._feed if modes else s79._feed,
|
||||
on_cycle=on_cycle,
|
||||
store=store_cheap(n) if modes else None)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
m.resolve = real_resolve
|
||||
return [r + (x,) for r, x in zip(rows, extra_rows)], c
|
||||
|
||||
|
||||
def digest(rows) -> str:
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--summary" in sys.argv:
|
||||
for k, v in SCENARIOS.items():
|
||||
rows = run(**v)[0]
|
||||
print(f"{k}: {len(rows)} cycles, phases "
|
||||
f"{sorted({str(r[-1][-1]) for r in rows})}, targets "
|
||||
f"{sorted({r[1] for r in rows})[:6]}")
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-79: with `peak_margin_w: 0`, is every command byte-identical to before?
|
||||
|
||||
python3 tools/safety79_identity.py # prints one digest per scenario
|
||||
|
||||
tools/opt11_identity.py's harness (the REAL Controller, read_inputs + cycle, in
|
||||
simulated time) with a P1 meter, so the peak guard runs, and a peak limit the
|
||||
house goes over. Each row hashes opt11_identity's fields plus the guard's floor,
|
||||
limit and sentence, its charge headroom (what arbitrage is sized to) and the
|
||||
maintenance shaping's suspension. The two `_maint` scenarios run a maintenance
|
||||
charge in a cheap window with a demand forecast that crosses cap - 50 .. cap,
|
||||
so `shaping_suspended()` and `maintenance_charge_floor()` are both reached.
|
||||
It uses only the API release/1.0 ed739d4 already had, so
|
||||
the same file runs on the old tree (which ignores `peak_margin_w`) and the new.
|
||||
test_peak_margin.py part 1 holds the new tree to the digests printed on ed739d4.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
from app import packmode, peakguard # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
CAP_W = 2500
|
||||
|
||||
|
||||
def load(t):
|
||||
"""A house under the limit, over it, a solar surplus, far over, over."""
|
||||
if t < 300:
|
||||
return 800.0
|
||||
if t < 900:
|
||||
return 3200.0
|
||||
if t < 1500:
|
||||
return -2600.0
|
||||
if t < 2100:
|
||||
return 6000.0
|
||||
return 4200.0
|
||||
|
||||
|
||||
def soc(u, t):
|
||||
return 60.0 # above the peak reserve: the guard may discharge
|
||||
|
||||
|
||||
def _feed(c, t):
|
||||
"""One P1 telegram per cycle: the meter reads what the house and the
|
||||
batteries' measured power leave."""
|
||||
imp, exp = split_signed(c.hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t),
|
||||
ingest_mono=time.monotonic()))
|
||||
|
||||
|
||||
def store_off(name):
|
||||
"""A store where battery `name` is switched Off (OPT-11)."""
|
||||
s = rig._Store()
|
||||
s.data[unit_key(name, packmode.USE_KEY)] = False
|
||||
return s
|
||||
|
||||
|
||||
def forecast(t):
|
||||
"""A demand forecast that sweeps 2300-2600 W every 10 minutes: across
|
||||
cap - 50 and the cap, so the margin moves the forecast checks."""
|
||||
return 2300.0 + (t % 600) / 600 * 300
|
||||
|
||||
|
||||
def _feed_maint(c, t):
|
||||
"""`_feed`, plus the forecast and a cheap price (now under average)."""
|
||||
if not getattr(c.hass, "_s79", False):
|
||||
read = c.hass.number
|
||||
fixed = {"sensor.pnow": 0.05, "sensor.pavg": 0.20}
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.fc":
|
||||
return forecast(c.hass.t)
|
||||
if e in fixed:
|
||||
return fixed[e]
|
||||
return await read(e, invert)
|
||||
c.hass.number, c.hass._s79 = number, True
|
||||
_feed(c, t)
|
||||
|
||||
|
||||
def store_charging(n):
|
||||
"""A store where the first battery is in its maintenance CHARGE phase."""
|
||||
s = rig._Store()
|
||||
for k in ("phase", "phase_started"):
|
||||
s.data[k if n == 1 else unit_key("inverter-a", k)] = (
|
||||
"charge" if k == "phase" else rig.BASE.isoformat())
|
||||
return s
|
||||
|
||||
|
||||
P1 = {"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
|
||||
"meter_phases": 1, "peak_cap_w": CAP_W}
|
||||
MODES = {"battery_modes_enabled": True}
|
||||
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11,
|
||||
"peak_forecast_entity": "sensor.fc", "price_now_entity": "sensor.pnow",
|
||||
"price_avg_entity": "sensor.pavg"}
|
||||
|
||||
SCENARIOS = {
|
||||
"n1": dict(n=1),
|
||||
"n2": dict(n=2),
|
||||
# OPT-11: the only battery Off, so only the peak guard makes it give.
|
||||
"n1_off": dict(n=1, modes=True, off="inverter-a"),
|
||||
# OPT-11: B Off, so B covers what A cannot.
|
||||
"n2_off": dict(n=2, modes=True, off="inverter-b"),
|
||||
# A maintenance charge in a cheap window, with a demand forecast.
|
||||
"n1_maint": dict(n=1, maint=True),
|
||||
"n2_maint": dict(n=2, maint=True),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, margin=None, modes=False, off=None, maint=False, dur=2700.0):
|
||||
"""Rows for one scenario; `margin=None` leaves the key out."""
|
||||
extra = dict(P1, **(MODES if modes else {}), **(MAINT if maint else {}))
|
||||
if margin is not None:
|
||||
extra["peak_margin_w"] = margin
|
||||
guard = []
|
||||
|
||||
def on_cycle(c, t):
|
||||
g = c.peak_guard
|
||||
guard.append((c._maint_shape_suspended, None if g is None else (
|
||||
g.engaged, g.floor_w, g.ceiling_w, g.ceiling_from, g.reason,
|
||||
peakguard.charge_headroom_w(g))))
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s79-"), "tariff.db")
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=soc,
|
||||
cut=lambda u, t: False,
|
||||
before=_feed_maint if maint else _feed,
|
||||
on_cycle=on_cycle,
|
||||
store=(store_off(off) if off else
|
||||
store_charging(n) if maint else None))
|
||||
finally:
|
||||
rig.Controller = real
|
||||
return [r + (g,) for r, g in zip(rows, guard)], c
|
||||
|
||||
|
||||
def digest(rows) -> str:
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
def digests(margin=None) -> dict:
|
||||
return {k: digest(run(**v, margin=margin)[0]) for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
m = None if len(sys.argv) < 2 else int(sys.argv[1])
|
||||
for k, v in digests(m).items():
|
||||
print(f"{k} {v}")
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-81: when does "Load too high for battery" fire?
|
||||
|
||||
python3 tools/safety81_sim.py # alarms and a control digest per run
|
||||
|
||||
The REAL Controller (read_inputs + cycle) at a 1 s cycle, on a plant shaped
|
||||
like the SAFETY-79 test rig: one battery switched Off (so only the peak guard
|
||||
makes it give, max 2000 W), a 3500 W cap, and a P1 meter that publishes
|
||||
0.3 s before each cycle. The battery reaches a new command 0.5 s after it is
|
||||
written. One meter sample in ten arrives late (the add-on still holds the one
|
||||
before), so the held meter reading can predate the battery's last change:
|
||||
the double count behind the rig's false alarms. The cycle on a quarter
|
||||
boundary, before the new quarter's first sample, files no peak claim, which
|
||||
is the rig's 1 s grid spike at each quarter change. Each run returns the
|
||||
alarm times and a digest of every command, so the same runs show both what
|
||||
changed and what did not.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
TICK = 1.0
|
||||
SAMPLE_LEAD_S = 0.3 # the meter publishes this long before each cycle
|
||||
RESPONSE_S = 0.5 # a new command reaches the battery this much later
|
||||
LATE_P = 0.1 # share of meter samples that arrive late
|
||||
ALARM = "goodwe_peak_saturated"
|
||||
|
||||
|
||||
def constant(w):
|
||||
return lambda t: w
|
||||
|
||||
|
||||
def noisy(mean, amp, seed=81):
|
||||
"""`mean` +/- `amp`, a new uniform value every second."""
|
||||
rnd = random.Random(seed)
|
||||
table = {}
|
||||
|
||||
def f(t):
|
||||
k = int(t)
|
||||
if k not in table:
|
||||
table[k] = mean + rnd.uniform(-amp, amp)
|
||||
return table[k]
|
||||
return f
|
||||
|
||||
|
||||
def sine(mean, amp, period=120.0, noise=50.0):
|
||||
import math
|
||||
n = noisy(0.0, noise, seed=79)
|
||||
return lambda t: mean + amp * math.sin(2 * math.pi * t / period) + n(t)
|
||||
|
||||
|
||||
def step(before, after, at):
|
||||
return lambda t: before if t < at else after
|
||||
|
||||
|
||||
def ramp(before, after, at, secs):
|
||||
return lambda t: before + (after - before) * min(max(t - at, 0.0) / secs, 1.0)
|
||||
|
||||
|
||||
class _Plant:
|
||||
"""One battery that reaches its command RESPONSE_S after the write."""
|
||||
|
||||
def __init__(self, load, rating, soc, batt_gap=()):
|
||||
self.load, self.rating, self.soc = load, rating, soc
|
||||
self.batt_gap = batt_gap # (from, to): the battery reading is lost
|
||||
self.t = 0.0
|
||||
self.hist = [(-1e9, 0.0)] # (time the command takes effect, W)
|
||||
self.notes = []
|
||||
self.meter = 0.0 # the last meter reading delivered
|
||||
|
||||
def batt(self, at):
|
||||
w = 0.0
|
||||
for when, v in self.hist:
|
||||
if when <= at:
|
||||
w = v
|
||||
return max(-self.rating, min(self.rating, float(round(w))))
|
||||
|
||||
def grid(self, at):
|
||||
return self.load(at) - self.batt(at)
|
||||
|
||||
async def limits(self, e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
if service == "create":
|
||||
self.notes.append((self.t, data.get("notification_id")))
|
||||
return True
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
if e in ("sensor.grid", "sensor.net"):
|
||||
return self.meter
|
||||
if e.startswith("sensor.soc"):
|
||||
return self.soc
|
||||
if e.startswith("sensor.batt"):
|
||||
if self.batt_gap and self.batt_gap[0] <= self.t < self.batt_gap[1]:
|
||||
return None
|
||||
return self.batt(self.t)
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.hist.append((self.t + RESPONSE_S, float(v)))
|
||||
return True
|
||||
|
||||
|
||||
def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
|
||||
late_at=(), soc=60.0, batt_gap=()):
|
||||
"""Alarm times (s from the start of the first quarter), a digest of every
|
||||
cycle's target and written command, the Controller, the rows, and the
|
||||
times of the ERROR log line. A sample is also late at each `t % 900` in
|
||||
`late_at`."""
|
||||
opts = rig._options(1, {
|
||||
"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
|
||||
"meter_phases": 1, "peak_cap_w": cap, "peak_margin_w": margin,
|
||||
"battery_modes_enabled": True, "max_w": rating})
|
||||
store = rig._Store()
|
||||
store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
|
||||
store.data[packmode.USE_KEY] = False
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s81-"), "tariff.db")
|
||||
|
||||
async def go():
|
||||
plant = _Plant(load, rating, soc, batt_gap)
|
||||
c = Controller(opts, plant, store, rig._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=db)
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, clock = m.datetime, [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return rig.BASE + timedelta(seconds=clock[0])
|
||||
m.datetime = _DT
|
||||
rows, late = [], random.Random(8100)
|
||||
errors = []
|
||||
|
||||
class _Err(logging.Handler):
|
||||
def emit(self, r):
|
||||
if "battery alone cannot keep" in r.getMessage():
|
||||
errors.append(plant.t)
|
||||
h = _Err()
|
||||
logging.getLogger("goodwe").addHandler(h)
|
||||
try:
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
plant.t = clock[0] = t
|
||||
ts = t - SAMPLE_LEAD_S
|
||||
if late.random() >= LATE_P and t % 900 not in late_at:
|
||||
plant.meter = plant.grid(ts)
|
||||
imp, exp = split_signed(plant.meter)
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=ts),
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
rows.append((t, c.target, round(plant.hist[-1][1], 6)))
|
||||
t += TICK
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
logging.getLogger("goodwe").removeHandler(h)
|
||||
return plant, c, rows, errors
|
||||
plant, c, rows, errors = asyncio.run(go())
|
||||
alarms = [t for t, nid in plant.notes if nid == ALARM]
|
||||
return (alarms, hashlib.sha256(repr(rows).encode()).hexdigest(), c, rows,
|
||||
errors)
|
||||
|
||||
|
||||
# Three quarters each (2700 s); a new quarter starts at 900 s and 1800 s.
|
||||
SCENARIOS = {
|
||||
# F1: constant and sine loads the battery carries, across quarter edges.
|
||||
"const4500": dict(load=constant(4500.0)),
|
||||
"const4500_m0": dict(load=constant(4500.0), margin=0),
|
||||
"sine4500": dict(load=sine(4500.0, 400.0)),
|
||||
# F1 at the start of a quarter: the sample after the battery's return
|
||||
# is late, so the held reading is the 1 s spike (the battery at 0 W).
|
||||
"edge4600": dict(load=constant(4600.0), late_at=(2.0,)),
|
||||
"edge4600_m0": dict(load=constant(4600.0), margin=0, late_at=(2.0,)),
|
||||
# 6000 W in a quarter's last 20 s: over cap + battery, but the quarter
|
||||
# still lands between the aim and the cap.
|
||||
"burst_end": dict(load=lambda t: 6000.0 if t % 900 >= 880 else 4500.0),
|
||||
# F2: a noisy load the battery carries, at margin 50 and 0.
|
||||
"noise5000_m50": dict(load=noisy(5000.0, 150.0)),
|
||||
"noise5000_m0": dict(load=noisy(5000.0, 150.0), margin=0),
|
||||
# Real: from 1200 s the house needs 1000 W more than cap + battery.
|
||||
"step6500": dict(load=step(4500.0, 6500.0, 1200.0)),
|
||||
# Real, small: from 1200 s, 60 W (+/- 20 W noise) more than cap + battery.
|
||||
"miss5560": dict(load=lambda t: step(4500.0, 5560.0, 1200.0)(t)
|
||||
+ noisy(0.0, 20.0)(t)),
|
||||
# Real, slow: 4500 -> 6500 W over 200 s from 1200 s, through the band
|
||||
# between the aim (cap - 50) and the real cap.
|
||||
"ramp6500": dict(load=ramp(4500.0, 6500.0, 1200.0, 200.0)),
|
||||
# The pack under its peak reserve (20 %), and a load far over cap +
|
||||
# battery: that is "Battery stopped limiting the peak", not this alarm.
|
||||
"step7500_reserve": dict(load=step(4500.0, 7500.0, 1200.0), soc=15.0),
|
||||
# Review: a load swinging 4500 +/- 1200 W every 30 s (a hob or heater
|
||||
# cycling). It tops cap + battery for about 5 s a swing, and the quarter
|
||||
# still lands under the aim.
|
||||
"sine4500_fast": dict(load=sine(4500.0, 1200.0, period=30.0, noise=200.0)),
|
||||
# Review: two real overloads in one quarter, 1000-1100 s and 1500-1700 s.
|
||||
"twice7000": dict(load=lambda t: 7000.0 if (1000 <= t < 1100
|
||||
or 1500 <= t < 1700)
|
||||
else 4500.0),
|
||||
# Review: step6500, and the battery reading is lost 1203-1215 s.
|
||||
"step6500_gap": dict(load=step(4500.0, 6500.0, 1200.0),
|
||||
batt_gap=(1203.0, 1215.0)),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for k, v in SCENARIOS.items():
|
||||
a, d, _, _, _ = run(**v)
|
||||
print(f"{k:16s} alarms at {a} digest {d}")
|
||||
@@ -0,0 +1,58 @@
|
||||
"""STYLE.md sections 1 and 4 as code: the banned words and the counts.
|
||||
|
||||
Shared by test_messages.py (UX-08) and test_ui_text.py (UX-13 P5), so the two
|
||||
checks cannot drift apart. Moved here unchanged from test_messages.py.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
BANNED = re.compile(
|
||||
r"\b(?:(?:SAFETY|TARIFF|TEL|CFG|FCST|OPT|UX|HW|FW|WITNESS|PKG|ARCH)-\d+|"
|
||||
r"integrator|anti-?windup|windup|clamp(?:ed|s)?|i_clamped|saturat\w*|"
|
||||
r"BUG SIGNAL|latch(?:ed)?|arbiter|allocator|registers?|0x[0-9A-Fa-f]+|"
|
||||
r"layer [123]|heartbeat|watchdog|telegrams?|ACKs?|CRC|OTA|Modbus|planner|"
|
||||
r"capaciteitstarief|journalctl|ssh|grep|"
|
||||
r"\d+\.\d+\.\d+\.\d+|[\w-]+\.(?:py|md|yaml|json|db))\b|§\s?\d", re.I)
|
||||
ENTITY = re.compile(r"\b(?:sensor|number|binary_sensor|input_\w+|switch|select)"
|
||||
r"\.[a-z0-9_*]+")
|
||||
SNAKE = re.compile(r"\b[a-z0-9]+(?:_[a-z0-9]+)+\b")
|
||||
SHOUT = re.compile(r"\b[A-Z]{2,}[A-Z0-9]*(?:[\s-]+[A-Z]{2,}[A-Z0-9]*)+\b")
|
||||
PLACEHOLDER = re.compile(r"\{[^{}]*\}|%[-+ #0-9.]*[sdrfgx%]")
|
||||
|
||||
|
||||
def words(text):
|
||||
return [w for w in re.findall(r"\S+", PLACEHOLDER.sub("X", text))
|
||||
if re.search(r"[A-Za-z0-9]", w)]
|
||||
|
||||
|
||||
def sentences(text):
|
||||
"""Sentence count: a stop followed by a space and a capital, or the end."""
|
||||
t = PLACEHOLDER.sub("X", text.strip())
|
||||
t = re.sub(r"\b(e\.g|i\.e|etc)\.", "eg", t)
|
||||
return len([s for s in re.split(r"[.!?](?:\s+|$)", t) if s.strip()])
|
||||
|
||||
|
||||
def title_problems(title):
|
||||
out = []
|
||||
if len(words(title)) > 5:
|
||||
out.append(f"title over 5 words ({len(words(title))})")
|
||||
if title.lower().startswith("goodwe"):
|
||||
out.append("title carries a GoodWe prefix")
|
||||
if title.rstrip().endswith("."):
|
||||
out.append("title ends in a full stop")
|
||||
return out + text_problems(title, keys=False, entities=False)
|
||||
|
||||
|
||||
def text_problems(text, *, keys, entities):
|
||||
t = PLACEHOLDER.sub("X", text)
|
||||
out = []
|
||||
hit = sorted({m.group(0) for m in BANNED.finditer(t)})
|
||||
if hit:
|
||||
out.append("banned: " + ", ".join(hit[:4]))
|
||||
if not entities and ENTITY.search(t):
|
||||
out.append("entity id: " + ENTITY.search(t).group(0))
|
||||
if not keys and SNAKE.search(ENTITY.sub("", t)):
|
||||
out.append("option key: " + SNAKE.search(ENTITY.sub("", t)).group(0))
|
||||
if SHOUT.search(t):
|
||||
out.append("capitals: " + SHOUT.search(t).group(0))
|
||||
return out
|
||||
@@ -0,0 +1,233 @@
|
||||
"""UX-13 P3: the headless-browser check of the Batteries screen and Settings,
|
||||
Simple. Run by `CHECK=1 tools/ux13_screenshots.sh`, like the screenshots.
|
||||
|
||||
For every battery-mode fixture (tools/ux13_demo.py) it opens both screens at
|
||||
phone and desktop width, light and dark, in English and Dutch, and fails on a
|
||||
page error, a console error or a sideways scroll. Then it clicks the switch
|
||||
and the mode list on both screens and checks what was posted and what the
|
||||
cards show after.
|
||||
|
||||
python3 ux13_browser_check.py # DEMO_<STATE> = the fixture's URL
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
STATES = ("running", "modes", "off", "off_peak", "refusal", "maint")
|
||||
SIZES = {"phone": {"width": 390, "height": 844},
|
||||
"desktop": {"width": 1000, "height": 900}}
|
||||
FAILS = []
|
||||
|
||||
|
||||
def url(state):
|
||||
return os.environ[f"DEMO_{state.upper()}"]
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
FAILS.append(name)
|
||||
|
||||
|
||||
def open_page(browser, state, route, *, size, scheme, lang):
|
||||
ctx = browser.new_context(locale=lang, viewport=SIZES[size],
|
||||
color_scheme=scheme, reduced_motion="reduce")
|
||||
page = ctx.new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(f"pageerror {e}"))
|
||||
page.on("console", lambda m: errors.append(f"console {m.text}")
|
||||
if m.type == "error" else None)
|
||||
page.goto(url(state) + route)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1200)
|
||||
return ctx, page, errors
|
||||
|
||||
|
||||
def sweep(browser):
|
||||
n = 0
|
||||
for state in STATES:
|
||||
for route in ("#batteries", "#settings"):
|
||||
for size in SIZES:
|
||||
for scheme in ("light", "dark"):
|
||||
for lang in ("en-GB", "nl-BE"):
|
||||
ctx, page, errors = open_page(
|
||||
browser, state, route, size=size, scheme=scheme,
|
||||
lang=lang)
|
||||
wide = page.evaluate(
|
||||
"document.documentElement.scrollWidth > "
|
||||
"document.documentElement.clientWidth")
|
||||
if route == "#batteries":
|
||||
cards = page.locator("#bcards .bcard").count()
|
||||
texts = page.locator("#bcards dd").all_inner_texts()
|
||||
ok = cards == 2 and all(t.strip() for t in texts)
|
||||
else:
|
||||
ok = (page.locator("#simple .card").count() >= 3
|
||||
and page.locator("#groups").is_hidden())
|
||||
where = f"{state} {route} {size} {scheme} {lang}"
|
||||
if errors or wide or not ok:
|
||||
FAILS.append(f"{where}: errors={errors} "
|
||||
f"wide={wide} drawn={ok}")
|
||||
print(f" FAIL {where}: {errors} wide={wide} "
|
||||
f"drawn={ok}")
|
||||
n += 1
|
||||
ctx.close()
|
||||
check(f"{n} views: 0 page errors, 0 console errors, no sideways scroll, "
|
||||
f"every card drawn", not [f for f in FAILS if " #" in f])
|
||||
|
||||
|
||||
def posts(page):
|
||||
sent = []
|
||||
page.on("request", lambda r: sent.append(json.loads(r.post_data))
|
||||
if r.url.endswith("/action") and r.method == "POST" else None)
|
||||
return sent
|
||||
|
||||
|
||||
def actions(browser):
|
||||
# The Batteries screen: switch B off, then set A to Leftover solar.
|
||||
ctx, page, errors = open_page(browser, "modes", "#batteries",
|
||||
size="desktop", scheme="light", lang="en-GB")
|
||||
sent = posts(page)
|
||||
page.locator("#bsw-c1").click()
|
||||
page.wait_for_timeout(1500)
|
||||
card_b = page.locator("#bcards .bcard").nth(1)
|
||||
check("Batteries: B's switch posts pack_use false, and B's card turns Off "
|
||||
"with its mode list greyed out",
|
||||
sent[-1:] == [{"action": "pack_use", "unit": "Battery B",
|
||||
"value": False}]
|
||||
and "off" in (card_b.get_attribute("class") or "")
|
||||
and page.locator("#bsel-c1").is_disabled()
|
||||
and "You switched it off." in card_b.inner_text())
|
||||
page.locator("#bsel-c0").focus()
|
||||
page.locator("#bsel-c0").select_option("leftover_solar")
|
||||
page.wait_for_timeout(1500)
|
||||
check("...A's list posts pack_mode leftover_solar and the card keeps it",
|
||||
sent[-1:] == [{"action": "pack_mode", "unit": "Battery A",
|
||||
"value": "leftover_solar"}]
|
||||
and page.locator("#bsel-c0").input_value() == "leftover_solar"
|
||||
and "Leftover solar" in page.locator("#bcards .bcard").nth(0)
|
||||
.inner_text())
|
||||
check("...the cheap-hours choice and the selling switch are greyed out",
|
||||
page.locator("#bsel-c0 option[disabled]").count() == 1
|
||||
and page.locator("#bcards .bcard .f .switch[disabled]").count() == 2)
|
||||
check("...and the focus stays on the control that was used",
|
||||
page.evaluate("document.activeElement.id") == "bsel-c0")
|
||||
check("...with 0 page errors", not errors)
|
||||
ctx.close()
|
||||
# Settings, Simple: the same switch, and the Installer view is one tap.
|
||||
ctx, page, errors = open_page(browser, "modes", "#settings",
|
||||
size="phone", scheme="dark", lang="nl-BE")
|
||||
sent = posts(page)
|
||||
page.locator("#bsw-s1").click()
|
||||
page.wait_for_timeout(1500)
|
||||
check("Simple (NL, phone, dark): B's switch posts pack_use true and reads "
|
||||
"Aan", sent[-1:] == [{"action": "pack_use", "unit": "Battery B",
|
||||
"value": True}]
|
||||
and page.locator("#bsw-s1").get_attribute("aria-checked") == "true")
|
||||
page.locator("#set-view [data-v=installer]").click()
|
||||
page.wait_for_timeout(500)
|
||||
check("...Installer shows the groups and hides Simple",
|
||||
page.locator("#groups").is_visible()
|
||||
and page.locator("#simple").is_hidden())
|
||||
check("...with 0 page errors", not errors)
|
||||
ctx.close()
|
||||
|
||||
|
||||
def switching(browser):
|
||||
# Review: an unsaved change survives the view switch both ways, the
|
||||
# Installer's locked values still need "Change", and only the person's
|
||||
# own pick of a view is remembered.
|
||||
ctx, page, errors = open_page(browser, "modes", "#settings",
|
||||
size="desktop", scheme="light", lang="en-GB")
|
||||
chips = [c.locator(".chip").first.inner_text()
|
||||
for c in page.locator("#simple > .card").all()]
|
||||
check(f"Simple's badges: the peak limit and shadow mode need a restart, "
|
||||
f"the batteries apply at once ({chips})",
|
||||
chips == ["Applies after a restart", "Applies right away",
|
||||
"Applies after a restart"])
|
||||
num = page.locator("#simple .card input.num").first
|
||||
num.fill("4.2")
|
||||
num.dispatch_event("change")
|
||||
page.wait_for_timeout(600)
|
||||
n1 = page.locator("#sb-text").inner_text()
|
||||
page.locator("#set-view [data-v=installer]").click()
|
||||
page.wait_for_timeout(600)
|
||||
val = page.locator("#groups [data-key=peak_cap_w] input.num").input_value()
|
||||
n2 = page.locator("#sb-text").inner_text()
|
||||
locked = (page.locator("#groups [data-key=peak_soc_floor] input").count(),
|
||||
page.locator("#groups [data-key=peak_soc_floor] button.link")
|
||||
.count())
|
||||
page.locator("#set-view [data-v=simple]").click()
|
||||
page.wait_for_timeout(600)
|
||||
back = page.locator("#simple .card input.num").first.input_value()
|
||||
n3 = page.locator("#sb-text").inner_text()
|
||||
check("an unsaved peak limit survives Simple -> Installer -> Simple, and "
|
||||
"the save bar counts it once", n1.startswith("1 ") and n2 == n1
|
||||
and n3 == n1 and val == "4.2" and back == "4.2"
|
||||
and page.locator("#savebar").is_visible())
|
||||
check("...the Installer's locked value has no input until 'Change'",
|
||||
locked == (0, 1))
|
||||
ctx.close()
|
||||
ctx, page, errors = open_page(browser, "running", "#batteries",
|
||||
size="desktop", scheme="light", lang="en-GB")
|
||||
page.locator("#batt-off a").click()
|
||||
page.wait_for_timeout(800)
|
||||
page.reload()
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1200)
|
||||
check("a jump to Installer from a link is not remembered: Settings "
|
||||
"opens on Simple again", page.locator("#simple").is_visible()
|
||||
and page.locator("#groups").is_hidden() and not errors)
|
||||
ctx.close()
|
||||
|
||||
|
||||
def modes_off(browser):
|
||||
ctx, page, errors = open_page(browser, "running", "#batteries",
|
||||
size="phone", scheme="light", lang="en-GB")
|
||||
page.locator("#batt-off a").click()
|
||||
page.wait_for_timeout(1500)
|
||||
check("modes off: the card's button opens Settings on the Installer view",
|
||||
page.url.endswith("#settings") and page.locator("#groups")
|
||||
.is_visible() and not errors)
|
||||
ctx.close()
|
||||
|
||||
|
||||
def month_peak(browser):
|
||||
# The fixture has no month peak yet, so give /status one on the way in.
|
||||
ctx = browser.new_context(locale="en-GB", viewport=SIZES["desktop"])
|
||||
page = ctx.new_page()
|
||||
|
||||
def with_peak(route):
|
||||
r = route.fetch()
|
||||
body = r.json()
|
||||
body["month_peak"]["peak_w"] = 2860.0
|
||||
route.fulfill(response=r, json=body)
|
||||
page.route("**/status*", with_peak)
|
||||
page.goto(url("modes") + "#settings")
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1500)
|
||||
check("Simple: the peak card shows this month's peak from /status",
|
||||
"This month's peak so far: 2.9 kW." in page.locator("#simple")
|
||||
.inner_text())
|
||||
ctx.close()
|
||||
|
||||
|
||||
def main():
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
sweep(b)
|
||||
actions(b)
|
||||
month_peak(b)
|
||||
switching(b)
|
||||
modes_off(b)
|
||||
b.close()
|
||||
if FAILS:
|
||||
print(f"{len(FAILS)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,10 @@
|
||||
"""UX-13: serve the real page and routes over a fixture controller, for a
|
||||
headless browser check. `python3 tools/ux13_demo.py STATE PORT [HOST]`
|
||||
|
||||
STATE is running, shadow or alarm. The controller is the shipped `Controller`
|
||||
STATE is running, shadow, alarm, new (P4) or setup (UX-15's setup-only
|
||||
mode, P5). P3 adds the battery-mode states in `P3`: modes (on, B on Leftover
|
||||
solar), off (B switched off), off_peak (B off but giving for the peak limit),
|
||||
refusal (A refuses to give power) and maint (A in its monthly cycle). The controller is the shipped `Controller`
|
||||
with its readings set by hand (two batteries, solar, today's prices in the
|
||||
mockup's shape, trading on). Nothing here talks to Home Assistant or to an
|
||||
inverter: `/action` changes only this fixture. Used for the P1 render check
|
||||
@@ -33,8 +36,10 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
from aiohttp import web as aioweb # noqa: E402
|
||||
|
||||
from app import homeview, web # noqa: E402
|
||||
from app import homeview, packmode, web # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN # noqa: E402
|
||||
from app.setuponly import SetupOnly # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
@@ -203,8 +208,9 @@ class Supervisor:
|
||||
async def info(self):
|
||||
return {"options": json.loads(json.dumps(self.options)),
|
||||
"schema": schema_ui(CONFIG),
|
||||
"translations": {"en": TL.parse_translation_file(
|
||||
os.path.join(HERE, "translations", "en.yaml"))}}
|
||||
"translations": {lang: TL.parse_translation_file(
|
||||
os.path.join(HERE, "translations", f"{lang}.yaml"))
|
||||
for lang in ("en", "nl")}}
|
||||
|
||||
async def write_options(self, options):
|
||||
self.options = json.loads(json.dumps(options))
|
||||
@@ -228,8 +234,14 @@ OPTS = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||
"max_w": 2500, "capacity_kwh": 5.4}]}
|
||||
|
||||
|
||||
# UX-13 P3: the battery-mode states, each with battery modes on.
|
||||
P3 = ("modes", "off", "off_peak", "refusal", "maint")
|
||||
|
||||
|
||||
def controller(state):
|
||||
opts = dict(OPTS, shadow_mode="on" if state == "shadow" else "off")
|
||||
if state in P3:
|
||||
opts["battery_modes_enabled"] = True
|
||||
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
now = datetime.now(timezone.utc)
|
||||
@@ -252,10 +264,46 @@ def controller(state):
|
||||
c.pv_w = 3900.0
|
||||
if state == "alarm":
|
||||
c.inverters[0].sign_wd.tripped = True
|
||||
if state in P3:
|
||||
a, b = c.inverters
|
||||
b.store_set(packmode.MODE_KEY, packmode.LEFTOVER_SOLAR)
|
||||
if state in ("off", "off_peak"):
|
||||
b.store_set(packmode.USE_KEY, False)
|
||||
b.batt = 0.0
|
||||
if state == "off_peak":
|
||||
# A cloudy evening: the house takes 6 kW and the peak limit needs
|
||||
# both batteries, so B gives 0.5 kW although it is switched off.
|
||||
c.pv_w, c.grid = 0.0, 3490.0
|
||||
a.batt, b.batt = 2000.0, 510.0
|
||||
c.peak_guard = PeakDecision(engaged=True, floor_w=2500.0,
|
||||
ceiling_w=3500.0, ceiling_from="cap",
|
||||
projected_w=3600.0)
|
||||
if state == "refusal":
|
||||
# A is at its cut-out level and refuses: asked 0 W, B takes over.
|
||||
c.pv_w, c.grid = 0.0, 5.0
|
||||
a.soc, a.batt, a.refused = 9.0, -4.0, 1
|
||||
b.batt = 1210.0
|
||||
if state == "maint":
|
||||
c.maints[0].store.set("phase", DRAIN) # MAINT-05: A's own key
|
||||
c.pv_w, c.grid = 0.0, 0.0
|
||||
a.batt, b.batt = 2000.0, 0.0
|
||||
return c
|
||||
|
||||
|
||||
class ReadOnlyHass(Hass):
|
||||
read_only = True # app/setuponly.py serves only on a read-only HA
|
||||
|
||||
|
||||
async def main(state, port, host="127.0.0.1"):
|
||||
if state == "setup":
|
||||
# UX-15's setup-only mode: shipped defaults, nothing controlled.
|
||||
stored = defaults(CONFIG)
|
||||
c = SetupOnly(dict(stored), ReadOnlyHass(), Store())
|
||||
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
||||
await runner.setup()
|
||||
await aioweb.TCPSite(runner, host, port).start()
|
||||
print(f"serving setup on http://127.0.0.1:{port}/", flush=True)
|
||||
await asyncio.Event().wait()
|
||||
if state == "new":
|
||||
stored = dict(defaults(CONFIG), setpoint_entity="number.a")
|
||||
store = Store()
|
||||
@@ -274,6 +322,7 @@ async def main(state, port, host="127.0.0.1"):
|
||||
p1_net_entity="sensor.grid", price_provider="generic_dayahead",
|
||||
peak_forecast_entity="sensor.peak_forecast",
|
||||
shadow_mode=c.o.get("shadow_mode", "off"),
|
||||
battery_modes_enabled=state in P3,
|
||||
inverters=[dict(i, bms_soc_floor=10)
|
||||
for i in OPTS["inverters"]])
|
||||
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""UX-13 P5: the DOCS screenshots of the new screens, taken from the real page.
|
||||
|
||||
Run by tools/ux13_screenshots.sh, which serves tools/ux13_demo.py fixtures and
|
||||
runs this inside the Playwright image. Each shot fails the run on a page
|
||||
error, so a broken screen is never pictured.
|
||||
|
||||
python3 ux13_screenshots.py OUT_DIR # DEMO_<STATE> = the fixture's URL
|
||||
|
||||
P3 adds the Batteries screen and Settings, Simple (the default Settings
|
||||
view), so the Installer shots click Installer first.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
OUT = sys.argv[1]
|
||||
DESKTOP = {"width": 1000, "height": 900}
|
||||
PHONE = {"width": 390, "height": 844}
|
||||
|
||||
|
||||
def url(state):
|
||||
return os.environ[f"DEMO_{state.upper()}"]
|
||||
|
||||
|
||||
def shot(browser, name, state, route, *, lang="en-GB", size=DESKTOP,
|
||||
full=True, act=None, only=None):
|
||||
ctx = browser.new_context(locale=lang, viewport=size,
|
||||
color_scheme="light", reduced_motion="reduce")
|
||||
page = ctx.new_page()
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.goto(url(state) + route)
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(1500) # the first /status and checks
|
||||
if act:
|
||||
act(page)
|
||||
page.wait_for_timeout(1500) # the debounced start checks
|
||||
path = os.path.join(OUT, name + ".png")
|
||||
if only:
|
||||
page.locator(only).screenshot(path=path)
|
||||
else:
|
||||
page.screenshot(path=path, full_page=full)
|
||||
ctx.close()
|
||||
if errors:
|
||||
raise SystemExit(f"{name}: page errors {errors}")
|
||||
print(f" {name}.png")
|
||||
|
||||
|
||||
def installer(page):
|
||||
page.locator("#set-view [data-v=installer]").click()
|
||||
|
||||
|
||||
def open_tariff(page):
|
||||
installer(page)
|
||||
page.locator("details.grp[data-g=tariff] > summary").click()
|
||||
page.locator("details.grp[data-g=meter] > summary").click() # close it
|
||||
# The fixture site is on a dynamic contract: its fallback gets windows.
|
||||
page.get_by_role("radiogroup", name="Price when the feed is down") \
|
||||
.get_by_role("radio", name="Other time windows").click()
|
||||
|
||||
|
||||
def windows(page, overlap=False):
|
||||
open_tariff(page)
|
||||
add = page.get_by_role("button", name="+ Add a time window")
|
||||
rows = (("07:00", "23:00" if overlap else "22:00", "mon-fri", "0.34"),
|
||||
("22:00", "07:00", "mon-fri", "0.26"),
|
||||
("00:00", "24:00", "sat,sun", "0.24"))
|
||||
for i, (a, b, days, price) in enumerate(rows, start=1):
|
||||
add.click()
|
||||
page.get_by_label(f"Window {i}, From").fill(a)
|
||||
page.get_by_label(f"Window {i}, To").fill(b)
|
||||
page.get_by_label(f"Window {i}, Days").select_option(days)
|
||||
page.get_by_label(f"Window {i}, Price").fill(price)
|
||||
page.locator("[data-key='ui.periods']").scroll_into_view_if_needed()
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch()
|
||||
shot(b, "home-running", "running", "#home")
|
||||
shot(b, "home-running-nl", "running", "#home", lang="nl-BE")
|
||||
shot(b, "home-shadow", "shadow", "#home")
|
||||
shot(b, "home-phone", "running", "#home", size=PHONE)
|
||||
shot(b, "home-setup-only", "setup", "#home", full=False)
|
||||
shot(b, "settings-installer", "running", "#settings", act=installer)
|
||||
shot(b, "settings-installer-nl", "running", "#settings", lang="nl-BE",
|
||||
act=installer)
|
||||
shot(b, "batteries", "modes", "#batteries")
|
||||
shot(b, "batteries-nl", "modes", "#batteries", lang="nl-BE")
|
||||
shot(b, "batteries-off-peak", "off_peak", "#batteries")
|
||||
shot(b, "batteries-phone", "refusal", "#batteries", size=PHONE,
|
||||
full=False)
|
||||
shot(b, "batteries-modes-off", "running", "#batteries", full=False)
|
||||
shot(b, "settings-simple", "modes", "#settings")
|
||||
shot(b, "settings-simple-nl", "modes", "#settings", lang="nl-BE")
|
||||
tariff = "details.grp[data-g=tariff]"
|
||||
shot(b, "settings-time-windows", "running", "#settings",
|
||||
act=windows, only=tariff)
|
||||
shot(b, "settings-time-windows-overlap", "running", "#settings",
|
||||
act=lambda page: windows(page, overlap=True), only=tariff)
|
||||
shot(b, "setup-step-1", "new", "#setup")
|
||||
shot(b, "setup-step-1-nl", "new", "#setup", lang="nl-BE")
|
||||
b.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# UX-13 P5: regenerate the DOCS screenshots in docs/img/ux13/ from the real page.
|
||||
#
|
||||
# sh goodwe_controller/tools/ux13_screenshots.sh [OUT_DIR]
|
||||
# CHECK=1 sh goodwe_controller/tools/ux13_screenshots.sh # P3 browser check
|
||||
#
|
||||
# Serves tools/ux13_demo.py's fixtures (running, shadow, new, setup) in the CI
|
||||
# test image, then runs tools/ux13_screenshots.py in the Playwright image on a
|
||||
# private Docker network (the image has the browsers; the matching Python
|
||||
# package is installed into the container on each run). Nothing talks to Home Assistant or an inverter. The
|
||||
# prices and the "now" line follow the clock, so the pictures change a little
|
||||
# from run to run. Needs Docker and PyPI access; pulls the Playwright image once.
|
||||
set -eu
|
||||
HERE=$(cd "$(dirname "$0")/.." && pwd)
|
||||
OUT=${1:-$HERE/../docs/img/ux13}
|
||||
PW=mcr.microsoft.com/playwright/python:v1.55.0-noble
|
||||
NET=ux13-shots-$$
|
||||
STATES="running shadow new setup modes off off_peak refusal maint"
|
||||
RUN=/t/ux13_screenshots.py
|
||||
[ "${CHECK:-}" = 1 ] && RUN=/t/ux13_browser_check.py
|
||||
mkdir -p "$OUT"
|
||||
OUT=$(cd "$OUT" && pwd)
|
||||
|
||||
cleanup() {
|
||||
for s in $STATES; do docker rm -f "$NET-$s" >/dev/null 2>&1 || true; done
|
||||
docker network rm "$NET" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
docker build -q -t goodwe-addon-checks:1 "$HERE/../.gitea/ci" >/dev/null
|
||||
docker network create "$NET" >/dev/null
|
||||
ENVS=""
|
||||
for s in $STATES; do
|
||||
docker run -d --name "$NET-$s" --network "$NET" -e PYTHONDONTWRITEBYTECODE=1 \
|
||||
-v "$HERE:/w:ro" goodwe-addon-checks:1 \
|
||||
python3 /w/tools/ux13_demo.py "$s" 8099 0.0.0.0 >/dev/null
|
||||
ENVS="$ENVS -e DEMO_$(echo "$s" | tr a-z A-Z)=http://$NET-$s:8099/"
|
||||
done
|
||||
sleep 3
|
||||
# shellcheck disable=SC2086
|
||||
docker run --rm --network "$NET" --user "$(id -u):$(id -g)" -e HOME=/tmp \
|
||||
$ENVS -v "$HERE/tools:/t:ro" -v "$OUT:/out" "$PW" \
|
||||
sh -euc 'pip install -q --disable-pip-version-check --target /tmp/pw \
|
||||
playwright==1.55.0
|
||||
PYTHONPATH=/tmp/pw python3 '"$RUN"' /out'
|
||||
echo "screenshots in $OUT"
|
||||
@@ -10,7 +10,8 @@ own validator run over them.
|
||||
The wizard edits the page model one step at a time and saves it through
|
||||
uipage.to_options(), like the Installer view. `blobs()` walks each step's
|
||||
choices (meter source and sign, one to three inverters, solar rows, every
|
||||
contract and price source, region, peak limit, shadow mode) from a new
|
||||
contract and price source, time windows (P5), region, peak limit, shadow
|
||||
mode) from a new
|
||||
install as shipped, one with a power request entity, and the TEST-23 sites, and keeps what the shipped validator
|
||||
accepts, because the wizard saves nothing it refuses. test_setup.py checks
|
||||
the set; `--supervisor` runs it through Supervisor 2026.08.0's `AppOptions`
|
||||
@@ -103,6 +104,29 @@ PRICES = {
|
||||
"csv": {"ui.contract": "dynamic", "price_provider": "manual_csv",
|
||||
"price_csv_path": "/share/prices.csv", "ui.fallback": "fixed",
|
||||
"ui.fixed_price": 0.3},
|
||||
# P5: the time-window editor, odd windows the day/night preset cannot say.
|
||||
"periods": {"ui.contract": "custom", "ui.fallback": "custom",
|
||||
"ui.periods": [
|
||||
{"from": "06:30", "to": "21:15", "days": "mon-fri",
|
||||
"price": 0.31},
|
||||
{"from": "21:15", "to": "06:30", "days": "mon-fri",
|
||||
"price": 0.19},
|
||||
{"from": "00:00", "to": "12:00", "days": "sat",
|
||||
"price": 0.2},
|
||||
{"from": "12:00", "to": "24:00", "days": "sat",
|
||||
"price": 0.25},
|
||||
{"from": "00:00", "to": "24:00", "days": "sun",
|
||||
"price": 0.18}]},
|
||||
"periods_one": {"ui.contract": "custom", "ui.fallback": "custom",
|
||||
"ui.periods": [{"from": "00:00", "to": "00:00",
|
||||
"days": "mon-sun", "price": 0.29}]},
|
||||
"dynamic_periods": {"ui.contract": "dynamic",
|
||||
"price_provider": "generic_dayahead",
|
||||
"ui.fallback": "custom", "ui.periods": [
|
||||
{"from": "07:00", "to": "23:00",
|
||||
"days": "mon-sun", "price": 0.32},
|
||||
{"from": "23:00", "to": "07:00",
|
||||
"days": "mon-sun", "price": 0.21}]},
|
||||
}
|
||||
REGIONS = {"flanders": {"ui.region": "flanders"},
|
||||
"own": {"ui.region": "own", "price_taxes_eur_kwh": 0.15}}
|
||||
@@ -139,6 +163,9 @@ def plans():
|
||||
"shadow": "on"},
|
||||
{"meter": "mqtt", "roster": "two", "solar": "none",
|
||||
"prices": "formula", "region": "flanders", "peak": "low",
|
||||
"shadow": "on"},
|
||||
{"meter": "dsmr", "roster": "two", "solar": "one",
|
||||
"prices": "periods", "region": "flanders", "peak": "forecast",
|
||||
"shadow": "on"}):
|
||||
seen.append(plan)
|
||||
return seen
|
||||
|
||||
@@ -82,6 +82,18 @@ configuration:
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485 emergency stop is fitted"
|
||||
description: "Switch this on if the emergency stop is installed. It only changes a warning on the status page."
|
||||
battery_modes_enabled:
|
||||
name: "Hardware · Battery modes"
|
||||
description: "Give each battery an on and off switch and a mode, set on the Batteries screen."
|
||||
pv_forecast_entity:
|
||||
name: "Hardware · Solar forecast, today (kWh)"
|
||||
description: "Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give."
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Solar forecast, tomorrow (kWh)"
|
||||
description: "Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight."
|
||||
solar_trust_pct:
|
||||
name: "Hardware · Trust in the forecast (%)"
|
||||
description: "Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast."
|
||||
pv_entity:
|
||||
name: "Hardware · Solar production entity (W)"
|
||||
description: "Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control."
|
||||
@@ -142,6 +154,9 @@ configuration:
|
||||
peak_cap_w:
|
||||
name: "Limits · Peak limit (W)"
|
||||
description: "Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract."
|
||||
peak_margin_w:
|
||||
name: "Limits · Margin below the peak limit (W)"
|
||||
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
|
||||
peak_forecast_entity:
|
||||
name: "Limits · 15-minute demand forecast entity (W)"
|
||||
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
|
||||
|
||||
@@ -63,6 +63,18 @@ configuration:
|
||||
estop_fitted:
|
||||
name: "Hardware · RS485-noodstop is geplaatst"
|
||||
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
|
||||
battery_modes_enabled:
|
||||
name: "Hardware · Batterijmodi"
|
||||
description: "Geef elke batterij een aan- en uitschakelaar en een modus, in te stellen op het scherm Batterijen."
|
||||
pv_forecast_entity:
|
||||
name: "Hardware · Zonneprognose, vandaag (kWh)"
|
||||
description: "Optioneel. Een prognosesensor met de zonne-energie die vandaag nog komt, zoals Forecast.Solar of Solcast. Een batterij koopt dan alleen wat de zon niet geeft."
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Zonneprognose, morgen (kWh)"
|
||||
description: "Optioneel. Dezelfde prognose voor morgen. Ze helpt een goedkoop uur voor middernacht te plannen."
|
||||
solar_trust_pct:
|
||||
name: "Hardware · Vertrouwen in de prognose (%)"
|
||||
description: "Prognoses zijn vaak te zonnig. Bij 70 % plant de add-on met 7 van elke 10 kWh uit de prognose."
|
||||
pv_entity:
|
||||
name: "Hardware · Entiteit zonneproductie (W)"
|
||||
description: "Optioneel. De vermogenssensor van uw zonne-omvormer, positief tijdens productie. De add-on houdt ze bij en regelt er niet mee."
|
||||
@@ -123,6 +135,9 @@ configuration:
|
||||
peak_cap_w:
|
||||
name: "Grenzen · Piekgrens (W)"
|
||||
description: "Uw eigen getal: het gemiddelde vermogen per kwartier waaronder de add-on de afname houdt. Neem het over uit uw netcontract."
|
||||
peak_margin_w:
|
||||
name: "Grenzen · Marge onder de piekgrens (W)"
|
||||
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
|
||||
peak_forecast_entity:
|
||||
name: "Grenzen · Entiteit kwartierprognose (W)"
|
||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."
|
||||
|
||||