Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02ed1200dc | ||
|
|
f13dab1407 | ||
|
|
b307db1049 | ||
|
|
e51e3e02b2 | ||
|
|
69dc1f0e7b | ||
|
|
858f77cfb5 | ||
|
|
214051d1a8 | ||
|
|
05eb93f139 | ||
|
|
f3e409cf76 | ||
|
|
4cd604afcb | ||
|
|
6872353e2e | ||
|
|
8c438ab2e6 | ||
|
|
b37b7f95dc | ||
|
|
28cab03947 | ||
|
|
61072ca460 | ||
|
|
7b723a6cd5 | ||
|
|
4b81387908 | ||
|
|
935878193b | ||
|
|
b4d25dd485 | ||
|
|
a4a04d203c | ||
|
|
aa501ffec7 | ||
|
|
d89c093318 | ||
|
|
d0db5b8c39 | ||
|
|
b8ddfd787f | ||
|
|
1c41a1e412 | ||
|
|
ba31e7c8b1 | ||
|
|
513183aba3 | ||
|
|
4ad8298ead | ||
|
|
5c9f64d87a | ||
|
|
43a52e7f60 | ||
|
|
9d5b7d129a | ||
|
|
bc6d744c6c | ||
|
|
79b94024cb | ||
|
|
1145a931ac | ||
|
|
56cafefa79 | ||
|
|
3f69c174af | ||
|
|
d77f1b1fdf | ||
|
|
b171ada894 | ||
|
|
0b96e3191c | ||
|
|
35cdffe766 | ||
|
|
a692560960 | ||
|
|
fbd884797d | ||
|
|
516bc8b4ec | ||
|
|
4f25b24aa6 | ||
|
|
044fc0d5bd | ||
|
|
f967073cfe | ||
|
|
aabc2a27fd | ||
|
|
af39c10893 | ||
|
|
6eb67c0f9c | ||
|
|
5d27f8496f | ||
|
|
eb10537aee | ||
|
|
5a4cdc273e | ||
|
|
7104515229 | ||
|
|
6dab4ec16b | ||
|
|
b014fd2e16 | ||
|
|
902524ae5e | ||
|
|
7a00f12c76 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
|
||||
0c04488a9194039c2bf851b680a8ffc53beff671
|
||||
|
||||
@@ -1,5 +1,132 @@
|
||||
# Changelog
|
||||
|
||||
## 0.14.0
|
||||
|
||||
**A minor bump: new behaviour with two inverters, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (83
|
||||
options). Options that start 0.13.0 also start 0.14.0. With one inverter,
|
||||
control is the same as in 0.13.0.
|
||||
|
||||
⭐ **Two inverters are now supported** for the monthly maintenance (drain,
|
||||
charge, hold) and the peak guard. To add a second inverter, follow
|
||||
`docs/commissioning-b.md` in the `admin/goodwe` repo. Its Stage 5, where the
|
||||
add-on takes control of it, needs 0.14.0. Read the known issues below first:
|
||||
three of them are about two inverters.
|
||||
|
||||
Contents of this release, in merge order since 0.13.0 (`8c438ab`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `f3e409c` | Two inverters: a battery at or under its peak reserve gives last, after the others give their full power. It is freed again above reserve + 1 point (e.g. above 21 % for a 20 % reserve). At a quarter's edge the peak guard no longer drops out for a second ([[SAFETY-91]]) |
|
||||
| `858f77c` | Two inverters: when a maintenance charge ends, for any reason, the other battery no longer starts charging from the grid; control hands over in small steps ([[SAFETY-93]]) |
|
||||
| `e51e3e0` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-13]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
|
||||
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.13.0
|
||||
|
||||
**A minor bump: new behaviour, a new add-on page (Home, Settings, Batteries,
|
||||
Setup), and six new options.** No option was removed or renamed, and no
|
||||
default of an existing option changed (77 options -> 83). The only new
|
||||
start-up checks are on the new options, so options that start 0.12.0 also
|
||||
start 0.13.0.
|
||||
|
||||
New options, and what they do if you leave them alone:
|
||||
|
||||
| option | default | left alone |
|
||||
|---|---|---|
|
||||
| `peak_margin_w` | `50` | the peak guard aims 50 W below your peak limit, or below a higher peak already reached this month; `0` is the old aim ([[SAFETY-79]]) |
|
||||
| `battery_modes_enabled` | `false` | no battery modes, control as before ([[OPT-11]]) |
|
||||
| `pv_forecast_entity`, `pv_forecast_tomorrow_entity` | empty | no solar forecast is used ([[OPT-11]]) |
|
||||
| `solar_trust_pct` | `70` | only used with a solar forecast ([[OPT-11]]) |
|
||||
| `pv_invert_entities` | `[]` | PV readings as before ([[TEL-14]]) |
|
||||
|
||||
⚠️ **Turn on Watchdog** on the add-on's Info tab. It is off by default. If
|
||||
the control loop crashes, the add-on now sets the batteries to 0 W and stops.
|
||||
Home Assistant only starts it again when Watchdog is on ([[SAFETY-61]],
|
||||
[[DOC-5]]).
|
||||
|
||||
⚠️ **The peak guard now starts a little earlier.** It aims 50 W below your
|
||||
peak limit, so the batteries give a bit more at peak times. Set
|
||||
`peak_margin_w: 0` for the old aim ([[SAFETY-79]]).
|
||||
|
||||
Contents of this release, in merge order since 0.12.0 (`a45ed06`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `fa3d237` | Docs name the right P1 age entity and a battery list that starts ([[DOC-1]]) |
|
||||
| `6e65272` | If the control loop crashes, the batteries go to 0 W and the add-on stops ([[SAFETY-61]]) |
|
||||
| `3e38baf` | The check tools refuse a file they cannot read, instead of saying all clear (tools only) ([[SAFETY-60]], [[SAFETY-62]]) |
|
||||
| `43b2740` | A missing value in a check is a named failure (tests only) ([[TEST-26]]) |
|
||||
| `20260e8` | Docs: turn on Watchdog ([[DOC-5]]) |
|
||||
| `16c796f` | New option `pv_invert_entities`: flip the sign of a PV sensor that reads backwards ([[TEL-14]]) |
|
||||
| `6a8a5ff` | CI tests against the real inverter simulator (CI only) ([[CI-12]]) |
|
||||
| `82f032e` | CI pin checks report failures properly (CI only) ([[TOOL-9]]) |
|
||||
| `fe13251` | CI pin update (CI only) |
|
||||
| `397dd15` | Alarm notifications that are still active come back after a Home Assistant restart ([[SAFETY-34]]) |
|
||||
| `59415ea` | CI pin update (CI only) |
|
||||
| `2503de1` | "Battery is not keeping up" is a warning with its own notification, not a "bug" alarm; its figures are correct ([[SAFETY-40]]) |
|
||||
| `48ef06a` | Shorter, plainer notifications ([[UX-08]]) |
|
||||
| `5c151a3` | CI pin update (CI only) |
|
||||
| `d57767f` | Shorter, plainer status rows and banners ([[UX-08]]) |
|
||||
| `e1d0970` | Details link works on current Home Assistant; plainer words ([[UX-09]]) |
|
||||
| `a02cd4b` | Shorter refusals, errors and option texts ([[UX-08]]) |
|
||||
| `32b9e56` | Plainer log lines and docs ([[UX-08]]) |
|
||||
| `125b070` | CI pin update (CI only) |
|
||||
| `2b14b88` | Last plain-language fixes in logs and the status page ([[UX-10]]) |
|
||||
| `c4032eb` | CI pin update (CI only) |
|
||||
| `d1f7e33` | Safety log lines: one plain sentence and a code ([[UX-11]]) |
|
||||
| `652097a` | A battery that refuses power (e.g. empty) is asked for 0 W, and tried at 200 W for 34 s every 15 min ([[SAFETY-52]]) |
|
||||
| `afd6af0` | Groundwork for the new settings pages ([[UX-13]]) |
|
||||
| `a36dca6` | New page with a Home screen ([[UX-13]]) |
|
||||
| `28a49cc` | With two or more inverters, a battery that refuses power is held on its own; the others take over ([[SAFETY-73]]) |
|
||||
| `515c11b` | New Installer settings view ([[UX-13]]) |
|
||||
| `4de9675` | New setup wizard for installers ([[UX-13]]) |
|
||||
| `8c34647` | A new install with no inverter opens the setup wizard instead of stopping ([[UX-15]]) |
|
||||
| `a5dddad` | Dutch text and a time-period editor ([[UX-13]]) |
|
||||
| `ed739d4` | Battery modes (off by default): per-battery limits, on/off, solar order ([[OPT-11]]) |
|
||||
| `7995e4d` | Simple settings view and a Batteries screen ([[UX-13]]) |
|
||||
| `7337b79` | Battery modes in Home Assistant, when modes are on ([[OPT-11]]) |
|
||||
| `7a00f12` | New option `peak_margin_w`: the peak guard keeps a margin below the limit ([[SAFETY-79]]) |
|
||||
| `5a4cdc2` | Battery modes: charge in cheap hours, hold for expensive hours; selling is off for now ([[OPT-11]]) |
|
||||
| `4f25b24` | No false "Load too high" alarms at quarter edges or with a noisy load (10 s hold) ([[SAFETY-81]]) |
|
||||
| `35cdffe` | Maintenance drain with two batteries no longer stalls ([[SAFETY-78]]) |
|
||||
| `1145a93` | Battery modes: a solar forecast decides how much to buy from the grid ([[OPT-11]]) |
|
||||
| `9d5b7d1` | With one battery, the peak guard wins over the maintenance drain ([[SAFETY-83]]) |
|
||||
| `4ad8298` | Maintenance charge with two batteries no longer stalls; it gives way to the peak guard ([[SAFETY-84]]) |
|
||||
| `b8ddfd7` | The peak guard wins over the maintenance hold; the hold ends on time, with no top-up ([[SAFETY-86]]) |
|
||||
| `9358781` | With two or more batteries, the peak guard uses every battery that can still give; peak alarms no longer say control stopped ([[SAFETY-87]]) |
|
||||
| `61072ca` | No false "Battery is not keeping up" during maintenance; real shortfalls still warn; the status row counts each episode once ([[SAFETY-85]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- Two inverters: at the end of a maintenance charge one battery can charge from the grid while the house uses a lot. Do not add a second inverter before the next release ([[SAFETY-93]]).
|
||||
- A healthy battery that turns from charging to giving can be held at 0 W for up to 15 min ([[SAFETY-94]]).
|
||||
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
|
||||
- Two batteries: one at its peak reserve can be used before a draining battery has used its room ([[SAFETY-91]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.12.0
|
||||
|
||||
**A MINOR BUMP FOR ONE NEW OPTION: `pv_entities` ([[TEL-12]]), for sites with
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -29,4 +29,101 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
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.
|
||||
Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4.
|
||||
- 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.
|
||||
|
||||
@@ -69,6 +69,8 @@ which does not run the startup gate) the absence costs a shave and never a pack.
|
||||
|
||||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||||
|
||||
*At N>=2 one pack at its floor no longer stops the others: [[SAFETY-87]].*
|
||||
|
||||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# 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) outranked the peak floor (50): a 5000 W house imported
|
||||
3000 W against a 2500 W limit. Fixed by [[SAFETY-83]].
|
||||
- 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. Fixed by [[SAFETY-84]].
|
||||
@@ -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.
|
||||
@@ -0,0 +1,34 @@
|
||||
# SAFETY-83 - the peak guard outranks the maintenance drain at N=1
|
||||
|
||||
**Status: DECIDED 2026-09-29 (owner go, P1).** Follows SAFETY-78's "Not
|
||||
changed" list. Tests: `test_safety83.py`.
|
||||
|
||||
**The defect.** At N=1 the drain is the site SET at `P_MAINTENANCE` (60). The
|
||||
peak floor is a limit at `P_PEAK` (50), and clause 2 applies only limits at or
|
||||
above the winner, so the floor was ignored and the house imported what the
|
||||
drain left. A 5000 W house under a 2500 W limit and a 2000 W drain banked
|
||||
3000 W quarters; on the owner's settings (limit 3500, drain 2500, margin 50) a
|
||||
breach starts at about 5950 W.
|
||||
|
||||
**The fix.** While a drain owns the setpoint at N=1 and the peak floor asks
|
||||
for more than the drain rate, `main.py` (`_peak_over_drain()`) refiles that
|
||||
peak claim at `P_MAINTENANCE`. The arbiter then applies it: the target is the
|
||||
floor, still inside the safety limits, and a safety SET (100) still parks
|
||||
everything. Where the floor is at or below the drain rate nothing changes.
|
||||
|
||||
- The order in effect is now the same at N=1 and N=2: safety limits and the
|
||||
SAFETY-73 refusal > peak guard (with the SAFETY-79 margin) > maintenance >
|
||||
modes > trade > loop (`arbiter.py`, beside the priorities).
|
||||
- The drain keeps counting: the battery discharges at least the drain rate on
|
||||
every cycle, so the cycle is not reset or stalled; it only ends sooner.
|
||||
- Below the drain rate the rest is still exported (owner, 2026-09-29).
|
||||
- Identity: `tools/safety78_identity.py --diff` against 35cdffe changes only
|
||||
cycles where the guard asked for more than the drain; after such an episode
|
||||
the commands are identical again and only the log and the idle loop's
|
||||
integrator differ. N=2 is unchanged.
|
||||
|
||||
**Not changed.**
|
||||
- Between the drain rate and the peak aim, the house still imports what the
|
||||
drain leaves (N=1 behaviour kept by the identity). At N=2 the loop covers it.
|
||||
- A maintenance HOLD (0 W, 120 min, battery full) still outranked the peak
|
||||
guard at N=1. Fixed by [[SAFETY-86]].
|
||||
@@ -0,0 +1,46 @@
|
||||
# SAFETY-84 - a maintenance charge at two inverters
|
||||
|
||||
**Status: DECIDED 2026-09-30.** Follows SAFETY-78's "Not changed" list.
|
||||
Tests: `test_safety84.py`; identity: `tools/safety84_identity.py`.
|
||||
|
||||
**The defect.** In a cheap window the charging pack files its own
|
||||
`hi = -charge` (P_MAINT_SHAPE). At N>=2 the loop's site target sat above it
|
||||
(a house to cover, or its 400 W slew step from 0 W), so the other pack had to
|
||||
give while this one charged. The allocator refuses opposite directions
|
||||
(SAFETY-31), so the site sat at 0 W whatever the house
|
||||
(`tools/safety84_identity.py` on 9d5b7d1: 436 of 480 cycles refused). N=1
|
||||
was fine.
|
||||
|
||||
**The fix.**
|
||||
- While a pack's own limits make it charge at N>=2, `main.py` files a site
|
||||
limit `charge`, `hi` = what that pack's own `hi` places
|
||||
(`allocator.hi_w()`, whole W as the allocator rounds). The site target is
|
||||
never above it, so the other pack only adds power in the same direction.
|
||||
Opposite splits stay forbidden. Mirror of SAFETY-78's `drain` floor.
|
||||
- Peak guard first (owner order, SAFETY-83). The charge is sized to the
|
||||
quarter's headroom under the aim (`charge_headroom_w()`, cap or banked peak
|
||||
minus `peak_margin_w`), so it charges less instead of stopping: the meter
|
||||
sits on the aim and the guard does not need to step in. Where the house
|
||||
alone is over the aim the guard engages, the shaping is suspended as before
|
||||
(`shaping_suspended()`) and no `charge` is filed, so the guard's `lo` and
|
||||
this `hi` never meet in one cycle (no `lo > hi`, no park at 0 W).
|
||||
Without the headroom sizing the aim still held (the suspension did it), but
|
||||
a battery gave power back on 285 of 540 cycles at a 3000 W house
|
||||
(test_safety84.py part 2).
|
||||
- A site stop (a safety SET above P_MAINT_SHAPE) sizes the charge to 0 W:
|
||||
the unit `hi` is a hard limit, so a charge under a 0 W stop needs the other
|
||||
pack to give. It was refused, with an ARBITER line, on most cycles of the
|
||||
stop (test_safety84.py part 4). The stop still parked the site at 0 W.
|
||||
- A pack that cannot take the charge (a refusal, a full pack, a stale link)
|
||||
files no ceiling; the other pack follows the loop.
|
||||
- Not filed at N=1: byte-identical (`tools/safety84_identity.py`, which hashes
|
||||
every claim, in and out of a cheap window and under a banked peak).
|
||||
|
||||
**Not changed.**
|
||||
- At N=1 the charge is not sized to the headroom: the guard still suspends it
|
||||
and the battery gives until the quarter is safe. The same sizing would fit
|
||||
there; that is an owner question, not this fix.
|
||||
- Outside a cheap window (`hi = 0`, solar only) nothing is filed, at any N.
|
||||
- A HOLD (later fixed by [[SAFETY-86]]) and the "Battery is not keeping up"
|
||||
alarm (SAFETY-85) are untouched. In the identity runs that alarm's log line
|
||||
comes as often at N=2 as at N=1 (2 in 480 cycles).
|
||||
@@ -0,0 +1,91 @@
|
||||
# SAFETY-85 - no false "not keeping up" while another owner sets the battery
|
||||
|
||||
**Status: implemented 2026-10-01, for review.** Tests: `test_safety85.py`.
|
||||
|
||||
**The defect.** "Battery is not keeping up" (SAFETY-40, `goodwe_integrator_bound`)
|
||||
fired about every 2.5 min during maintenance (drain, charge, HOLD) and under the
|
||||
peak guard, while the battery gave exactly what it was told. Each warning
|
||||
re-created the HA notification. Rig counts: 24 in an hour (SAFETY-83 F1), 43 in
|
||||
7 episodes (SAFETY-86), 35 in 16 (SAFETY-87), 12 in 6 (SAFETY-84). The page
|
||||
counter counted cycles (861), not episodes.
|
||||
|
||||
**Cause, measured on the real Controller.**
|
||||
- N=1: when a higher claim decides, `cycle()` re-seeds the integrator from the
|
||||
target each cycle, then `compute()` adds `gain * error` for a grid error the
|
||||
loop does not own. Target + kick crosses the rail and sets `i_clamped`. The
|
||||
carried integrator is not wound (SAFETY-48 caps the lead at one slew step),
|
||||
so control comes back one slew step at a time.
|
||||
- N>=2: a maintenance pin is a unit claim. The loop stays the site winner and
|
||||
winds to its rail for watts the pinned battery may not give. When the pin
|
||||
lifts, the site jumps: 2500 W in one cycle when a HOLD ends under a 4500 W
|
||||
house, 1390 W when a charge pin lifts (`s84 n2_charge_dear`, t=1800 s).
|
||||
|
||||
**Decision.** Three rules; the alarm, its text and its id are unchanged.
|
||||
1. **The loop's clamp counts only on a cycle the loop decided.** The winner is
|
||||
the loop. Its number is on the wire, or only a rating (`device`,
|
||||
`supervised`) cut it. No bound held it, and nothing was unmet.
|
||||
(`Controller._loop_alarm_input`.)
|
||||
2. **A battery another owner set to discharge alarms when it gives less than
|
||||
it was told.** This covers every battery on a cycle the loop did not
|
||||
decide, and a battery with its own pin otherwise. The test is more than
|
||||
`saturation_w` short of the least it stood at over `SHORT_CYCLES` cycles
|
||||
and `SHORT_SPAN_S` (the plant's measured settle, SAFETY-13/73), for
|
||||
`saturation_cycles` cycles in a row (`clamp.delivery_short`). It covers a
|
||||
real shortfall during maintenance or under the peak guard. Before, that
|
||||
shortfall was silent unless the kick happened to reach the rail (house
|
||||
1000 W, drain 2000 W, battery gives nothing: 0 warnings before, 4 in 10 min
|
||||
now).
|
||||
3. **At N>=2, while maintenance holds a battery, the loop is bounded** by
|
||||
OPT-11's `loop_bound()` (what the batteries may place). It does not wind,
|
||||
and control comes back one slew step a cycle. Why maintenance only: that is
|
||||
where the jump was measured and where the owner order puts maintenance
|
||||
above the loop. Other N>=2 limits (pack cut-out, SAFETY-73 refusal) keep
|
||||
the old law. Rule 1 still silences them (the target is unmet), so they no
|
||||
longer warn either.
|
||||
|
||||
Fixing the windup was chosen over only ignoring the wound part, because at
|
||||
N>=2 the wound integrator moves real watts at hand-back. At N=1 there was
|
||||
nothing to fix: re-seeding already prevents windup.
|
||||
|
||||
**What SAFETY-03/40 protect is kept.** With the loop in charge, the integrator
|
||||
bound still warns (a runaway, a house above the rating), and a saturated
|
||||
inverter under the loop's own command still does not alarm (SAFETY-03 AC 2):
|
||||
rule 2 grades only batteries another owner set.
|
||||
|
||||
**The counter.** `ClampWatchdog.bound_episodes` counts episodes that had a
|
||||
bound engagement. The status row shows it ("N times since start").
|
||||
`bound` (cycles) stays for the log and the tests.
|
||||
|
||||
**Re-pinned digests.** Every change is intended; nothing at N=1 moves a watt.
|
||||
|
||||
| digest | what changed |
|
||||
|---|---|
|
||||
| s78 `n1_drain_owner` | the false clamp rows are gone from the log; commands identical |
|
||||
| s78 `n2_drain`, s84 `n2_charge`, `n2_charge_banked`, s79 `n2_maint` (test_peak_margin) | loop reason (`mode-bound`), integrator and loop claim while bounded; commands identical |
|
||||
| opt11 `n2_maint` (test_modes) | A drops out at t=1655 s: B follows the house at once, 8 cycles differ (max 920 W) |
|
||||
| s84 `n2_charge_dear` | A's pin lifts at t=1800 s: 400 W a cycle, not a 1390 W jump; the surplus step after it; 98 cycles |
|
||||
|
||||
test_safety86's "without the ceiling the log floods" now needs the ceiling
|
||||
and the bound both removed: either one alone keeps the loop inside what the
|
||||
batteries can give. The `alarmed()` helpers in test_safety84/86 also read
|
||||
SAFETY-81's quarter stamp, because ALLOCATOR rows can push its row out of
|
||||
the 40-row log.
|
||||
|
||||
**Review (PR #163).** The first draft graded each battery against last
|
||||
cycle's command and graded charges too. On plantsim's battery (10 s lag) at
|
||||
the live 2 s meter, every drain or charge start warned once, and an N=2 drain
|
||||
or HOLD under the peak guard with a stepping house warned 15 times an hour. A
|
||||
maintenance charge tapering near full warned 11 times in 50 min, which
|
||||
SAFETY-42 section 7 had ruled out. With the settle window and discharge only:
|
||||
0 in all of those cases. A battery that gives nothing still warns, lagging
|
||||
or not. At a hypothetical 20 s lag, it warns at most twice an hour. The cost:
|
||||
a battery that does not take a charge another owner set is not named by this
|
||||
alarm (the same gap SAFETY-42 states; the charge has its 36 h timeout).
|
||||
`test_safety85.py` part 7.
|
||||
|
||||
**Not changed, open for the owner.**
|
||||
- With the loop in charge and the house above the batteries' total rating,
|
||||
it still warns (SAFETY-40). On the rig's 2 x 2000 W N=2, most of SAFETY-84's
|
||||
count came from houses of 4500 and 6500 W.
|
||||
- TEL-10's `Loop reason` reads `mode-bound` while the maintenance bound holds
|
||||
the loop, with modes off. DOCS lists the value with both meanings.
|
||||
@@ -0,0 +1,47 @@
|
||||
# SAFETY-86 - the peak guard outranks the maintenance HOLD
|
||||
|
||||
**Status: DECIDED 2026-09-30 (owner).** Follows SAFETY-83's "Not changed"
|
||||
list. Tests: `test_safety86.py`.
|
||||
|
||||
**The defect.** The HOLD (battery resting full after the maintenance charge)
|
||||
is a SET of 0 W at `P_MAINTENANCE` (60), above the peak floor (`P_PEAK`, 50),
|
||||
so the floor did not bind. At N=1 a 6500 W house banked 6500 W quarters and
|
||||
'Load too high' fired (SAFETY-83 rig test, check 4). At N=2 only the other
|
||||
battery gave: 4500 W against a 3950 W aim (SAFETY-84 rig test, check 6).
|
||||
|
||||
**The fix** (`main.py`, `_peak_over_hold()`).
|
||||
- N=1: during a HOLD a peak floor above 0 W is refiled at `P_MAINTENANCE`, as
|
||||
for a drain (SAFETY-83). The battery gives the floor; the Why reads "raised
|
||||
for the peak limit".
|
||||
- N>=2: the holding battery's pin is raised to the floor minus what the other
|
||||
live batteries may place (their hard `hi`, or their pin). The others give
|
||||
first, the holding one only the shortfall. Why this split and not the
|
||||
allocator's shares: the hold is maintenance and outranks the others' use
|
||||
(owner order), and the owner rule for an Off battery is the same, "give way
|
||||
only for the shortfall". A battery Off by its mode (P_MODE, below
|
||||
maintenance) therefore still gives before the holding one.
|
||||
- N>=2, while the holding battery gives: a site `hold` ceiling at the floor
|
||||
(P_MAINTENANCE), so the loop does not ask for watts the hold keeps back.
|
||||
Without it the moving pin re-logged the ALLOCATOR "could not be placed"
|
||||
row each cycle and filled the action log (review, PR #161).
|
||||
- Below the aim nothing changes: the floor is 0 W, the hold is 0 W, the rows
|
||||
are identical to before (test_safety86.py part 2).
|
||||
- A site stop (a SET above `P_PEAK`) decides alone: nothing is raised at N>=2,
|
||||
and the refiled floor at N=1 stays under `P_SAFETY`. If another battery is
|
||||
pinned to charge (a SAFETY-73 probe) the pin is not raised, so the site is
|
||||
never split both ways.
|
||||
- The hold resumes by itself: the rule is evaluated each cycle, so when the
|
||||
guard lets go the command is 0 W again.
|
||||
|
||||
**The level dips below the target: the hold finishes anyway.** Owner
|
||||
decision 2026-10-01: the HOLD ends on its clock (`maintenance_hold_min`) even
|
||||
when the peak guard pulls the level down; no top-up. A top-up would re-enter
|
||||
CHARGE (up to 36 h, `CHARGE_TIMEOUT_MIN`) and keep the site's one maintenance
|
||||
slot meanwhile.
|
||||
|
||||
**Not changed.**
|
||||
- The peak guard still grades the emptiest battery (SAFETY-32): at N=2 with
|
||||
B under `peak_soc_floor` the floor is limited and A holds even when it is
|
||||
full (SAFETY-87).
|
||||
- "Battery is not keeping up" during maintenance (SAFETY-85), the N=2 charge
|
||||
flapping (SAFETY-88) and the N=1 charge headroom sizing are untouched.
|
||||
@@ -0,0 +1,83 @@
|
||||
# SAFETY-87 - the peak guard uses every battery that can still give
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner card).** Follows SAFETY-86's "Not
|
||||
changed" list. Tests: `test_safety87.py`; identity:
|
||||
`tools/safety87_identity.py`.
|
||||
|
||||
**The defect.** At N>=2 the guard graded the emptiest battery against the
|
||||
highest floor ([[SAFETY-32]] §3). One battery under `peak_soc_floor` made the
|
||||
whole site floor-limited: the claim asked for 0 W and the reserve alarm fired,
|
||||
even with the other battery full. Measured: a maintenance charge (A at 11 %)
|
||||
raised the alarm 10 times while both batteries gave 2000 W and the quarter
|
||||
landed on the aim (SAFETY-84 rig test, check 3); a HOLD (A at 99 %, B at
|
||||
15 %) left A at 0 W and banked 4051 W, raising the month peak (SAFETY-86
|
||||
review; rig case 7a: 4563.8 W).
|
||||
|
||||
**The fix.**
|
||||
- `decide()` and its call are unchanged. At N>=2 `Controller._peak_on_able()`
|
||||
then grades each battery on its own floor (`peakguard.reserve_split()`).
|
||||
One at or under it is *held*. The others that are live and not refusing
|
||||
(SAFETY-73) are *able*.
|
||||
- A floor-limited decision is re-taken on the able batteries
|
||||
(`peakguard.place_on_able()`): their own permission (min SoC against max
|
||||
floor, among them), and a floor of `min(need, able_w)`. `able_w` is their
|
||||
rating plus the drain a held battery gives anyway (its maintenance pin).
|
||||
- The held batteries weigh 0 in the split while the site gives
|
||||
(`arbiter.resolve(reserve=)`), so the floor lands on the able ones. During a
|
||||
HOLD, a held battery is not counted among the "others" (`_peak_over_hold()`),
|
||||
so the holding battery covers the need.
|
||||
- Floor-limited now means: some battery is held and the able ones cannot hold
|
||||
the aim. The claim still asks them for `able_w`. The alarm fires only if
|
||||
they miss the real limit at full power for `ALARM_HOLD_S`, the same test as
|
||||
'Load too high' (`_reserve_short()`), so a one-cycle miss at a quarter's end
|
||||
is not an alarm. With nothing able (all held, or the rest refusing or
|
||||
dropped) it is the old state: 0 W, immediate alarm.
|
||||
- The alarm and the row name `held`, each battery graded on its own floor. The
|
||||
old code named batteries against the site's highest floor.
|
||||
- Page: the two peak rows carry `runs`. A red `runs` row gives the banner its
|
||||
title without "Not ready", and the Home page is not shown as held at 0 W.
|
||||
Rows that do stop control still outrank it.
|
||||
|
||||
**How SAFETY-32's concern is still covered.** SAFETY-32 took min(SoC) against
|
||||
max(floor) so the guard never sizes a forced discharge a battery's own BMS
|
||||
stops. Now no battery at or under its own floor is asked for the peak: it is
|
||||
graded on its own floor, and an unread SoC or unknown cut-out counts as held.
|
||||
The min/max pairing still decides permission, over the able batteries. The
|
||||
roster verdict `decide()` echoes (`soc`, `soc_floor`) is kept, so SAFETY-32's
|
||||
call-site and reduction checks still bite. What changed is only that one held
|
||||
battery no longer stops the others. One exception, with battery modes on: see
|
||||
Open.
|
||||
|
||||
**Maintenance pins beside a held battery** (review). A draining battery that
|
||||
may still give is raised from its drain rate towards its rating for the peak
|
||||
floor, after the others (`_drain_over_reserve()`; peak > drain), else the
|
||||
floor's rest landed on the held battery. In a HOLD, a held battery counts its
|
||||
own pin (a drain) among the others, and a held battery pinned to charge stops
|
||||
the raise (never split both ways).
|
||||
|
||||
**Before -> after** (test_safety87.py, rig of 2 x 2500 W, aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| charge, A 11 %, house 4500 | 3 reserve alarms, quarter 3450 | 0 alarms, 3450 |
|
||||
| HOLD, A 99 % / B 15 %, house 5500 | A 0 W, B 2488 W, 3 alarms | A 2054 W, B 0 W, 0 alarms, 3450 |
|
||||
| HOLD, house 6500 | A 0 W, B 2488 W, 4051 W | A 2500 W, B 0 W, 4014 W, 1 alarm (A alone is short) |
|
||||
| HOLD, month peak 4000, house 6000 | A 0 W, B 2488 W, 3551 W, alarms | A 2054 W, B 0 W, 3950 (peak - margin), 0 alarms |
|
||||
| both under the reserve | alarm | alarm (unchanged) |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Open, for the owner.**
|
||||
- The loop still draws a held battery (SAFETY-25 option A: the reserve binds
|
||||
the peak guard only). It gives last, after the able ones. So with house 6500
|
||||
in the charge case, B alone cannot hold the aim, the alarm fires as
|
||||
specified, and the loop then lands the quarter on the aim from A. The text
|
||||
says "cannot keep … alone", not "will go over", so it stays true.
|
||||
- Battery modes on, A Off and B held: when B alone can place the floor, OPT-11
|
||||
keeps A resting and B gives it. When B cannot, A gives first. Making Off give
|
||||
before a held battery in the first case needs a third allocator tier. The
|
||||
same at N=3: A Off, B held, C able, floor 4000 W gives C 2500, B 1500, A 0.
|
||||
Until then, with modes on, a held battery can give for the peak.
|
||||
- The notification and row text live in `messages.py`, which has no NL layer
|
||||
yet (UX-19). The Home page text, which has NL, is unchanged.
|
||||
|
||||
**Not changed.** N=1. 'Load too high' (SAFETY-81). SAFETY-85, -88, -89, -90.
|
||||
@@ -0,0 +1,74 @@
|
||||
# SAFETY-91 - a held battery is drawn only after the others are full
|
||||
|
||||
**Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A
|
||||
kept).** Follows SAFETY-87. Tests: `test_safety91.py`.
|
||||
|
||||
**The defect** (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under
|
||||
its own peak reserve was held back (weight 0) only on cycles where the peak
|
||||
guard asked for power.
|
||||
- F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was
|
||||
near zero the loop set the target, A had its normal weight and B's drain
|
||||
pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 ->
|
||||
10.7 %) while B stayed at 1000 W. In the next quarter A reached its
|
||||
cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
|
||||
- F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block
|
||||
with no seconds left and asked for nothing for one cycle. The HOLD's
|
||||
ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W
|
||||
for 1 s.
|
||||
|
||||
**The fix** (`main.py`, N>=2 only).
|
||||
- `_held_back()`: the batteries at or under their own reserve while another
|
||||
can still give (live, not refusing), graded as in `_peak_on_able()`. They
|
||||
weigh 0 in every split where the site gives, not only for the peak. With
|
||||
nobody able (all held, or the rest refusing or dropped) nobody is held back
|
||||
and the split is as before.
|
||||
Review: once held back, a battery stays so until its SoC reads more than 1
|
||||
point above its reserve. A reading flipping 20/21 % otherwise swapped
|
||||
~1000 W between the batteries on each flip (grid +-500 W with A 10 s and
|
||||
B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
|
||||
- `_drain_rise()`: while a battery is held back, a draining battery's pin
|
||||
becomes a floor at its drain rate (`lo`), so it rises towards its rating
|
||||
before the held one gives. Not for a held battery's own drain, not under a
|
||||
site stop, not for a battery that is not live.
|
||||
- `tariff.next_quarter()`: past a block's end, before the next block's first
|
||||
telegram, the guard grades the next quarter, projected from the held
|
||||
reading (the averager credits it there). Not when that reading is too old
|
||||
to be credited (`max_hold_s`).
|
||||
|
||||
**What stays.** SAFETY-25 option A: once every able or draining battery is
|
||||
at full power, the loop still draws a held battery (`allocator._fill()`'s
|
||||
last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W
|
||||
after B is full. Nothing is placed on a battery at its cut-out: OPT-09's
|
||||
pack limit closes it (`hi = 0`), and the drain rise lets B take the rest.
|
||||
|
||||
**Before -> after** (`test_safety91.py`, 2 x 2500 W, lagging battery 10 s):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| B drain 1000, A held, house 4000 | A 2500, B 1000 | A 1500, B 2500 |
|
||||
| same, house 5500 | A 2500, B 1000 | A 2500, B 2500 (option A) |
|
||||
| cycles A asked while B not full | 612 of 900 | 0 |
|
||||
| A at its cut-out, house 5500: B | 1572 W mean | 2500 W, A 0 W |
|
||||
| HOLD, late telegram at the edge | A 0, B 2470 for 1 cycle | A 2050, B 0 |
|
||||
| idle, A held, house 2000 | A 1005, B 1005 | A 0, B 2020 |
|
||||
| N=1, every scenario | | byte-identical |
|
||||
|
||||
**Why N>=2 only.** At N=1 there is no other battery to give first. The edge
|
||||
roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86
|
||||
rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical;
|
||||
extending it is a one-line change for a later ticket.
|
||||
|
||||
**Re-pinned.** `test_modes.py` `n2` and `n2_off`: from t=1600 battery A is
|
||||
at its 20 % reserve, so B carries the house and A gives last (100 of 540
|
||||
cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the
|
||||
test grades that.
|
||||
Two fixtures changed with it: `test_safety87.py` part 10's "without the
|
||||
raise" run also turns off `_drain_rise()` (else B rises anyway), and
|
||||
`test_peakguard.py` 10g and `test_watchdog.py`'s two-inverter rig give
|
||||
their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).
|
||||
|
||||
**Not changed.** The peak guard's own rules (SAFETY-87). A drain with
|
||||
nobody held stays a pin (SAFETY-78). An Off battery beside a held one
|
||||
(SAFETY-87 Open). The first cycle after start, before any quarter is
|
||||
measured, can still put the loop's first step on a held battery in a HOLD.
|
||||
SAFETY-88, -89, -90, -92, -93, -94.
|
||||
@@ -0,0 +1,60 @@
|
||||
# SAFETY-93 - the maintenance charge end hands back smoothly
|
||||
|
||||
**Status: implemented 2026-10-02, for review.** Tests: `test_safety93.py`.
|
||||
|
||||
**The defect** (SAFETY-85 rig test, check 4; also on 9358781). At N=2, when
|
||||
A's maintenance charge ended, B went 0 -> -1600 W in one cycle and charged
|
||||
from the grid with the house at 6000 W. The HOLD end was already smooth
|
||||
(SAFETY-85).
|
||||
|
||||
**Cause.** During the charge the site target holds A's charge (SAFETY-84's
|
||||
`charge` ceiling, -2000 W). At the end A gets its HOLD pin (0 W), but the
|
||||
loop starts its next step from last cycle's site target. The slew limit
|
||||
moves the site total by 400 W (-2000 -> -1600), and the only battery left
|
||||
to place it on is B. The same happens when the charge times out or the
|
||||
battery is switched Off (A free, its -2000 W shared out). With the charge
|
||||
suspended by the guard and A giving under the loop, B took A's share in
|
||||
one step (675 W).
|
||||
|
||||
**The fix** (`main.py`, `_reseed_charge_end()`, N>=2 only). On the cycle a
|
||||
battery leaves CHARGE, the loop's start point (`self.target`, and the
|
||||
integrator with it) is what the other batteries stood at plus that
|
||||
battery's new command: its pin (HOLD or abort, 0 W), or, if it is free, its
|
||||
last discharge (a charge stops). The loop then moves from there one slew
|
||||
step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
|
||||
pin, as before; it lowers the grid import. The end is kept until the loop
|
||||
next runs, so a missed poll on that cycle (the input hold) does not lose it.
|
||||
|
||||
Review: a charge also ends, for the site, when the battery is still in
|
||||
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
|
||||
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
|
||||
1200 W house). So the end is noted after the unit claims are filed: a
|
||||
battery leaves the charge when it leaves CHARGE, is not live, or its own
|
||||
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
|
||||
|
||||
Why a start-point reseed, and not a per-battery slew in the allocator: the
|
||||
jump comes from one stale number at one edge, the kind of edge SAFETY-73
|
||||
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
|
||||
The change stays at that edge; nothing else at N>=2 moves.
|
||||
|
||||
**Before -> after** (`test_safety93.py`, 2 x 2500 W, 10 s lag, 2 s meter):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| rig case: house 6000, A charges 2000 to the end | B 0 -> -1600, charges 4 cycles | B 0 -> 400 -> 800 ..., never charges |
|
||||
| house 1200 | B 0 -> -1600 (2800 W from the grid) | B steps of 400, never charges |
|
||||
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
|
||||
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
|
||||
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
|
||||
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
|
||||
| N=1, both charge ends above | | byte-identical |
|
||||
|
||||
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
|
||||
N=2) are byte-identical to f3e409c; none of their scenarios reaches a
|
||||
charge end.
|
||||
|
||||
**Not changed.** The peak guard's floor still moves the site in one step
|
||||
when it engages (a house step, a quarter edge): the guard outranks the slew
|
||||
(owner order). A HOLD pin still drops the holding battery to 0 W at once
|
||||
and SAFETY-86 gives way to the others first. SAFETY-88 (charge flapping
|
||||
above the aim) is untouched.
|
||||
+93
-17
@@ -184,6 +184,9 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| 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. |
|
||||
@@ -520,9 +523,37 @@ below.
|
||||
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. *Leftover solar* charges from the solar they cannot take. Both cover the house at any hour. Charging from the grid in cheap hours, and selling, come in a later update.
|
||||
- **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).
|
||||
|
||||
The peak limit, the safety stops and the monthly cycle always come before a battery's mode.
|
||||
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.
|
||||
|
||||
@@ -530,6 +561,8 @@ The peak limit, the safety stops and the monthly cycle always come before a batt
|
||||
|
||||
*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
|
||||
@@ -544,7 +577,7 @@ accumulator is doing:
|
||||
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
|
||||
| `Loop reason` | — | the law's own word for this cycle |
|
||||
|
||||
`Loop reason` takes one of ten values:
|
||||
`Loop reason` takes one of eleven values:
|
||||
|
||||
| value | what happened |
|
||||
|---|---|
|
||||
@@ -555,6 +588,7 @@ accumulator is doing:
|
||||
| `slew-limited` | the command could not move as far as the integrator asked for; `slew_w` is what stopped it |
|
||||
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
|
||||
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
|
||||
| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance |
|
||||
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
|
||||
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
|
||||
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
|
||||
@@ -1606,7 +1640,7 @@ help - see "Why the tuning is what it is".
|
||||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
|
||||
| `maintenance_soc_target` | 99 | Charge target |
|
||||
@@ -1628,6 +1662,24 @@ 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.
|
||||
If the house would still go over the peak limit, the battery being emptied
|
||||
gives more than its drain rate, with one battery too (SAFETY-83).
|
||||
A battery at its peak reserve gives last: the others give their full power
|
||||
first, and the battery being emptied rises above its drain rate before it
|
||||
(SAFETY-91).
|
||||
In a cheap window the battery being filled charges and the others only add
|
||||
charge; none gives while it charges. The charge is cut to what keeps the
|
||||
15-minute peak at the limit minus `peak_margin_w` (SAFETY-84).
|
||||
While a full battery rests after its charge (the hold), it gives just enough
|
||||
to keep the 15-minute peak at that aim if the house needs it; with two
|
||||
batteries the other one gives first. The hold still ends on its clock
|
||||
(SAFETY-86).
|
||||
When a charge ends, the other batteries take over one slew step a cycle,
|
||||
and none starts charging from the grid (SAFETY-93).
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
⚠️ **This section described the exposure BEFORE SAFETY-07. The loop is now
|
||||
@@ -1720,7 +1772,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
|
||||
@@ -1761,7 +1814,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
It will discharge straight past this number and keep going until the pack's own
|
||||
BMS cut-out stops it. What you are promised by `peak_soc_floor: 20` is *"the
|
||||
capacity-peak guard will not spend the pack for money below 20 %"*, and
|
||||
**nothing more**. See below for what that costs on a real evening.
|
||||
**nothing more**. See below for what that costs on a real evening. With two
|
||||
or more batteries, one under it is used last: only once the others give
|
||||
their full power (SAFETY-91).
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
|
||||
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
|
||||
@@ -1906,6 +1961,15 @@ over the rating is logged as a software fault, at `ERROR`, and it has its own
|
||||
notification budget.
|
||||
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
|
||||
|
||||
The integrator bound counts only on a cycle the loop decided. While
|
||||
maintenance, the peak guard or a safety limit sets a battery to discharge, the
|
||||
same alarm fires only when that battery gives more than `saturation_w` less
|
||||
than the least it was told over the plant's settle time (at least 23 s), for
|
||||
`saturation_cycles` cycles. A charge is not graded: a full battery takes less
|
||||
as it tops up. At two or more inverters the loop does not wind while
|
||||
maintenance holds a battery.
|
||||
Why: [`DECISIONS/SAFETY-85-no-false-not-keeping-up.md`](DECISIONS/SAFETY-85-no-false-not-keeping-up.md).
|
||||
|
||||
### The frozen-meter alarm
|
||||
|
||||
A meter that has died is easy — it stops answering, the sample age climbs past
|
||||
@@ -3075,7 +3139,10 @@ The battery gives less power than the add-on asks for. This is normal for a
|
||||
short time, for example just after a large load switches on or the sun comes
|
||||
out. It is not a fault, and control keeps running. If it keeps coming back,
|
||||
check that the battery is switched on, and that it is not empty (when it should
|
||||
give power) or full (when it should charge).
|
||||
give power) or full (when it should charge). During the monthly maintenance or
|
||||
while the peak limit is guarded, it only shows when the battery gives
|
||||
clearly less than it was told. The status page counts how many times it
|
||||
happened.
|
||||
|
||||
### Power request too high
|
||||
|
||||
@@ -3110,6 +3177,10 @@ and what to do:
|
||||
- **A battery cut-out level is not known.** Fill in “Battery cut-out level”
|
||||
for each inverter in the settings.
|
||||
|
||||
With several inverters, a battery at its peak reserve does not give for the
|
||||
peak and the others do. This alarm then comes only when the others cannot
|
||||
keep the period under the limit alone. Control keeps running meanwhile.
|
||||
|
||||
The peak reserve only applies to keeping the peak down. Normal home use can
|
||||
still draw the battery below it, down to the battery cut-out level, where the
|
||||
battery switches itself off.
|
||||
@@ -3176,8 +3247,9 @@ section without a link. The documentation tab adds no heading anchors, so the
|
||||
link cannot jump to the section.
|
||||
|
||||
**Battery is not keeping up.** The grid-following loop's integrator reached its
|
||||
anti-windup bound (`integrator_max_w`, SAFETY-40). No command went over the
|
||||
rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
|
||||
anti-windup bound (`integrator_max_w`, SAFETY-40), or a battery that another
|
||||
owner set to discharge gave less than it was told (SAFETY-85). No command went
|
||||
over the rating. The log line is a WARNING with reason code `OUTPUT_CLAMP`. See *The
|
||||
winding-loop alarm*.
|
||||
|
||||
**Power request too high.** A computed command above a limit was cut before it
|
||||
@@ -3192,9 +3264,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
|
||||
@@ -3202,7 +3277,9 @@ state of charge is unread, or when an inverter's `bms_soc_floor` (label
|
||||
“Battery cut-out level”) is missing, because the reserve is graded against it.
|
||||
Charging from the grid stays blocked meanwhile. With several inverters the
|
||||
notification names the battery at its limit (SAFETY-32), each graded against
|
||||
its own cut-out level. ⚠️ **The reserve binds the peak guard only
|
||||
its own cut-out level. That battery is held (weight 0 while the site gives)
|
||||
and the guard's need goes to the others, up to their rating; the alarm fires
|
||||
only if they miss the limit at full power for `ALARM_HOLD_S` (SAFETY-87). ⚠️ **The reserve binds the peak guard only
|
||||
(SAFETY-25).** Self-consumption has no such floor. See *What `peak_soc_floor`
|
||||
does not protect*.
|
||||
|
||||
@@ -3263,9 +3340,8 @@ 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. Charging in cheap hours and selling are greyed out until a later
|
||||
update, and Today reads "Not planned yet" when nothing is planned for the
|
||||
battery.
|
||||
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."
|
||||
@@ -3341,7 +3417,7 @@ for a new install*.
|
||||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||||
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
|
||||
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
|
||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. A red peak row is an alarm while control keeps running, so the banner shows its title without *Not ready*. |
|
||||
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
|
||||
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
|
||||
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
|
||||
|
||||
@@ -344,6 +344,21 @@ class _Narrowed:
|
||||
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 hi_w(b):
|
||||
"""A unit's upper hard limit as `plan()` narrows it to whole W (SAFETY-84)."""
|
||||
return float(_int_envelope(b)[1])
|
||||
|
||||
|
||||
def loop_bound(bounds, live=None):
|
||||
"""OPT-11. `(lo, hi)`: what the units can place when the LOOP decides.
|
||||
|
||||
@@ -364,11 +379,8 @@ def loop_bound(bounds, live=None):
|
||||
continue
|
||||
if getattr(b, "contradiction", False):
|
||||
continue
|
||||
pv = getattr(b, "pinned_w", None)
|
||||
if getattr(b, "gate", None) is not None and pv is not None:
|
||||
lo, hi = _int_envelope(b)
|
||||
p = float(pv) if lo > hi else float(_clamp_int(round(float(pv)),
|
||||
lo, hi))
|
||||
p = pin_w(b)
|
||||
if p is not None:
|
||||
lo_sum += min(0.0, p)
|
||||
hi_sum += max(0.0, p)
|
||||
continue
|
||||
|
||||
@@ -76,6 +76,12 @@ from . import allocator
|
||||
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
|
||||
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
|
||||
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
|
||||
# The order in effect: safety limits and the SAFETY-73 refusal > the peak
|
||||
# guard > maintenance > modes > trade > loop. A peak floor above a drain still
|
||||
# binds: the `drain` site floor at N>=2 (SAFETY-78), refiled at P_MAINTENANCE
|
||||
# at N=1 (SAFETY-83); over a HOLD, `_peak_over_hold()` (SAFETY-86).
|
||||
# A cheap-window charge is suspended while the peak guard holds and, at N>=2,
|
||||
# sized to the quarter's headroom (SAFETY-84).
|
||||
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
|
||||
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
|
||||
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
|
||||
@@ -184,6 +190,22 @@ 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,
|
||||
# main.py: `Claim.limit("charge", P_MAINT_SHAPE, hi=...)`, SAFETY-84. The
|
||||
# mirror of `drain` for a cheap-window maintenance charge at N>=2: the site
|
||||
# target may not go above what the charging pack's own `hi` asks, or a
|
||||
# sibling would have to give against it. Never filed at N=1.
|
||||
# DECISIONS/SAFETY-84-charge-at-two.md.
|
||||
"charge": SCOPE_SITE,
|
||||
# main.py: `Claim.limit("hold", P_MAINTENANCE, hi=...)`, SAFETY-86. While a
|
||||
# holding pack gives the peak shortfall at N>=2, the site target stays at
|
||||
# the peak floor, what the pin and the others place. Never filed at N=1.
|
||||
"hold": SCOPE_SITE,
|
||||
|
||||
# --- UNIT: about one pack, one inverter, one device -------------------
|
||||
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
|
||||
@@ -613,7 +635,7 @@ def site_resolve(claims: list) -> Resolution:
|
||||
|
||||
|
||||
def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
tick=None) -> Resolution:
|
||||
tick=None, reserve=()) -> Resolution:
|
||||
"""The three stages, in order, over one cycle's claims.
|
||||
|
||||
`units` is the site's roster of inverters. Left None it is derived from the
|
||||
@@ -715,6 +737,13 @@ def resolve(claims: list, *, units=None, weights=None, live=None,
|
||||
gate, held = _unit_modes(out, site_claims, bounds, live)
|
||||
if callable(weights):
|
||||
weights = weights(out.target_w)
|
||||
# SAFETY-87/91: units under their peak reserve weigh 0 while the site
|
||||
# gives, so the others carry the target first. An Off battery gives the
|
||||
# peak's shortfall, so it goes before them when no other unit is left.
|
||||
reserve = set(reserve) if out.target_w > 0.0 else set()
|
||||
if held and reserve and not set(bounds) - held - reserve:
|
||||
held = set()
|
||||
held = held | reserve
|
||||
if held:
|
||||
weights = {u: (0.0 if u in held else
|
||||
(1.0 if weights is None else weights.get(u, 1.0)))
|
||||
|
||||
@@ -260,6 +260,22 @@ def loop_clamp_note(d) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def shortfall_note(named) -> str:
|
||||
"""SAFETY-85: the alarm text while another owner sets the batteries.
|
||||
`named` is `[(inverter name or None, asked W, given W)]`, one per battery
|
||||
that gave less than asked."""
|
||||
return f"{REASON_CODE}: " + ", ".join(
|
||||
f"{name or 'the battery'} gives {given:+.0f} W of the {asked:+.0f} W "
|
||||
f"asked" for name, asked, given in named)
|
||||
|
||||
|
||||
def delivery_short(asked_w, given_w, margin_w) -> bool:
|
||||
"""SAFETY-85: did a battery asked to discharge give more than `margin_w`
|
||||
less? A charge is never short: a full pack tapers (SAFETY-42 section 7)."""
|
||||
asked, given = float(asked_w), float(given_w)
|
||||
return asked > 0.0 and asked - given > abs(float(margin_w))
|
||||
|
||||
|
||||
def write_clamp_note(named) -> str:
|
||||
"""The write-side alarm text: `named` is `[(inverter name or None,
|
||||
ClampResult)]` for every inverter whose write was cut back this cycle.
|
||||
@@ -382,6 +398,10 @@ class ClampWatchdog:
|
||||
self.over_rating = 0 # bug=True: "Power request too high"
|
||||
self.bound = 0 # bug=False: "Battery is not keeping up"
|
||||
self.episodes = 0 # rising edges
|
||||
# SAFETY-85: episodes with a bug=False engagement. The status row
|
||||
# counts these; counting cycles read 861 on one rig run.
|
||||
self.bound_episodes = 0
|
||||
self._bound_counted = False
|
||||
self.since: datetime | None = None
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
@@ -436,6 +456,10 @@ class ClampWatchdog:
|
||||
self.spoken = False
|
||||
self.bug = False
|
||||
self.bug_spoken = False
|
||||
self._bound_counted = False
|
||||
if not bug and not self._bound_counted:
|
||||
self._bound_counted = True
|
||||
self.bound_episodes += 1
|
||||
if bug or not self.bug:
|
||||
self.reason = reason
|
||||
self.facts = tuple(facts)
|
||||
|
||||
@@ -382,6 +382,9 @@ class Decision:
|
||||
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
|
||||
# charge, 0 none), or None when the caller does not carry it.
|
||||
refused: int | None = None
|
||||
# SAFETY-85. The `bound` held the integrator or the command this cycle,
|
||||
# so the batteries' limits, not the loop, decided it.
|
||||
held: bool = False
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -614,11 +617,13 @@ def compute(
|
||||
# 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.
|
||||
held_by_bound = False
|
||||
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
|
||||
held_by_bound = True
|
||||
|
||||
# ⚠️ 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
|
||||
@@ -839,6 +844,7 @@ def compute(
|
||||
if inside != target:
|
||||
target = inside
|
||||
reason = MODE_BOUND
|
||||
held_by_bound = True
|
||||
# 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))
|
||||
@@ -860,7 +866,7 @@ def compute(
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
float(clamped_w), refused)
|
||||
float(clamped_w), refused, held_by_bound)
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
@@ -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
|
||||
@@ -10,8 +10,9 @@ note, section 3.3.
|
||||
The plan shows only what the add-on does today: trading on price (the
|
||||
arbitrage verdict, the same call the control cycle makes), the monthly battery
|
||||
cycle, the peak limit and "follows the house". OPT-11 P1 adds each battery's
|
||||
switch and mode to its row; the mode plan arrives with P3/P4, so nothing here
|
||||
invents one. Every sentence is in the words
|
||||
switch and mode to its row; P3 adds each battery's price runs and its role this
|
||||
cycle (`Controller.mode_runs`, `mode_tick`), read as the control cycle left
|
||||
them, so nothing here invents a plan. Every sentence is in the words
|
||||
section at the bottom and is held to STYLE.md by test_messages.py.
|
||||
"""
|
||||
|
||||
@@ -20,7 +21,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from . import arbitrage, messages, peakguard, uitext
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE
|
||||
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .price import Absence, Uncertainty, brussels
|
||||
from .static_tariff import static_plan
|
||||
from .tariff import BILLING_TZ
|
||||
@@ -312,6 +313,130 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
return runs, later, None
|
||||
|
||||
|
||||
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
|
||||
|
||||
def mode_runs(c, day0, day1, now, shadow):
|
||||
"""OPT-11 P3: each battery's price runs today, as the control cycle keeps
|
||||
them (`c.mode_runs`): the cheap run on the batteries that may charge
|
||||
from the grid, the give runs on Leftover solar and Cheap hours, and the
|
||||
sell run on those with selling on. Amounts are upper bounds ("up to"),
|
||||
from the battery's own room, rating and the run's length. Returns
|
||||
`(runs, no_trade_key)`."""
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if getattr(c, "mode_tick", None) is None:
|
||||
return [], None # no cycle yet: nothing is known
|
||||
if rp is None:
|
||||
return [], "no_prices"
|
||||
if not rp.on:
|
||||
return [], "not_worth"
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
out = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
if not pm.use or pm.mode not in PRICE_MODES:
|
||||
continue
|
||||
soc, cap = inv.soc, inv.capacity_kwh
|
||||
|
||||
def room(kind, hrs):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
if cap and soc is not None:
|
||||
kwh = min(kwh, max(0.0, (ceiling - soc) if kind == "grid"
|
||||
else (soc - floor)) / 100.0 * cap)
|
||||
return kwh
|
||||
blocks = []
|
||||
# P4: with a day plan, the grid block is the planned shortfall.
|
||||
need = plan_need(c, inv.name)
|
||||
if rp.charge_useful() and (need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
blocks.append(("grid", rp.cheap))
|
||||
sell = rp.sell if pm.sell and pm.can_sell else None
|
||||
for g in rp.give:
|
||||
if sell is not None and g.start <= sell.start < g.end:
|
||||
blocks.append(("sell", sell))
|
||||
else:
|
||||
blocks.append(("give", g))
|
||||
for kind, run in blocks:
|
||||
if run.end <= now or not (run.start < day1 and run.end > day0):
|
||||
continue
|
||||
hrs = (run.end - run.start).total_seconds() / 3600.0
|
||||
kwh = room(kind, hrs)
|
||||
if kind == "grid" and need is not None:
|
||||
kwh = min(kwh, need)
|
||||
if kwh < MIN_KWH:
|
||||
continue
|
||||
price = run.mean_eur_kwh
|
||||
if kind == "grid":
|
||||
what, why, amount = (run_grid_what(kwh, shadow),
|
||||
run_cheap_why(price),
|
||||
run_grid_amount(kwh * price))
|
||||
elif kind == "sell":
|
||||
# No money line: the price is what the house pays, and a sale
|
||||
# earns the lower injection price (OPT-11 P3 review).
|
||||
what, why, amount = (run_sell_what(kwh, shadow),
|
||||
run_dear_why(price), None)
|
||||
else:
|
||||
what, why, amount = (run_give_what(kwh, shadow),
|
||||
run_dear_why(price),
|
||||
run_give_amount(kwh * price))
|
||||
out.append(_run(i, "give" if kind == "sell" else kind,
|
||||
max(run.start, day0), min(run.end, day1), day0,
|
||||
day1, what, why, amount, kwh=kwh,
|
||||
eur=None if kind == "sell" else kwh * price,
|
||||
price=price))
|
||||
return out, None
|
||||
|
||||
|
||||
def plan_need(c, name):
|
||||
"""P4: kWh this battery buys from the grid by the day plan, or None when
|
||||
there is no plan for it (then P3 option A applies)."""
|
||||
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
|
||||
if not targets or name not in targets:
|
||||
return None
|
||||
inv = next((i for i in c.inverters if i.name == name), None)
|
||||
if inv is None or inv.soc is None or not inv.capacity_kwh:
|
||||
return None
|
||||
return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float(
|
||||
inv.capacity_kwh)
|
||||
|
||||
|
||||
def plan_state(c):
|
||||
"""P4: `"plan"` (a day plan is made), `"learning"` (no house profile
|
||||
yet), `"no_forecast"` (set but missing or stale), or None (not set, or
|
||||
battery modes off)."""
|
||||
if not getattr(c, "modes_on", False) or \
|
||||
getattr(c, "fc_entity", None) is None:
|
||||
return None
|
||||
if getattr(c, "solar_fc", None) is None:
|
||||
return "no_forecast"
|
||||
hp = getattr(c, "house_profile", None)
|
||||
if hp is not None and not hp.ready():
|
||||
return "learning"
|
||||
return "plan" if getattr(c, "day_plan", None) is not None else None
|
||||
|
||||
|
||||
def solar_block(c, now):
|
||||
"""P4: the solar forecast line for the Batteries screen and the Simple
|
||||
settings view: `{state, fc_kwh, count_kwh, updated, line}`."""
|
||||
st = plan_state(c)
|
||||
out = {"state": st, "fc_kwh": None, "count_kwh": None, "updated": None,
|
||||
"line": ""}
|
||||
if st is None and getattr(c, "solar_fc", None) is None:
|
||||
return out
|
||||
fc = getattr(c, "solar_fc", None)
|
||||
if fc is not None:
|
||||
trust = float(getattr(c, "solar_trust_pct", 70)) / 100.0
|
||||
out.update(fc_kwh=round(fc.today.kwh, 1),
|
||||
count_kwh=round(fc.today.kwh * trust, 1),
|
||||
updated=hhmm(fc.at))
|
||||
if st in ("no_forecast", "learning"):
|
||||
out["line"] = _t("solar." + st)
|
||||
elif fc is not None:
|
||||
out["line"] = line_solar(fc.today.kwh, out["count_kwh"],
|
||||
out["updated"])
|
||||
return out
|
||||
|
||||
|
||||
def maintenance_runs(c, day0, day1, now, shadow):
|
||||
"""The monthly battery cycle: under way now, or starting later today.
|
||||
The cycle starts in the hour `maintenance_start_hour` names, read as the
|
||||
@@ -434,10 +559,17 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
runs, lines = [], []
|
||||
# The control cycle trades on the fresh plan only, so this does too.
|
||||
verdict, trade_why = trade_verdict(c, fresh)
|
||||
modes_on = bool(getattr(c, "modes_on", False))
|
||||
if running:
|
||||
off = {i for i, inv in enumerate(c.inverters) if _off(c, inv)}
|
||||
runs += house_runs(len(c.inverters), day0, day1, now, shadow, off)
|
||||
if verdict is not None:
|
||||
if modes_on:
|
||||
# OPT-11 P3: with battery modes on, each battery's price runs.
|
||||
mine, no_trade = mode_runs(c, day0, day1, now, shadow)
|
||||
runs += mine
|
||||
if no_trade:
|
||||
lines.append(line_no_trade(no_trade))
|
||||
elif verdict is not None:
|
||||
trade, later, no_trade = trade_runs(c, fresh, verdict, day0, day1,
|
||||
now, shadow)
|
||||
runs += trade
|
||||
@@ -544,6 +676,11 @@ def battery_rows(c, shadow, running, held=False):
|
||||
pms = {inv.name: c.pack_mode(inv) for inv in c.inverters} \
|
||||
if modes_on else {}
|
||||
peak = peak_engaged(c)
|
||||
# OPT-11 P3: each battery's role this cycle and the price runs, as the
|
||||
# control cycle left them.
|
||||
tick = getattr(c, "mode_tick", None) if modes_on else None
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
now = datetime.now(timezone.utc)
|
||||
rows = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
m = c.maints[i] if i < len(c.maints) else None
|
||||
@@ -558,13 +695,33 @@ def battery_rows(c, shadow, running, held=False):
|
||||
# carries its share (SAFETY-73)?
|
||||
other = next((o.name for o in c.inverters if o is not inv
|
||||
and refusing(c, o) > 0 and not _off(c, o)), None)
|
||||
# SAFETY-78: another battery drains for its monthly cycle, so this
|
||||
# one never charges now. Not while that drain waits: it files no
|
||||
# floor then, so this battery may charge from solar.
|
||||
waits = getattr(c, "_drain_waits", ())
|
||||
cycling = next((o.name for j, o in enumerate(c.inverters)
|
||||
if o is not inv and j < len(c.maints)
|
||||
and c.maints[j].phase == M_DRAIN
|
||||
and o.name not in waits), None)
|
||||
role = None if tick is None else tick.roles.get(inv.name)
|
||||
# Only once a cycle has run with modes on (`tick`): before that
|
||||
# nothing is known, so nothing is claimed about the prices.
|
||||
plan = (None if tick is None or pm.mode not in PRICE_MODES
|
||||
else "no_prices" if rp is None
|
||||
else "no_spread" if not rp.on else None)
|
||||
nxt = rp.next_give(now) if rp is not None and rp.on else None
|
||||
at = "" if nxt is None else (
|
||||
f"{hhmm(nxt.start)} - {hhmm(nxt.end)}")
|
||||
why = battery_why(
|
||||
modes_on=modes_on, mode=None if pm is None else pm.mode, off=off,
|
||||
reading=inv.soc is not None and inv.batt is not None,
|
||||
phase=phase, shadow=shadow, running=running, held=held,
|
||||
refused=refusing(c, inv), peak=peak,
|
||||
giving=flow(inv.batt) == "giving", other_refuses=other,
|
||||
sf_ahead=sf_ahead)
|
||||
sf_ahead=sf_ahead, role=role, plan=plan, has_at=bool(at),
|
||||
other_cycle=cycling,
|
||||
day=None if pm is None or pm.mode not in PRICE_MODES
|
||||
else day_key(c, inv.name))
|
||||
rows.append({
|
||||
"name": inv.name,
|
||||
"level_pct": None if inv.soc is None else float(inv.soc),
|
||||
@@ -582,7 +739,8 @@ def battery_rows(c, shadow, running, held=False):
|
||||
# UX-13 P3: why it does what "now" says, from what the add-on
|
||||
# knows this cycle. Price and forecast reasons are OPT-11 P3/P4.
|
||||
"why_key": why,
|
||||
"why": why_words(why, name=other),
|
||||
"why": why_words(why, name=cycling if why == "other_cycle"
|
||||
else other, at=at),
|
||||
})
|
||||
if modes_on:
|
||||
# OPT-11 P1: the battery's switch and mode, for the Batteries
|
||||
@@ -590,13 +748,33 @@ def battery_rows(c, shadow, running, held=False):
|
||||
# without them.
|
||||
rows[-1].update({"use": pm.use, "mode_key": pm.key,
|
||||
"mode_choice": pm.mode,
|
||||
"mode": mode_word(pm.key)})
|
||||
"mode": mode_word(pm.key),
|
||||
# P3: selling, for Leftover solar and Cheap
|
||||
# hours only (Solar first never sells).
|
||||
"sell": pm.sell, "can_sell": pm.can_sell,
|
||||
"role": role})
|
||||
return rows
|
||||
|
||||
|
||||
def day_key(c, name):
|
||||
"""P4: the day plan's word for one Leftover solar or Cheap hours
|
||||
battery: `sun_fills`, `tops_up`, `no_forecast`, or None."""
|
||||
st = plan_state(c)
|
||||
if st == "no_forecast":
|
||||
mode = c.pack_mode(next(i for i in c.inverters if i.name == name))
|
||||
return "no_forecast" if mode.mode == LEFTOVER_SOLAR else None
|
||||
if st != "plan":
|
||||
return None
|
||||
need = plan_need(c, name)
|
||||
if need is None:
|
||||
return None
|
||||
return "tops_up" if need >= MIN_KWH else "sun_fills"
|
||||
|
||||
|
||||
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
held, refused, peak, sf_ahead, giving=False,
|
||||
other_refuses=None) -> str:
|
||||
other_refuses=None, role=None, plan=None,
|
||||
has_at=False, other_cycle=None, day=None) -> str:
|
||||
"""The key of one battery's "why" line (`home_words.why`), most urgent
|
||||
first. Pure, so every branch is tested without a controller."""
|
||||
if not reading:
|
||||
@@ -616,22 +794,39 @@ def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
return "refuses_give" if refused > 0 else "refuses_charge"
|
||||
if peak:
|
||||
return "peak"
|
||||
if other_cycle:
|
||||
return "other_cycle"
|
||||
if giving and other_refuses:
|
||||
return "other_refuses"
|
||||
if not modes_on:
|
||||
return "modes_off"
|
||||
# OPT-11 P3: the battery's role in the price runs this cycle.
|
||||
if role == "hold" and day is not None:
|
||||
return day # P4: what the day plan decided
|
||||
if role in ROLE_WHY:
|
||||
if role == "hold" and not has_at:
|
||||
return "saves_later"
|
||||
return ROLE_WHY[role]
|
||||
if plan in ("no_prices", "no_spread"):
|
||||
return plan
|
||||
if giving:
|
||||
return "covers_house" # both modes cover the house until P3
|
||||
if mode == LEFTOVER_SOLAR:
|
||||
return "covers_house"
|
||||
if mode in PRICE_MODES:
|
||||
return "leftover" if sf_ahead else "leftover_alone"
|
||||
return "solar_first"
|
||||
|
||||
|
||||
# OPT-11 P3: a role (modetrade) -> its Why key.
|
||||
ROLE_WHY = {"charge": "grid_charge", "rest": "rests_charge",
|
||||
"sell": "selling", "give": "expensive", "hold": "saves"}
|
||||
|
||||
|
||||
def attach_today(rows, runs, *, modes_on, running=True, held=False,
|
||||
shadow=False) -> None:
|
||||
"""UX-13 P3: each battery's runs today from the plan (the same blocks as
|
||||
Home's chart), or one line saying there are none. With battery modes on
|
||||
and no run, the mode's own plan is OPT-11 P3/P4, so it says so."""
|
||||
Home's chart), or one line saying there are none. With battery modes on,
|
||||
a Leftover solar or Cheap hours battery with no price run left today
|
||||
says so; the others follow the house."""
|
||||
for i, row in enumerate(rows):
|
||||
mine = [r for r in runs if r["battery"] == i and r["kind"] != "house"]
|
||||
row["today"] = [{"from": r["from"], "to": r["to"], "kind": r["kind"],
|
||||
@@ -644,8 +839,11 @@ def attach_today(rows, runs, *, modes_on, running=True, held=False,
|
||||
row["today_line"] = today_words("stopped")
|
||||
elif row.get("use") is False:
|
||||
row["today_line"] = today_words("off")
|
||||
elif modes_on:
|
||||
row["today_line"] = today_words("not_planned")
|
||||
elif modes_on and row.get("mode_choice") in PRICE_MODES:
|
||||
# OPT-11 P3: a price mode with no run left today, or no plan
|
||||
# made yet (no cycle has run with modes on).
|
||||
row["today_line"] = today_words("no_runs" if row.get("role")
|
||||
else "not_planned")
|
||||
else:
|
||||
row["today_line"] = today_words("house_would" if shadow
|
||||
else "house")
|
||||
@@ -725,24 +923,25 @@ def state_of(c, level, shadow):
|
||||
|
||||
|
||||
def build(c, *, level: str, checks: list, now: datetime | None = None,
|
||||
lang: str = "en") -> dict:
|
||||
lang: str = "en", held: bool | None = None) -> dict:
|
||||
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
|
||||
the controller."""
|
||||
_LANG[0] = uitext.lang_of(lang)
|
||||
try:
|
||||
return _build(c, level=level, checks=checks, now=now)
|
||||
return _build(c, level=level, checks=checks, now=now, held=held)
|
||||
finally:
|
||||
_LANG[0] = "en"
|
||||
|
||||
|
||||
def _build(c, *, level, checks, now):
|
||||
def _build(c, *, level, checks, now, held=None):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
shadow = bool(c.shadow)
|
||||
kind = state_of(c, level, shadow)
|
||||
# A red banner means every inverter is at 0 W (the status cascade), so
|
||||
# nothing is planned until it clears. Shadow mode still plans: it is
|
||||
# what the add-on would do.
|
||||
held = level == "bad" and not shadow
|
||||
# what the add-on would do. `held` False: an alarm while control runs
|
||||
# (SAFETY-87, the peak alarms).
|
||||
held = level == "bad" and not shadow and held is not False
|
||||
running = (bool(c.auto) or shadow) and not held
|
||||
settled = all(at_zero(inv) for inv in c.inverters)
|
||||
ctl = next((r for r in checks if r.get("id") == "controller"), None)
|
||||
@@ -752,6 +951,7 @@ def _build(c, *, level, checks, now):
|
||||
modes_on=bool(getattr(c, "modes_on", False)),
|
||||
running=running, held=held, shadow=shadow)
|
||||
return {
|
||||
"solar": solar_block(c, now),
|
||||
"state": {"kind": kind, "pill": pill(kind),
|
||||
"ran": ctl.get("text", "") if ctl else "",
|
||||
"sub": banner_sub(kind)},
|
||||
@@ -954,10 +1154,11 @@ def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
|
||||
return _t("now.resting")
|
||||
|
||||
|
||||
def why_words(key, name=None) -> str:
|
||||
def why_words(key, name=None, at="") -> str:
|
||||
"""One battery's "why" line (UX-13 P3), from `battery_why()`'s key.
|
||||
`name`: the battery that refuses, for `other_refuses`."""
|
||||
return _t("why." + key, name=name or "")
|
||||
`name`: the battery that refuses, for `other_refuses`; `at`: the
|
||||
give run's time, for `saves`."""
|
||||
return _t("why." + key, name=name or "", at=at)
|
||||
|
||||
|
||||
def today_words(key) -> str:
|
||||
@@ -989,6 +1190,18 @@ def run_grid_amount(eur) -> str:
|
||||
return _t("run.grid_amount", eur=_eur(eur))
|
||||
|
||||
|
||||
def run_sell_what(kwh, shadow) -> str:
|
||||
return _w(shadow, "run.sell_what", kwh=_kwh(kwh))
|
||||
|
||||
|
||||
def run_cheap_why(price) -> str:
|
||||
return _t("run.cheap_why", eur=_eur(price))
|
||||
|
||||
|
||||
def run_dear_why(price) -> str:
|
||||
return _t("run.dear_why", eur=_eur(price))
|
||||
|
||||
|
||||
def run_give_what(kwh, shadow) -> str:
|
||||
return _w(shadow, "run.give_what", kwh=_kwh(kwh))
|
||||
|
||||
@@ -1035,6 +1248,10 @@ def run_house_amount() -> str:
|
||||
return _t("run.house_amount")
|
||||
|
||||
|
||||
def line_solar(fc_kwh, count_kwh, at) -> str:
|
||||
return _t("solar.line", kwh=_kwh(fc_kwh), count=_kwh(count_kwh), at=at)
|
||||
|
||||
|
||||
def line_no_trade(key) -> str:
|
||||
return "" if key == "off" else _t("no_trade." + key)
|
||||
|
||||
@@ -1177,6 +1394,8 @@ def _samples() -> list:
|
||||
off=off, peak=peak)))
|
||||
out += [("plan", why_words(k, name="Battery A")) for k in words["why"]]
|
||||
out += [("plan", today_words(k)) for k in words["today"]]
|
||||
out += [("plan", line_solar(9.2, 6.4, "10:15")),
|
||||
("plan", _t("solar.no_forecast")), ("plan", _t("solar.learning"))]
|
||||
out += [("plan", line_no_trade(k)) for k in
|
||||
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
|
||||
"no_level")]
|
||||
|
||||
+952
-51
File diff suppressed because it is too large
Load Diff
@@ -217,7 +217,7 @@ def peak_saturated(ceiling_w, *, shadow: bool = False) -> Notice:
|
||||
|
||||
|
||||
def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> Notice:
|
||||
packs=(), others: bool = False) -> Notice:
|
||||
"""SAFETY-24/25/32: the peak guard may not draw the battery any lower.
|
||||
|
||||
The last sentence is SAFETY-25 and must stay: the reserve binds the peak
|
||||
@@ -227,7 +227,8 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
roster means some inverter's cut-out level is missing (the peak reserve
|
||||
itself always has a value) - so the action names the cut-out level.
|
||||
Both are named by their setting labels, "Peak reserve (%)" and "Battery
|
||||
cut-out level (%)".
|
||||
cut-out level (%)". `others`: the other batteries give all they can and
|
||||
it is not enough (SAFETY-87), so the limit MAY be passed.
|
||||
"""
|
||||
if soc_unread:
|
||||
why = "The battery level cannot be read"
|
||||
@@ -241,6 +242,12 @@ def peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
why = (f"The battery of {names(packs)} is at its peak reserve" if packs
|
||||
else "The battery is at its peak reserve")
|
||||
act = "Switch off large loads, or lower the peak reserve."
|
||||
if others:
|
||||
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
|
||||
f"{why}, and the other batteries cannot keep this "
|
||||
f"15-minute period under your {watts(ceiling_w)} peak "
|
||||
f"limit alone. {act} Home use can still draw a battery "
|
||||
f"below its reserve, down to its cut-out level.")
|
||||
return Notice(ID_PEAK_RESERVE, ALARM, T_PEAK_RESERVE,
|
||||
f"{why}, so this 15-minute period will go over your "
|
||||
f"{watts(ceiling_w)} peak limit and raise this month's peak. "
|
||||
@@ -470,8 +477,9 @@ def row_peak_saturated(ceiling_w) -> str:
|
||||
|
||||
|
||||
def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
packs=()) -> str:
|
||||
"""SAFETY-24/25/32. The second sentence is SAFETY-25 and must stay."""
|
||||
packs=(), others: bool = False) -> str:
|
||||
"""SAFETY-24/25/32. The last sentence is SAFETY-25 and must stay.
|
||||
`others`: as in `peak_reserve()` (SAFETY-87)."""
|
||||
if soc_unread:
|
||||
why = "Battery level cannot be read"
|
||||
elif floor_unknown:
|
||||
@@ -479,6 +487,10 @@ def row_peak_reserve(ceiling_w, *, soc_unread: bool, floor_unknown: bool,
|
||||
else:
|
||||
why = (f"{names(packs)} battery at its peak reserve" if packs
|
||||
else "Battery at its peak reserve")
|
||||
if others:
|
||||
return (f"{why}. The others cannot hold the {watts(ceiling_w)} peak "
|
||||
f"limit alone. Home use still draws it lower, to the cut-out "
|
||||
f"level.")
|
||||
return (f"{why}, so the {watts(ceiling_w)} peak limit will be passed. Home "
|
||||
f"use still draws the battery below it, down to the cut-out level.")
|
||||
|
||||
@@ -835,21 +847,28 @@ WHY_LIMIT = {
|
||||
"pack": "the battery level",
|
||||
"reserve": "the battery reserve",
|
||||
"maintenance": "battery maintenance",
|
||||
"charge": "battery maintenance",
|
||||
"hold": "battery maintenance",
|
||||
"refused": "a battery that refuses power",
|
||||
}
|
||||
|
||||
|
||||
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 +920,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",
|
||||
@@ -922,7 +943,9 @@ HINTS = {
|
||||
"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",
|
||||
}
|
||||
|
||||
@@ -932,6 +955,8 @@ 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.",
|
||||
@@ -1001,6 +1026,9 @@ def status_samples() -> list:
|
||||
("row_peak_guard_off", AMBER, row_peak_guard_off()),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"])),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=False, floor_unknown=False, packs=["inverter-b"],
|
||||
others=True)),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
5000, soc_unread=True, floor_unknown=False)),
|
||||
("row_peak_reserve", RED, row_peak_reserve(
|
||||
@@ -1103,6 +1131,8 @@ def samples() -> list:
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
||||
packs=["inverter-b"]),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=False,
|
||||
packs=["inverter-b"], others=True),
|
||||
peak_reserve(5000, soc_unread=True, floor_unknown=False),
|
||||
peak_reserve(5000, soc_unread=False, floor_unknown=True),
|
||||
circulating(["inverter-a"], ["inverter-b"]),
|
||||
@@ -1164,6 +1194,9 @@ LABELS = {
|
||||
"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
|
||||
@@ -453,7 +453,8 @@ def discovery_payloads(units=(), omit=(), include=(), modes=()) -> list:
|
||||
#
|
||||
# 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"}
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""OPT-11 P1: each battery's mode and on/off switch, as limits and weights.
|
||||
"""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.
|
||||
@@ -14,13 +14,11 @@ Two rules hold for every mode, and they are what keeps modes safe:
|
||||
allocator._fill()'s last-resort pass still uses it. So "may not" is a range,
|
||||
and weights only set the order among batteries that may.
|
||||
|
||||
P1 has no price runs and no forecast (P3, P4). So both modes give power to the
|
||||
house at any hour, and the only mode that closes a direction is Off. What P1
|
||||
changes is the solar order: while the site charges, Leftover solar batteries
|
||||
weigh 0, so they get solar only once the Solar first batteries are full or at
|
||||
their power limit. Selling (the owner's 2026-09-27 answer) is a per-battery
|
||||
choice that arrives with the price runs in P3; `PackMode.sell` and the
|
||||
`pack_sell` store key are reserved for it and read by nothing yet.
|
||||
`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
|
||||
@@ -29,17 +27,32 @@ from .store import unit_key
|
||||
|
||||
SOLAR_FIRST = "solar_first"
|
||||
LEFTOVER_SOLAR = "leftover_solar"
|
||||
CHEAP_HOURS = "cheap_hours" # reserved for P3; not accepted in P1
|
||||
CHEAP_HOURS = "cheap_hours" # OPT-11 P3
|
||||
|
||||
# The modes a battery may be set to in this release, in the order the page
|
||||
# lists them. The first is the default when the store has no value.
|
||||
MODES = (SOLAR_FIRST, LEFTOVER_SOLAR)
|
||||
# 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" # reserved for P3
|
||||
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:
|
||||
@@ -50,10 +63,16 @@ def enabled(opts) -> bool:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackMode:
|
||||
"""One battery's choice: on or off, its mode, and (P3) selling."""
|
||||
"""One battery's choice: on or off, its mode, and selling."""
|
||||
use: bool = True
|
||||
mode: str = DEFAULT_MODE
|
||||
sell: bool = False # reserved for P3, never read in P1
|
||||
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:
|
||||
@@ -69,15 +88,15 @@ def read(data: dict, name: str) -> PackMode:
|
||||
mode = data.get(unit_key(name, MODE_KEY), DEFAULT_MODE)
|
||||
return PackMode(use=use is not False,
|
||||
mode=mode if mode in MODES else DEFAULT_MODE,
|
||||
sell=data.get(unit_key(name, SELL_KEY)) is True)
|
||||
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 mode claim. Always holds 0 W.
|
||||
"""`(lo, hi)` in W for this battery's switch. Always holds 0 W.
|
||||
|
||||
Off asks for 0 W both ways. In P1 both modes may charge and give power up
|
||||
to the battery's rating: Leftover solar holds back its power only in the
|
||||
planned expensive hours, and there are none before P3.
|
||||
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
|
||||
@@ -92,7 +111,7 @@ def charge_weights(base, modes: dict, target_w: float):
|
||||
charges (`target_w < 0`), a Leftover solar battery weighs 0 and a Solar
|
||||
first battery keeps its weight, so the fill gives the Solar first batteries
|
||||
everything they can take before the last-resort pass reaches the others.
|
||||
Giving power keeps `base` (P3 adds the expensive-run order).
|
||||
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
|
||||
@@ -102,7 +121,8 @@ def charge_weights(base, modes: dict, target_w: float):
|
||||
return base
|
||||
names = sorted(modes)
|
||||
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
|
||||
out = {n: (0.0 if modes[n].mode == LEFTOVER_SOLAR else w[n])
|
||||
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
|
||||
|
||||
@@ -397,7 +397,7 @@ function, so whichever way the owner answers, this file does not change and does
|
||||
not need re-testing. Nothing here indexes a phase.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from . import messages
|
||||
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
||||
@@ -490,11 +490,18 @@ class PeakDecision:
|
||||
# floor refused to size it. Mutually exclusive with `saturated` by
|
||||
# construction: this branch asks for nothing, so it cannot have run out of
|
||||
# inverter. `soc_floor_reason` says WHICH of the two ways it bound.
|
||||
# SAFETY-87: at N>=2 it may ask the batteries above their reserve for
|
||||
# `able_w`, all they have, when that is not enough (`place_on_able()`).
|
||||
floor_limited: bool = False
|
||||
soc: float | None = None
|
||||
soc_floor: float | None = None
|
||||
soc_floor_reason: str | None = None
|
||||
reason: str = ""
|
||||
# SAFETY-87, N>=2 only: the batteries at or under their own reserve,
|
||||
# which give nothing for the peak, and what the others can give (None
|
||||
# when they are not asked). See `place_on_able()`.
|
||||
held: tuple = ()
|
||||
able_w: float | None = None
|
||||
|
||||
def explain(self) -> str:
|
||||
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
|
||||
@@ -733,6 +740,43 @@ def binding_soc_floor(packs, soc_floor):
|
||||
return max(floors)
|
||||
|
||||
|
||||
def reserve_split(packs, soc_floor) -> tuple[list, tuple]:
|
||||
"""SAFETY-87: `(packs the guard may spend, names of those it may not)`,
|
||||
each pack graded on its own floor by `pack_permissions()`."""
|
||||
perms = pack_permissions(packs, soc_floor)
|
||||
return ([p for p, (_n, why) in zip(packs, perms) if why is None],
|
||||
tuple(n for n, why in perms if why is not None))
|
||||
|
||||
|
||||
def place_on_able(decision, held, *, able_w, soc, soc_floor) -> PeakDecision:
|
||||
"""SAFETY-87: `decision` with the batteries in `held` (at or under their
|
||||
own reserve) giving nothing for the peak. `able_w`, `soc` and `soc_floor`
|
||||
describe the others, as `decide()`'s arguments do for the roster. A
|
||||
floor-limited decision is re-taken on them: they are asked for the need
|
||||
up to `able_w`, and it stays floor-limited only if that is not enough.
|
||||
`soc` and `soc_floor` keep the roster's verdict, as `decide()` echoed it.
|
||||
DECISIONS/SAFETY-87-guard-uses-able-batteries.md."""
|
||||
held = tuple(held)
|
||||
if not decision.floor_limited or decision.wanted_w is None \
|
||||
or discharge_permitted(soc, soc_floor) is not None:
|
||||
return replace(decision, held=held)
|
||||
wanted = float(decision.wanted_w)
|
||||
floor = min(wanted, float(able_w))
|
||||
head = (f"this 15-minute period heads for {decision.projected_w:.0f} W "
|
||||
f"against a {decision.ceiling_w:.0f} W limit "
|
||||
f"({decision.ceiling_from}), aiming at {decision.target_w:.0f} W. "
|
||||
f"At the peak reserve, so giving nothing for the peak: "
|
||||
f"{messages.names(held)}. The others are asked for {floor:+.0f} W")
|
||||
if floor >= wanted:
|
||||
return replace(decision, floor_limited=False, floor_w=floor,
|
||||
soc_floor_reason=None, held=held, able_w=float(able_w),
|
||||
reason=f"PEAK_GUARD: {head}")
|
||||
return replace(decision, floor_w=floor, held=held, able_w=float(able_w),
|
||||
reason=(f"PEAK_GUARD_FLOOR_LIMITED: {head}, all they can "
|
||||
f"give, but {wanted:+.0f} W was needed, so this "
|
||||
f"period may go over the limit"))
|
||||
|
||||
|
||||
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.
|
||||
@@ -866,6 +910,40 @@ def decide(projection, *, batt_w, cap_w, margin_w, reading, achievable_w, soc,
|
||||
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.
|
||||
@@ -1063,5 +1141,6 @@ def claim(decision: PeakDecision) -> Claim | None:
|
||||
return Claim.limit(SOURCE, P_PEAK, lo=float(decision.floor_w),
|
||||
reason=("blind" if decision.blind
|
||||
else "SoC floor" if decision.floor_limited
|
||||
and not decision.floor_w
|
||||
else "quarter peak"),
|
||||
actuator=ACTUATOR_SITE)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -314,11 +314,11 @@
|
||||
/* ---------------- 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 and cheap-hours charging
|
||||
// are shown greyed out until OPT-11 P3. The Now, Today and Why lines are
|
||||
// `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"];
|
||||
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;
|
||||
@@ -345,15 +345,7 @@
|
||||
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"))),
|
||||
h("option", { value: "", disabled: true }, t("batteries.later")));
|
||||
}
|
||||
function sellRow(i, where) {
|
||||
const lid = "bsl-" + where + i;
|
||||
return h("div", { class: "f inline" },
|
||||
h("div", {}, h("div", { class: "lbl", id: lid }, t("batteries.sell.name")), h("div", { class: "help" }, t("batteries.sell.description"))),
|
||||
h("div", { class: "ctl" }, h("button", { type: "button", class: "switch", role: "switch", "aria-checked": "false",
|
||||
"aria-labelledby": lid, disabled: true })));
|
||||
...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 || "–";
|
||||
@@ -372,8 +364,7 @@
|
||||
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")),
|
||||
sellRow(i, "c")) : null,
|
||||
h("div", { class: "help" }, t("batteries.choice." + b.mode_key + ".description"))) : null,
|
||||
levelBar(b.level_pct, b.name),
|
||||
h("dl", { class: "kv" },
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||||
@@ -412,6 +403,9 @@
|
||||
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"))]));
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
<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">
|
||||
|
||||
@@ -446,7 +446,17 @@
|
||||
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));
|
||||
@@ -458,6 +468,7 @@
|
||||
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() {
|
||||
|
||||
@@ -822,6 +822,22 @@ class PeakTracker:
|
||||
held_w=averager.held_net_w)
|
||||
|
||||
|
||||
def next_quarter(p: Projection, max_hold_s: float) -> Projection:
|
||||
"""SAFETY-91: `p` past its block's end, before the next block's first
|
||||
telegram: the next quarter, projected from the held reading (the averager
|
||||
credits it there). `p` itself otherwise, or when that reading is too old
|
||||
to be credited. DECISIONS/SAFETY-91-held-battery-last.md."""
|
||||
if p.block_start is None or p.elapsed_s < QUARTER_S \
|
||||
or p.elapsed_s * (1.0 - p.covered_frac) > float(max_hold_s):
|
||||
return p
|
||||
held = max(p.held_w, 0.0)
|
||||
return Projection(projected_w=held, elapsed_w=held,
|
||||
elapsed_s=p.elapsed_s - QUARTER_S, covered_frac=1.0,
|
||||
complete=True,
|
||||
block_start=p.block_start + timedelta(seconds=QUARTER_S),
|
||||
held_w=p.held_w)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ClosedBlock:
|
||||
"""A block reconstructed from a checkpoint. Shaped like `p1.QuarterBlock` so
|
||||
|
||||
@@ -87,18 +87,18 @@
|
||||
"on": "On", "off": "Off",
|
||||
"choice": {
|
||||
"solar_first": {"name": "Solar first", "description": "Charges from solar before the other batteries and covers the house."},
|
||||
"leftover_solar": {"name": "Leftover solar", "description": "Charges from solar the other batteries cannot take, and covers the house."},
|
||||
"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."}
|
||||
},
|
||||
"later": "Cheap hours only, coming later",
|
||||
"today": "Today", "why": "Why",
|
||||
"sell": {"name": "Sell in expensive hours", "description": "Selling comes in a later update."},
|
||||
"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 also covers the house."},
|
||||
"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 always come first, and charging from the grid and selling come later."}},
|
||||
"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"}
|
||||
},
|
||||
@@ -119,7 +119,7 @@
|
||||
"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."}},
|
||||
"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"},
|
||||
@@ -209,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.",
|
||||
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"home_words": {
|
||||
"mode_house": "Follows the house",
|
||||
"modes": {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "off": "Off"},
|
||||
"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",
|
||||
@@ -326,10 +326,24 @@
|
||||
"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_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.",
|
||||
@@ -356,6 +370,10 @@
|
||||
"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.",
|
||||
|
||||
@@ -84,18 +84,18 @@
|
||||
"on": "Aan", "off": "Uit",
|
||||
"choice": {
|
||||
"solar_first": {"name": "Eerst zonne-energie", "description": "Laadt met zonne-energie vóór de andere batterijen en dekt het huis."},
|
||||
"leftover_solar": {"name": "Overschot zonne-energie", "description": "Laadt met zonne-energie die de andere batterijen niet opnemen, en dekt het huis."},
|
||||
"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."}
|
||||
},
|
||||
"later": "Alleen goedkope uren, volgt later",
|
||||
"today": "Vandaag", "why": "Waarom",
|
||||
"sell": {"name": "Verkopen in dure uren", "description": "Verkopen komt in een latere update."},
|
||||
"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 dekt ook het huis."},
|
||||
"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 altijd voor, en laden van het net en verkopen volgen later."}},
|
||||
"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"}
|
||||
},
|
||||
@@ -114,7 +114,7 @@
|
||||
"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."}},
|
||||
"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"},
|
||||
@@ -190,7 +190,7 @@
|
||||
"5": {"name": "Piekgrens", "description": "Het net blijft onder deze grens, gemiddeld over elk kwartier."},
|
||||
"6": {"name": "Nakijken en starten", "description": "De add-on kijkt alles na met zijn eigen startcontroles en herstart dan."}
|
||||
},
|
||||
"modes_later": {"description": "Een modus kiezen per batterij komt in een latere update."},
|
||||
"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.",
|
||||
@@ -224,7 +224,7 @@
|
||||
},
|
||||
"home_words": {
|
||||
"mode_house": "Volgt het huis",
|
||||
"modes": {"solar_first": "Eerst zonne-energie", "leftover_solar": "Overschot zonne-energie", "off": "Uit"},
|
||||
"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",
|
||||
@@ -268,10 +268,24 @@
|
||||
"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_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.",
|
||||
@@ -298,6 +312,10 @@
|
||||
"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.",
|
||||
|
||||
@@ -129,7 +129,14 @@ GROUPS = (
|
||||
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),)),
|
||||
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("", (
|
||||
|
||||
@@ -246,6 +246,10 @@ NOT READY banner already names the problem, (b) does not apply.
|
||||
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
|
||||
@@ -324,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 (
|
||||
@@ -1199,6 +1204,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
|
||||
# --- 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")
|
||||
@@ -1751,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
|
||||
|
||||
|
||||
@@ -1790,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.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -2045,6 +2066,33 @@ def _margin_problems(opts: dict) -> list[str]:
|
||||
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 == "":
|
||||
|
||||
@@ -34,7 +34,8 @@ succeeds, so the page stops opening the wizard by itself (uipage.setup_needed).
|
||||
UX-15's setup-only mode serves these routes from `setuponly.SetupOnly`, which
|
||||
commands nothing; there `/action` answers 409.
|
||||
OPT-11 P1 adds two `/action` values, `pack_use` and `pack_mode`, which set one
|
||||
battery's on/off switch or mode in the store (Controller._pack_action). They
|
||||
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.
|
||||
"""
|
||||
@@ -58,7 +59,7 @@ 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_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/<lang>.json (English
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.12.0"
|
||||
version: "0.14.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -151,6 +151,13 @@ options:
|
||||
# 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
|
||||
@@ -951,6 +958,9 @@ schema:
|
||||
|
||||
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.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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: thirteen sources, each classified where it is BUILT")
|
||||
print("ARB-03 2. the split: sixteen 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,17 @@ 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",
|
||||
# SAFETY-84.
|
||||
"charge": "the site target may not go above a cheap-window maintenance "
|
||||
"charge, or a sibling would give against it. Filed at N>1 "
|
||||
"only",
|
||||
# SAFETY-86.
|
||||
"hold": "the site target stays at the peak floor while a holding pack "
|
||||
"gives the shortfall. Filed at N>1 only",
|
||||
}
|
||||
UNIT_SOURCES = {
|
||||
"device": "one inverter's own reported min/max rating",
|
||||
@@ -202,9 +213,9 @@ 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 THIRTEEN claim sources are classified, and only those thirteen",
|
||||
check("all SIXTEEN claim sources are classified, and only those sixteen",
|
||||
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
|
||||
check("the six that are about the GRID CONNECTION are site claims",
|
||||
check("the nine 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 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))
|
||||
@@ -225,8 +236,8 @@ check("with one inverter every source routes to the site, per-inverter ones "
|
||||
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 seven "
|
||||
"per-inverter sources route to the unit this controller drives, the six "
|
||||
"site sources stay on the grid connection",
|
||||
"per-inverter sources route to the unit this controller drives, the "
|
||||
"nine 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}})
|
||||
@@ -507,7 +518,7 @@ check("...and at N=1, where every source legitimately routes to the site, the "
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=0.0)]) == [])
|
||||
check("an unclassified ninth source is a violation wherever it is filed",
|
||||
scope_violations([Claim.limit("reserve", P_LOOP, hi=0.0)]) != [])
|
||||
check("and the eight, routed by actuator_for() at a two-inverter site, are "
|
||||
check("and the sixteen, routed by actuator_for() at a two-inverter site, are "
|
||||
"clean - which is what says the router and the checker agree",
|
||||
scope_violations(
|
||||
[Claim.limit(s, P_SAFETY, -1, 1,
|
||||
|
||||
@@ -36,7 +36,7 @@ import aiohttp # noqa: E402
|
||||
from aiohttp import web as aioweb # noqa: E402
|
||||
|
||||
import ux13_demo as D # noqa: E402
|
||||
from app import homeview, packmode, uilayout as L, uipage, web # noqa: E402
|
||||
from app import homeview, messages, packmode, uilayout as L, uipage, web # noqa
|
||||
from app.maintenance import IDLE, DRAIN # noqa: E402
|
||||
|
||||
fails = []
|
||||
@@ -93,7 +93,8 @@ WANT = {
|
||||
"off": ("solar_first", "off"),
|
||||
"off_peak": ("peak", "off_peak"),
|
||||
"refusal": ("refuses_give", "other_refuses"),
|
||||
"maint": ("cycle_mode", "leftover"),
|
||||
# SAFETY-78: the other battery says why it does not charge.
|
||||
"maint": ("cycle_mode", "other_cycle"),
|
||||
}
|
||||
|
||||
# ============================================================================ #
|
||||
@@ -120,13 +121,15 @@ for st, want in WANT.items():
|
||||
for e, n in zip(en, nl)))
|
||||
|
||||
_modes = ROWS[("modes", "en")]
|
||||
check("modes on: each row carries its switch and mode, and Today says the "
|
||||
"mode's plan is not made yet (OPT-11 P3/P4), without inventing one",
|
||||
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 all(b["today_line"] == EN["home_words"]["today"]["not_planned"]
|
||||
for b in _modes["batteries"]))
|
||||
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
|
||||
@@ -212,16 +215,25 @@ 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)
|
||||
_m = build("modes", 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: A has them, Off B has "
|
||||
"no block and waits for no cycle (with B on, it does)",
|
||||
{"house", "grid", "give", "cycle"} <= _kinds[0] and not _kinds[1]
|
||||
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 {"grid", "give"} <= {r["kind"] for r in _m["plan"]["runs"]
|
||||
if r["battery"] == 1}
|
||||
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",
|
||||
@@ -290,6 +302,41 @@ _cases = [
|
||||
(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}")
|
||||
@@ -336,9 +383,13 @@ _r = asyncio.run(_serve("modes", [
|
||||
"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) = _r
|
||||
(_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"
|
||||
@@ -351,9 +402,18 @@ check("set A to Leftover solar: 200, and /status?lang=nl shows it in Dutch",
|
||||
check("switch B back on: its stored mode returns",
|
||||
_s3 == 200 and _st3["batteries"][1]["mode_key"]
|
||||
== packmode.LEFTOVER_SOLAR)
|
||||
check("a mode the list only shows greyed out is refused with a plain 400, and "
|
||||
"nothing changes", _s4 == 400 and _j4["ok"] is False and _j4["error"]
|
||||
and _st4["batteries"][0]["mode_choice"] == 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, "
|
||||
@@ -376,14 +436,19 @@ check("Settings has a Simple / Installer choice as a radio group, and Simple "
|
||||
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}}", _acts == set(web.PACK_ACTIONS)
|
||||
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 names cheap hours as coming "
|
||||
"later, not choosable", 'role: "switch"' in JS and '"aria-checked"' in JS
|
||||
and 'h("option", { value: "", disabled: true }, t("batteries.later"))'
|
||||
in JS)
|
||||
check("the selling switch is greyed out until OPT-11 P3 (no pack_sell is "
|
||||
"posted)", "pack_sell" not in JS and "disabled: true })" 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"]
|
||||
|
||||
@@ -553,12 +553,14 @@ check("the status page flags it WHILE it is engaged - amber, since it is the "
|
||||
any(not c["ok"] and c["warn"] and c.get("id") == "not_keeping_up"
|
||||
and c["text"].startswith(messages.T_LOOP_BOUND)
|
||||
for c in ctl_r.red_snapshot))
|
||||
check("...and once clear, a warn row still says how many times it engaged, "
|
||||
"under the integrator bound's own title",
|
||||
# SAFETY-85: the row counts episodes, not cycles.
|
||||
check("...and once clear, a warn row still says how many times it happened "
|
||||
"(episodes, not cycles), under the integrator bound's own title",
|
||||
any(c["warn"] and c.get("id") == "not_keeping_up"
|
||||
and c["text"].startswith(messages.T_LOOP_BOUND)
|
||||
and messages.times(ctl_r.clamp_wd.bound) in c["text"]
|
||||
and "fine now" in c["text"] for c in ctl_r.checks()))
|
||||
and c["text"] == messages.row_before(
|
||||
messages.T_LOOP_BOUND, ctl_r.clamp_wd.bound_episodes)
|
||||
for c in ctl_r.checks())
|
||||
and 0 < ctl_r.clamp_wd.bound_episodes < ctl_r.clamp_wd.bound)
|
||||
# UX-07 item 1: the after-episode row used to say "output clamp ... engaged N
|
||||
# time(s)" for integrator-bound cycles. Each count now sits under its own name.
|
||||
check("UX-07 item 1: bound-only engagements are counted as the bound, and no "
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -108,10 +108,40 @@ A5DDDAD = {
|
||||
_claims, _undo_c = spy_claims()
|
||||
_bounds, _undo_b = spy_bounds()
|
||||
try:
|
||||
_now = rig.digests()
|
||||
_now = {k: rig.digest(rig.run(**v)[0])
|
||||
for k, v in rig.SCENARIOS.items() if k != "n2_maint"}
|
||||
_now["resolve"] = rig.digest(rig.resolve_rows())
|
||||
finally:
|
||||
_undo_b()
|
||||
# SAFETY-85: n2_maint (maintenance at N=2) is the one run with a bound.
|
||||
_bounds_m, _undo_b = spy_bounds()
|
||||
try:
|
||||
_now["n2_maint"] = rig.digest(rig.run(**rig.SCENARIOS["n2_maint"])[0])
|
||||
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.
|
||||
# SAFETY-85 changes it again ON PURPOSE: while A's drain pin stands the loop
|
||||
# is bounded by what the batteries can place, so when A drops out at t=1655 B
|
||||
# follows the house down at once instead of unwinding 920 W too high. Was
|
||||
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
|
||||
SAFETY78_N2_MAINT = \
|
||||
"1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497"
|
||||
# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its
|
||||
# 20 % reserve, so B carries the house first and A gives last. With
|
||||
# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are
|
||||
# a5dddad's again, which the check below grades.
|
||||
SAFETY91_N2 = \
|
||||
"9535c08f556d6d50bec8fb0872445173480c29b10df1a7c437ef73d6ddb2814d"
|
||||
_real_hb = app_main.Controller._held_back
|
||||
app_main.Controller._held_back = lambda self: self._peak_reserve()
|
||||
try:
|
||||
_n2_s87 = rig.digest(rig.run(**rig.SCENARIOS["n2"])[0])
|
||||
finally:
|
||||
app_main.Controller._held_back = _real_hb
|
||||
_S91 = ("n2", "n2_off", "n2_maint")
|
||||
for k in A5DDDAD:
|
||||
print(f" {k:7} {'same' if _now[k] == A5DDDAD[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
@@ -119,12 +149,23 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
|
||||
"command, reason and allocation record over 540 cycles each is "
|
||||
"byte-identical to a5dddad (house steps, a solar surplus, a battery "
|
||||
"at its cut-out level, at N=2 a refusal with probes, both batteries "
|
||||
"due for maintenance), and so is the arbiter on 6000 claim lists",
|
||||
_now == A5DDDAD)
|
||||
check("...and no cycle in those runs filed a `mode` claim or handed "
|
||||
"`compute()` a bound",
|
||||
"due for maintenance; n2, n2_off and n2_maint apart, below), and so "
|
||||
"is the arbiter on 6000 claim lists",
|
||||
{k: v for k, v in _now.items() if k not in _S91}
|
||||
== {k: v for k, v in A5DDDAD.items() if k not in _S91})
|
||||
check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
|
||||
"last), and SAFETY-87's held rule gives a5dddad's back",
|
||||
_now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"]
|
||||
and _n2_s87 == A5DDDAD["n2"])
|
||||
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), bounded by "
|
||||
"SAFETY-85",
|
||||
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
|
||||
check("...and no cycle in those runs filed a `mode` claim, and only "
|
||||
"n2_maint's (SAFETY-85: maintenance at N=2) 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})
|
||||
and _bounds and set(_bounds) == {None}
|
||||
and any(b is not None for b in _bounds_m))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
@@ -335,11 +376,11 @@ _bad = [asyncio.run(_c.handle_action("pack_use", unit="inverter-z",
|
||||
asyncio.run(_c.handle_action("pack_use", unit=A, value=0)),
|
||||
asyncio.run(_c.handle_action("pack_use", unit=A, value=1)),
|
||||
asyncio.run(_c.handle_action("pack_mode", unit=A,
|
||||
value=packmode.CHEAP_HOURS)),
|
||||
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 "
|
||||
"this release does not offer (Cheap hours arrives in P3) are refused, "
|
||||
"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
|
||||
|
||||
@@ -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")
|
||||
@@ -996,7 +996,8 @@ check("modes off: every configuration announces byte-for-byte what ed739d4 "
|
||||
MODES = packmode.MODES
|
||||
ON1, SUB1 = announce_p2(["inverter-a"], modes=MODES)
|
||||
m, c, a = report("roster of one, modes on", ON1)
|
||||
_opt = '["Solar first", "Leftover solar"]'
|
||||
# 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", '
|
||||
@@ -1062,16 +1063,19 @@ 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))
|
||||
_later = dict(mqtt.MODE_LABELS, cheap_hours="Cheap hours only")
|
||||
# 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 = _later
|
||||
mqtt.MODE_LABELS = {k: v for k, v in _orig.items() if k != "cheap_hours"}
|
||||
try:
|
||||
_p3 = mqtt.mode_payloads(NAMES2, MODES + ("cheap_hours",))
|
||||
_p2 = mqtt.mode_payloads(NAMES2, MODES[:2])
|
||||
finally:
|
||||
mqtt.MODE_LABELS = _orig
|
||||
_p2 = mqtt.mode_payloads(NAMES2, MODES)
|
||||
check("...(simulated P3: adding `cheap_hours` changes only the options list, "
|
||||
"by one entry at its end)",
|
||||
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"}
|
||||
@@ -1261,7 +1265,7 @@ BAD = [
|
||||
(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"Cheap hours only"), # not offered before P3
|
||||
(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"),
|
||||
|
||||
@@ -64,9 +64,21 @@ _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]}")
|
||||
# SAFETY-84 changes n2_maint ON PURPOSE: on 9d5b7d1 its N=2 cheap-window
|
||||
# charge was refused on 214 of 540 cycles (site at 0 W); now none is.
|
||||
# Printed on the SAFETY-84 tree the same way (margin 0).
|
||||
# SAFETY-85: the loop is bounded while A charges (reasons change, commands
|
||||
# do not). Was 95722b262f1bcf504493d7b5babc3895799ac56cc15f5d2ccdbe7c9f04adfcbb.
|
||||
SAFETY84_N2_MAINT = \
|
||||
"ef97ae2e13668b3e0b2d22f3f5f37835ea46cd306c2cc475c97e284f9dcc6545"
|
||||
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)
|
||||
"byte-identical to ed739d4 (n2_maint apart, below)",
|
||||
{k: v for k, v in _zero.items() if k != "n2_maint"}
|
||||
== {k: v for k, v in ED739D4.items() if k != "n2_maint"})
|
||||
check("...and n2_maint at margin 0 is the SAFETY-84 digest: the N=2 charge "
|
||||
"that 9d5b7d1 refused on 214 of 540 cycles now runs (test_safety84.py)",
|
||||
_zero["n2_maint"] == SAFETY84_N2_MAINT != ED739D4["n2_maint"])
|
||||
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))
|
||||
@@ -163,14 +175,15 @@ check("...and decide() sizes the floor to land the quarter 50 W under it "
|
||||
# ============================================================================ #
|
||||
print("\n3. the bill and the reports keep the real cap")
|
||||
_c = _runs["n1_off", None][1]
|
||||
_pk = _c.peak.peak().peak_w
|
||||
_t = rig.rig.BASE # the runs' clock, not the wall clock (a new month)
|
||||
_pk = _c.peak.peak(_t).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")
|
||||
f"warning {_c.peak.warning_frac(_t):.3f}, headroom "
|
||||
f"{_c.peak.headroom_w(_t):.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)
|
||||
_c.peak.cap_w == CAP and _c.peak.warning_frac(_t) == _pk / CAP
|
||||
and _c.peak.headroom_w(_t) == 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}")
|
||||
|
||||
@@ -109,6 +109,7 @@ import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import inf, isinf
|
||||
@@ -1259,6 +1260,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)
|
||||
@@ -2530,12 +2538,18 @@ print(f" banner ({len(_pay25['banner'])} chars): {_pay25['banner']}")
|
||||
# that is going over the ceiling.
|
||||
# UX-08: the banner is the notification's own title, and the row below keeps
|
||||
# the SAFETY-25 sentence.
|
||||
check("the Not ready banner is a short headline - the notification's title - "
|
||||
"and NOT the row repeated at the top of the page. A headline that is a "
|
||||
"paragraph is a headline nobody reads",
|
||||
# SAFETY-87: control goes on while the guard is floor-limited, so the banner
|
||||
# does not say "Not ready". This fixture also has a row that does stop control
|
||||
# (no SoC entity), which outranks it; the peak row alone is graded below.
|
||||
c25.checks = lambda: rows25
|
||||
_pay25 = c25.status_payload()
|
||||
del c25.checks
|
||||
print(f" banner, peak row alone: {_pay25['banner']}")
|
||||
check("the banner is a short headline - the notification's title, without "
|
||||
"'Not ready' (control goes on) - and NOT the row repeated at the top of "
|
||||
"the page. A headline that is a paragraph is a headline nobody reads",
|
||||
_pay25["level"] == "bad"
|
||||
and _pay25["banner"] == (f"{messages.NOT_READY} — "
|
||||
f"{messages.T_PEAK_RESERVE}")
|
||||
and _pay25["banner"] == messages.T_PEAK_RESERVE
|
||||
and not _row_scope(_pay25["banner"]))
|
||||
check("...and shortening the banner did NOT shorten the row: the SAFETY-25 "
|
||||
"sentence is still what the checks list carries, which is the surface the "
|
||||
@@ -2674,9 +2688,22 @@ check("...and so does the persistent notification, which outlives the page and "
|
||||
# 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.
|
||||
# ⚠️ SAFETY-84 ADDED `charge`, ITS MIRROR, AND IT IS NOT THE RESERVE EITHER. A
|
||||
# site `hi` at P_MAINT_SHAPE, filed only at N>=2 while a pack's cheap-window
|
||||
# maintenance charge makes it charge, equal to that pack's own `hi`. Never at
|
||||
# N=1. DECISIONS/SAFETY-84-charge-at-two.md.
|
||||
# SAFETY-86 added `hold`: a site `hi` at the peak floor while a holding pack
|
||||
# gives at N>=2. Not a reserve either. DECISIONS/SAFETY-86-peak-over-hold.md.
|
||||
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
"arbitrage", "maintenance", "loop", "circulation",
|
||||
"sign_site", "pack", "refused", "mode"}
|
||||
"sign_site", "pack", "refused", "mode", "drain", "charge",
|
||||
"hold"}
|
||||
# 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.
|
||||
@@ -2718,7 +2745,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 THIRTEEN. THIS "
|
||||
check("...and the sources that cycle can file are EXACTLY these SIXTEEN. 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 "
|
||||
@@ -2858,7 +2885,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, SAFETY-78's, SAFETY-84's and SAFETY-86's additions: the nine 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; "
|
||||
@@ -2867,7 +2894,8 @@ 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", "charge", "hold"}
|
||||
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
|
||||
== {"device", "supervised", "failsafe", "maintenance", "pack",
|
||||
"refused", "mode"})
|
||||
@@ -2971,7 +2999,9 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
|
||||
"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, "charge": ACTUATOR_SITE,
|
||||
"hold": 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)
|
||||
@@ -3050,13 +3080,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 FOURTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
check("all SEVENTEEN 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) == 14
|
||||
len(_direct) == 17
|
||||
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 "
|
||||
@@ -3073,7 +3103,7 @@ 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. Nine of the fourteen appends are SCOPE_UNIT - `device`, "
|
||||
"share. Nine of the sixteen 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 "
|
||||
@@ -3090,8 +3120,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 sixteen appends account for exactly the "
|
||||
"thirteen "
|
||||
check("...and between them the nineteen appends account for exactly the "
|
||||
"sixteen "
|
||||
"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())
|
||||
@@ -3102,6 +3132,10 @@ check("...and between them the sixteen appends account for exactly the "
|
||||
# through this file's reading of main.py. It grades three of the eight appends.
|
||||
# The static pin above is what covers the other five.
|
||||
_b2, _restore_b2 = second_inverter(c_src)
|
||||
# SAFETY-91: B states a cut-out, as every roster entry must (rule 8). Without
|
||||
# one it is held back at its reserve and gives last, so A alone would carry
|
||||
# this cycle's 500 W and the broadcast check below could not see a split.
|
||||
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
|
||||
_n2_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
@@ -3152,7 +3186,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", "mode"})
|
||||
"refused", "mode", "drain", "charge", "hold"})
|
||||
# ⚠️ 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
|
||||
@@ -4116,18 +4150,22 @@ check("⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so no "
|
||||
and _g_deepa.soc_floor != _g_shallow.soc_floor
|
||||
and [pk.bms_floor for pk in _g_ctl_deepa.packs]
|
||||
== [B_PACK_FLOOR_DEEP_SIM, B_PACK_FLOOR_SIM])
|
||||
check("...and it is not just a number in a report - THE SITE'S VERDICT FLIPS. "
|
||||
"With both packs at 25 % the shallow site is permitted and the guard "
|
||||
"sizes a positive forced discharge; BOTH deep sites are FLOOR-LIMITED, "
|
||||
"because 25 % is at or below the 35 % cut-out wherever on the roster it "
|
||||
"sits. Grading the roster against one position would spend a pack its "
|
||||
"BMS stops - through `sim/inject.py`, which does not run rule 8 - and "
|
||||
"this line is what goes red when it does",
|
||||
# SAFETY-87 changed what the verdict is at N>=2: the pack at or under its own
|
||||
# floor is HELD (named, gives nothing for the peak) and the others still shave.
|
||||
check("...and it is not just a number in a report - THE HELD PACK FOLLOWS IT. "
|
||||
"With both packs at 25 % the shallow site holds nobody back; on BOTH deep "
|
||||
"sites the pack whose 35 % cut-out is at or above 25 % is held, wherever "
|
||||
"it sits, gives nothing for the peak (weight 0), and the other pack "
|
||||
"carries a positive floor (SAFETY-87). Holding a position instead of "
|
||||
"the pack would spend a pack its BMS stops",
|
||||
_g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0
|
||||
and _g_deep.floor_limited is True and _g_deep.floor_w == 0.0
|
||||
and _g_deep.soc_floor_reason == FLOOR_AT_SOC_FLOOR
|
||||
and _g_deepa.floor_limited is True and _g_deepa.floor_w == 0.0
|
||||
and _g_deepa.soc_floor_reason == FLOOR_AT_SOC_FLOOR)
|
||||
and _g_shallow.held == ()
|
||||
and _g_deep.floor_limited is False and _g_deep.floor_w > 0.0
|
||||
and _g_deep.held == ("inverter-b",)
|
||||
and _g_ctl_deep._peak_reserve() == {"inverter-b"}
|
||||
and _g_deepa.floor_limited is False and _g_deepa.floor_w > 0.0
|
||||
and _g_deepa.held == ("inverter-a",)
|
||||
and _g_ctl_deepa._peak_reserve() == {"inverter-a"})
|
||||
check("...and the precondition that makes the three runs comparable (habit 1): "
|
||||
"the two deep runs carry THE SAME PAIR of cut-outs in opposite orders, "
|
||||
"those two numbers really are different, and all six packs are at the "
|
||||
@@ -4198,14 +4236,14 @@ check("⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster
|
||||
for r in _g3_runs)
|
||||
and [[pk.bms_floor for pk in r[2].packs] for r in _g3_runs]
|
||||
== [r[1] for r in _g3_runs])
|
||||
check("...and the VERDICT flips with it, on all three: 25 % is at or below the "
|
||||
"35 % cut-out wherever it sits, so every one of these sites is "
|
||||
"FLOOR-LIMITED and sizes no forced discharge at all - where the "
|
||||
"all-shallow two-pack baseline above was permitted and sized a positive "
|
||||
"one. A slice that dropped the deep position would spend, through "
|
||||
"`sim/inject.py`, a pack its BMS stops",
|
||||
all(r[3].floor_limited is True and r[3].floor_w == 0.0
|
||||
and r[3].soc_floor_reason == FLOOR_AT_SOC_FLOOR for r in _g3_runs)
|
||||
check("...and the HELD pack follows it, on all three (SAFETY-87): 25 % is at "
|
||||
"or below the 35 % cut-out wherever it sits, so that pack and only that "
|
||||
"pack is held and gives nothing for the peak, while the two others carry "
|
||||
"a positive floor. A slice that dropped the deep position would spend, "
|
||||
"through `sim/inject.py`, a pack its BMS stops",
|
||||
all(r[3].floor_limited is False and r[3].floor_w > 0.0
|
||||
and r[3].held == (r[2].inverters[r[1].index(_G3_DEEP_SIM)].name,)
|
||||
and r[2]._peak_reserve() == set(r[3].held) for r in _g3_runs)
|
||||
and _g_shallow.floor_limited is False and _g_shallow.floor_w > 0.0)
|
||||
check("...and the precondition that makes the three comparable (habit 1): every "
|
||||
"pack in all three rosters is at the SAME state of charge, so neither the "
|
||||
@@ -4363,12 +4401,18 @@ async def _notify32(packs_socs, floors):
|
||||
inv_b, _ = second_inverter(ctl)
|
||||
for inv, so, fl in zip(ctl.inverters, packs_socs, floors):
|
||||
inv.soc, inv.bms_soc_floor = so, fl
|
||||
ctl.peak_guard = PeakDecision(
|
||||
# SAFETY-87: the full pack refuses to give (SAFETY-73), so no battery
|
||||
# can carry the peak and the alarm still fires; it names the held one.
|
||||
inv.refused = 1 if so > SOC_FLOOR else 0
|
||||
_idle = [types.SimpleNamespace(phase="idle", owns_setpoint=False,
|
||||
setpoint_w=None)] * 2
|
||||
ctl.peak_guard = ctl._peak_on_able(PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=CAP_W, ceiling_from=CEILING_CAP,
|
||||
projected_w=CAP_W + 500.0, excess_w=500.0, wanted_w=500.0,
|
||||
floor_limited=True, soc=min(packs_socs),
|
||||
soc_floor=binding_soc_floor(ctl.packs, SOC_FLOOR),
|
||||
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x")
|
||||
soc_floor_reason=FLOOR_AT_SOC_FLOOR, reason="PEAK GUARD FLOOR-LIMITED: x"),
|
||||
_idle)
|
||||
ctl._peak_floor_limited = False
|
||||
await ctl.check_peak_floor_limited()
|
||||
return hs.last_message("goodwe_peak_soc_floor")
|
||||
@@ -4558,18 +4602,19 @@ check("...and the TWO-pack mirror, which is what production reaches the moment "
|
||||
[r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2
|
||||
and [[inv.soc for inv in r[2].inverters] for r in _s2_runs]
|
||||
== [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]])
|
||||
check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a "
|
||||
"defect rather than a reporting difference. With the emptiest pack at "
|
||||
"11 % - below the 20 % EMS floor - every one of those five sites is "
|
||||
"FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline "
|
||||
"with its emptiest pack at 25 % is permitted and sizes a positive one. A "
|
||||
"reduction that dropped the empty pack's position would grade this site "
|
||||
"for PERMISSION against a pack 39 points fuller and spend, through "
|
||||
"`sim/inject.py`, a pack its own BMS stops",
|
||||
all(r[3].floor_limited is True and r[3].floor_w == 0.0
|
||||
# SAFETY-87: at N>=2 the emptiest pack is HELD and the others still shave.
|
||||
check("⚠️ ...AND THE HELD PACK FOLLOWS IT, which is what makes this a defect "
|
||||
"rather than a reporting difference. With the emptiest pack at 11 % - "
|
||||
"below the 20 % EMS floor - that pack and only that pack is held on "
|
||||
"every one of those five sites and gives nothing for the peak, while "
|
||||
"the fuller ones carry a positive floor (SAFETY-87); the baseline holds "
|
||||
"nobody back. A reduction that dropped the empty pack's position would "
|
||||
"spend, through `sim/inject.py`, a pack its own BMS stops",
|
||||
all(r[3].floor_limited is False and r[3].floor_w > 0.0
|
||||
and r[3].held == (r[2].inverters[r[1].index(_S_EMPTY_SIM)].name,)
|
||||
for r in _s3_runs + _s2_runs)
|
||||
and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0
|
||||
and _s_ok.soc == SOC_BETWEEN)
|
||||
and _s_ok.held == () and _s_ok.soc == SOC_BETWEEN)
|
||||
check("...and the precondition that makes the six runs comparable (habit 1): "
|
||||
"every pack in every roster carries the SAME cut-out, so "
|
||||
"`binding_soc_floor()` answers the SAME site floor on all of them and the "
|
||||
@@ -5704,8 +5749,8 @@ MUT.append(("main.py grading the site against ONE roster position - "
|
||||
_g_deep.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
|
||||
and _g_deepa.soc_floor == float(B_PACK_FLOOR_DEEP_SIM)
|
||||
and _g_shallow.soc_floor == float(SOC_FLOOR)
|
||||
and _g_deep.floor_limited and _g_deepa.floor_limited
|
||||
and not _g_shallow.floor_limited,
|
||||
and _g_deep.held == ("inverter-b",)
|
||||
and _g_deepa.held == ("inverter-a",) and _g_shallow.held == (),
|
||||
"part 12g, REWRITTEN as behaviour: real two-pack cycles, the SAME "
|
||||
"pair of cut-outs carried in both orders, and both the floor "
|
||||
"reaching decide() and the site's verdict follow the ROSTER rather "
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
"""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. The N=1 and N=2 digests hold, 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. the N=1 and N=2 digests")
|
||||
# Printed by `python3 tools/safety78_identity.py`, goodwe-addon-checks:1,
|
||||
# 2026-09-29. 480 cycles each, draining throughout, every claim and the
|
||||
# newest action-log line hashed. N=2: the same on release/1.0 35cdffe. N=1:
|
||||
# re-pinned by SAFETY-83, whose `--diff` against 35cdffe changes only cycles
|
||||
# where the peak guard asked for more than the drain (test_safety83.py).
|
||||
# SAFETY-85 re-pinned two: n1_drain_owner loses its false 'not keeping up'
|
||||
# rows (commands unchanged); n2_drain's loop is bounded while A drains
|
||||
# (reasons and the loop claim change, commands unchanged).
|
||||
PINNED = {
|
||||
"n1_drain":
|
||||
"b0f9ef44149163bc0acece537fe047871c00f0d9ea213e7a7fc8efc4ad3aa500",
|
||||
"n1_drain_modes":
|
||||
"95ab82426e23bce308db3edebe18b1ab37d956952e9f521adce37150ce87ee8a",
|
||||
"n1_drain_owner":
|
||||
"607c2583c7ae4bdafb90d780b3414c62d76c430ca67798cd117d1a46f3bb40ae",
|
||||
"n2_drain":
|
||||
"5806a6f457b7baf8dfb44daddced502f1939c114e15b84029dbb2659be8c8b0f",
|
||||
"n2_drain_owner":
|
||||
"05750347f1829c249f99154613f068f442a59ef59f409093907f6d0add78dd4c",
|
||||
}
|
||||
_now = s78.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ a drain under a house below, at and above its rate, a surplus and a "
|
||||
"load over the peak limit, at N=1 (modes off and on) and N=2, matches "
|
||||
"the pinned digests: commands, reasons, roles, trades and every claim",
|
||||
_now == PINNED)
|
||||
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")
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Runnable check for SAFETY-83: the peak guard outranks the drain at N=1.
|
||||
`python3 test_safety83.py`
|
||||
|
||||
At one inverter the monthly drain is the site SET at P_MAINTENANCE, above the
|
||||
peak floor, so the house imported what the drain left and quarters went over
|
||||
the peak limit. Now a peak floor above the drain rate is refiled at
|
||||
P_MAINTENANCE and wins. Drives the REAL Controller (tools/opt11_identity.py's
|
||||
harness on a P1 meter). DECISIONS/SAFETY-83-peak-over-drain.md has the record.
|
||||
|
||||
1. A house of 5000, 6000 and 7000 W during a drain, limits 2500 and 3500,
|
||||
margins 0 and 50: every quarter banks at or under the aim, the drain keeps
|
||||
running, no 'Load too high' alarm, and without the fix the same runs go over.
|
||||
Under a month peak banked above the limit the aim is that peak - margin.
|
||||
2. The drain never makes the banked quarter worse than no drain, below and
|
||||
above the drain rate; below it the rest is still exported.
|
||||
3. The drain still ends, and the order holds: a lost reading parks the
|
||||
battery, the rating caps the floor without a clash.
|
||||
4. Only cycles where the guard wanted more than the drain change, and N=2 is
|
||||
untouched.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
|
||||
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 safety78_identity as s78 # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app.arbiter import (Claim, P_MAINTENANCE, P_PEAK, P_SAFETY, # noqa
|
||||
resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import CHARGE, DRAIN, MaintResult # noqa: E402
|
||||
|
||||
fails = []
|
||||
TOL = 1.0 # watts over the aim a banked quarter may show
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(house, cap, margin, drain_w, *, maint=True, dur=2700.0, soc=None,
|
||||
before=None, store=None, month_peak=None, **extra):
|
||||
"""N=1 on a P1 meter; returns (rows, controller, banked quarters).
|
||||
`month_peak` banks that quarter earlier in the month."""
|
||||
opts = dict(s79.P1, peak_cap_w=cap, peak_margin_w=margin, **extra)
|
||||
if maint:
|
||||
opts.update(maintenance_enabled=True, maintenance_start_hour=11,
|
||||
maintenance_discharge_w=drain_w)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s83-"), "tariff.db")
|
||||
if month_peak is not None:
|
||||
tariff.PeakStore(db)._db.execute(
|
||||
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
|
||||
(tariff.month_key(rig.BASE), month_peak,
|
||||
"2026-09-02T10:00:00+00:00")).connection.commit()
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(1, extra=opts, dur=dur,
|
||||
load=house if callable(house) else lambda t: house,
|
||||
soc=soc or (lambda u, t: 60.0),
|
||||
cut=lambda u, t: False, before=before or s79._feed,
|
||||
store=store)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
return rows, c, {b.start.strftime("%H:%M"): b.offtake_avg_w
|
||||
for b in c.p1.blocks}
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
real = Controller._peak_over_drain
|
||||
Controller._peak_over_drain = lambda self, claims, results: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_over_drain = real
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])["a"]
|
||||
|
||||
|
||||
def alarmed(c):
|
||||
"""Did SAFETY-81's 'Load too high' alarm fire on this run?"""
|
||||
return any("PEAK_GUARD_SATURATED" in e for e in c.events)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. banked quarters during a drain, N=1")
|
||||
CASES = [(cap, drain, m, h) for cap, drain in ((2500, 2000), (3500, 2500))
|
||||
for m in (0, 50) for h in (5000.0, 6000.0, 7000.0)]
|
||||
RES, OLD = {}, {}
|
||||
for k in CASES:
|
||||
cap, drain, m, h = k
|
||||
RES[k] = run(h, cap, m, drain)
|
||||
OLD[k] = without_fix(lambda: run(h, cap, m, drain))
|
||||
print(f" limit {cap} drain {drain} margin {m:>2} house {h:.0f}: "
|
||||
f"banked {[round(v) for v in RES[k][2].values()]} W "
|
||||
f"(before the fix {[round(v) for v in OLD[k][2].values()]} W)")
|
||||
check("⭐ every quarter banks at or under the aim (limit - margin) plus 1 W, "
|
||||
"for a 5000, 6000 and 7000 W house, limits 2500 and 3500, margins 0 "
|
||||
"and 50",
|
||||
all(len(b) >= 2 and max(b.values()) <= k[0] - k[2] + TOL
|
||||
for k, (_r, _c, b) in RES.items()))
|
||||
check("⭐ the drain keeps running: the phase is drain on every run and the "
|
||||
"battery gives at least the drain rate on every cycle",
|
||||
all(c.maints[0].phase == DRAIN and all(cmd(r) >= k[1] for r in rows)
|
||||
for k, (rows, c, _b) in RES.items()))
|
||||
check("...and where the house is over drain + aim, the battery gives more "
|
||||
"than the drain rate, bound by the peak floor",
|
||||
all(any(cmd(r) > k[1] and "limited by peak" in r[6] for r in rows)
|
||||
for k, (rows, _c, _b) in RES.items() if k[3] - k[1] > k[0] - k[2]))
|
||||
check("non-vacuous: without the fix the same runs bank over the limit "
|
||||
"wherever the house is above drain + aim (the reported defect: 3000 W "
|
||||
"at a 5000 W house against 2500 W)",
|
||||
all(max(b.values()) > k[0] + 100.0 for k, (_r, _c, b) in OLD.items()
|
||||
if k[3] - k[1] > k[0])
|
||||
and round(OLD[(2500, 2000, 50, 5000.0)][2]["11:15"]) == 3000)
|
||||
# A hob cycling: one 5 s spike to 11000 W every 150 s on a 6000 W house. Each
|
||||
# spike saturates the guard for one cycle, shorter than ALARM_HOLD_S.
|
||||
_sat = []
|
||||
|
||||
|
||||
def _spiky_feed(c, t):
|
||||
s79._feed(c, t)
|
||||
_sat.append(bool(c.peak_guard is not None and c.peak_guard.saturated))
|
||||
|
||||
|
||||
_k_rows, _k, _kb = run(lambda t: 11000.0 if t % 150 < 5 else 6000.0,
|
||||
3500, 50, 2500, before=_spiky_feed)
|
||||
check("⭐ a drain the guard raises gives no 'Load too high' alarm (SAFETY-81), "
|
||||
"steady or with 5 s spikes that saturate the guard",
|
||||
not any(alarmed(c) for _r, c, _b in RES.values())
|
||||
and any(_sat) and not alarmed(_k))
|
||||
|
||||
# Owner (SAFETY-79): under a month peak banked above the limit, the aim is that
|
||||
# peak - margin, during a drain too. Owner's settings, peak banked at 4500 W.
|
||||
BANK = {h: (run(h, 3500, 50, 2500, month_peak=4500.0),
|
||||
without_fix(lambda: run(h, 3500, 50, 2500, month_peak=4500.0)))
|
||||
for h in (6500.0, 7500.0)}
|
||||
for h, (new, old) in BANK.items():
|
||||
print(f" month peak 4500, house {h:.0f}: banked "
|
||||
f"{[round(v) for v in new[2].values()]} W (before the fix "
|
||||
f"{[round(v) for v in old[2].values()]} W)")
|
||||
check("⭐ under a month peak of 4500 W: a 7500 W house banks at 4450 W "
|
||||
"(peak - margin, not the 3450 W cap aim), was 5000 W; a 6500 W house "
|
||||
"stays under the drain alone at 4000 W",
|
||||
all(4430.0 <= v <= 4450.0 + TOL for v in BANK[7500.0][0][2].values())
|
||||
and max(BANK[7500.0][1][2].values()) > 4900.0
|
||||
and all(round(v) in (4000, 4014)
|
||||
for v in BANK[6500.0][0][2].values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the drain never makes the banked quarter worse, N=1")
|
||||
CAP, DRAIN_W, MARGIN = 3500, 2500, 50 # the owner's settings
|
||||
AIM = CAP - MARGIN
|
||||
WORSE = {}
|
||||
for h in (1200.0, 3000.0, 5000.0, 6500.0, 7000.0):
|
||||
_rows, _c, b = run(h, CAP, MARGIN, DRAIN_W)
|
||||
_r0, _c0, b0 = run(h, CAP, MARGIN, DRAIN_W, maint=False)
|
||||
WORSE[h] = (b, b0, _rows)
|
||||
print(f" house {h:.0f}: with the drain {[round(v) for v in b.values()]}"
|
||||
f" W, without {[round(v) for v in b0.values()]} W")
|
||||
check("⭐ below and above the drain rate, every quarter with the drain banks "
|
||||
"no more than the larger of the aim and the same quarter without it",
|
||||
all(b[q] <= max(AIM, b0[q]) + TOL for b, b0, _r in WORSE.values()
|
||||
for q in b))
|
||||
_r12 = WORSE[1200.0][2]
|
||||
check("below the drain rate the battery gives the drain rate and exports the "
|
||||
"rest (owner, 2026-09-29): 2500 W against a 1200 W house",
|
||||
all(cmd(r) == DRAIN_W for r in _r12[1:]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the drain ends, and the order holds")
|
||||
_e_rows, _e, _eb = run(6500.0, CAP, MARGIN, DRAIN_W, dur=1200.0,
|
||||
soc=lambda u, t: max(5.0, 60.0 - t / 20.0))
|
||||
check("⭐ with the guard binding, the drain still reaches its floor and moves "
|
||||
"to the charge phase, with no abort",
|
||||
_e.maints[0].phase == CHARGE
|
||||
and not any("abort" in e.lower() for e in _e.events))
|
||||
|
||||
|
||||
def blind_at(t0):
|
||||
"""The battery power reading goes missing at `t0`: a safety SET."""
|
||||
def before(c, t):
|
||||
s79._feed(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.batt_a" else await real(e, invert)
|
||||
c.hass.number, c.hass._blind = number, True
|
||||
return before
|
||||
|
||||
|
||||
_s_rows, _s, _sb = run(6500.0, CAP, MARGIN, DRAIN_W, dur=600.0,
|
||||
before=blind_at(300.0))
|
||||
print(f" reading lost at 300 s: last {cmd(_s_rows[-1]):+.0f} W, "
|
||||
f"{_s_rows[-1][6]}")
|
||||
check("⭐ a safety SET outranks the refiled peak floor: before the reading "
|
||||
"goes the battery gives above the drain rate, after it it is at 0 W",
|
||||
any(cmd(r) > DRAIN_W for r in _s_rows if r[0] < 300.0)
|
||||
and all(cmd(r) == 0.0 for r in _s_rows if r[0] >= 400.0)
|
||||
and _s_rows[-1][6].startswith("safety -> 0 W"))
|
||||
_m_rows, _m, _mb = run(7000.0, 2500, 50, 2000, dur=900.0, max_w=3000)
|
||||
print(f" max_w 3000, house 7000: last {cmd(_m_rows[-1]):+.0f} W, "
|
||||
f"{_m_rows[-1][6]}")
|
||||
check("⭐ the rating caps the floor without a clash: at max_w 3000 the "
|
||||
"battery gives 3000 W, never 0 W, and nothing contradicts",
|
||||
all(cmd(r) >= 2000.0 for r in _m_rows)
|
||||
and cmd(_m_rows[-1]) == 3000.0
|
||||
and not any("CONTRADICTION" in (r[6] or "") for r in _m_rows))
|
||||
check("...and there, with the battery at its rating and the house still over, "
|
||||
"the 'Load too high' alarm does fire", alarmed(_m))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. only the cycles where the guard wanted more change")
|
||||
|
||||
|
||||
def promoted(row):
|
||||
return [c for c in row[-1][0] if c[0] == "peak"]
|
||||
|
||||
|
||||
_id = {k: s78.run(**v)[0] for k, v in s78.SCENARIOS.items()}
|
||||
_bad = []
|
||||
for k, rows in _id.items():
|
||||
for r in rows:
|
||||
claims, guard = r[-1][0], r[-1][1]
|
||||
rate = [c[3] for c in claims if c[0] == "maintenance" and c[2] == "set"]
|
||||
pk = promoted(r)
|
||||
if k.startswith("n2"):
|
||||
ok = all(c[1] == P_PEAK for c in pk)
|
||||
elif s78.wanted_more(r):
|
||||
ok = ([c[1] for c in pk] == [P_MAINTENANCE]
|
||||
and r[1] == guard[1] and "limited by peak" in r[6])
|
||||
else:
|
||||
ok = (all(c[1] == P_PEAK for c in pk)
|
||||
and (not rate or r[1] == rate[0]))
|
||||
if not ok:
|
||||
_bad.append((k, r[0], r[1], r[6], pk))
|
||||
_bad1 = [b for b in _bad if b[0].startswith("n1")]
|
||||
_bad2 = [b for b in _bad if b[0].startswith("n2")]
|
||||
_more = {k: sum(map(s78.wanted_more, rows)) for k, rows in _id.items()}
|
||||
print(f" cycles with the guard above the drain: {_more}; bad {_bad[:3]}")
|
||||
check("⭐ at N=1, on every cycle where the guard asks for more than the drain "
|
||||
"the peak claim is refiled at P_MAINTENANCE and the target is its floor; "
|
||||
"on every other cycle the peak claim stays at P_PEAK and the target is "
|
||||
"the drain rate",
|
||||
not _bad1 and all(_more[k] > 50 for k in _id if k.startswith("n1")))
|
||||
check("⭐ at N=2 the peak claim is never refiled (the SAFETY-78 floor does "
|
||||
"that job), and test_safety78.py part 5 holds N=2 to the digests of "
|
||||
"release/1.0 35cdffe", not _bad2)
|
||||
|
||||
|
||||
# The rule itself, at the boundary: a floor AT the drain rate changes nothing.
|
||||
_one = types.SimpleNamespace(inverters=[None])
|
||||
_edge = {}
|
||||
for lo in (1999.5, 2000.0, 2000.5):
|
||||
cl = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, DRAIN),
|
||||
Claim.limit("peak", P_PEAK, lo=lo, reason="quarter peak")]
|
||||
Controller._peak_over_drain(_one, cl, [MaintResult(DRAIN, True, 2000.0,
|
||||
False, [])])
|
||||
_edge[lo] = cl[1].priority
|
||||
check("at the boundary: a floor at or below the 2000 W drain stays at P_PEAK, "
|
||||
"a floor 0.5 W above it is refiled at P_MAINTENANCE",
|
||||
_edge == {1999.5: P_PEAK, 2000.0: P_PEAK, 2000.5: P_MAINTENANCE})
|
||||
|
||||
_stop = [Claim.set("maintenance", P_MAINTENANCE, 2000.0, DRAIN),
|
||||
Claim.limit("peak", P_PEAK, lo=4000.0, reason="quarter peak"),
|
||||
Claim.set("safety", P_SAFETY, 0.0, "inputs missing")]
|
||||
Controller._peak_over_drain(_one, _stop, [MaintResult(DRAIN, True, 2000.0,
|
||||
False, [])])
|
||||
check("⭐ and a refiled floor stays under a safety SET: the site resolves "
|
||||
"to 0 W", _stop[1].priority == P_MAINTENANCE
|
||||
and resolve(_stop).target_w == 0.0)
|
||||
|
||||
|
||||
def store_hold():
|
||||
st = rig._Store()
|
||||
st.data["phase"], st.data["phase_started"] = "hold", rig.BASE.isoformat()
|
||||
return st
|
||||
|
||||
|
||||
_h_rows, _h, _hb = run(6000.0, 2500, 50, 2000, dur=600.0, store=store_hold())
|
||||
check("a maintenance HOLD at N=1 now gives way to the guard too (SAFETY-86, "
|
||||
"test_safety86.py): the battery gives with the house over the limit",
|
||||
_h.maints[0].phase == "hold" and _h.peak_guard.engaged
|
||||
and cmd(_h_rows[-1]) > 0.0 and "limited by peak" in _h_rows[-1][6])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,510 @@
|
||||
"""Runnable check for SAFETY-84: a maintenance charge at N=2 no longer stalls.
|
||||
`python3 test_safety84.py`
|
||||
|
||||
In a cheap window the charging battery's own `hi` makes it charge. At two
|
||||
inverters the loop's site target sat above it, the other battery had to give,
|
||||
the allocator refused the opposite directions and the site sat at 0 W. Now a
|
||||
site `hi` (`charge`, P_MAINT_SHAPE) keeps the site target at or below the
|
||||
charge, and the charge is sized to the quarter's headroom so it yields to the
|
||||
peak guard. Drives the REAL Controller (tools/safety84_identity.py's harness).
|
||||
DECISIONS/SAFETY-84-charge-at-two.md has the record.
|
||||
|
||||
1. N=2 in a cheap window, house below, at and above the charge rate and a
|
||||
solar surplus: the charge runs, no opposite split, no refusal, and it ends.
|
||||
2. The peak guard: the charge yields just enough to hold the aim (cap -
|
||||
margin, and a banked month peak - margin); over the aim the guard wins,
|
||||
the charging battery gives too, and nothing parks; no false 'Load too
|
||||
high'. Without P1 the charge is not sized, as at N=1.
|
||||
3. Outside a cheap window nothing changes.
|
||||
4. A safety stop parks both batteries without a refusal, and the charge
|
||||
resumes after it.
|
||||
5. One battery refusing, or dropped, and B Off with battery modes on.
|
||||
6. A trade outranked by the charge (P_ARBITRAGE < P_MAINT_SHAPE).
|
||||
7. A seeded sweep of the ceiling against the loop, trades, refusals, packs
|
||||
and modes.
|
||||
8. The N=1 digests equal release/1.0 9d5b7d1, and the N=2 ones are pinned.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
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 safety84_identity as s84 # noqa: E402
|
||||
from app import arbitrage, packmode # noqa: E402
|
||||
from app.arbiter import (Claim, P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, # noqa
|
||||
P_MODE, P_SAFETY, resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import CHARGE, HOLD # 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)
|
||||
|
||||
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
|
||||
TOL = 10.0 # W over the aim a banked quarter may show (SAFETY-81)
|
||||
|
||||
|
||||
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 charge_claims(r):
|
||||
"""The site `charge` claims main.py handed resolve() on row `r`."""
|
||||
return [c for c in r[-1][0] if c[0] == "charge"]
|
||||
|
||||
|
||||
def banked(c):
|
||||
return [b.offtake_avg_w for b in c.p1.blocks]
|
||||
|
||||
|
||||
def alarmed(c):
|
||||
"""Did SAFETY-81's 'Load too high' alarm fire on this run? Its quarter
|
||||
stamp too, since ALLOCATOR rows can push its row out of the 40-row log
|
||||
(SAFETY-85)."""
|
||||
return c._peak_alarm_block is not None or any(
|
||||
"PEAK_GUARD_SATURATED" in e for e in c.events)
|
||||
|
||||
|
||||
def run(house, n=2, **kw):
|
||||
return s84.run(n, load=house if callable(house) else lambda t: house,
|
||||
**kw)
|
||||
|
||||
|
||||
def rise(u, t):
|
||||
"""A fills from 60 % (1 % a cycle); B stays at 50 %."""
|
||||
return min(100.0, 60.0 + t / 5.0) if u == "a" else 50.0
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. N=2 in a cheap window: the charge runs whatever the house uses")
|
||||
RUNS = {h: run(h, dur=900.0) for h in (800.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; {rows[-1][6]}; refusals "
|
||||
f"{refused(rows)}")
|
||||
check("⭐ below, at and above the charge rate and under a solar surplus, "
|
||||
"battery A charges on EVERY cycle (it used to sit at 0 W from the "
|
||||
"first cycle)",
|
||||
all(cmd(r)["a"] < 0.0 for rows, _c in RUNS.values() for r in rows)
|
||||
and all(c.maints[0].phase == CHARGE for _r, c in RUNS.values()))
|
||||
check("⭐ no cycle splits the site both ways and the allocator refuses "
|
||||
"nothing: battery B never gives while A charges",
|
||||
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))
|
||||
check("with the quarter's headroom above the charge rate, A charges at the "
|
||||
"full 2000 W and the Why names the charge",
|
||||
all(cmd(r)["a"] == -2000.0 for h in (800.0, -1500.0)
|
||||
for r in RUNS[h][0])
|
||||
and "limited by charge" in RUNS[800.0][0][-1][6])
|
||||
print(f" Why: {RUNS[800.0][1].why_words}")
|
||||
check("...and the page's Why reads as at one battery: 'held back by battery "
|
||||
"maintenance'",
|
||||
RUNS[800.0][1].why_words.endswith("held back by battery maintenance"))
|
||||
|
||||
_end_rows, _end = run(800.0, dur=600.0, soc=rise)
|
||||
check("⭐ and the charge ENDS: A reaches its 99 % target and the cycle moves "
|
||||
"on to its hold, with no refusal on the way",
|
||||
_end.maints[0].phase == HOLD and refused(_end_rows) == 0
|
||||
and any("charge complete" in e for e in _end.events))
|
||||
|
||||
_real = Controller._charge_ceiling
|
||||
Controller._charge_ceiling = lambda self, claims: None
|
||||
try:
|
||||
_bite, _bc = run(800.0, dur=120.0)
|
||||
finally:
|
||||
Controller._charge_ceiling = _real
|
||||
print(f" without the ceiling: refusals {refused(_bite)}/{len(_bite)}, "
|
||||
f"last A {cmd(_bite[-1])['a']:+.0f} W")
|
||||
check("non-vacuous: with the ceiling 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)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the peak guard outranks the charge")
|
||||
_q_rows, _q = run(3000.0, dur=2700.0)
|
||||
print(f" house 3000: banked {[round(v) for v in banked(_q)]} W, last A "
|
||||
f"{cmd(_q_rows[-1])['a']:+.0f} W")
|
||||
check("⭐ house 3000 W, aim 3450 W: the charge yields just enough - every "
|
||||
"banked quarter lands on the aim, not on house + 2000 W",
|
||||
len(banked(_q)) >= 2
|
||||
and all(AIM - TOL <= v <= AIM + TOL for v in banked(_q)))
|
||||
check("...and it yields by charging less, not by stopping: A charges and B "
|
||||
"is 0 W on every cycle, nothing is refused, no 'Load too high'",
|
||||
all(cmd(r)["a"] < 0.0 and cmd(r)["b"] == 0.0 for r in _q_rows)
|
||||
and refused(_q_rows) == 0 and not alarmed(_q))
|
||||
check("...and the peak guard never has to step in (the charge stays under "
|
||||
"what the quarter can afford)",
|
||||
not any(r[-1][1] and r[-1][1][0] for r in _q_rows))
|
||||
|
||||
_o_rows, _o = run(5000.0, dur=2700.0)
|
||||
_both = [r for r in _o_rows
|
||||
if charge_claims(r) and any(c[0] == "peak" for c in r[-1][0])]
|
||||
print(f" house 5000: banked {[round(v) for v in banked(_o)]} W, last "
|
||||
f"{cmd(_o_rows[-1])}, {_o_rows[-1][6]}")
|
||||
check("⭐ house 5000 W, over the aim alone: the peak guard wins - every "
|
||||
"banked quarter is at or under the aim, both batteries give in the "
|
||||
"same direction, nothing is refused and nothing parks at 0 W",
|
||||
all(v <= AIM + TOL for v in banked(_o)) and opposite(_o_rows) == 0
|
||||
and refused(_o_rows) == 0
|
||||
and sum(cmd(r)["a"] + cmd(r)["b"] > 0.0 for r in _o_rows) > 100)
|
||||
check("...and the guard's `lo` never meets the charge's `hi` in one cycle "
|
||||
"(the charge is suspended while the guard holds)", not _both)
|
||||
check("...with no false 'Load too high' alarm", not alarmed(_o))
|
||||
|
||||
_k_rows, _k = run(3000.0, dur=2700.0, month_peak=4000.0)
|
||||
print(f" month peak 4000 banked, house 3000: banked "
|
||||
f"{[round(v) for v in banked(_k)]} W")
|
||||
check("⭐ under a month peak banked at 4000 W the aim is 3950 W: the charge "
|
||||
"takes the extra headroom and every banked quarter lands on 3950 W",
|
||||
len(banked(_k)) >= 2
|
||||
and all(3950.0 - TOL <= v <= 3950.0 + TOL for v in banked(_k))
|
||||
and refused(_k_rows) == 0 and not alarmed(_k))
|
||||
|
||||
_real = Controller._charge_headroom
|
||||
Controller._charge_headroom = lambda self, floor, claims: floor
|
||||
try:
|
||||
_nh_rows, _nh = run(3000.0, dur=2700.0)
|
||||
finally:
|
||||
Controller._charge_headroom = _real
|
||||
_gave = sum(1 for r in _nh_rows if cmd(r)["a"] > 0.0 or cmd(r)["b"] > 0.0)
|
||||
print(f" without the headroom: cycles a battery gives {_gave}, banked "
|
||||
f"{[round(v) for v in banked(_nh)]} W")
|
||||
check("non-vacuous: charging at the full rate instead, the guard has to "
|
||||
"step in and the batteries give back on many cycles",
|
||||
_gave > 50)
|
||||
|
||||
|
||||
def peak_run(house):
|
||||
rows, c = run(house, dur=1800.0)
|
||||
return rows, c, [r for r in rows if r[-1][1] and r[-1][1][0]]
|
||||
|
||||
|
||||
# A 6500 W house needs 3050 W off the meter: more than B's 2500 W alone.
|
||||
_p_rows, _p, _p_eng = peak_run(6500.0)
|
||||
print(f" house 6500: banked {[round(v) for v in banked(_p)]} W, A gives "
|
||||
f"on {sum(cmd(r)['a'] > 0.0 for r in _p_eng)} of {len(_p_eng)} "
|
||||
f"engaged cycles")
|
||||
check("⭐ house 6500 W, more than B alone can cover: the charge is suspended "
|
||||
"and A, the battery being filled, gives too; the quarter lands on the "
|
||||
"aim, nothing is refused, no 'Load too high'",
|
||||
len(_p_eng) > 100 and all(cmd(r)["a"] > 0.0 for r in _p_eng)
|
||||
and all(v <= AIM + TOL for v in banked(_p))
|
||||
and refused(_p_rows) == 0 and not alarmed(_p))
|
||||
_real = s84.m.peakguard.shaping_suspended
|
||||
s84.m.peakguard.shaping_suspended = lambda *a: False
|
||||
try:
|
||||
_ns_rows, _ns, _ns_eng = peak_run(6500.0)
|
||||
finally:
|
||||
s84.m.peakguard.shaping_suspended = _real
|
||||
check("non-vacuous: with the suspension taken out, A is held at 0 W, the "
|
||||
"quarter goes over the cap and 'Load too high' fires",
|
||||
not any(cmd(r)["a"] > 0.0 for r in _ns_eng)
|
||||
and max(banked(_ns)) > 3500.0 and alarmed(_ns))
|
||||
|
||||
|
||||
def prices_only(c, t):
|
||||
"""A cheap price and no P1 telegram (`meter_source: off`)."""
|
||||
if not getattr(c.hass, "_px", False):
|
||||
read, px = c.hass.number, {"sensor.pnow": 0.05, "sensor.pavg": 0.20}
|
||||
|
||||
async def number(e, invert=False):
|
||||
return px[e] if e in px else await read(e, invert)
|
||||
c.hass.number, c.hass._px = number, True
|
||||
|
||||
|
||||
_p1 = s84.s79.P1
|
||||
s84.s79.P1 = {"meter_source": "off", "peak_cap_w": 3500}
|
||||
try:
|
||||
_off_rows, _off = run(800.0, dur=600.0, before=prices_only)
|
||||
finally:
|
||||
s84.s79.P1 = _p1
|
||||
check("⭐ with no P1 meter nothing measures the quarter and the charge is not "
|
||||
"sized, as at N=1: A charges at 2000 W on every cycle, nothing refused",
|
||||
_off.peak_guard is None and all(cmd(r)["a"] == -2000.0 for r in _off_rows)
|
||||
and refused(_off_rows) == 0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. outside a cheap window nothing changes")
|
||||
_d = {h: run(h, cheap=False, dur=600.0) for h in (800.0, -1500.0)}
|
||||
check("⭐ no `charge` claim is filed outside a cheap window: B covers the "
|
||||
"house, the charge takes solar only, nothing is refused",
|
||||
not any(charge_claims(r) for rows, _c in _d.values() for r in rows)
|
||||
and cmd(_d[800.0][0][-1]) == {"a": 0.0, "b": 800.0}
|
||||
and cmd(_d[-1500.0][0][-1])["a"] < 0.0
|
||||
and all(refused(rows) == 0 for rows, _c in _d.values()))
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. a safety stop outranks the charge")
|
||||
|
||||
|
||||
def blind_at(t0, t1):
|
||||
"""A's power reading is missing from `t0` to `t1`: a safety SET."""
|
||||
def before(c, t):
|
||||
s84.feed(True)(c, t)
|
||||
if not getattr(c.hass, "_blind", False):
|
||||
real = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.batt_a" and t0 <= c.hass.t < t1:
|
||||
return None
|
||||
return await real(e, invert)
|
||||
c.hass.number, c.hass._blind = number, True
|
||||
return before
|
||||
|
||||
|
||||
_s_rows, _s = run(800.0, dur=600.0, before=blind_at(150.0, 400.0))
|
||||
_stop = [r for r in _s_rows if 175.0 <= r[0] < 400.0]
|
||||
_last = _s_rows[79] # t = 395 s, still without the reading
|
||||
print(f" A's reading lost 150-400 s: {_last[2]}, {_last[6]}; after: "
|
||||
f"{_s_rows[-1][2]}")
|
||||
check("⭐ with a reading gone the safety SET parks both batteries at 0 W "
|
||||
"with no refusal and no ARBITER line (it used to refuse every cycle)",
|
||||
all(cmd(r) == {"a": 0.0, "b": 0.0} for r in _stop)
|
||||
and _last[0] == 395.0 and _last[6].startswith("safety -> 0 W")
|
||||
and refused(_s_rows) == 0 and not any("ARBITER" in e for e in _s.events))
|
||||
check("...and the charge ran before and runs again after",
|
||||
cmd(_s_rows[20])["a"] == -2000.0 and cmd(_s_rows[-1])["a"] == -2000.0)
|
||||
|
||||
|
||||
_full = _s._charge_headroom(2000.0, [])
|
||||
check("the stop test reads only SITE SETs: a unit's own safety SET (on B) "
|
||||
"or a site limit does not cut A's charge, a site safety SET does",
|
||||
_s._charge_headroom(2000.0, [Claim.set("failsafe", P_SAFETY, 0.0,
|
||||
actuator=B)]) == _full > 0.0
|
||||
and _s._charge_headroom(2000.0, [Claim.limit("peak", P_SAFETY,
|
||||
lo=0.0)]) == _full
|
||||
and _s._charge_headroom(2000.0, [Claim.set("safety", P_SAFETY,
|
||||
0.0)]) == 0.0)
|
||||
|
||||
|
||||
def stop_at(t0):
|
||||
def before(c, t):
|
||||
s84.feed(True)(c, t)
|
||||
if t >= t0:
|
||||
c.auto = False
|
||||
return before
|
||||
|
||||
|
||||
_st_rows, _st = run(800.0, dur=400.0, before=stop_at(200.0))
|
||||
check("control switched off mid-charge: both batteries at 0 W, no refusal",
|
||||
cmd(_st_rows[-1]) == {"a": 0.0, "b": 0.0} and refused(_st_rows) == 0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. one battery refusing, dropped, or Off")
|
||||
|
||||
|
||||
def a_takes_nothing(c, t):
|
||||
"""A reads 0 W whenever it is asked to charge (a battery that refuses)."""
|
||||
s84.feed(True)(c, t)
|
||||
if not getattr(c.hass, "_rt", False):
|
||||
real = c.hass.power
|
||||
c.hass.power = lambda u: (0.0 if u == "a" and c.hass.cmd["a"] < 0.0
|
||||
else real(u))
|
||||
c.hass._rt = True
|
||||
|
||||
|
||||
_r_rows, _r = run(800.0, dur=900.0, before=a_takes_nothing)
|
||||
print(f" A refuses to charge: {_r_rows[-1][2]}, {_r_rows[-1][6]}")
|
||||
check("⭐ A refuses to charge (SAFETY-73): no ceiling is filed for it, B "
|
||||
"covers the 800 W house, and no cycle is split both ways or refused",
|
||||
_r.inverters[0].refused == -1 and cmd(_r_rows[-1]) == {"a": 0.0,
|
||||
"b": 800.0}
|
||||
and not charge_claims(_r_rows[-1])
|
||||
and opposite(_r_rows) == 0 and refused(_r_rows) == 0)
|
||||
|
||||
_g_rows, _g = run(3000.0, dur=600.0, cut=lambda u, t: u == "b")
|
||||
check("B refuses to give: A still charges and nothing is refused",
|
||||
all(cmd(r)["a"] < 0.0 for r in _g_rows) and refused(_g_rows) == 0)
|
||||
|
||||
|
||||
def reject_a(c, t):
|
||||
"""Every write to A is rejected, so A's link goes stale (not live)."""
|
||||
s84.feed(True)(c, t)
|
||||
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 = 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 charging battery that is dropped (stale link) adds no ceiling: "
|
||||
"B covers the 1500 W house instead of charging for A",
|
||||
"dropped inverter-a" in _l_rows[-1][7]
|
||||
and 1490.0 <= cmd(_l_rows[-1])["b"] <= 1530.0
|
||||
and not charge_claims(_l_rows[-1]))
|
||||
|
||||
|
||||
def modes_store(b_mode=None, b_off=False):
|
||||
s = rig._Store()
|
||||
for k in ("phase", "phase_started"):
|
||||
s.data[unit_key(A, k)] = ("charge" if k == "phase"
|
||||
else rig.BASE.isoformat())
|
||||
if b_mode:
|
||||
s.data[unit_key(B, packmode.MODE_KEY)] = b_mode
|
||||
if b_off:
|
||||
s.data[unit_key(B, packmode.USE_KEY)] = False
|
||||
return s
|
||||
|
||||
|
||||
_m = {h: run(h, dur=600.0, extra={"battery_modes_enabled": True},
|
||||
store=modes_store(b_off=True)) for h in (800.0, -3000.0)}
|
||||
print(f" B Off: {[(h, cmd(rows[-1])) for h, (rows, _c) in _m.items()]}")
|
||||
check("⭐ battery modes on, B Off: A charges at 2000 W over an 800 W house "
|
||||
"and takes 2500 W of a 3000 W surplus, B stays at 0 W, nothing is "
|
||||
"refused",
|
||||
cmd(_m[800.0][0][-1]) == {"a": -2000.0, "b": 0.0}
|
||||
and cmd(_m[-3000.0][0][-1]) == {"a": -2500.0, "b": 0.0}
|
||||
and all(refused(rows) == 0 and opposite(rows) == 0
|
||||
for rows, _c in _m.values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. a trade does not outrank the charge")
|
||||
|
||||
|
||||
def sell_feed(c, t):
|
||||
p3._feed(c, t)
|
||||
s84.feed(True)(c, t)
|
||||
|
||||
|
||||
_trades = []
|
||||
_t_rows, _t = run(800.0, dur=5400.0, before=sell_feed,
|
||||
extra=dict(p3.ARB),
|
||||
soc=lambda u, t: 60.0)
|
||||
print(f" modes off, a sell at 75-90 min: last {_t_rows[-1][2]}")
|
||||
_sells = [r for r in _t_rows if any(c[0] == "arbitrage" and c[3] > 0.0
|
||||
for c in r[-1][0])]
|
||||
check("⭐ battery modes off: the site trade asks to SELL while A charges; "
|
||||
"the charge (P_MAINT_SHAPE) outranks it, so A keeps charging and "
|
||||
"nothing is refused",
|
||||
len(_sells) > 50
|
||||
and all(cmd(r)["a"] < 0.0 and cmd(r)["b"] <= 0.0 for r in _sells)
|
||||
and refused(_t_rows) == 0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. a seeded sweep of the ceiling")
|
||||
_c = run(800.0, dur=30.0)[1]
|
||||
_rnd = random.Random(84)
|
||||
_bad = []
|
||||
for _i in range(20000):
|
||||
ra, rb = _rnd.choice((2000.0, 2500.0)), _rnd.choice((1000.0, 2500.0))
|
||||
f = _rnd.uniform(1.0, ra)
|
||||
cl = [Claim.limit("supervised", P_SAFETY, -ra, ra, actuator=A),
|
||||
Claim.limit("supervised", P_SAFETY, -rb, rb, actuator=B),
|
||||
Claim.limit("maintenance", P_MAINT_SHAPE, hi=-f, actuator=A)]
|
||||
x = _rnd.random()
|
||||
if x < 0.1:
|
||||
cl.append(Claim.limit("refused", P_SAFETY, 0.0, actuator=A))
|
||||
elif x < 0.2:
|
||||
cl.append(Claim.limit("pack", P_SAFETY, 0.0, actuator=A))
|
||||
y = _rnd.random()
|
||||
if y < 0.15:
|
||||
cl.append(Claim.limit("refused", P_SAFETY, *_rnd.choice(
|
||||
((0.0, float("inf")), (float("-inf"), 0.0))), actuator=B))
|
||||
elif y < 0.3:
|
||||
cl.append(Claim.limit("mode", P_MODE, *_rnd.choice(
|
||||
((0.0, 0.0), (0.0, rb), (-rb, 0.0))), actuator=B))
|
||||
cl.append(Claim.set("loop", P_LOOP, _rnd.uniform(-6000, 6000)))
|
||||
if _rnd.random() < 0.4:
|
||||
cl.append(Claim.set("arbitrage", P_ARBITRAGE,
|
||||
_rnd.uniform(-5000, 5000)))
|
||||
ceil = _c._charge_ceiling(cl)
|
||||
if ceil is not None:
|
||||
cl.append(Claim.limit("charge", P_MAINT_SHAPE, hi=ceil))
|
||||
res = resolve(cl, units=(A, B), live={A, B})
|
||||
sh = res.shares or {}
|
||||
why = []
|
||||
if not res.allocated or res.contradiction:
|
||||
why.append("refused or contradicted")
|
||||
if sh.get(A, 0.0) * sh.get(B, 0.0) < 0.0:
|
||||
why.append("opposite")
|
||||
a_free = x >= 0.2
|
||||
if a_free and (ceil is None or sh.get(A, 0.0) > -f + 1.0):
|
||||
why.append("A does not charge")
|
||||
if not a_free and ceil is not None:
|
||||
why.append("ceiling filed for a pack that cannot charge")
|
||||
if why:
|
||||
_bad.append((_i, why))
|
||||
print(f" 20000 claim lists, bad {len(_bad)}: {_bad[:3]}")
|
||||
check("⭐ 20000 random N=2 claim lists (a charge on A, refusals, a full "
|
||||
"pack, B's mode, the loop and trades in both directions): never "
|
||||
"refused, never both ways, A always charges at least its rate, and "
|
||||
"no ceiling for a pack that cannot charge", not _bad)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n8. the N=1 and N=2 digests")
|
||||
# Printed by `python3 tools/safety84_identity.py` in goodwe-addon-checks:1,
|
||||
# 2026-09-30, 480 cycles each, every claim hashed. N=1 and n2_charge_dear:
|
||||
# the same on release/1.0 9d5b7d1 (the tool copied in). N=2 in a cheap
|
||||
# window: new, the charge that 9d5b7d1 refused on every cycle.
|
||||
# SAFETY-85 re-pinned the three N=2 ones: the loop is bounded while A's
|
||||
# charge pin stands. n2_charge and _banked change only the loop's reason and
|
||||
# claim on 2 cycles; n2_charge_dear changes commands on 98 cycles, where A's
|
||||
# pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step.
|
||||
PINNED = {
|
||||
"n1_charge":
|
||||
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
|
||||
"n1_charge_dear":
|
||||
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
|
||||
"n1_charge_banked":
|
||||
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
|
||||
"n2_charge":
|
||||
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
|
||||
"n2_charge_dear":
|
||||
"00a197d89a5dd1e8b3ae6a5d1af66329a4dc4850476569c5931c125fa4d9eaee",
|
||||
"n2_charge_banked":
|
||||
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
|
||||
}
|
||||
_now = s84.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
|
||||
f"{_now[k][:16]}")
|
||||
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
|
||||
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
|
||||
"claim), and N=2 outside a cheap window matches its SAFETY-85 pin",
|
||||
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
|
||||
"n1_charge_banked",
|
||||
"n2_charge_dear")))
|
||||
check("...and the N=2 cheap-window digests match the pins",
|
||||
_now["n2_charge"] == PINNED["n2_charge"]
|
||||
and _now["n2_charge_banked"] == PINNED["n2_charge_banked"])
|
||||
_n1 = s84.run(1, dur=600.0)[0]
|
||||
check("...and no N=1 cycle files a `charge` claim",
|
||||
not any(charge_claims(r) for r in _n1))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-84 checks passed")
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Runnable check for SAFETY-85: no false 'not keeping up' while another owner
|
||||
sets the battery. `python3 test_safety85.py`
|
||||
|
||||
The loop's integrator bound fired every ~2.5 min during maintenance and under
|
||||
the peak guard, though the battery gave what it was told: the loop's own ask,
|
||||
re-seeded and pushed by a grid error it did not own, crossed its rail. Now the
|
||||
loop's clamp counts only on a cycle the loop decided, a battery another owner
|
||||
set alarms only when it gives less than told, and at N>=2 the loop is bounded
|
||||
while maintenance holds a battery. Drives the REAL Controller
|
||||
(tools/safety84_identity.py's harness). DECISIONS/SAFETY-85-no-false-not-keeping-up.md.
|
||||
|
||||
1. N=1 and N=2: a drain, a charge and a HOLD with the house above what the
|
||||
battery is set to, an hour each: no warning; without the fix they warn.
|
||||
2. A battery that gives less than told during a drain does alarm (N=1, N=2);
|
||||
a two-cycle dip does not.
|
||||
3. Control coming back from maintenance does not jump (N=2 hold end, N=2
|
||||
charge pin lifting, N=1 hold end).
|
||||
4. The loop's own alarm stays (house over the rating); a safety limit at N=2
|
||||
(a battery at its cut-out) does not warn.
|
||||
5. The status row counts episodes, not cycles.
|
||||
6. The rules at their boundaries.
|
||||
7. Review: a battery the peak guard sets that gives nothing warns; a battery
|
||||
that lags (10 s) and a charge that tapers do not.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
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)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import clamp, messages # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
A = "inverter-a"
|
||||
HOUR = 3600.0
|
||||
SLEW = 400.0 # the rig's slew_w
|
||||
OPT0 = dict(s84.OPT)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
async def _call(self, *a):
|
||||
"""The rig's Home Assistant, recording each notification id it is sent."""
|
||||
if len(a) >= 3 and a[1] == "create" and isinstance(a[2], dict):
|
||||
self.__dict__.setdefault("notes", []).append(
|
||||
a[2].get("notification_id"))
|
||||
return True
|
||||
|
||||
|
||||
rig._Hass.call = _call
|
||||
|
||||
|
||||
def store(n, phase):
|
||||
"""Battery A in `phase` from the first cycle ('drain': due at 11:00;
|
||||
'idle': only with `maint=False`)."""
|
||||
if phase == "drain":
|
||||
return rig._Store()
|
||||
if phase == "charge":
|
||||
return s79.store_charging(n)
|
||||
s = rig._Store()
|
||||
if phase == "hold":
|
||||
for k, v in (("phase", "hold"), ("phase_started",
|
||||
rig.BASE.isoformat())):
|
||||
s.data[k if n == 1 else unit_key(A, k)] = v
|
||||
return s
|
||||
|
||||
|
||||
def run(n, phase, house, cap, *, dur=HOUR, soc=None, cut=None, extra=None,
|
||||
maint=True):
|
||||
"""Rows and controller; `cap` is the peak limit (20000: never binds);
|
||||
`house` is watts or a function of time."""
|
||||
s84.OPT = dict(OPT0, peak_cap_w=cap, maintenance_enabled=maint)
|
||||
if soc is None:
|
||||
def soc(u, t):
|
||||
return 99.0 if phase == "hold" and u == "a" else 60.0
|
||||
try:
|
||||
load = house if callable(house) else (lambda t: house)
|
||||
return s84.run(n, load=load, cheap=(phase == "charge"),
|
||||
store=store(n, phase), soc=soc, dur=dur, cut=cut,
|
||||
extra=dict({"maintenance_hold_min": 600,
|
||||
"maintenance_discharge_w": 2000},
|
||||
**(extra or {})))
|
||||
finally:
|
||||
s84.OPT = OPT0
|
||||
|
||||
|
||||
def warned(c):
|
||||
"""'Battery is not keeping up': notifications, cycles, episodes."""
|
||||
return (getattr(c.hass, "notes", []).count(messages.ID_LOOP_BOUND),
|
||||
c.clamp_wd.bound, c.clamp_wd.bound_episodes)
|
||||
|
||||
|
||||
def patched(obj, name, value):
|
||||
"""Run `fn` with `obj.name` replaced (the 'without the fix' runs)."""
|
||||
def wrap(fn):
|
||||
real = getattr(obj, name)
|
||||
setattr(obj, name, value)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
setattr(obj, name, real)
|
||||
return wrap
|
||||
|
||||
|
||||
no_gate = patched(Controller, "_loop_alarm_input", lambda self, r: None)
|
||||
no_bound = patched(Controller, "loop_bound", lambda self, claims: None)
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def steps(rows, lo, hi):
|
||||
"""The largest change in the total command between cycles in [lo, hi]."""
|
||||
tot = [(r[0], sum(cmd(r).values())) for r in rows]
|
||||
return max(abs(b - a) for (t0, a), (t1, b) in zip(tot, tot[1:])
|
||||
if lo <= t1 <= hi)
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. the house above what the battery is set to, an hour each")
|
||||
CASES = [
|
||||
# (n, phase, house, cap): the peak guard raises the drain / the HOLD at a
|
||||
# 3500 W limit; the rest are the phase alone under a house that pushes the
|
||||
# loop's re-seeded ask past its rail.
|
||||
(1, "drain", 6500.0, 3500), (1, "drain", 7500.0, 20000),
|
||||
(1, "charge", 10000.0, 20000),
|
||||
(1, "hold", 6500.0, 3500), (1, "hold", 9000.0, 20000),
|
||||
(2, "drain", 6500.0, 3500), (2, "charge", 10000.0, 20000),
|
||||
(2, "hold", 4500.0, 20000), (2, "hold", 6500.0, 3500),
|
||||
]
|
||||
NEW, OLD = {}, {}
|
||||
for k in CASES:
|
||||
NEW[k] = run(*k)
|
||||
OLD[k] = no_gate(lambda: no_bound(lambda: run(*k)))
|
||||
print(f" N={k[0]} {k[1]:6} house {k[2]:5.0f} limit {k[3]:5}: phases "
|
||||
f"{sorted({p for r in NEW[k][0] for p in r[-1][3]})}, warnings "
|
||||
f"(notes, cycles, episodes) {warned(NEW[k][1])}, without the fix "
|
||||
f"{warned(OLD[k][1])}")
|
||||
check("⭐ no 'Battery is not keeping up' notification, cycle or episode in any "
|
||||
"of the nine hours, at N=1 and N=2",
|
||||
all(warned(c) == (0, 0, 0) for _r, c in NEW.values()))
|
||||
check("...the phase held all hour (the battery was set by maintenance, or by "
|
||||
"the guard over it, throughout)",
|
||||
all(all(r[-1][3][0] == k[1] for r in NEW[k][0]) for k in CASES))
|
||||
check("non-vacuous: without the fix every one of the nine warns, the "
|
||||
"notification re-created on the re-assert cadence",
|
||||
all(warned(c)[0] >= 1 and warned(c)[1] > 0 for _r, c in OLD.values())
|
||||
and sum(warned(c)[0] for _r, c in OLD.values()) >= 9 * 5)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a battery that really gives less than told still alarms")
|
||||
|
||||
|
||||
def gone(t0, t1):
|
||||
"""Battery A gives nothing from t0 to t1."""
|
||||
return lambda u, t: u == "a" and t0 <= t < t1
|
||||
|
||||
|
||||
SHORT = {n: run(n, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200))
|
||||
for n in (1, 2)}
|
||||
for n, (rows, c) in SHORT.items():
|
||||
print(f" N={n}: {warned(c)}, log "
|
||||
f"{[e for e in c.events if 'OUTPUT_CLAMP' in e][:1]}")
|
||||
_r1, _c1 = SHORT[1]
|
||||
check("⭐ N=1, a drain of 2000 W that gives nothing: it warns, and the log "
|
||||
"says what was asked and what came",
|
||||
warned(_c1)[0] >= 1 and any(
|
||||
"the battery gives -4 W of the +2000 W asked" in e
|
||||
for e in _c1.events))
|
||||
_r2, _c2 = SHORT[2]
|
||||
check("⭐ N=2, the same on battery A while B follows the house: it warns and "
|
||||
"names inverter-a",
|
||||
warned(_c2)[0] >= 1 and any(
|
||||
"inverter-a gives -4 W of the +2000 W asked" in e
|
||||
for e in _c2.events))
|
||||
_hb = run(2, "hold", 4500.0, 20000, dur=1200.0,
|
||||
cut=lambda u, t: u == "b" and t >= 600)
|
||||
print(f" N=2 HOLD on A, B gives nothing: {warned(_hb[1])}")
|
||||
check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the "
|
||||
"loop stands at what B may give, and it warns naming inverter-b",
|
||||
warned(_hb[1])[0] >= 1 and any(
|
||||
"inverter-b gives -4 W of the +2500 W asked" in e
|
||||
for e in _hb[1].events))
|
||||
check("...it starts no earlier than the saturation cycles allow (3 x 5 s)",
|
||||
all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2)
|
||||
for e in c.events if "OUTPUT_CLAMP" in e))
|
||||
_quiet = patched(app_main, "delivery_short", lambda *a: False)(
|
||||
lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200)))
|
||||
check("non-vacuous: without the delivery rule the N=1 drain that gives "
|
||||
"nothing is silent (the loop's clamp never reaches its rail here)",
|
||||
warned(_quiet[1]) == (0, 0, 0))
|
||||
_dip = run(1, "drain", 1000.0, 20000, dur=900.0, cut=gone(600, 610))
|
||||
check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. control coming back from maintenance does not jump")
|
||||
END = {"maintenance_hold_min": 10}
|
||||
_bounds = []
|
||||
_real_compute = app_main.compute
|
||||
|
||||
|
||||
def _spy_compute(*a, **kw):
|
||||
_bounds.append(kw.get("bound"))
|
||||
return _real_compute(*a, **kw)
|
||||
|
||||
|
||||
app_main.compute = _spy_compute
|
||||
try:
|
||||
_h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
||||
finally:
|
||||
app_main.compute = _real_compute
|
||||
_h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0,
|
||||
extra=END))
|
||||
_h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
||||
s84.OPT = dict(OPT0)
|
||||
_cd = s84.run(2, cheap=False)
|
||||
_cd_old = no_bound(lambda: s84.run(2, cheap=False))
|
||||
print(f" N=2 hold end: largest step {steps(_h2[0], 560, 700):.0f} W "
|
||||
f"(without the bound {steps(_h2_old[0], 560, 700):.0f} W); N=1 "
|
||||
f"{steps(_h1[0], 560, 700):.0f} W; N=2 charge pin lifts at 1800 s: "
|
||||
f"{steps(_cd[0], 1780, 1840):.0f} W (without "
|
||||
f"{steps(_cd_old[0], 1780, 1840):.0f} W)")
|
||||
check("⭐ N=2, the HOLD ends under a 4500 W house: the batteries move at most "
|
||||
"one slew step a cycle",
|
||||
_h2[1].maints[0].phase != "hold" and steps(_h2[0], 560, 700) <= SLEW)
|
||||
_held = [any(p != "idle" for p in r[-1][3]) for r in _h2[0]]
|
||||
print(f" bound per cycle: {len(_bounds)} calls, {len(_held)} rows, "
|
||||
f"{sum(_held)} in maintenance, "
|
||||
f"{sum(b is not None for b in _bounds)} bounded")
|
||||
check("...and the bound is on while A holds and off from the cycle the HOLD "
|
||||
"ends (review)",
|
||||
len(_bounds) == len(_held) and any(not h for h in _held)
|
||||
and all((b is not None) == h for b, h in zip(_bounds, _held)))
|
||||
check("⭐ N=2, A's charge pin lifts at a quarter's start: at most one slew "
|
||||
"step a cycle", steps(_cd[0], 1780, 1840) <= SLEW)
|
||||
check("...and N=1 already moved one slew step at a time (re-seeded each "
|
||||
"cycle), unchanged", steps(_h1[0], 560, 700) <= SLEW)
|
||||
check("non-vacuous: without the bound the N=2 loop stands wound at its rail "
|
||||
"and both jump (2500 W at the hold's end)",
|
||||
steps(_h2_old[0], 560, 700) >= 2000.0
|
||||
and steps(_cd_old[0], 1780, 1840) >= 1000.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. what still warns, and what a safety limit does")
|
||||
_rail = run(1, "drain", 6500.0, 20000, dur=600.0, maint=False)
|
||||
print(f" N=1 no maintenance, house 6500 over a 5000 W rating: "
|
||||
f"{warned(_rail[1])}")
|
||||
check("⭐ the loop in charge at its rail (house over the rating) still warns, "
|
||||
"as SAFETY-40 decided", warned(_rail[1])[0] >= 1)
|
||||
|
||||
|
||||
def at_cutout(u, t):
|
||||
return 9.0 if u == "a" else 60.0
|
||||
|
||||
|
||||
_cut = run(2, "idle", 6000.0, 20000, dur=900.0, soc=at_cutout, maint=False)
|
||||
_cut_old = no_gate(lambda: run(2, "idle", 6000.0, 20000, dur=900.0,
|
||||
soc=at_cutout, maint=False))
|
||||
print(f" N=2, A at its cut-out, house 6000: {warned(_cut[1])}, without "
|
||||
f"the fix {warned(_cut_old[1])}")
|
||||
check("⭐ N=2, A at its cut-out level (a safety limit sets it to 0 W) and B "
|
||||
"at its rating: no warning, each battery gives what it is told",
|
||||
warned(_cut[1]) == (0, 0, 0) and cmd(_cut[0][-1]) == {"a": 0.0,
|
||||
"b": 2500.0})
|
||||
check("non-vacuous: without the fix it warns",
|
||||
warned(_cut_old[1])[0] >= 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the status row counts episodes")
|
||||
_wd = clamp.ClampWatchdog()
|
||||
_t = datetime(2026, 9, 20, 11, 0, tzinfo=timezone.utc)
|
||||
for ep in range(3):
|
||||
for i in range(10):
|
||||
_wd.note(True, "x", _t, bug=False)
|
||||
_t += timedelta(seconds=5)
|
||||
for i in range(clamp.CLEAR_CYCLES):
|
||||
_wd.note(False, "", _t)
|
||||
_t += timedelta(seconds=5)
|
||||
_wd.note(True, "y", _t, bug=True)
|
||||
check("three bound episodes of ten cycles and one over-rating: 3 episodes, "
|
||||
"30 cycles", (_wd.bound_episodes, _wd.bound, _wd.episodes) == (3, 30, 4))
|
||||
_rows = [x for x in _c1.checks() if x.get("id") == "not_keeping_up"]
|
||||
check("⭐ the page row reads the episodes, not the cycles "
|
||||
f"({_c1.clamp_wd.bound_episodes} vs {_c1.clamp_wd.bound})",
|
||||
_rows and _c1.clamp_wd.bound > _c1.clamp_wd.bound_episodes >= 1
|
||||
and _rows[0]["text"].startswith(messages.T_LOOP_BOUND + ": "
|
||||
+ messages.times(
|
||||
_c1.clamp_wd.bound_episodes)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the rules at their boundaries")
|
||||
DS = [((2000, 1499, 500), True), ((2000, 1500, 500), False),
|
||||
((2000, 2600, 500), False), ((2000, -4, 500), True),
|
||||
((0, -900, 500), False), ((0, 900, 500), False),
|
||||
((-2000, -1499, 500), False), ((-2000, 600, 500), False)]
|
||||
check("delivery_short: a discharge more than the margin short; more than "
|
||||
"asked, 0 W asked, or a charge (a full pack tapers) is never short",
|
||||
all(clamp.delivery_short(*a) is want for a, want in DS))
|
||||
check("the note names each battery, asked before given",
|
||||
clamp.shortfall_note([("inverter-a", 2000.0, -4.0)])
|
||||
== "OUTPUT_CLAMP: inverter-a gives -4 W of the +2000 W asked"
|
||||
and clamp.shortfall_note([(None, -2000.0, 0.0)])
|
||||
== "OUTPUT_CLAMP: the battery gives +0 W of the -2000 W asked")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. review: the peak guard, a battery that lags, a charge that tapers")
|
||||
import math # noqa: E402
|
||||
|
||||
|
||||
def under_guard(n, cut):
|
||||
"""A HOLD the peak guard raises (house 6500, limit 3500), 20 min."""
|
||||
return run(n, "hold", 6500.0, 3500, dur=1200.0, cut=cut)
|
||||
|
||||
|
||||
_g1 = under_guard(1, lambda u, t: t >= 600)
|
||||
_g2 = under_guard(2, lambda u, t: u == "a" and t >= 600)
|
||||
print(f" under the guard, the battery gives nothing: N=1 {warned(_g1[1])}, "
|
||||
f"N=2 {warned(_g2[1])}")
|
||||
check("⭐ the peak guard sets the battery and it gives nothing: it warns at "
|
||||
"N=1, and at N=2 (A, raised for the peak, gives nothing; B gives all "
|
||||
"it is told)",
|
||||
warned(_g1[1])[0] >= 1 and warned(_g2[1])[0] >= 1
|
||||
and all("peak" in r[6] for rows, _c in (_g1, _g2) for r in rows
|
||||
if 600 <= r[0] <= 620))
|
||||
check("...and it gives what it is told until then (no warning before 600 s)",
|
||||
all(e.split(" ", 1)[0] >= "11:10:00" for c in (_g1[1], _g2[1])
|
||||
for e in c.events if "OUTPUT_CLAMP" in e))
|
||||
|
||||
|
||||
def lagging(fn, lag=10.0):
|
||||
"""Run `fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag)
|
||||
at the live 2 s meter tick."""
|
||||
real_p, real_t = rig._Hass.power, rig.TICK
|
||||
|
||||
def power(self, u):
|
||||
st = self.__dict__.setdefault("_plant", {})
|
||||
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
||||
for _ in range(int(round((self.t - tl) / 0.1))):
|
||||
d = self.cmd[u] - tgt
|
||||
tgt += math.copysign(min(abs(d), 80.0), d)
|
||||
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
|
||||
st[u] = (a, tgt, self.t)
|
||||
if self.cut(u, self.t) and self.cmd[u] >= -4.0:
|
||||
return -4.0
|
||||
return float(round(a))
|
||||
rig._Hass.power, rig.TICK = power, 2.0
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_p, real_t
|
||||
|
||||
|
||||
def kettle(t):
|
||||
"""800 W, plus 2 kW 3 min in 10, plus 3 kW 1 min in 4."""
|
||||
return (800.0 + (2000.0 if t % 600 < 180 else 0.0)
|
||||
+ (3000.0 if t % 240 < 60 else 0.0))
|
||||
|
||||
|
||||
def lag_runs():
|
||||
return [run(n, ph, kettle, 3500, dur=1800.0)
|
||||
for n, ph in ((1, "drain"), (2, "drain"), (2, "hold"))]
|
||||
|
||||
|
||||
_lag = lagging(lag_runs)
|
||||
_lag_old = patched(app_main, "SHORT_CYCLES", 1)(
|
||||
lambda: patched(app_main, "SHORT_SPAN_S", 0.0)(
|
||||
lambda: lagging(lag_runs)))
|
||||
_lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0,
|
||||
cut=gone(600, 900)))
|
||||
print(f" lagging battery, house steps under the guard: "
|
||||
f"{[warned(c) for _r, c in _lag]}, graded on last cycle's command "
|
||||
f"{[warned(c) for _r, c in _lag_old]}; gives nothing "
|
||||
f"{warned(_lag_dead[1])}")
|
||||
check("⭐ a battery that lags (10 s) behind maintenance and peak-guard steps "
|
||||
"at a 2 s meter does not warn (N=1 drain, N=2 drain and HOLD, 30 min)",
|
||||
all(warned(c) == (0, 0, 0) for _r, c in _lag))
|
||||
check("non-vacuous: graded on last cycle's command alone, it warns",
|
||||
sum(warned(c)[0] for _r, c in _lag_old) >= 3)
|
||||
check("...and a lagging battery that gives nothing still warns",
|
||||
warned(_lag_dead[1])[0] >= 1)
|
||||
|
||||
|
||||
def taper(fn):
|
||||
"""Battery A takes the full charge until 600 s, then its acceptance falls
|
||||
to 150 W over 30 min (constant-voltage taper)."""
|
||||
real_p = rig._Hass.power
|
||||
|
||||
def power(self, u):
|
||||
c = self.cmd[u]
|
||||
if c < 0 and u == "a":
|
||||
c = max(c, -max(150.0, 2500.0 - max(0.0, self.t - 600) / 1800
|
||||
* 2350.0))
|
||||
return float(round(c))
|
||||
rig._Hass.power = power
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig._Hass.power = real_p
|
||||
|
||||
|
||||
def charge_runs():
|
||||
s84.OPT = dict(OPT0, peak_cap_w=20000)
|
||||
try:
|
||||
return [s84.run(n, load=lambda t: 800.0, cheap=True,
|
||||
store=s79.store_charging(n), soc=lambda u, t: 60.0,
|
||||
dur=2400.0) for n in (1, 2)]
|
||||
finally:
|
||||
s84.OPT = OPT0
|
||||
|
||||
|
||||
def both_ways(asked, given, margin):
|
||||
asked, given = float(asked), float(given)
|
||||
return asked != 0.0 and (asked - given) * math.copysign(1.0, asked) > margin
|
||||
|
||||
|
||||
_tp = taper(charge_runs)
|
||||
_tp_old = patched(app_main, "delivery_short", both_ways)(
|
||||
lambda: taper(charge_runs))
|
||||
print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading "
|
||||
f"charges too {[warned(c) for _r, c in _tp_old]}")
|
||||
check("⭐ a maintenance charge that tapers near full does not warn (N=1, N=2)",
|
||||
all(warned(c) == (0, 0, 0) for _r, c in _tp)
|
||||
and all("charge" in {p for r in rows for p in r[-1][3]}
|
||||
for rows, _c in _tp))
|
||||
check("non-vacuous: grading charges too, the taper warns",
|
||||
all(warned(c)[0] >= 3 for _r, c in _tp_old))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-85 checks passed")
|
||||
@@ -0,0 +1,442 @@
|
||||
"""Runnable check for SAFETY-86: the peak guard outranks the maintenance HOLD.
|
||||
`python3 test_safety86.py`
|
||||
|
||||
During the HOLD the battery rests full at 0 W (P_MAINTENANCE, above the peak
|
||||
floor), so the house imported everything and quarters went over the peak
|
||||
limit. Now the holding battery gives what keeps the quarter at the aim: at N=1
|
||||
the peak floor is refiled at P_MAINTENANCE, at N>=2 the hold's pin is raised
|
||||
to what the other batteries cannot give. Drives the REAL Controller
|
||||
(tools/safety84_identity.py's harness). DECISIONS/SAFETY-86-peak-over-hold.md.
|
||||
|
||||
1. N=1, house over the aim: every quarter lands on the aim, the hold goes on,
|
||||
no 'Load too high'; without the fix the quarter goes over and it fires.
|
||||
2. Under the aim nothing changes, at N=1 and N=2 (rows identical).
|
||||
3. N=2, house over the aim: the other battery gives first, the holding one
|
||||
only the shortfall; with battery modes on and B Off too.
|
||||
4. A month peak banked above the limit: the aim is that peak - margin.
|
||||
5. A safety stop still parks everything, and the hold gives again after.
|
||||
6. The level dips below the target: the hold still ends on its clock.
|
||||
7. The rule at its boundaries, on claim lists.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
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 safety84_identity as s84 # noqa: E402
|
||||
from app import control as _ctl # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.arbiter import (Claim, P_MAINTENANCE, P_PEAK, P_SAFETY, # noqa
|
||||
resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import (CHARGE, DRAIN, HOLD, IDLE, # noqa: E402
|
||||
MaintResult)
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
|
||||
TOL = 10.0 # W over the aim a banked quarter may show (SAFETY-81)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def store_hold(n, b_off=False):
|
||||
"""A store with battery A in its HOLD from the first cycle."""
|
||||
s = rig._Store()
|
||||
for k in ("phase", "phase_started"):
|
||||
s.data[k if n == 1 else unit_key(A, k)] = (
|
||||
"hold" if k == "phase" else rig.BASE.isoformat())
|
||||
if b_off:
|
||||
s.data[unit_key(B, packmode.USE_KEY)] = False
|
||||
return s
|
||||
|
||||
|
||||
def full(u, t):
|
||||
"""A rests full; B is half full."""
|
||||
return 99.0 if u == "a" else 60.0
|
||||
|
||||
|
||||
def run(house, n, *, hold_min=600, b_off=False, extra=None, **kw):
|
||||
kw.setdefault("soc", full)
|
||||
kw.setdefault("dur", 2700.0)
|
||||
return s84.run(n, load=house if callable(house) else lambda t: house,
|
||||
cheap=False, store=store_hold(n, b_off),
|
||||
extra=dict({"maintenance_hold_min": hold_min},
|
||||
**(extra or {})), **kw)
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
real = Controller._peak_over_hold
|
||||
Controller._peak_over_hold = lambda self, claims, results: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_over_hold = real
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def banked(c):
|
||||
return [b.offtake_avg_w for b in c.p1.blocks]
|
||||
|
||||
|
||||
def on_aim(c, aim=AIM):
|
||||
b = banked(c)
|
||||
return len(b) >= 2 and all(aim - TOL <= v <= aim + TOL for v in b)
|
||||
|
||||
|
||||
def alarmed(c):
|
||||
"""Did SAFETY-81's 'Load too high' alarm fire on this run? Its quarter
|
||||
stamp too, since ALLOCATOR rows can push its row out of the 40-row log
|
||||
(SAFETY-85)."""
|
||||
return c._peak_alarm_block is not None or any(
|
||||
"PEAK_GUARD_SATURATED" in e for e in c.events)
|
||||
|
||||
|
||||
def refused(rows):
|
||||
return sum(1 for r in rows if r[7] and "REFUSED" in r[7])
|
||||
|
||||
|
||||
def peak_prio(r):
|
||||
return [c[1] for c in r[-1][0] if c[0] == "peak"]
|
||||
|
||||
|
||||
def hold_pin(r):
|
||||
return [c[3] for c in r[-1][0] if c[0] == "maintenance" and c[2] == "set"]
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. N=1: a house over the aim during the HOLD")
|
||||
N1 = {h: run(h, 1) for h in (5000.0, 6500.0, 7000.0)}
|
||||
N1_OLD = {h: without_fix(lambda: run(h, 1)) for h in N1}
|
||||
for h in N1:
|
||||
print(f" house {h:.0f}: banked {[round(v) for v in banked(N1[h][1])]} W"
|
||||
f" (before the fix {[round(v) for v in banked(N1_OLD[h][1])]} W), "
|
||||
f"last {N1[h][0][-1][6]}")
|
||||
check("⭐ every quarter lands on the aim (3450 W) for a 5000, 6500 and "
|
||||
"7000 W house, and 'Load too high' never fires",
|
||||
all(on_aim(c) and not alarmed(c) for _r, c in N1.values()))
|
||||
check("...the battery gives, raised for the peak limit, and stays in its "
|
||||
"HOLD: the phase is hold and the claim still says so",
|
||||
all(c.maints[0].phase == HOLD and cmd(rows[-1])["a"] > 1000.0
|
||||
and "limited by peak" in rows[-1][6]
|
||||
and c.why_words.endswith("raised for the peak limit")
|
||||
for rows, c in N1.values()))
|
||||
check("non-vacuous: without the fix the battery sits at 0 W, the quarter "
|
||||
"banks the whole house and 'Load too high' fires",
|
||||
all(all(cmd(r)["a"] == 0.0 for r in rows) and min(banked(c)) >= h - 1
|
||||
and alarmed(c) for h, (rows, c) in N1_OLD.items()))
|
||||
|
||||
|
||||
def hob(t):
|
||||
"""A 6000 W house with a 5 s spike to 11000 W every 150 s."""
|
||||
return 11000.0 if t % 150 < 5 else 6000.0
|
||||
|
||||
|
||||
_sp_rows, _sp = run(hob, 1)
|
||||
check("...and 5 s spikes that saturate the guard for a cycle do not fire it "
|
||||
"(SAFETY-81's 10 s hold)",
|
||||
any(r[-1][1] and r[-1][1][1] >= 5000.0 for r in _sp_rows)
|
||||
and not alarmed(_sp))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. under the aim the HOLD is as before")
|
||||
BELOW = {(n, h): (run(h, n, dur=1800.0),
|
||||
without_fix(lambda: run(h, n, dur=1800.0)))
|
||||
for n, h in ((1, 1200.0), (1, 3000.0), (1, -1500.0),
|
||||
(2, 1500.0), (2, 3000.0), (2, 5000.0))}
|
||||
check("⭐ N=1 at a 1200 W and 3000 W house and a 1500 W surplus, N=2 at "
|
||||
"1500, 3000 and 5000 W (B covers it): battery A is at 0 W on every "
|
||||
"cycle and every row equals the run without the fix",
|
||||
all(all(cmd(r)["a"] == 0.0 for r in new[0]) and new[0] == old[0]
|
||||
for new, old in BELOW.values()))
|
||||
check("...and the peak claim stays at P_PEAK on every cycle",
|
||||
all(p == P_PEAK for new, _o in BELOW.values() for r in new[0]
|
||||
for p in peak_prio(r)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. N=2: the other battery gives first, the holding one the rest")
|
||||
N2 = {h: run(h, 2) for h in (6500.0, 7000.0)}
|
||||
N2_OLD = {h: without_fix(lambda: run(h, 2)) for h in N2}
|
||||
for h, (rows, c) in N2.items():
|
||||
print(f" house {h:.0f}: banked {[round(v) for v in banked(c)]} W "
|
||||
f"(before the fix {[round(v) for v in banked(N2_OLD[h][1])]} W), "
|
||||
f"last {rows[-1][2]}")
|
||||
check("⭐ house 6500 and 7000 W, B at most 2500 W: every quarter lands on "
|
||||
"the aim, nothing is refused or split both ways, no 'Load too high'",
|
||||
all(on_aim(c) and refused(rows) == 0 and not alarmed(c)
|
||||
and all(cmd(r)["a"] * cmd(r)["b"] >= 0.0 for r in rows)
|
||||
for rows, c in N2.values()))
|
||||
_gives = [r for rows, _c in N2.values() for r in rows if cmd(r)["a"] > 0.0]
|
||||
check("⭐ A gives only the shortfall: on every cycle A gives, B is at its "
|
||||
"2500 W and A's pin is the guard's floor minus 2500 W",
|
||||
len(_gives) > 500
|
||||
and all(cmd(r)["b"] == 2500.0 and hold_pin(r)
|
||||
and abs(hold_pin(r)[0] - (r[-1][1][1] - 2500.0)) < 1e-6
|
||||
for r in _gives))
|
||||
check("...A stays in its HOLD, and its last command is the shortfall "
|
||||
"(6500 - 3450 - 2500 = 550 W, give or take the meter's 1 W)",
|
||||
all(c.maints[0].phase == HOLD for _r, c in N2.values())
|
||||
and 540.0 <= cmd(N2[6500.0][0][-1])["a"] <= 560.0)
|
||||
check("non-vacuous: without the fix A sits at 0 W, the quarter goes over "
|
||||
"the limit and 'Load too high' fires",
|
||||
all(all(cmd(r)["a"] == 0.0 for r in rows) and max(banked(c)) > 3900.0
|
||||
and alarmed(c) for rows, c in N2_OLD.values()))
|
||||
|
||||
_off_rows, _off = run(6500.0, 2, b_off=True,
|
||||
extra={"battery_modes_enabled": True})
|
||||
print(f" B Off, house 6500: banked {[round(v) for v in banked(_off)]} W, "
|
||||
f"last {_off_rows[-1][2]}")
|
||||
check("⭐ battery modes on, B Off: the Off battery still gives before the "
|
||||
"holding one (maintenance outranks a mode), the quarter lands on the "
|
||||
"aim, nothing refused",
|
||||
on_aim(_off) and cmd(_off_rows[-1])["b"] == 2500.0
|
||||
and 540.0 <= cmd(_off_rows[-1])["a"] <= 560.0
|
||||
and refused(_off_rows) == 0 and not alarmed(_off))
|
||||
|
||||
|
||||
|
||||
def noisy(t):
|
||||
"""A 6500 W house that moves up to 150 W from one second to the next."""
|
||||
return 6500.0 + ((int(t) * 7919) % 301) - 150.0
|
||||
|
||||
|
||||
def no_ceiling(fn):
|
||||
"""Run with the pin raised but no `hold` site ceiling (review check)."""
|
||||
real = Controller._peak_over_hold
|
||||
|
||||
def pin_only(self, claims, results):
|
||||
real(self, claims, results)
|
||||
Controller._peak_over_hold = pin_only
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_over_hold = real
|
||||
|
||||
|
||||
def rows_logged(c, word):
|
||||
return sum(1 for e in c.events if word in e)
|
||||
|
||||
|
||||
def no_bound(fn):
|
||||
"""Run without SAFETY-85's loop bound (modes are off here)."""
|
||||
real = Controller.loop_bound
|
||||
Controller.loop_bound = lambda self, claims: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller.loop_bound = real
|
||||
|
||||
|
||||
_nz_rows, _nz = run(noisy, 2)
|
||||
# SAFETY-85's loop bound also keeps the loop inside what the batteries can
|
||||
# give, so the flood needs both the ceiling and the bound taken out.
|
||||
_nz_old = no_bound(lambda: no_ceiling(lambda: run(noisy, 2)))[1]
|
||||
_nz_nc = no_ceiling(lambda: run(noisy, 2))[1]
|
||||
print(f" noisy house: ALLOCATOR rows {rows_logged(_nz, 'ALLOCATOR')}, "
|
||||
f"clamp rows {rows_logged(_nz, 'OUTPUT_CLAMP')} (without the site "
|
||||
f"ceiling {rows_logged(_nz_old, 'ALLOCATOR')} and "
|
||||
f"{rows_logged(_nz_old, 'OUTPUT_CLAMP')})")
|
||||
check("⭐ while A gives, the site target is the guard's floor (the `hold` "
|
||||
"ceiling), so a noisy house does not fill the action log: no ALLOCATOR "
|
||||
"row and at most one clamp row",
|
||||
on_aim(_nz) and all(abs(r[1] - r[-1][1][1]) < 1e-6 for r in _nz_rows
|
||||
if cmd(r)["a"] > 0.0)
|
||||
and rows_logged(_nz, "ALLOCATOR") == 0
|
||||
and rows_logged(_nz, "OUTPUT_CLAMP") <= 1)
|
||||
check("non-vacuous: without the ceiling and SAFETY-85's bound the loop asks "
|
||||
"5000 W and the ALLOCATOR and clamp rows fill the 40-row log",
|
||||
rows_logged(_nz_old, "ALLOCATOR") >= 10 and len(_nz_old.events) == 40)
|
||||
check("...and either one alone keeps it clean (without the ceiling: "
|
||||
f"{rows_logged(_nz_nc, 'ALLOCATOR')} ALLOCATOR rows)",
|
||||
on_aim(_nz_nc) and rows_logged(_nz_nc, "ALLOCATOR") == 0
|
||||
and rows_logged(_nz_nc, "OUTPUT_CLAMP") <= 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. a month peak banked above the limit")
|
||||
BANK = {n: (run(6500.0, n, month_peak=4000.0),
|
||||
without_fix(lambda: run(6500.0, n, month_peak=4000.0)))
|
||||
for n in (1, 2)}
|
||||
for n, (new, old) in BANK.items():
|
||||
print(f" N={n} house 6500, month peak 4000: banked "
|
||||
f"{[round(v) for v in banked(new[1])]} W (before the fix "
|
||||
f"{[round(v) for v in banked(old[1])]} W)")
|
||||
check("⭐ under a month peak of 4000 W the aim is 3950 W (peak - margin), "
|
||||
"at N=1 and N=2, and no 'Load too high'",
|
||||
all(on_aim(new[1], 3950.0) and not alarmed(new[1])
|
||||
for new, _old in BANK.values()))
|
||||
check("non-vacuous: before the fix N=1 banks the whole house and N=2 banks "
|
||||
"4000 W, over that aim",
|
||||
min(banked(BANK[1][1][1])) >= 6499.0
|
||||
and min(banked(BANK[2][1][1])) >= 3999.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. a safety stop still wins")
|
||||
|
||||
|
||||
def blind_at(t0, t1):
|
||||
"""A's power reading is missing from `t0` to `t1`: a site safety SET."""
|
||||
def before(c, t):
|
||||
s84.feed(False)(c, t)
|
||||
if not getattr(c.hass, "_blind", False):
|
||||
real = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.batt_a" and t0 <= c.hass.t < t1:
|
||||
return None
|
||||
return await real(e, invert)
|
||||
c.hass.number, c.hass._blind = number, True
|
||||
return before
|
||||
|
||||
|
||||
STOP = {n: run(6500.0, n, dur=900.0, before=blind_at(300.0, 600.0))
|
||||
for n in (1, 2)}
|
||||
for n, (rows, _c) in STOP.items():
|
||||
print(f" N={n} reading lost 300-600 s: at {rows[100][0]:.0f} s "
|
||||
f"{rows[100][2]}, {rows[100][6]}; after {rows[-1][2]}")
|
||||
check("⭐ with a reading gone the safety SET parks every battery at 0 W, at "
|
||||
"N=1 and N=2, with nothing refused",
|
||||
all(all(v == 0.0 for r in rows if 330.0 <= r[0] < 600.0
|
||||
for v in cmd(r).values())
|
||||
and any(r[6].startswith("safety -> 0 W") for r in rows)
|
||||
and refused(rows) == 0 for rows, _c in STOP.values()))
|
||||
check("...the hold gave before the stop and gives again after it",
|
||||
all(cmd(rows[50])["a"] > 0.0 and cmd(rows[-1])["a"] > 0.0
|
||||
for rows, _c in STOP.values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the level dips below the hold's target")
|
||||
|
||||
|
||||
def draining(u, t):
|
||||
"""A's level falls 1 % a minute from 99 % (set, not integrated); B 60 %."""
|
||||
return max(20.0, 99.0 - t / 60.0) if u == "a" else 60.0
|
||||
|
||||
|
||||
DIP = {n: run(6500.0, n, hold_min=30, dur=2400.0, soc=draining)
|
||||
for n in (1, 2)}
|
||||
for n, (rows, c) in DIP.items():
|
||||
print(f" N={n}: phases {sorted({tuple(x[-1][3]) for x in rows})}, "
|
||||
f"last event {[e for e in c.events if 'maintenance' in e][:1]}")
|
||||
check("⭐ the level falls from 99 % to 70 % while the hold gives, and "
|
||||
"the hold still ends on its 30 min clock: HOLD -> IDLE, cycle "
|
||||
"COMPLETE, never back to CHARGE, at N=1 and N=2",
|
||||
all(c.maints[0].phase == IDLE
|
||||
and any("cycle COMPLETE" in e for e in c.events)
|
||||
and {x[-1][3][0] for x in rows} == {HOLD, IDLE}
|
||||
and all(x[-1][3][0] == HOLD for x in rows if x[0] < 1795.0)
|
||||
for rows, c in DIP.values()))
|
||||
check("...the hold gave on the way (the level really dipped)",
|
||||
all(any(cmd(r)["a"] > 0.0 for r in rows if r[0] < 1795.0)
|
||||
for rows, _c in DIP.values()))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. the rule at its boundaries")
|
||||
_c = run(1500.0, 2, dur=10.0)[1]
|
||||
_c1 = run(1500.0, 1, dur=10.0)[1]
|
||||
HOLDING = [MaintResult(HOLD, True, 0.0, False, []),
|
||||
MaintResult(IDLE, False, None, False, [])]
|
||||
|
||||
|
||||
def pin_after(floor, *extra, live=(A, B), results=HOLDING, c=_c):
|
||||
fake = types.SimpleNamespace(inverters=c.inverters,
|
||||
actuator_of=c.actuator_of,
|
||||
live_units=lambda: tuple(live),
|
||||
_peak_reserve=frozenset)
|
||||
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A),
|
||||
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B),
|
||||
Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator=A),
|
||||
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
|
||||
*extra]
|
||||
ceiling = Controller._peak_over_hold(fake, cl, results)
|
||||
pin_after.ceiling = ceiling
|
||||
return next((x.value for x in cl
|
||||
if x.source == "maintenance" and x.kind == "set"), None), cl
|
||||
|
||||
|
||||
check("⭐ at N=2 a floor under or at B's 2500 W leaves A at 0 W (never "
|
||||
"charging); 0.5 W above it raises A's pin to 0.5 W",
|
||||
pin_after(1000.0)[0] == 0.0 and pin_after(2500.0)[0] == 0.0
|
||||
and pin_after(2500.5)[0] == 0.5)
|
||||
check("⭐ B not live, contradicting itself, or refusing to give: A gives the "
|
||||
"whole floor",
|
||||
pin_after(2000.0, live=(A,))[0] == 2000.0
|
||||
and pin_after(2000.0, Claim.limit("pack", P_SAFETY, 10.0, -10.0,
|
||||
actuator=B))[0] == 2000.0
|
||||
and pin_after(2000.0, Claim.limit("refused", P_SAFETY, hi=0.0,
|
||||
actuator=B))[0] == 2000.0)
|
||||
_probe = _ctl.PROBE_W
|
||||
check("⭐ B probing: giving counts its probe (A gives the rest), charging "
|
||||
"leaves A at 0 W so the site is never split both ways",
|
||||
pin_after(2000.0, Claim.set("refused", P_SAFETY, _probe, "probe",
|
||||
actuator=B))[0] == 2000.0 - _probe
|
||||
and pin_after(2000.0, Claim.set("refused", P_SAFETY, -_probe, "probe",
|
||||
actuator=B))[0] == 0.0)
|
||||
_p, _cl = pin_after(4000.0, Claim.set("safety", P_SAFETY, 0.0,
|
||||
"inputs missing"))
|
||||
check("⭐ a site safety SET: A stays at 0 W and the site resolves to 0 W; "
|
||||
"a unit safety SET on B does not stop A giving",
|
||||
_p == 0.0 and resolve(_cl, units=(A, B), live={A, B}).target_w == 0.0
|
||||
and pin_after(3000.0, Claim.set("failsafe", P_SAFETY, 0.0,
|
||||
actuator=B))[0] == 3000.0)
|
||||
_p, _cl = pin_after(2600.0)
|
||||
_hi = pin_after.ceiling
|
||||
_cl += [Claim.limit("hold", P_MAINTENANCE, hi=_hi, actuator="site"),
|
||||
Claim.set("loop", 10, 5000.0, "the loop asks for the whole house")]
|
||||
pin_after(2500.0)
|
||||
check("⭐ at N=2 the `hold` site ceiling is the floor itself, only when A "
|
||||
"gives, and a 5000 W loop ask resolves to that floor",
|
||||
_hi == 2600.0 and pin_after.ceiling is None
|
||||
and resolve(_cl, units=(A, B), live={A, B}).target_w == 2600.0)
|
||||
check("only a HOLD: a DRAIN pin or an idle pack is not raised",
|
||||
pin_after(4000.0, results=[MaintResult(DRAIN, True, 0.0, False, []),
|
||||
HOLDING[1]])[0] == 0.0
|
||||
and pin_after(4000.0, results=[HOLDING[1], HOLDING[1]])[0] == 0.0)
|
||||
|
||||
|
||||
def refile(lo, results=(HOLDING[0],)):
|
||||
fake = types.SimpleNamespace(inverters=_c1.inverters,
|
||||
actuator_of=_c1.actuator_of)
|
||||
cl = [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD),
|
||||
Claim.limit("peak", P_PEAK, lo=lo, reason="quarter peak")]
|
||||
Controller._peak_over_hold(fake, cl, list(results))
|
||||
return cl
|
||||
|
||||
|
||||
check("⭐ at N=1 a floor of 0 W stays at P_PEAK, 0.5 W is refiled at "
|
||||
"P_MAINTENANCE, and a blind claim (a ceiling only) is not refiled",
|
||||
refile(0.0)[1].priority == P_PEAK
|
||||
and refile(0.5)[1].priority == P_MAINTENANCE
|
||||
and refile(float("-inf"))[1].priority == P_PEAK)
|
||||
_stop = refile(3000.0) + [Claim.set("safety", P_SAFETY, 0.0,
|
||||
"inputs missing")]
|
||||
check("⭐ at N=1 a refiled floor stays under a safety SET: 0 W",
|
||||
resolve(_stop).target_w == 0.0)
|
||||
check("at N=1 outside a HOLD (idle, or a CHARGE) the claim is not touched "
|
||||
"by this rule",
|
||||
refile(3000.0, (MaintResult(IDLE, False, None, False, []),))[1]
|
||||
.priority == P_PEAK
|
||||
and refile(3000.0, (MaintResult(CHARGE, False, None, True, []),))[1]
|
||||
.priority == P_PEAK)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-86 checks passed")
|
||||
@@ -0,0 +1,712 @@
|
||||
"""Runnable check for SAFETY-87: the peak guard uses every battery that can
|
||||
still give. `python3 test_safety87.py`
|
||||
|
||||
At N>=2 the guard graded the EMPTIEST battery (SAFETY-32), so one battery under
|
||||
the peak reserve stopped the guard for all of them: it asked for nothing and
|
||||
raised the reserve alarm while the other battery was full. Now a battery at or
|
||||
under its own reserve is HELD (it gives nothing for the peak, weight 0) and
|
||||
the guard's need is placed on the others; the reserve alarm fires only when
|
||||
they cannot hold the limit. Drives the REAL Controller (tools/
|
||||
safety84_identity.py's harness). DECISIONS/SAFETY-87-guard-uses-able-batteries.md.
|
||||
|
||||
1. Maintenance charge at N=2 (A under the reserve, B able), house over the
|
||||
aim: no reserve alarm, the quarter on the aim, A gives nothing for the
|
||||
peak while B can carry it. When B cannot, the alarm fires and names A.
|
||||
2. HOLD at N=2 (A full holding, B under the reserve): A gives, B gives
|
||||
nothing, the quarter lands on the aim; at a banked month peak too.
|
||||
3. Both under the reserve, or the other one refusing: the alarm still fires
|
||||
and names the right battery.
|
||||
4. The alarm at the quarter's edge: a one-cycle miss does not fire it.
|
||||
5. place_on_able() at its boundaries, and the claim it files.
|
||||
6. The allocator: a held battery gives last, after an Off one.
|
||||
7. A held battery draining for maintenance counts for its drain.
|
||||
8. The page: a peak alarm does not say "Not ready" or "0 W until the alarm
|
||||
clears" while control goes on.
|
||||
9. N=1 is byte-identical to release/1.0 b8ddfd7 (tools/safety87_identity.py).
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
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 safety84_identity as s84 # noqa: E402
|
||||
import safety87_identity as s87 # noqa: E402
|
||||
from app import messages, packmode, peakguard # noqa: E402
|
||||
from app.arbiter import (Claim, P_MAINTENANCE, P_MODE, P_PEAK, # noqa: E402
|
||||
P_SAFETY, UnitBounds, resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
fails = []
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
|
||||
TOL = 10.0 # W over the aim a banked quarter may show (SAFETY-81)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def store(phase, *, b_off=False):
|
||||
"""Battery A in `phase` from the first cycle (B idle, cycle done)."""
|
||||
s = rig._Store()
|
||||
s.data[unit_key(A, "phase")] = phase
|
||||
s.data[unit_key(A, "phase_started")] = rig.BASE.isoformat()
|
||||
s.data[unit_key(B, "last_completed")] = rig.BASE.isoformat()
|
||||
if phase == IDLE:
|
||||
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
|
||||
if b_off:
|
||||
s.data[unit_key(B, packmode.USE_KEY)] = False
|
||||
return s
|
||||
|
||||
|
||||
def run(house, phase, socs, *, cheap=False, n=2, **kw):
|
||||
"""Rows, controller, the notifications raised and one record per cycle:
|
||||
(floor_limited, held, floor_w, reserve set, alarm latched)."""
|
||||
notes, seen = [], []
|
||||
real_check = Controller.check_peak_floor_limited
|
||||
|
||||
async def spy(self):
|
||||
await real_check(self)
|
||||
g = self.peak_guard
|
||||
seen.append(None if g is None else (
|
||||
g.floor_limited, g.held, g.floor_w, self._peak_reserve(),
|
||||
self._peak_floor_limited))
|
||||
|
||||
def before(c, t):
|
||||
if not getattr(c.hass, "_s87", False):
|
||||
real = c.hass.call
|
||||
|
||||
async def call(*a):
|
||||
if a[:2] == ("persistent_notification", "create"):
|
||||
notes.append(a[2])
|
||||
return await real(*a)
|
||||
c.hass.call, c.hass._s87 = call, True
|
||||
return s84.feed(cheap)(c, t)
|
||||
|
||||
kw.setdefault("dur", 2700.0)
|
||||
extra = dict({"maintenance_hold_min": 600}, **kw.pop("extra", {}))
|
||||
Controller.check_peak_floor_limited = spy
|
||||
try:
|
||||
rows, c = s84.run(
|
||||
n, load=house if callable(house) else lambda t: house,
|
||||
cheap=cheap, store=kw.pop("store", None) or store(phase),
|
||||
soc=lambda u, t: socs[u], before=before, extra=extra, **kw)
|
||||
finally:
|
||||
Controller.check_peak_floor_limited = real_check
|
||||
return rows, c, notes, seen
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""The shipped b8ddfd7 behaviour: no battery is ever held back."""
|
||||
real = Controller._peak_on_able
|
||||
Controller._peak_on_able = lambda self, decision, results: decision
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._peak_on_able = real
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def banked(c):
|
||||
return [b.offtake_avg_w for b in c.p1.blocks]
|
||||
|
||||
|
||||
def on_aim(c, aim=AIM):
|
||||
b = banked(c)
|
||||
return len(b) >= 2 and all(aim - TOL <= v <= aim + TOL for v in b)
|
||||
|
||||
|
||||
def reserve_notes(notes):
|
||||
return [x for x in notes
|
||||
if x["notification_id"] == messages.ID_PEAK_RESERVE]
|
||||
|
||||
|
||||
def limited_events(c):
|
||||
return sum(1 for e in c.events if "PEAK_GUARD_FLOOR_LIMITED" in e)
|
||||
|
||||
|
||||
def show(tag, rows, c, notes, seen):
|
||||
a = [cmd(r)["a"] for r in rows]
|
||||
b = [cmd(r)["b"] for r in rows]
|
||||
print(f" {tag}: banked {[round(v, 1) for v in banked(c)]} W, mean A "
|
||||
f"{sum(a) / len(a):.0f} W, B {sum(b) / len(b):.0f} W, reserve "
|
||||
f"alarms {len(reserve_notes(notes))}, held "
|
||||
f"{sorted({h for s in seen if s for h in s[1]})}")
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. maintenance charge at N=2: A under the reserve, B able")
|
||||
LOW_A = {"a": 11.0, "b": 60.0}
|
||||
CH = run(4500.0, "charge", LOW_A, cheap=True)
|
||||
CH_OLD = without_fix(lambda: run(4500.0, "charge", LOW_A, cheap=True))
|
||||
show("house 4500", *CH)
|
||||
show("house 4500, before the fix", *CH_OLD)
|
||||
check("⭐ house 4500 W, B can carry the guard alone: no reserve alarm (no "
|
||||
"notification, no PEAK_GUARD_FLOOR_LIMITED line) and every quarter on "
|
||||
"the aim",
|
||||
not reserve_notes(CH[2]) and limited_events(CH[1]) == 0
|
||||
and on_aim(CH[1]))
|
||||
check("...A is held (it gives nothing for the peak) on every cycle the guard "
|
||||
"asks for power, and the guard asks for a positive floor",
|
||||
any(s and s[2] and s[2] > 0.0 for s in CH[3])
|
||||
and all(s[1] == (A,) and s[3] == {A} for s in CH[3]
|
||||
if s and s[2] and s[2] > 0.0))
|
||||
check("non-vacuous: before the fix the same run raised the reserve alarm "
|
||||
"(the SAFETY-84 rig test's check 2/3 false alarm)",
|
||||
len(reserve_notes(CH_OLD[2])) >= 1 and limited_events(CH_OLD[1]) >= 1)
|
||||
_b_carry = [r for r, s in zip(CH[0], CH[3])
|
||||
if s and s[3] == {A} and r[1] <= 2500.0 and r[1] > 0.0]
|
||||
check("...and while B can carry the site target (at most its 2500 W) A is "
|
||||
"not asked to give at all (weight 0 behind B)",
|
||||
len(_b_carry) >= 3 and all(cmd(r)["a"] <= 0.0 for r in _b_carry))
|
||||
|
||||
CH65 = run(6500.0, "charge", LOW_A, cheap=True)
|
||||
show("house 6500", *CH65)
|
||||
_n65 = reserve_notes(CH65[2])
|
||||
check("⭐ house 6500 W, B (2500 W) cannot hold the 3450 W aim alone: the "
|
||||
"reserve alarm fires and names A, the battery under its reserve, and "
|
||||
"says the others cannot hold the limit alone",
|
||||
len(_n65) >= 1
|
||||
and all("inverter-a is at its peak reserve" in x["message"]
|
||||
and "inverter-b" not in x["message"]
|
||||
and "cannot keep this 15-minute period under your 3 500 W peak "
|
||||
"limit alone" in x["message"] for x in _n65))
|
||||
check("...and while it is limited the guard still asks B for everything B "
|
||||
"has (floor 2500 W), so B is never left idle",
|
||||
any(s and s[0] and s[2] == 2500.0 for s in CH65[3])
|
||||
and all(s[2] == 2500.0 for s in CH65[3] if s and s[0]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. HOLD at N=2: A full and holding, B under the reserve")
|
||||
LOW_B = {"a": 99.0, "b": 15.0}
|
||||
HD = run(5500.0, HOLD, LOW_B)
|
||||
HD_OLD = without_fix(lambda: run(5500.0, HOLD, LOW_B))
|
||||
show("house 5500", *HD)
|
||||
show("house 5500, before the fix", *HD_OLD)
|
||||
check("⭐ house 5500 W: A gives, B gives nothing, every quarter lands on the "
|
||||
"aim, no reserve alarm, and A stays in its HOLD",
|
||||
on_aim(HD[1]) and not reserve_notes(HD[2])
|
||||
and limited_events(HD[1]) == 0
|
||||
and all(cmd(r)["b"] == 0.0 for r in HD[0])
|
||||
and max(cmd(r)["a"] for r in HD[0]) > 2000.0
|
||||
and HD[1].maints[0].phase == HOLD)
|
||||
check("non-vacuous: before the fix A sat at 0 W, B (at 15 %) gave through "
|
||||
"the loop and the reserve alarm fired (SAFETY-86 rig case 7a)",
|
||||
all(cmd(r)["a"] == 0.0 for r in HD_OLD[0])
|
||||
and max(cmd(r)["b"] for r in HD_OLD[0]) > 2000.0
|
||||
and len(reserve_notes(HD_OLD[2])) >= 1)
|
||||
|
||||
HD65 = run(6500.0, HOLD, LOW_B)
|
||||
HD65_OLD = without_fix(lambda: run(6500.0, HOLD, LOW_B))
|
||||
show("house 6500", *HD65)
|
||||
show("house 6500, before the fix", *HD65_OLD)
|
||||
_n = reserve_notes(HD65[2])
|
||||
check("⭐ house 6500 W, A alone (2500 W) cannot hold the aim: A gives all it "
|
||||
"has, B still gives nothing, and the reserve alarm fires ONCE naming B",
|
||||
all(cmd(r)["b"] == 0.0 for r in HD65[0])
|
||||
and max(cmd(r)["a"] for r in HD65[0]) == 2500.0
|
||||
and len(_n) == 1 and "inverter-b is at its peak reserve" in
|
||||
_n[0]["message"] and "inverter-a" not in _n[0]["message"])
|
||||
check("...and the quarter is no worse than before the fix (4051 W in the "
|
||||
"SAFETY-86 review), with B kept at its reserve",
|
||||
max(banked(HD65[1])) <= max(banked(HD65_OLD[1])) + 1.0)
|
||||
|
||||
BK = run(6000.0, HOLD, LOW_B, month_peak=4000.0)
|
||||
show("house 6000, month peak 4000", *BK)
|
||||
check("⭐ under a banked month peak of 4000 W the aim is 3950 W (peak - "
|
||||
"margin): A gives, B nothing, every quarter on 3950 W, no alarm",
|
||||
on_aim(BK[1], 3950.0) and not reserve_notes(BK[2])
|
||||
and all(cmd(r)["b"] == 0.0 for r in BK[0]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. a real loss of peak limiting still alarms")
|
||||
BOTH = run(5500.0, IDLE, {"a": 15.0, "b": 12.0})
|
||||
show("both under", *BOTH)
|
||||
_nb = reserve_notes(BOTH[2])
|
||||
check("⭐ both batteries under the reserve: the reserve alarm fires and "
|
||||
"names both, the guard asks for nothing (claim lo 0 W, 'SoC floor')",
|
||||
len(_nb) >= 1
|
||||
and all("inverter-a and inverter-b" in x["message"] for x in _nb)
|
||||
and all(not s[2] for s in BOTH[3] if s and s[0])
|
||||
and any(c[0] == "peak" and c[4] == 0.0 and c[6] == "SoC floor"
|
||||
for r in BOTH[0] for c in r[-1][0]))
|
||||
|
||||
|
||||
_ctl2 = BOTH[1]
|
||||
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, 1)):
|
||||
_inv.soc, _inv.refused = _s, _r # SAFETY-73: B refuses to give
|
||||
_ref = _ctl2._peak_on_able(_lim_all := peakguard.PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=3500.0,
|
||||
ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0,
|
||||
wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0,
|
||||
soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR,
|
||||
reason="PEAK_GUARD_FLOOR_LIMITED: x"),
|
||||
[MaintResult(IDLE, False, None, False, [])] * 2)
|
||||
check("⭐ A under the reserve and B refusing to give (SAFETY-73): nothing can "
|
||||
"give for the peak, so it stays limited at 0 W (the alarm fires) and "
|
||||
"only A is named (test_peakguard.py part 12h drives the notification)",
|
||||
_ref.floor_limited and _ref.floor_w == 0.0 and _ref.held == (A,)
|
||||
and _ref.able_w is None and _ctl2._peak_reserve() == frozenset())
|
||||
_ctl2.inverters[1].refused = 0
|
||||
_ctl2.live_units = lambda: (A,) # B dropped from the split
|
||||
_drop = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
|
||||
[])] * 2)
|
||||
del _ctl2.live_units
|
||||
check("...and the same with B dropped (not live): nothing can give, the "
|
||||
"alarm state stands at 0 W, only A named",
|
||||
_drop.floor_limited and _drop.floor_w == 0.0 and _drop.held == (A,)
|
||||
and _drop.able_w is None)
|
||||
|
||||
|
||||
async def _notify(ctl, socs, cutouts, refused):
|
||||
"""The shipped check_peak_floor_limited() on `ctl`, its notification."""
|
||||
sent = []
|
||||
|
||||
async def call(*a):
|
||||
sent.append(a)
|
||||
return True
|
||||
for inv, so, cut, ref in zip(ctl.inverters, socs, cutouts, refused):
|
||||
inv.soc, inv.bms_soc_floor, inv.refused = so, cut, ref
|
||||
real, ctl.hass.call = ctl.hass.call, call
|
||||
ctl.peak_guard = ctl._peak_on_able(dataclasses.replace(
|
||||
_lim_all, soc=min(socs), soc_floor=peakguard.binding_soc_floor(
|
||||
ctl.packs, 20.0)), [MaintResult(IDLE, False, None, False, [])] * 2)
|
||||
ctl._peak_floor_limited = False
|
||||
try:
|
||||
await ctl.check_peak_floor_limited()
|
||||
finally:
|
||||
ctl.hass.call = real
|
||||
for inv in ctl.inverters:
|
||||
inv.bms_soc_floor, inv.refused = 10, 0
|
||||
return [a[2]["message"] for a in sent
|
||||
if a[2].get("notification_id") == messages.ID_PEAK_RESERVE]
|
||||
|
||||
|
||||
# Cut-outs A 10 % / B 35 % (simulated; rule 8 refuses it, sim/inject.py does
|
||||
# not run rule 8), A at 25 % refusing, B at 30 %. Only B is at its own floor;
|
||||
# A is above its own but under the site's highest (35 %).
|
||||
_msg = asyncio.run(_notify(_ctl2, (25.0, 30.0), (10, 35), (1, 0)))
|
||||
print(f" differing cut-outs: {_msg}")
|
||||
check("⭐ the alarm names the battery at ITS OWN floor (B, 30 % <= 35 %), not "
|
||||
"every battery under the site's highest floor (A at 25 % is above its "
|
||||
"own 20 %)",
|
||||
len(_msg) == 1 and "inverter-b is at its peak reserve" in _msg[0]
|
||||
and "inverter-a" not in _msg[0])
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the reserve alarm at the quarter's edge")
|
||||
def spiky(t):
|
||||
"""A 5000 W house (B alone can hold the aim) with a 5 s spike to
|
||||
11000 W every 150 s, which B alone cannot."""
|
||||
return 11000.0 if t % 150 < 5 else 5000.0
|
||||
|
||||
|
||||
LOW_A_IDLE = {"a": 15.0, "b": 60.0}
|
||||
SP = run(spiky, IDLE, LOW_A_IDLE)
|
||||
_flips = sum(1 for s in SP[3] if s and s[0])
|
||||
show("spiky house", *SP)
|
||||
print(f" {_flips} cycles floor-limited (B alone short during a spike)")
|
||||
check("⭐ a 5 s spike that B alone cannot cover makes the guard limited for a "
|
||||
"cycle, and that does not fire the alarm: the miss must hold for "
|
||||
"ALARM_HOLD_S, as for 'Load too high'. No quarter goes over the aim",
|
||||
_flips >= 5 and not reserve_notes(SP[2])
|
||||
and not any(s[4] for s in SP[3] if s)
|
||||
and len(banked(SP[1])) >= 2 and max(banked(SP[1])) <= AIM)
|
||||
|
||||
|
||||
def ungated(fn):
|
||||
real = Controller._reserve_short
|
||||
Controller._reserve_short = lambda self, g: True
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._reserve_short = real
|
||||
|
||||
|
||||
_ug = ungated(lambda: run(spiky, IDLE, LOW_A_IDLE))
|
||||
check("non-vacuous: without the hold the same run raises the alarm",
|
||||
len(reserve_notes(_ug[2])) >= 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. place_on_able() at its boundaries")
|
||||
_lim = peakguard.PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=3500.0,
|
||||
ceiling_from=peakguard.CEILING_CAP, projected_w=4000.0, excess_w=550.0,
|
||||
wanted_w=2000.0, target_w=3450.0, floor_limited=True, soc=15.0,
|
||||
soc_floor=20.0, soc_floor_reason=peakguard.FLOOR_AT_SOC_FLOOR,
|
||||
reason="PEAK_GUARD_FLOOR_LIMITED: x")
|
||||
|
||||
|
||||
def place(d=_lim, held=("b",), able_w=2000.0, soc=60.0, floor=20.0):
|
||||
return peakguard.place_on_able(d, held, able_w=able_w, soc=soc,
|
||||
soc_floor=floor)
|
||||
|
||||
|
||||
_eq, _short = place(), place(able_w=1999.0)
|
||||
check("⭐ the others can give exactly the need (2000 W): not limited, floor "
|
||||
"2000 W, the claim reads 'quarter peak' (the page says 'the peak "
|
||||
"limit')",
|
||||
not _eq.floor_limited and _eq.engaged and _eq.floor_w == 2000.0
|
||||
and _eq.held == ("b",) and _eq.able_w == 2000.0
|
||||
and peakguard.claim(_eq).lo == 2000.0
|
||||
and peakguard.claim(_eq).reason == "quarter peak"
|
||||
and _eq.reason.startswith("PEAK_GUARD: "))
|
||||
check("...1 W short: limited, but the claim still asks them for all they "
|
||||
"have (1999 W), not 0 W",
|
||||
_short.floor_limited and _short.floor_w == 1999.0
|
||||
and peakguard.claim(_short).lo == 1999.0
|
||||
and peakguard.claim(_short).reason == "quarter peak"
|
||||
and _short.reason.startswith("PEAK_GUARD_FLOOR_LIMITED: "))
|
||||
check("...the others' own permission is graded: at or under their floor, or "
|
||||
"unread, nothing is asked (0 W, 'SoC floor')",
|
||||
all(place(soc=s).floor_w == 0.0 and place(soc=s).floor_limited
|
||||
and peakguard.claim(place(soc=s)).reason == "SoC floor"
|
||||
for s in (20.0, 19.0, None)))
|
||||
check("...the roster's verdict is kept as decide() echoed it (soc 15 %, "
|
||||
"floor 20 %), and a decision that was not limited is only labelled",
|
||||
_eq.soc == 15.0 and _eq.soc_floor == 20.0
|
||||
and all(place(d=d) == dataclasses.replace(d, held=("b",))
|
||||
for d in (dataclasses.replace(
|
||||
_lim, floor_limited=False, engaged=False,
|
||||
floor_w=None),
|
||||
dataclasses.replace(_lim, wanted_w=None))))
|
||||
check("...and at N=1 the floor-limited claim is unchanged: 0 W, 'SoC floor'",
|
||||
peakguard.claim(_lim).lo == 0.0
|
||||
and peakguard.claim(_lim).reason == "SoC floor")
|
||||
|
||||
|
||||
|
||||
def three(socs, cuts):
|
||||
"""`_peak_on_able()` on a stub roster of three (SIMULATED packs)."""
|
||||
names = ("x", "y", "z")
|
||||
invs = [types.SimpleNamespace(name=n, refused=0, rating_w=lambda: 2500.0)
|
||||
for n in names]
|
||||
stub = types.SimpleNamespace(
|
||||
inverters=invs, o={"peak_soc_floor": 20},
|
||||
packs=[peakguard.Pack(n, so, c) for n, so, c in zip(names, socs, cuts)],
|
||||
live_units=lambda: names)
|
||||
return Controller._peak_on_able(stub, _lim, [MaintResult(
|
||||
IDLE, False, None, False, [])] * 3)
|
||||
|
||||
|
||||
# x 25 % (cut-out 10 -> floor 20), y 40 % (cut-out 30), z 15 % (held). x and
|
||||
# y each clear their own floor, but 25 % is not above y's 30 %: the pair is
|
||||
# graded min SoC against max floor (SAFETY-32), so nothing is asked.
|
||||
_t3 = three((25.0, 40.0, 15.0), (10, 30, 10))
|
||||
_t3ok = three((35.0, 40.0, 15.0), (10, 30, 10))
|
||||
check("⭐ three batteries, z held: the two others are graded as a pair, the "
|
||||
"EMPTIEST of them (25 %) against the HIGHEST of their floors (30 %), "
|
||||
"so nothing is asked; at 35 % they are asked for the need",
|
||||
_t3.held == ("z",) and _t3.floor_limited and _t3.floor_w == 0.0
|
||||
and _t3ok.held == ("z",) and not _t3ok.floor_limited
|
||||
and _t3ok.floor_w == 2000.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. the allocator: a held battery gives last")
|
||||
|
||||
|
||||
def split(target, reserve, modes=(), floor=1500.0):
|
||||
cl = [Claim.set("loop", 10, target),
|
||||
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=A),
|
||||
Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=B),
|
||||
Claim.limit("peak", P_PEAK, lo=min(target, floor))]
|
||||
cl += [Claim.limit("mode", P_MODE, 0.0, 0.0, "off", actuator=u)
|
||||
for u in modes]
|
||||
r = resolve(cl, units=(A, B), live=(A, B), weights={A: 1.0, B: 3.0},
|
||||
reserve=reserve)
|
||||
return {u: round(v) for u, v in r.shares.items()}
|
||||
|
||||
|
||||
check("⭐ B held: A carries the site target first (B's weight 3.0 ignored) "
|
||||
"and B only takes what A cannot: 1500 -> A 1500 / B 0, 4000 -> A 2500 "
|
||||
"/ B 1500",
|
||||
split(1500.0, {B}) == {A: 1500, B: 0}
|
||||
and split(4000.0, {B}) == {A: 2500, B: 1500})
|
||||
check("...no one held: the weights decide, as before",
|
||||
split(1500.0, set()) == {A: 375, B: 1125})
|
||||
check("...charging is not touched: a held battery may still charge first",
|
||||
split(-2000.0, {B}) == split(-2000.0, set()))
|
||||
check("⭐ A Off and B under the reserve, a 3000 W floor B cannot place: the "
|
||||
"Off battery gives first (2500 W) and B only the last 500 W - not an "
|
||||
"equal 1500/1500 split",
|
||||
split(3000.0, {B}, modes=(A,), floor=3000.0) == {A: 2500, B: 500})
|
||||
check("...OPEN, recorded: when B alone CAN place the floor, OPT-11 keeps the "
|
||||
"Off battery resting and B gives it (1500 W). See the DECISIONS entry",
|
||||
split(1500.0, {B}, modes=(A,)) == {A: 0, B: 1500})
|
||||
check("...and with B Off and nobody held, Off still gives way to the other "
|
||||
"battery, as before (OPT-11)",
|
||||
split(1500.0, set(), modes=(B,)) == {A: 1500, B: 0})
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. a held battery draining for maintenance")
|
||||
_ctl = HD[1]
|
||||
|
||||
|
||||
def able_w(results, socs=(15.0, 60.0)):
|
||||
for inv, s in zip(_ctl.inverters, socs):
|
||||
inv.soc, inv.refused = s, 0
|
||||
d = _ctl._peak_on_able(dataclasses.replace(
|
||||
_lim, wanted_w=6000.0), results)
|
||||
return d.able_w, d.held, d.floor_w
|
||||
|
||||
|
||||
_idle = [MaintResult(IDLE, False, None, False, [])] * 2
|
||||
_drain = [MaintResult(DRAIN, True, 2000.0, False, []),
|
||||
MaintResult(IDLE, False, None, False, [])]
|
||||
print(f" A at 15 % idle: {able_w(_idle)}; draining 2000 W: "
|
||||
f"{able_w(_drain)}")
|
||||
check("⭐ A under the reserve but draining 2000 W for its monthly cycle: "
|
||||
"that drain still counts (able 4500 W = B 2500 + 2000), so B is not "
|
||||
"asked to carry it; idle, only B's 2500 W",
|
||||
able_w(_idle) == (2500.0, (A,), 2500.0)
|
||||
and able_w(_drain) == (4500.0, (A,), 4500.0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n8. the page while a peak alarm stands and control goes on")
|
||||
_rows65, _c65 = CH65[0], CH65[1]
|
||||
_c65._peak_floor_limited = True
|
||||
_c65.peak_guard = dataclasses.replace(
|
||||
_lim, floor_w=2500.0, held=(A,), able_w=2500.0)
|
||||
_pay = _c65.status_payload()
|
||||
_txt = json.dumps(_pay, ensure_ascii=False)
|
||||
_row = [r for r in _pay["checks"] if r.get("id") == "peak_guard"]
|
||||
print(f" banner: {_pay['banner']}; row: {_row[0]['text'] if _row else '-'}")
|
||||
check("⭐ the banner is the alarm's title without 'Not ready', and nothing "
|
||||
"on the page says the inverters go to 0 W or that an alarm stopped "
|
||||
"control",
|
||||
_pay["banner"] == messages.T_PEAK_RESERVE and _pay["level"] == "bad"
|
||||
and "Not ready" not in _txt and "until the alarm clears" not in _txt
|
||||
and "An alarm stopped control" not in _txt)
|
||||
check("...the row names A and says the others cannot hold the limit alone, "
|
||||
"in three short sentences",
|
||||
len(_row) == 1 and _row[0]["text"].startswith(
|
||||
"inverter-a battery at its peak reserve. The others cannot hold "
|
||||
"the 3 500 W peak limit alone."))
|
||||
_c65._peak_floor_limited = False
|
||||
_blip = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
|
||||
_c65._peak_floor_limited = True
|
||||
check("...and while the alarm is not latched (a one-cycle miss) the row is "
|
||||
"the ordinary amber guard row, not the red reserve row",
|
||||
len(_blip) == 1 and _blip[0]["warn"] and not _blip[0].get("runs")
|
||||
and "peak reserve" not in _blip[0]["text"])
|
||||
_c65.auto = False
|
||||
_stopped = _c65.status_payload()
|
||||
_c65.auto = True
|
||||
check("...a stopped controller still says so: the peak alarm does not take "
|
||||
"the banner from it", _stopped["banner"] != messages.T_PEAK_RESERVE
|
||||
and _stopped["level"] != "bad")
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
|
||||
PINNED = { # tools/safety87_identity.py on b8ddfd7
|
||||
"n1_under":
|
||||
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
|
||||
"n1_crossing":
|
||||
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
|
||||
"n1_above":
|
||||
"45726a285aa84f40542d56482652397c3c1e212ba4fe2009f6cc52f537ccad52",
|
||||
"n1_under_banked":
|
||||
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
|
||||
"n1_charge_low":
|
||||
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
|
||||
"n1_hold":
|
||||
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
|
||||
}
|
||||
_now = s87.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
|
||||
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
|
||||
"log) equals b8ddfd7's, under, crossing and above the reserve, in a "
|
||||
"charge and a HOLD, and under a banked peak",
|
||||
_now == PINNED)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n10. review: maintenance pins beside a held battery, N=3, the page")
|
||||
|
||||
|
||||
def _drain_store():
|
||||
s = rig._Store()
|
||||
s.data[unit_key(B, "phase")] = DRAIN
|
||||
s.data[unit_key(B, "phase_started")] = rig.BASE.isoformat()
|
||||
s.data[unit_key(A, "last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def _drain_run():
|
||||
return run(8000.0, IDLE, {"a": 15.0, "b": 60.0}, store=_drain_store(),
|
||||
dur=900.0)
|
||||
|
||||
|
||||
DR = _drain_run()
|
||||
_real_dor = Controller._drain_over_reserve
|
||||
_real_rise = Controller._drain_rise # SAFETY-91 would raise B as well
|
||||
Controller._drain_over_reserve = lambda self, claims, results: None
|
||||
Controller._drain_rise = lambda self, claims, results: None
|
||||
try:
|
||||
DR_OLD = _drain_run()
|
||||
finally:
|
||||
Controller._drain_over_reserve = _real_dor
|
||||
Controller._drain_rise = _real_rise
|
||||
|
||||
|
||||
def _a_for_peak(rows):
|
||||
"""A's command on the cycles where the site target is the peak floor
|
||||
and B alone (2500 W) could place it."""
|
||||
return [cmd(r)["a"] for r in rows
|
||||
if r[-1][1] and r[-1][1][1] and r[1] <= 2500.0
|
||||
and abs(r[1] - r[-1][1][1]) < 1.0]
|
||||
|
||||
|
||||
print(f" B draining, A held: A for the peak {_a_for_peak(DR[0])[:4]}, "
|
||||
f"without the raise {_a_for_peak(DR_OLD[0])[:4]}")
|
||||
check("⭐ B draining (2000 W pin) while A is held: B is raised to its rating "
|
||||
"for the peak floor, so A gives nothing for it (peak > drain)",
|
||||
DR[1].maints[1].phase == DRAIN and _a_for_peak(DR[0])
|
||||
and all(a <= 0.0 for a in _a_for_peak(DR[0])))
|
||||
check("non-vacuous: without the raise A gave the floor's rest above B's pin",
|
||||
any(a > 0.0 for a in _a_for_peak(DR_OLD[0])))
|
||||
|
||||
|
||||
def _stub(names, reserve, live=None):
|
||||
"""A Controller stand-in for the pin rules, on SIMULATED units."""
|
||||
s = types.SimpleNamespace(
|
||||
inverters=[types.SimpleNamespace(name=n) for n in names],
|
||||
actuator_of=lambda src, inv=None: inv.name,
|
||||
live_units=lambda: tuple(live or names),
|
||||
_peak_reserve=lambda: frozenset(reserve))
|
||||
s._held_pin = lambda cl, u: Controller._held_pin(s, cl, u)
|
||||
return s
|
||||
|
||||
|
||||
def _hold(floor, names, reserve, extra=(), results=None):
|
||||
"""`_peak_over_hold()`: x HOLDs; returns (x's pin, resolved shares)."""
|
||||
s = _stub(names, reserve)
|
||||
cl = [Claim.limit("supervised", P_SAFETY, -2500.0, 2500.0, actuator=n)
|
||||
for n in names]
|
||||
cl += [Claim.set("maintenance", P_MAINTENANCE, 0.0, HOLD, actuator="x"),
|
||||
Claim.limit("peak", P_PEAK, lo=floor, reason="quarter peak"),
|
||||
*extra]
|
||||
res = results or ([MaintResult(HOLD, True, 0.0, False, [])]
|
||||
+ [MaintResult(IDLE, False, None, False, [])]
|
||||
* (len(names) - 1))
|
||||
hi = Controller._peak_over_hold(s, cl, res)
|
||||
if hi is not None:
|
||||
cl.append(Claim.limit("hold", P_MAINTENANCE, hi=hi))
|
||||
r = resolve(cl + [Claim.set("loop", 10, floor)], units=tuple(names),
|
||||
live=set(names), reserve=reserve)
|
||||
pin = next((c.value for c in cl
|
||||
if c.source == "maintenance" and c.actuator == "x"), None)
|
||||
return pin, {u: round(v) for u, v in r.shares.items()}
|
||||
|
||||
|
||||
_h3 = _hold(3000.0, ("x", "y", "z"), {"z"})
|
||||
_h3b = _hold(2000.0, ("x", "y", "z"), {"z"})
|
||||
print(f" N=3 HOLD x, y able, z held: floor 3000 {_h3}, 2000 {_h3b}")
|
||||
check("⭐ HOLD at N=3 with one held: the able battery gives first, the "
|
||||
"holding one the rest (3000 -> y 2500, x 500), the held one nothing",
|
||||
_h3 == (500.0, {"x": 500, "y": 2500, "z": 0})
|
||||
and _h3b == (0.0, {"x": 0, "y": 2000, "z": 0}))
|
||||
_hd = _hold(2000.0, ("x", "z"), {"z"},
|
||||
(Claim.set("maintenance", P_MAINTENANCE, 1500.0, DRAIN,
|
||||
actuator="z"),),
|
||||
[MaintResult(HOLD, True, 0.0, False, []),
|
||||
MaintResult(DRAIN, True, 1500.0, False, [])])
|
||||
_hp = _hold(2000.0, ("x", "z"), {"z"},
|
||||
(Claim.set("refused", P_SAFETY, -50.0, "probe", actuator="z"),))
|
||||
print(f" held z draining 1500: {_hd}; held z charge probe: {_hp}")
|
||||
check("⭐ a held battery's own pin counts: draining 1500 W, the hold gives "
|
||||
"only the 500 W rest (no over-placement); a charge probe leaves the "
|
||||
"hold at 0 W (never split both ways, no park)",
|
||||
_hd == (500.0, {"x": 500, "z": 1500}) and _hp[0] == 0.0)
|
||||
|
||||
_unk = three((60.0, 60.0, 60.0), (10, None, 10))
|
||||
_unr = three((60.0, None, 60.0), (10, 10, 10))
|
||||
check("⭐ N=3: an unknown cut-out or an unread level holds that battery "
|
||||
"only; the two others are asked for the need",
|
||||
_unk.held == ("y",) and not _unk.floor_limited
|
||||
and _unk.floor_w == 2000.0 and _unr.held == ("y",)
|
||||
and not _unr.floor_limited and _unr.floor_w == 2000.0)
|
||||
|
||||
_stop_row = {"ok": False, "warn": False, "id": "meter", "text": "Meter down."}
|
||||
_peak_rows = [r for r in _c65.checks() if r.get("id") == "peak_guard"]
|
||||
_c65.checks = lambda: _peak_rows[:1] + [_stop_row]
|
||||
_both = _c65.status_payload()
|
||||
del _c65.checks
|
||||
check("⭐ a row that stops control outranks the running peak alarm: the "
|
||||
"banner says 'Not ready' for it and the Home page shows the hold",
|
||||
len(_peak_rows) == 1 and _peak_rows[0].get("runs")
|
||||
and _both["banner"].startswith(messages.NOT_READY)
|
||||
and "Meter down" in _both["banner"]
|
||||
and "until the alarm clears" in json.dumps(_both, ensure_ascii=False))
|
||||
|
||||
for _inv, _s, _r in zip(_ctl2.inverters, (15.0, 60.0), (0, -1)):
|
||||
_inv.soc, _inv.refused = _s, _r # B refuses only to CHARGE
|
||||
_chg = _ctl2._peak_on_able(_lim_all, [MaintResult(IDLE, False, None, False,
|
||||
[])] * 2)
|
||||
_ctl2.inverters[1].refused = 0
|
||||
check("⭐ a battery that refuses only to charge can still give: it stays "
|
||||
"able and carries the floor (2000 W of 2500 W)",
|
||||
not _chg.floor_limited and _chg.floor_w == 2000.0
|
||||
and _chg.able_w == 2500.0 and _chg.held == (A,))
|
||||
|
||||
|
||||
def _short_for(held_w, secs=30.0):
|
||||
"""`_reserve_short()` over `secs` of a steady quarter: the meter at
|
||||
`held_w` with the able batteries already at their full 2500 W."""
|
||||
s = types.SimpleNamespace(_reserve_batt_prev=None,
|
||||
_reserve_high_since=None,
|
||||
_peak_floor_limited=False, site_batt_w=2500.0)
|
||||
g = dataclasses.replace(_lim, floor_w=2500.0, able_w=2500.0)
|
||||
out = []
|
||||
for k in range(int(secs / 5.0) + 1):
|
||||
s._peak_proj = types.SimpleNamespace(
|
||||
block_start=rig.BASE, elapsed_s=450.0 + 5.0 * k,
|
||||
projected_w=held_w, held_w=held_w)
|
||||
out.append(Controller._reserve_short(s, g))
|
||||
return out
|
||||
|
||||
|
||||
_aim_miss, _limit_miss = _short_for(3480.0), _short_for(3600.0)
|
||||
check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
|
||||
"quarter ending at 3480 W (over the 3450 W aim, under 3500 W) never "
|
||||
"alarms; one ending at 3600 W alarms once it holds ALARM_HOLD_S",
|
||||
not any(_aim_miss) and not any(_limit_miss[:2])
|
||||
and all(_limit_miss[2:]))
|
||||
|
||||
_runs_src = {}
|
||||
for _f in ("main.py", "homeview.py"):
|
||||
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
|
||||
_src = _fh.read()
|
||||
for _part in _src.split('out.append({')[1:]:
|
||||
if '"runs": True' in _part.split("})", 1)[0]:
|
||||
_runs_src.setdefault(_f, []).append(
|
||||
_part.split('"id": "', 1)[-1].split('"', 1)[0])
|
||||
check("⭐ 'control still runs' is set on the two peak rows only (saturated "
|
||||
"and reserve); no row that stops control carries it",
|
||||
_runs_src == {"main.py": ["peak_guard", "peak_guard"]})
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-87 checks passed")
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Runnable check for SAFETY-91: a held battery is drawn only after the others
|
||||
are full. `python3 test_safety91.py`
|
||||
|
||||
At N>=2 a battery at or under its peak reserve was held back only while the
|
||||
peak guard asked for power. The self-consumption loop drew it at its full
|
||||
rating while a draining battery stayed at its drain rate (SAFETY-87 rig test
|
||||
F1), and at a quarter's edge in a HOLD the held battery gave 2500 W for one
|
||||
cycle (F2). Now a held battery weighs 0 whenever the site gives, a draining
|
||||
battery rises above its drain rate before it, and the edge is graded on the
|
||||
next quarter. SAFETY-25 option A stands: once the others are full the loop
|
||||
may still draw it. Drives the REAL Controller on a lagging battery (10 s, as
|
||||
on the rig). DECISIONS/SAFETY-91-held-battery-last.md.
|
||||
|
||||
1. B draining, A held, house over B's rating: B rises to full before A gives.
|
||||
2. A held reaching its cut-out: nothing is placed on it, B gives its rating.
|
||||
3. HOLD across a quarter edge: the held battery gives nothing at the edge.
|
||||
4. Idle, A held: B carries the house first; both held or B refusing: as before;
|
||||
a SoC reading flipping across the reserve does not swap the split.
|
||||
5. The rules at their boundaries (claim lists, N=3, a site stop).
|
||||
6. next_quarter() at its boundaries.
|
||||
7. N=1 is untouched.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from datetime import timedelta
|
||||
|
||||
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 safety84_identity as s84 # noqa: E402
|
||||
from app import peakguard # noqa: E402
|
||||
from app.arbiter import (Claim, P_MAINTENANCE, P_SAFETY, # noqa: E402
|
||||
resolve)
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN, HOLD, IDLE, MaintResult # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import QUARTER_S, Projection, next_quarter # noqa: E402
|
||||
|
||||
fails = []
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
FULL = 2500.0
|
||||
CUT = 10.0 # bms_soc_floor of both test batteries
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def lagging(fn, lag=10.0, tick=1.0, lags=None):
|
||||
"""`fn` on plantsim's battery (800 W/s ramp, `lag` s first-order lag, or
|
||||
`lags[u]`), a 1 s meter tick, and a BMS that stops discharge at its
|
||||
cut-out."""
|
||||
real_p, real_t = rig._Hass.power, rig.TICK
|
||||
|
||||
def power(self, u):
|
||||
st = self.__dict__.setdefault("_plant", {})
|
||||
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
||||
for _ in range(int(round((self.t - tl) / 0.1))):
|
||||
d = self.cmd[u] - tgt
|
||||
tgt += math.copysign(min(abs(d), 80.0), d)
|
||||
a += (tgt - a) * (1 - math.exp(-0.1 / (lags or {}).get(u, lag)))
|
||||
st[u] = (a, tgt, self.t)
|
||||
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
|
||||
rig._Hass.power, rig.TICK = power, tick
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_p, real_t
|
||||
|
||||
|
||||
def store(phase_of, n=2):
|
||||
"""`phase_of`: {name: phase}; the others have just done their cycle."""
|
||||
s = rig._Store()
|
||||
for u in (A, B)[:n]:
|
||||
key = (lambda k: k) if n == 1 else (lambda k: unit_key(u, k))
|
||||
if u in phase_of:
|
||||
s.data[key("phase")] = phase_of[u]
|
||||
s.data[key("phase_started")] = rig.BASE.isoformat()
|
||||
else:
|
||||
s.data[key("last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def run(house, phase_of, socs, dur, late=0.0, kwh=15.36, n=2, read=None,
|
||||
lags=None):
|
||||
"""Rows and controller. Levels follow each battery's measured power; the
|
||||
P1 telegram read at each cycle is `late` s old. `read(u, t, level)` is
|
||||
the SoC the controller reads (default the level)."""
|
||||
soc, last, levels = dict(socs), [0.0], {}
|
||||
grid = []
|
||||
|
||||
def before(c, t):
|
||||
grid.append((t, c.hass.grid()))
|
||||
for u in soc:
|
||||
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
|
||||
/ 3600.0 / (kwh * 10.0))
|
||||
last[0], levels[t] = t, dict(soc)
|
||||
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 - late),
|
||||
ingest_mono=time.monotonic()))
|
||||
rows, c = lagging(lambda: s84.run(
|
||||
n, load=house if callable(house) else (lambda t: house), cheap=False,
|
||||
store=store(phase_of, n), before=before, dur=dur,
|
||||
soc=(lambda u, t: soc[u]) if read is None
|
||||
else (lambda u, t: read(u, t, soc[u])),
|
||||
extra={"maintenance_discharge_w": 1000,
|
||||
"maintenance_hold_min": 600}), lags=lags)
|
||||
c.levels, c.grid = levels, grid
|
||||
return rows, c
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""release/1.0 8c438ab: held only for the peak, a drain stays pinned, the
|
||||
quarter's edge is not rolled over."""
|
||||
real = (Controller._held_back, Controller._drain_rise)
|
||||
import app.main as m
|
||||
real_nq = m.next_quarter
|
||||
Controller._held_back = lambda self: self._peak_reserve()
|
||||
Controller._drain_rise = lambda self, claims, results: None
|
||||
m.next_quarter = lambda p, max_hold_s: p
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._held_back, Controller._drain_rise = real
|
||||
m.next_quarter = real_nq
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def seg(rows, lo, hi):
|
||||
"""Mean A and B commands over [lo, hi) s."""
|
||||
rs = [r for r in rows if lo <= r[0] < hi]
|
||||
return (round(sum(cmd(r)["a"] for r in rs) / len(rs)),
|
||||
round(sum(cmd(r)["b"] for r in rs) / len(rs)))
|
||||
|
||||
|
||||
def a_before_b(rows, t0=0.0):
|
||||
"""Cycles where held A is asked to give while B is not at full power."""
|
||||
return sum(1 for r in rows if r[0] >= t0
|
||||
and cmd(r)["a"] > 0.0 and cmd(r)["b"] < FULL)
|
||||
|
||||
|
||||
def unmet(rows, lo, hi):
|
||||
return sum(1 for r in rows if lo <= r[0] < hi and r[7] and "UNMET" in r[7])
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. B draining at 1000 W, A held at 15 %, house over B's rating")
|
||||
STEPS = (800.0, 4000.0, 5500.0)
|
||||
|
||||
|
||||
def steps(t):
|
||||
return STEPS[min(int(t // 300), 2)]
|
||||
|
||||
|
||||
DR = run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0}, 900.0)
|
||||
DR_OLD = without_fix(lambda: run(steps, {B: DRAIN}, {"a": 15.0, "b": 60.0},
|
||||
900.0))
|
||||
for tag, (rows, c) in (("after", DR), ("before", DR_OLD)):
|
||||
print(f" {tag}: house 800 A/B {seg(rows, 60, 300)}, 4000 "
|
||||
f"{seg(rows, 360, 600)}, 5500 {seg(rows, 660, 900)}; A asked while "
|
||||
f"B not full: {a_before_b(rows)} cycles; UNMET "
|
||||
f"{unmet(rows, 0, 900)}")
|
||||
check("⭐ A (held) is never asked to give while B has room: every cycle that "
|
||||
"puts power on A has B at its full 2500 W",
|
||||
a_before_b(DR[0]) == 0 and DR[1].maints[1].phase == DRAIN)
|
||||
check("...house 4000: B rises from its 1000 W drain rate to 2500 W and A "
|
||||
"gives only the 1500 W rest (before: A 2500, B 1000)",
|
||||
seg(DR[0], 360, 600)[1] >= 2480 and seg(DR[0], 360, 600)[0] <= 1520
|
||||
and seg(DR_OLD[0], 360, 600)[0] >= 2480
|
||||
and seg(DR_OLD[0], 360, 600)[1] <= 1020)
|
||||
check("...house 800: B still drains at its 1000 W rate (the rest is "
|
||||
"exported, as before) and A gives nothing",
|
||||
all(cmd(r)["b"] >= 1000.0 and cmd(r)["a"] == 0.0
|
||||
for r in DR[0] if 60 <= r[0] < 300))
|
||||
check("...house 5500 (over both ratings): option A stands, A is drawn as a "
|
||||
"last resort once B is full, and nothing is left UNMET",
|
||||
seg(DR[0], 660, 900) == (2500, 2500) and unmet(DR[0], 0, 900) == 0)
|
||||
check("non-vacuous: before the fix A was asked while B had room",
|
||||
a_before_b(DR_OLD[0]) >= 100)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the held battery reaches its cut-out (house 5500)")
|
||||
CO = run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0}, 1200.0)
|
||||
CO_OLD = without_fix(lambda: run(5500.0, {B: DRAIN}, {"a": 11.0, "b": 60.0},
|
||||
1200.0))
|
||||
|
||||
|
||||
def after_cut(rows, c):
|
||||
"""Rows once A reads its cut-out (its level follows its power)."""
|
||||
return [r for r in rows if c.levels[r[0]]["a"] <= CUT]
|
||||
|
||||
|
||||
_ac, _ac_old = after_cut(*CO), after_cut(*CO_OLD)
|
||||
print(f" after A's cut-out: B {seg(_ac, 0, 9e9)[1]} W over {len(_ac)} "
|
||||
f"cycles (before: {seg(_ac_old, 0, 9e9)[1]} W); A soc "
|
||||
f"{CO[1].inverters[0].soc:.2f} %")
|
||||
check("⭐ at its cut-out nothing is placed on A (0 W on every cycle), B "
|
||||
"gives its full 2500 W and nothing is left UNMET on the empty A",
|
||||
CO[1].inverters[0].soc <= CUT and len(_ac) >= 100
|
||||
and all(cmd(r)["a"] == 0.0 and cmd(r)["b"] == FULL for r in _ac)
|
||||
and unmet(CO[0], 600, 1200) == 0)
|
||||
check("non-vacuous: before the fix B stayed pinned under its rating on most "
|
||||
"of those cycles (the rig saw ~2422 W)",
|
||||
len(_ac_old) >= 100
|
||||
and sum(1 for r in _ac_old if cmd(r)["b"] < FULL) > len(_ac_old) / 2)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. HOLD across a quarter edge (A full holding, B held at 15 %)")
|
||||
EDGE = run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0}, 1000.0, late=0.5)
|
||||
EDGE_OLD = without_fix(lambda: run(5500.0, {A: HOLD}, {"a": 99.0, "b": 15.0},
|
||||
1000.0, late=0.5))
|
||||
|
||||
|
||||
def at_edge(rows):
|
||||
return [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows
|
||||
if QUARTER_S - 1 <= r[0] <= QUARTER_S + 1]
|
||||
|
||||
|
||||
print(f" edge after {at_edge(EDGE[0])}, before {at_edge(EDGE_OLD[0])}")
|
||||
check("⭐ the telegram at the edge comes late (0.5 s): B gives nothing on any "
|
||||
"cycle (the first cycle, before any quarter is measured, aside), A "
|
||||
"keeps giving and stays in its HOLD",
|
||||
all(cmd(r)["b"] == 0.0 for r in EDGE[0] if r[0] > 0)
|
||||
and all(a > 2000.0 for _t, a, _b in at_edge(EDGE[0]))
|
||||
and EDGE[1].maints[0].phase == HOLD)
|
||||
check("non-vacuous: before the fix B gave over 2000 W at the edge (F2)",
|
||||
any(b > 2000.0 for _t, _a, b in at_edge(EDGE_OLD[0])))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. idle, house 2000 W")
|
||||
ID = run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0)
|
||||
ID_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 60.0}, 300.0))
|
||||
print(f" A held: after {seg(ID[0], 120, 300)}, before "
|
||||
f"{seg(ID_OLD[0], 120, 300)}")
|
||||
check("⭐ A held, B able: B carries the house alone; A is asked only on the "
|
||||
"cycles where the lagging B is already at its full command",
|
||||
a_before_b(ID[0]) == 0 and seg(ID[0], 120, 300)[0] == 0
|
||||
and seg(ID[0], 120, 300)[1] >= 1950)
|
||||
check("non-vacuous: before the fix the weights gave A a share",
|
||||
seg(ID_OLD[0], 120, 300)[0] > 50)
|
||||
BOTH = run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0)
|
||||
BOTH_OLD = without_fix(lambda: run(2000.0, {}, {"a": 15.0, "b": 14.0}, 300.0))
|
||||
check("...both held: nothing to give first, the split is as before",
|
||||
[r[:3] for r in BOTH[0]] == [r[:3] for r in BOTH_OLD[0]])
|
||||
|
||||
|
||||
def held_back(socs, refused=(0, 0), live=(A, B)):
|
||||
c = ID[1]
|
||||
for inv, s, r in zip(c.inverters, socs, refused):
|
||||
inv.soc, inv.refused = s, r
|
||||
c.live_units = lambda: live
|
||||
try:
|
||||
return c._held_back()
|
||||
finally:
|
||||
del c.live_units
|
||||
for inv in c.inverters:
|
||||
inv.refused = 0
|
||||
|
||||
|
||||
check("...held back only while another can give: A held beside B able; "
|
||||
"nothing with B refusing to give, B dropped, or both under",
|
||||
held_back((15.0, 60.0)) == {A} and held_back((15.0, 60.0), (0, 1))
|
||||
== frozenset() and held_back((15.0, 60.0), live=(A,)) == frozenset()
|
||||
and held_back((15.0, 14.0)) == frozenset()
|
||||
and held_back((15.0, 60.0), (0, -1)) == {A})
|
||||
|
||||
# A's SoC READING flips 20/21 % across its 20 % reserve every 10 s, and B
|
||||
# follows its command faster (3 s) than A (10 s), as two different inverters.
|
||||
def _flip(u, t, level):
|
||||
return (20.0 + int(t // 10) % 2) if u == "a" else level
|
||||
|
||||
|
||||
def _a_jumps(rows, t0):
|
||||
rs = [r for r in rows if r[0] >= t0]
|
||||
return sum(1 for p, q in zip(rs, rs[1:])
|
||||
if abs(cmd(p)["a"] - cmd(q)["a"]) > 300.0)
|
||||
|
||||
|
||||
def _grid_rms(c, t0):
|
||||
g = [w + 10.0 for t, w in c.grid if t >= t0]
|
||||
return math.sqrt(sum(w * w for w in g) / len(g))
|
||||
|
||||
|
||||
_lags = {"a": 10.0, "b": 3.0}
|
||||
FL = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 600.0, read=_flip, lags=_lags)
|
||||
_real_was = Controller.__dict__["_held_back"]
|
||||
|
||||
|
||||
def _no_hyst(self):
|
||||
self._held_was = frozenset()
|
||||
return _real_was(self)
|
||||
|
||||
|
||||
Controller._held_back = _no_hyst
|
||||
try:
|
||||
FL_OLD = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 600.0, read=_flip,
|
||||
lags=_lags)
|
||||
finally:
|
||||
Controller._held_back = _real_was
|
||||
print(f" A's reading flipping 20/21 %: A jumps {_a_jumps(FL[0], 120)}, "
|
||||
f"grid rms {_grid_rms(FL[1], 120):.0f} W (no hysteresis: "
|
||||
f"{_a_jumps(FL_OLD[0], 120)}, {_grid_rms(FL_OLD[1], 120):.0f} W)")
|
||||
check("⭐ a SoC reading flipping across the reserve does not swap the split: "
|
||||
"A stays held back (0 W) and the grid stays near its target",
|
||||
_a_jumps(FL[0], 120) == 0 and seg(FL[0], 120, 600)[0] == 0
|
||||
and _grid_rms(FL[1], 120) < 30.0)
|
||||
check("non-vacuous: without the 1-point hysteresis the split swaps on "
|
||||
"every flip and the grid swings",
|
||||
_a_jumps(FL_OLD[0], 120) >= 20 and _grid_rms(FL_OLD[1], 120) > 100.0)
|
||||
REL = run(2000.0, {}, {"a": 20.0, "b": 60.0}, 300.0,
|
||||
read=lambda u, t, lv: (20.0 if t < 100 else 21.0 if t < 200
|
||||
else 22.0) if u == "a" else lv)
|
||||
check("...held back at 20 %, still at 21 %, released at 22 % (more than 1 "
|
||||
"point above its reserve)",
|
||||
seg(REL[0], 40, 100)[0] == 0 and seg(REL[0], 140, 200)[0] == 0
|
||||
and seg(REL[0], 240, 300)[0] > 500)
|
||||
ID[1]._held_was = frozenset({A, B})
|
||||
check("...a battery kept held back by the hysteresis is not one that can "
|
||||
"give: A at 20 % and B kept at 21 % hold nobody back",
|
||||
held_back((20.0, 21.0)) == frozenset())
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the rules at their boundaries")
|
||||
|
||||
|
||||
def stub(names, held, live=None):
|
||||
"""A Controller stand-in for `_drain_rise()`, on SIMULATED units."""
|
||||
s = types.SimpleNamespace(
|
||||
inverters=[types.SimpleNamespace(name=n) for n in names],
|
||||
actuator_of=lambda src, inv=None: inv.name,
|
||||
live_units=lambda: tuple(live or names),
|
||||
_held_back=lambda: frozenset(held))
|
||||
return s
|
||||
|
||||
|
||||
def rise(names, held, target, drains, extra=(), live=None):
|
||||
"""`_drain_rise()` then the split: (claims kinds, shares)."""
|
||||
cl = [Claim.limit("supervised", P_SAFETY, -FULL, FULL, actuator=n)
|
||||
for n in names]
|
||||
cl += [Claim.set("maintenance", P_MAINTENANCE, w, DRAIN, actuator=n)
|
||||
for n, w in drains.items()]
|
||||
cl += list(extra)
|
||||
res = [MaintResult(DRAIN, True, drains[n], False, []) if n in drains
|
||||
else MaintResult(IDLE, False, None, False, []) for n in names]
|
||||
Controller._drain_rise(stub(names, held, live), cl, res)
|
||||
r = resolve(cl + [Claim.set("loop", 10, target)], units=tuple(names),
|
||||
live=set(live or names), reserve=held)
|
||||
kinds = {c.actuator: c.kind for c in cl if c.source == "maintenance"}
|
||||
return kinds, {u: round(v) for u, v in r.shares.items()}
|
||||
|
||||
|
||||
_n3 = rise(("x", "y", "z"), {"z"}, 1500.0, {"x": 1000.0})
|
||||
print(f" N=3, x draining 1000, y able, z held, 1500 W: {_n3[1]}")
|
||||
check("⭐ N=3: the draining battery keeps its drain rate as a floor (x >= "
|
||||
"1000 W) while sharing with the able one; the held one gives nothing",
|
||||
_n3[0] == {"x": "limit"} and _n3[1]["x"] >= 1000 and _n3[1]["z"] == 0
|
||||
and sum(_n3[1].values()) == 1500)
|
||||
_stop = rise((A, B), {A}, 3000.0, {B: 1000.0},
|
||||
(Claim.set("safety", P_SAFETY, 0.0, "stopped"),))
|
||||
check("⭐ a site stop decides alone: the drain stays a pin, nothing rises",
|
||||
_stop[0] == {B: "set"} and _stop[1] == {A: 0, B: 0})
|
||||
_hd = rise((A, B), {A}, 5000.0, {A: 1000.0})
|
||||
check("...a held battery's own drain stays a pin (it is not raised for the "
|
||||
"loop): A 1000 W, B 2500 W",
|
||||
_hd[0] == {A: "set"} and _hd[1] == {A: 1000, B: 2500})
|
||||
_nh = rise((A, B), set(), 3000.0, {B: 1000.0})
|
||||
check("...with nothing held the drain stays a pin, as before (SAFETY-78)",
|
||||
_nh[0] == {B: "set"} and _nh[1] == {A: 2000, B: 1000})
|
||||
_dead = rise((A, B), {A}, 3000.0, {B: 1000.0}, live=(A,))
|
||||
check("...a draining battery that is not live is not touched",
|
||||
_dead[0] == {B: "set"})
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. next_quarter()")
|
||||
T0 = rig.BASE
|
||||
|
||||
|
||||
def proj(elapsed, covered=1.0, held=3000.0, start=T0):
|
||||
return Projection(projected_w=2000.0, elapsed_w=1900.0, elapsed_s=elapsed,
|
||||
covered_frac=covered, complete=covered >= 1.0,
|
||||
block_start=start, held_w=held)
|
||||
|
||||
|
||||
_in = proj(899.0)
|
||||
_past = next_quarter(proj(900.4, covered=899.9 / 900.4), 30.0)
|
||||
check("⭐ inside its block a projection is unchanged (the same object)",
|
||||
next_quarter(_in, 30.0) is _in)
|
||||
check("⭐ past its end with a fresh held reading: the next quarter, "
|
||||
"projected from that reading",
|
||||
_past.block_start == T0 + timedelta(seconds=QUARTER_S)
|
||||
and abs(_past.elapsed_s - 0.4) < 1e-9 and _past.projected_w == 3000.0
|
||||
and _past.covered_frac == 1.0
|
||||
and peakguard.blindness(_past, 30.0, False) is None)
|
||||
_old = proj(960.0, covered=880.0 / 960.0)
|
||||
_edge29 = proj(901.0, covered=871.5 / 901.0) # 29.5 s uncovered
|
||||
_edge31 = proj(901.0, covered=870.5 / 901.0) # 30.5 s uncovered
|
||||
check("...the held reading is credited up to max_hold_s and no further "
|
||||
"(29.5 s uncovered: rolled; 30.5 s: unchanged)",
|
||||
next_quarter(_edge29, 30.0) is not _edge29
|
||||
and next_quarter(_edge31, 30.0) is _edge31)
|
||||
check("...a held reading too old to be credited, no block, or an export: "
|
||||
"unchanged, or projected at 0 W offtake",
|
||||
next_quarter(_old, 30.0) is _old
|
||||
and next_quarter(proj(950.0, start=None), 30.0).block_start is None
|
||||
and next_quarter(proj(900.5, held=-800.0), 30.0).projected_w == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. N=1")
|
||||
for _name, _args in (("HOLD, late telegram at the edge",
|
||||
(5500.0, {A: HOLD}, {"a": 99.0}, 1000.0, 0.5)),
|
||||
("drain under the reserve, house 4000",
|
||||
(4000.0, {A: DRAIN}, {"a": 15.0}, 600.0, 0.5))):
|
||||
_new = run(*_args, n=1)
|
||||
_old = without_fix(lambda: run(*_args, n=1))
|
||||
print(f" {_name}: A at the edge "
|
||||
f"{[cmd(r)['a'] for r in _new[0] if 899 <= r[0] <= 901]}")
|
||||
check(f"⭐ N=1, {_name}: every row (claims, commands, the guard) is "
|
||||
"identical with and without the fix",
|
||||
_new[1].maints[0].phase == _args[1][A] and len(_new[0]) > 500
|
||||
and _new[0] == _old[0])
|
||||
_one = types.SimpleNamespace(inverters=[object()])
|
||||
_cl = [Claim.set("maintenance", P_MAINTENANCE, 1000.0, DRAIN)]
|
||||
check("⭐ at one inverter nothing is held back and no pin moves (the N=1 "
|
||||
"identity digests in test_safety78/79/83/84/86/87 stay pinned)",
|
||||
Controller._held_back(_one) == frozenset()
|
||||
and Controller._drain_rise(types.SimpleNamespace(
|
||||
inverters=[object()], _held_back=lambda: frozenset()), _cl,
|
||||
[MaintResult(DRAIN, True, 1000.0, False, [])]) is None
|
||||
and _cl[0].kind == "set")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-91 checks passed")
|
||||
@@ -0,0 +1,422 @@
|
||||
"""Runnable check for SAFETY-93: the maintenance charge end hands back
|
||||
smoothly, with no grid charging at a high house. `python3 test_safety93.py`
|
||||
|
||||
At N=2, when battery A's maintenance charge ended, its watts stayed in the
|
||||
loop's site target and landed on B: B went 0 -> -1600 W in one cycle and
|
||||
charged from the grid with the house at 6000 W (SAFETY-85 rig test, check 4).
|
||||
Now the loop starts from what B stood at plus A's new command. Drives the
|
||||
REAL Controller on a lagging battery (10 s, 2 s meter, as on the rig).
|
||||
DECISIONS/SAFETY-93-charge-end.md.
|
||||
|
||||
1. The rig case: A charges 2000 W late in a quarter with the house at 6000 W,
|
||||
then its charge ends (HOLD).
|
||||
2. The same at a low house.
|
||||
3. The charge ends another way: a timeout (abort) and the battery switched
|
||||
Off.
|
||||
4. The charge ends while the guard has suspended it (A gives under the loop).
|
||||
5. N=1 is untouched; a missed poll on the end cycle; the rule at its
|
||||
boundaries.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
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 safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import (CHARGE_TIMEOUT_MIN, HOLD, IDLE, # noqa: E402
|
||||
MaintResult)
|
||||
from app.store import unit_key # noqa: E402
|
||||
from named_absence import first, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
A = "inverter-a"
|
||||
SLEW = 400.0 # the rig's slew_w
|
||||
AIM = 3500 - 50 # peak_cap_w - peak_margin_w
|
||||
OPT0 = dict(s84.OPT)
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def lagging(fn, lag=10.0):
|
||||
"""plantsim's battery (800 W/s ramp, `lag` s first-order lag) at the
|
||||
live 2 s meter tick (test_safety85.py part 7)."""
|
||||
real_p, real_t = rig._Hass.power, rig.TICK
|
||||
|
||||
def power(self, u):
|
||||
st = self.__dict__.setdefault("_plant", {})
|
||||
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
||||
for _ in range(int(round((self.t - tl) / 0.1))):
|
||||
d = self.cmd[u] - tgt
|
||||
tgt += math.copysign(min(abs(d), 80.0), d)
|
||||
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
|
||||
st[u] = (a, tgt, self.t)
|
||||
return float(round(a))
|
||||
rig._Hass.power, rig.TICK = power, 2.0
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_p, real_t
|
||||
|
||||
|
||||
def run(n, house, end, *, step=None, dur=None, store=None, extra=None,
|
||||
before=None):
|
||||
"""A charging in a cheap window from the first cycle (quarters start at
|
||||
0, 900, 1800 s); the house is 1200 W, then `house` from `step`; A reads
|
||||
99 % (its charge target) from `end`. Returns (rows, controller)."""
|
||||
step = end if step is None else step
|
||||
s84.OPT = dict(OPT0, peak_cap_w=3500)
|
||||
feed = s84.feed(True)
|
||||
|
||||
def both(c, t):
|
||||
if before is not None:
|
||||
before(c, t)
|
||||
feed(c, t)
|
||||
try:
|
||||
return lagging(lambda: s84.run(
|
||||
n, load=lambda t: house if t >= step else 1200.0, cheap=True,
|
||||
store=store or s79.store_charging(n),
|
||||
soc=lambda u, t: (99.0 if t >= end else 90.0) if u == "a"
|
||||
else 60.0, dur=dur or end + 300.0, before=both,
|
||||
extra=dict({"maintenance_hold_min": 600}, **(extra or {}))))
|
||||
finally:
|
||||
s84.OPT = OPT0
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def moves(rows, u, lo, hi):
|
||||
"""The largest change in battery `u`'s command between cycles in
|
||||
(lo, hi]."""
|
||||
return max(abs(cmd(b)[u] - cmd(a)[u]) for a, b in zip(rows, rows[1:])
|
||||
if lo < b[0] <= hi)
|
||||
|
||||
|
||||
def charging_hi(rows, lo, hi, house_at):
|
||||
"""Cycles in [lo, hi] where a battery charges while the house is above
|
||||
the peak aim."""
|
||||
return sum(1 for r in rows if lo <= r[0] <= hi and house_at(r[0]) > AIM
|
||||
and min(cmd(r).values()) < 0.0)
|
||||
|
||||
|
||||
# None on a tree without the fix (f3e409c), where this file runs red.
|
||||
no_fix = getattr(Controller, "_reseed_charge_end", None)
|
||||
|
||||
|
||||
def without(fn):
|
||||
"""Run `fn` with the SAFETY-93 reseed taken out."""
|
||||
if no_fix is None:
|
||||
return fn()
|
||||
Controller._reseed_charge_end = lambda self, ended: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._reseed_charge_end = no_fix
|
||||
|
||||
|
||||
def phase_at(rows, t):
|
||||
return first((r[-1][3][0] for r in rows if r[0] == t), f"row {t}")
|
||||
|
||||
|
||||
def at(rows, t, u):
|
||||
"""Battery `u`'s command at `t` s."""
|
||||
r = first((r for r in rows if r[0] == t), f"row {t}")
|
||||
return dict(r[2])[u] if present(r) else r
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. the rig case: A charges 2000 W late in a quarter, house 6000 W")
|
||||
# House 1200 W until 1760 s, then 6000 W; the quarter (900-1800 s) has room,
|
||||
# so the charge stays at 2000 W. A is full at 1780 s. Graded to 1798 s: the
|
||||
# guard grades a new quarter from 1800 s.
|
||||
R1 = run(2, 6000.0, 1780.0, step=1760.0)
|
||||
R1_old = without(lambda: run(2, 6000.0, 1780.0, step=1760.0))
|
||||
|
||||
|
||||
def hi1(t):
|
||||
return 6000.0 if t >= 1760.0 else 1200.0
|
||||
|
||||
|
||||
for name, (rows, _c) in (("now", R1), ("without the fix", R1_old)):
|
||||
print(f" {name}: " + ", ".join(
|
||||
f"{r[0]:.0f}s A {cmd(r)['a']:.0f} B {cmd(r)['b']:.0f}"
|
||||
for r in rows if 1776 <= r[0] <= 1786))
|
||||
_r, _ = R1
|
||||
check("the case is the rig's: A charges 2000 W with the house at 6000 W the "
|
||||
"cycle before its charge ends, then holds",
|
||||
at(_r, 1778.0, "a") == -2000.0
|
||||
and phase_at(_r, 1778.0) == "charge" and phase_at(_r, 1780.0) == HOLD)
|
||||
check("⭐ no battery charges from the grid after the charge ends (house above "
|
||||
"the aim)", charging_hi(_r, 1780, 1798, hi1) == 0)
|
||||
check("⭐ B moves at most one slew step a cycle across the charge end, and A "
|
||||
"after its HOLD pin", moves(_r, "b", 1776, 1798) <= SLEW
|
||||
and moves(_r, "a", 1780, 1798) <= SLEW)
|
||||
check("...and B gives toward the house at once (+400 W at the end cycle)",
|
||||
at(_r, 1780.0, "b") == SLEW)
|
||||
check("non-vacuous: without the fix B jumps 0 -> -1600 W and charges from "
|
||||
"the grid for 4 cycles", moves(R1_old[0], "b", 1776, 1798) >= 1600
|
||||
and charging_hi(R1_old[0], 1780, 1798, hi1) >= 4)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the same at a low house")
|
||||
R2 = run(2, 1200.0, 1300.0)
|
||||
R2_old = without(lambda: run(2, 1200.0, 1300.0))
|
||||
print(f" B's largest move: {moves(R2[0], 'b', 1296, 1600):.0f} W, without "
|
||||
f"the fix {moves(R2_old[0], 'b', 1296, 1600):.0f} W; B's least "
|
||||
f"command after the end "
|
||||
f"{min(cmd(r)['b'] for r in R2[0] if r[0] >= 1300):.0f}"
|
||||
f" W, without {min(cmd(r)['b'] for r in R2_old[0] if r[0] >= 1300):.0f}"
|
||||
" W")
|
||||
check("⭐ house 1200 W: B moves at most one slew step a cycle and never "
|
||||
"charges from the grid after A's charge ends",
|
||||
moves(R2[0], "b", 1296, 1600) <= SLEW
|
||||
and min(cmd(r)["b"] for r in R2[0] if r[0] >= 1300) >= 0.0)
|
||||
check("non-vacuous: without the fix B jumps to -1600 W (2800 W from the grid)",
|
||||
min(cmd(r)["b"] for r in R2_old[0] if r[0] >= 1300) <= -1600.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the charge ends another way")
|
||||
|
||||
|
||||
def timed_out(end):
|
||||
"""A store whose charge passes CHARGE_TIMEOUT_MIN at `end` s."""
|
||||
s = s79.store_charging(2)
|
||||
s.data[unit_key(A, "phase_started")] = (
|
||||
rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1)
|
||||
).isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def b_done():
|
||||
"""A charging; B finished its monthly cycle, so it starts none here."""
|
||||
s = s79.store_charging(2)
|
||||
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def switch_off(at):
|
||||
"""Battery A switched Off at `at` s (OPT-11 modes on)."""
|
||||
def before(c, t):
|
||||
if t >= at:
|
||||
c.store.data[unit_key(A, packmode.USE_KEY)] = False
|
||||
return before
|
||||
|
||||
|
||||
ENDS = {
|
||||
"timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=timed_out(1780.0)),
|
||||
"Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=b_done(), before=switch_off(1780.0),
|
||||
extra={"battery_modes_enabled": True}),
|
||||
}
|
||||
for k, fn in ENDS.items():
|
||||
rows, c = fn()
|
||||
old = without(fn)[0]
|
||||
print(f" {k}: A {[cmd(r)['a'] for r in rows if 1776 <= r[0] <= 1784]}"
|
||||
f" B {[cmd(r)['b'] for r in rows if 1776 <= r[0] <= 1784]}; "
|
||||
f"without the fix B "
|
||||
f"{[cmd(r)['b'] for r in old if 1776 <= r[0] <= 1784]}")
|
||||
check(f"{k}: the charge ends at 1780 s",
|
||||
phase_at(rows, 1778.0) == "charge"
|
||||
and phase_at(rows, 1780.0) == IDLE)
|
||||
check(f"⭐ {k}: no battery charges from the grid after it, and B moves at "
|
||||
f"most one slew step a cycle",
|
||||
charging_hi(rows, 1780, 1798, hi1) == 0
|
||||
and moves(rows, "b", 1776, 1798) <= SLEW)
|
||||
check(f"non-vacuous ({k}): without the fix B jumps to -1600 W, from "
|
||||
f"the grid", at(old, 1780.0, "b") == -1600.0
|
||||
and charging_hi(old, 1780, 1798, hi1) >= 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the charge ends while the guard has suspended it")
|
||||
# House 6000 W from 1500 s: the guard suspends the shaping, the loop has both
|
||||
# batteries give. A is full at 1530 s; its HOLD pin takes it to 0 W.
|
||||
R4 = run(2, 6000.0, 1530.0, step=1500.0)
|
||||
R4_old = without(lambda: run(2, 6000.0, 1530.0, step=1500.0))
|
||||
_a4 = at(R4[0], 1528.0, "a")
|
||||
print(f" A gave {_a4:.0f} W before its HOLD; B's largest move "
|
||||
f"{moves(R4[0], 'b', 1526, 1798):.0f} W, without the fix "
|
||||
f"{moves(R4_old[0], 'b', 1526, 1798):.0f} W")
|
||||
check("A gives under the loop before its charge ends",
|
||||
present(_a4) and _a4 > SLEW and R4[1].maints[0].phase == HOLD)
|
||||
check("⭐ B takes A's share at one slew step a cycle (A's own give stops "
|
||||
"with its HOLD pin, as before)", moves(R4[0], "b", 1526, 1798) <= SLEW
|
||||
and charging_hi(R4[0], 1530, 1798, lambda t: 6000.0) == 0)
|
||||
check("non-vacuous: without the fix B jumps more than a slew step",
|
||||
moves(R4_old[0], "b", 1526, 1798) > SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. N=1 is untouched; a missed poll; the rule at its boundaries")
|
||||
|
||||
|
||||
def digest(rows):
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
_n1 = [digest(run(1, h, e, step=s)[0]) for h, e, s in
|
||||
((6000.0, 1780.0, 1760.0), (1200.0, 1300.0, None))]
|
||||
_n1_old = [digest(without(lambda: run(1, h, e, step=s))[0]) for h, e, s in
|
||||
((6000.0, 1780.0, 1760.0), (1200.0, 1300.0, None))]
|
||||
check("⭐ N=1, the charge ending at a high and a low house: every row "
|
||||
"byte-identical with and without the reseed", _n1 == _n1_old)
|
||||
|
||||
if no_fix is None:
|
||||
print(f"\n{len(fails)} FAILED (no _reseed_charge_end): " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def missed_poll(at_t):
|
||||
"""Battery B's power reading is missing on the cycle at `at_t` s."""
|
||||
def before(c, t):
|
||||
if not getattr(c.hass, "_s93", False):
|
||||
read = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.batt_b" and c.hass.t == at_t:
|
||||
return None
|
||||
return await read(e, invert)
|
||||
c.hass.number, c.hass._s93 = number, True
|
||||
return before
|
||||
|
||||
|
||||
R6 = run(2, 6000.0, 1780.0, step=1760.0, before=missed_poll(1780.0))
|
||||
R6_old = without(lambda: run(2, 6000.0, 1780.0, step=1760.0,
|
||||
before=missed_poll(1780.0)))
|
||||
print(f" a missed poll on the end cycle: B "
|
||||
f"{[cmd(r)['b'] for r in R6[0] if 1778 <= r[0] <= 1786]}, without "
|
||||
f"the fix {[cmd(r)['b'] for r in R6_old[0] if 1778 <= r[0] <= 1786]}")
|
||||
check("a missed poll on the cycle the charge ends: the loop holds that cycle",
|
||||
phase_at(R6[0], 1780.0) == HOLD
|
||||
and "inputs missing" in str(first(r[6] for r in R6[0] if r[0] == 1780.0)))
|
||||
check("⭐ ...and the end still reaches the loop on the next cycle: no grid "
|
||||
"charge (A's charge stands through the held cycle), one slew step a "
|
||||
"cycle", charging_hi(R6[0], 1782, 1798, hi1) == 0
|
||||
and moves(R6[0], "b", 1776, 1798) <= SLEW)
|
||||
check("non-vacuous: without the fix B charges from the grid",
|
||||
charging_hi(R6_old[0], 1782, 1798, hi1) >= 1)
|
||||
_c = R1[1]
|
||||
|
||||
|
||||
def reseeded(target, shares, res, ended=frozenset({A})):
|
||||
"""`_reseed_charge_end` on the rig's controller, A's charge ended and
|
||||
its result `res`: (target, i_w)."""
|
||||
_c.target, _c.i_w, _c._charge_ended = target, 123.0, ended
|
||||
for inv in _c.inverters:
|
||||
inv.target = shares[inv.name[-1]]
|
||||
_c._reseed_charge_end([res, _free])
|
||||
return _c.target, _c.i_w
|
||||
|
||||
|
||||
_pin = MaintResult(HOLD, True, 0.0, False, [])
|
||||
_free = MaintResult(IDLE, False, None, False, [])
|
||||
check("a HOLD pin: the start is B's command plus the pin, integrator with it",
|
||||
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _pin) == (0.0, 0.0)
|
||||
and reseeded(2770.0, {"a": 1385.0, "b": 1385.0}, _pin)
|
||||
== (1385.0, 1385.0) and _c._charge_ended == frozenset())
|
||||
check("a free battery: its charge leaves the start, its discharge stays",
|
||||
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _free) == (0.0, 0.0)
|
||||
and reseeded(2770.0, {"a": 1385.0, "b": 1385.0}, _free)
|
||||
== (2770.0, 2770.0))
|
||||
check("no charge ended: nothing moves (the integrator either)",
|
||||
reseeded(-2000.0, {"a": -2000.0, "b": 0.0}, _pin, frozenset())
|
||||
== (-2000.0, 123.0))
|
||||
_n1c = run(1, 6000.0, 1780.0, step=1760.0)[1]
|
||||
_n1c.target, _n1c.i_w = -2000.0, 5.0
|
||||
_n1c.inverters[0].target, _n1c._charge_ended = -2000.0, frozenset({A})
|
||||
_n1c._reseed_charge_end([_pin])
|
||||
check("N=1: nothing moves", (_n1c.target, _n1c.i_w) == (-2000.0, 5.0))
|
||||
_seen = []
|
||||
|
||||
|
||||
def _spy(self, maint_results):
|
||||
_seen.append(sorted(self._charge_ended))
|
||||
return no_fix(self, maint_results)
|
||||
|
||||
|
||||
Controller._reseed_charge_end = _spy
|
||||
try:
|
||||
run(2, 6000.0, 1780.0, step=1760.0, dur=2400.0,
|
||||
extra={"maintenance_hold_min": 5})
|
||||
finally:
|
||||
Controller._reseed_charge_end = no_fix
|
||||
check("it fires once, at the charge end: not at the HOLD end or on any "
|
||||
"other cycle", [x for x in _seen if x] == [[A]]
|
||||
and len(_seen) > 300)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. A still in CHARGE but no longer placed: stale link, refusal")
|
||||
|
||||
|
||||
def link_drop(at):
|
||||
"""A's writes rejected from `at` s, so it is dropped as a stale link."""
|
||||
def before(c, t):
|
||||
a = c.inverters[0]
|
||||
if t >= at and a.rejected_since is None:
|
||||
a.rejected_since = rig.BASE + timedelta(seconds=at)
|
||||
return before
|
||||
|
||||
|
||||
def refuses(at):
|
||||
"""A stops charging from `at` s (reads 0 W); SAFETY-73 drops it."""
|
||||
def before(c, t):
|
||||
if not getattr(c.hass, "_s93r", False):
|
||||
real = c.hass.power
|
||||
|
||||
def power(u):
|
||||
p = real(u)
|
||||
return 0.0 if u == "a" and c.hass.t >= at and p < 0 else p
|
||||
c.hass.power, c.hass._s93r = power, True
|
||||
return before
|
||||
|
||||
|
||||
for k, hook in (("stale link", link_drop), ("refusal", refuses)):
|
||||
def fn(hook=hook):
|
||||
return run(2, 1200.0, 9999.0, dur=1500.0, before=hook(1300.0))
|
||||
rows, _c6 = fn()
|
||||
old = without(fn)[0]
|
||||
drop = first((r[0] for r in rows if r[0] >= 1300
|
||||
and "dropped inverter-a" in str(r[7])), "A dropped")
|
||||
print(f" {k}: A dropped at {drop:.0f} s; B "
|
||||
f"{[cmd(r)['b'] for r in rows if drop - 2 <= r[0] <= drop + 4]}, "
|
||||
f"without the fix {[cmd(r)['b'] for r in old if drop - 2 <= r[0] <= drop + 4]}")
|
||||
check(f"⭐ {k}: A is dropped while still in CHARGE, and B never charges "
|
||||
f"from the grid and moves at most one slew step a cycle",
|
||||
phase_at(rows, drop) == "charge"
|
||||
and min(cmd(r)["b"] for r in rows if r[0] >= 1300) >= 0.0
|
||||
and moves(rows, "b", 1296, 1500) <= SLEW)
|
||||
check(f"non-vacuous ({k}): without the fix B jumps to -1600 W",
|
||||
min(cmd(r)["b"] for r in old if r[0] >= 1300) <= -1600.0)
|
||||
|
||||
_seen.clear()
|
||||
Controller._reseed_charge_end = _spy
|
||||
try:
|
||||
run(2, 6000.0, 1530.0, step=1500.0)
|
||||
finally:
|
||||
Controller._reseed_charge_end = no_fix
|
||||
check("the guard suspending the charge is no end: it fires once, at the HOLD",
|
||||
[x for x in _seen if x] == [[A]])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-93 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",
|
||||
|
||||
@@ -1631,7 +1631,10 @@ def _sign_site(hass, store=None, **over):
|
||||
_P(), stop=asyncio.Event())
|
||||
b = Inverter("inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS, store=c.store)
|
||||
max_w=B_RATING_HYPOTHESIS, store=c.store,
|
||||
# SAFETY-91: a cut-out, as rule 8 requires; without one B is
|
||||
# held back at its reserve and gets no share to reverse.
|
||||
bms_soc_floor=c.inverters[0].bms_soc_floor)
|
||||
b.dev_min, b.dev_max = -B_RATING_HYPOTHESIS, B_RATING_HYPOTHESIS
|
||||
c.inverters.append(b)
|
||||
c.auto = True
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/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.
|
||||
|
||||
SAFETY-83 adds the owner's settings (limit 3500, drain 2500, margin 50) with a
|
||||
house that goes over drain + aim and back, and N=2 runs of both. At N=1 the
|
||||
peak floor now outranks the drain where it asks for more, so the N=1 digests
|
||||
moved; `--dump FILE` on the base tree and `--diff FILE` here list every
|
||||
changed cycle and whether the peak guard wanted more than the drain on it.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import os
|
||||
import pickle
|
||||
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
|
||||
|
||||
|
||||
def load_owner(t):
|
||||
"""Below the 2500 W drain, above it, over drain + aim (5950 W), back."""
|
||||
return (1200.0, 3000.0, 6500.0, 3000.0, -1500.0)[min(int(t // 480), 4)]
|
||||
|
||||
|
||||
OWNER = {"peak_cap_w": 3500, "peak_margin_w": 50,
|
||||
"maintenance_discharge_w": 2500}
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_drain": dict(n=1),
|
||||
"n1_drain_modes": dict(n=1, modes=True),
|
||||
"n1_drain_owner": dict(n=1, load=load_owner, extra=OWNER),
|
||||
"n2_drain": dict(n=2),
|
||||
"n2_drain_owner": dict(n=2, load=load_owner, extra=OWNER),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
||||
extra = dict(s79.P1, **DRAIN, **(extra or {}))
|
||||
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()}
|
||||
|
||||
|
||||
FIELDS = ("t", "target", "commands", "loop reason", "integrator",
|
||||
"saturation", "why", "allocation")
|
||||
XFIELDS = ("claims", "guard", "roles", "trade", "phase", "log length",
|
||||
"newest log line")
|
||||
|
||||
|
||||
def wanted_more(row) -> bool:
|
||||
"""Did the peak guard ask for more than the drain rate on this cycle?"""
|
||||
claims, guard = row[-1][0] or (), row[-1][1]
|
||||
rate = [c[3] for c in claims if c[0] == "maintenance" and c[2] == "set"]
|
||||
return (bool(rate) and guard is not None and bool(guard[0])
|
||||
and guard[1] is not None and guard[1] > max(rate))
|
||||
|
||||
|
||||
def diff(base: dict, now: dict) -> dict:
|
||||
"""Per scenario: (cycles, changed, changed where the guard wanted more on
|
||||
either tree, changed cycles whose commands differ without that)."""
|
||||
out = {}
|
||||
for k, rows in now.items():
|
||||
old = base.get(k)
|
||||
if old is None:
|
||||
continue
|
||||
ch = [i for i, (a, b) in enumerate(zip(old, rows)) if a != b]
|
||||
ch += list(range(min(len(old), len(rows)), max(len(old), len(rows))))
|
||||
more = [i for i in ch if i < min(len(old), len(rows))
|
||||
and (wanted_more(old[i]) or wanted_more(rows[i]))]
|
||||
cmd = [i for i in ch if i not in more
|
||||
and (i >= min(len(old), len(rows))
|
||||
or old[i][1:3] != rows[i][1:3])]
|
||||
fields = set()
|
||||
for i in set(ch) - set(more) - set(cmd):
|
||||
fields |= {FIELDS[j] for j in range(8) if old[i][j] != rows[i][j]}
|
||||
fields |= {XFIELDS[j] for j in range(7)
|
||||
if old[i][8][j] != rows[i][8][j]}
|
||||
out[k] = (len(rows), ch, more, cmd, sorted(fields))
|
||||
return out
|
||||
|
||||
|
||||
def all_rows() -> dict:
|
||||
return {k: run(**v)[0] for k, v in SCENARIOS.items()}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--dump" in sys.argv:
|
||||
with open(sys.argv[sys.argv.index("--dump") + 1], "wb") as f:
|
||||
pickle.dump(all_rows(), f)
|
||||
sys.exit(0)
|
||||
if "--diff" in sys.argv:
|
||||
with open(sys.argv[sys.argv.index("--diff") + 1], "rb") as f:
|
||||
base = pickle.load(f)
|
||||
for k, (n, ch, more, cmd, fields) in diff(base, all_rows()).items():
|
||||
rest = sorted(set(ch) - set(more))
|
||||
print(f"{k}: {n} cycles, {len(ch)} changed, {len(more)} with the "
|
||||
f"guard above the drain, {len(rest)} others "
|
||||
f"({len(cmd)} with a different target or command"
|
||||
+ (f"; the rest differ only in {', '.join(fields)}"
|
||||
if fields else "") + ")"
|
||||
+ (f", first changed t={ch[0] * rig.TICK:.0f} s" if ch else ""))
|
||||
sys.exit(0)
|
||||
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,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,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-84: at one inverter, is a maintenance charge byte-identical to before?
|
||||
|
||||
python3 tools/safety84_identity.py # prints one digest per scenario
|
||||
|
||||
safety78_identity's harness (the REAL Controller on a P1 meter, every claim
|
||||
hashed) with the battery in its maintenance CHARGE phase from the first cycle,
|
||||
on the owner's settings (limit 3500, margin 50, charge 2000 W). The house steps
|
||||
below the charge rate, at it, under the aim, over it and into a solar surplus,
|
||||
in a cheap window, outside one, and under a month peak banked at 4000 W. Uses
|
||||
only the API release/1.0 9d5b7d1 has, so the same file runs on both trees;
|
||||
test_safety84.py holds this tree to the digests printed there (N=1) and to the
|
||||
new N=2 ones.
|
||||
"""
|
||||
|
||||
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 safety79_identity as s79 # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
|
||||
DUR = 2400.0
|
||||
OPT = {"peak_cap_w": 3500, "peak_margin_w": 50, "maintenance_enabled": True,
|
||||
"maintenance_start_hour": 11, "maintenance_charge_w": 2000,
|
||||
"price_now_entity": "sensor.pnow", "price_avg_entity": "sensor.pavg"}
|
||||
|
||||
|
||||
def load(t):
|
||||
"""Below the 2000 W charge, at it, under the aim, over it, a surplus."""
|
||||
return (800.0, 2000.0, 3000.0, 5000.0, -1500.0)[min(int(t // 480), 4)]
|
||||
|
||||
|
||||
def feed(cheap):
|
||||
"""s79's P1 feed plus a price now under (cheap) or over the average."""
|
||||
now = 0.05 if cheap else 0.30
|
||||
|
||||
def before(c, t):
|
||||
if not getattr(c.hass, "_s84", False):
|
||||
read = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.pnow":
|
||||
return now
|
||||
if e == "sensor.pavg":
|
||||
return 0.20
|
||||
return await read(e, invert)
|
||||
c.hass.number, c.hass._s84 = number, True
|
||||
s79._feed(c, t)
|
||||
return before
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_charge": dict(n=1),
|
||||
"n1_charge_dear": dict(n=1, cheap=False),
|
||||
"n1_charge_banked": dict(n=1, month_peak=4000.0),
|
||||
"n2_charge": dict(n=2),
|
||||
"n2_charge_dear": dict(n=2, cheap=False),
|
||||
"n2_charge_banked": dict(n=2, month_peak=4000.0),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
|
||||
before=None, cut=None, extra=None, store=None):
|
||||
"""Rows (opt11_identity's fields plus every claim, the guard and the
|
||||
phases) and the controller. `month_peak` banks that peak this month."""
|
||||
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 = c.peak_guard
|
||||
extra_rows.append((
|
||||
seen[-1] if seen else None,
|
||||
None if g is None else (g.engaged, g.floor_w, g.reason),
|
||||
c._maint_shape_suspended, [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="s84-"), "tariff.db")
|
||||
if month_peak is not None:
|
||||
tariff.PeakStore(db)._db.execute(
|
||||
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
|
||||
(tariff.month_key(rig.BASE), month_peak,
|
||||
"2026-09-02T10:00:00+00:00")).connection.commit()
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
m.resolve = spy
|
||||
try:
|
||||
rows, c = rig.run(n, extra=dict(s79.P1, **OPT, **(extra or {})),
|
||||
dur=dur, load=load, soc=soc or s79.soc,
|
||||
cut=cut or (lambda u, t: False),
|
||||
before=before or feed(cheap), on_cycle=on_cycle,
|
||||
store=store or s79.store_charging(n))
|
||||
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__":
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-87: at one inverter, is the peak guard byte-identical to before?
|
||||
|
||||
python3 tools/safety87_identity.py # prints one digest per scenario
|
||||
|
||||
safety84_identity's harness (the REAL Controller on a P1 meter, every claim
|
||||
and the guard's decision hashed) with the battery under, crossing and above
|
||||
the peak reserve, the house over the aim, in a maintenance charge and HOLD,
|
||||
and under a month peak banked at 4000 W. Uses only the API release/1.0
|
||||
b8ddfd7 has, so the same file runs on both trees; test_safety87.py holds this
|
||||
tree to the digests printed there.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
|
||||
DUR = 1800.0
|
||||
|
||||
|
||||
def crossing(u, t):
|
||||
"""30 % falling 1 % a minute to 12 %: crosses the 20 % reserve."""
|
||||
return max(12.0, 30.0 - t / 60.0)
|
||||
|
||||
|
||||
def store(phase=None):
|
||||
"""In `phase` from the first cycle, or idle with a cycle done yesterday."""
|
||||
s = rig._Store()
|
||||
if phase is None:
|
||||
s.data["last_completed"] = (rig.BASE - timedelta(days=1)).isoformat()
|
||||
else:
|
||||
s.data["phase"], s.data["phase_started"] = phase, rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
SCENARIOS = {
|
||||
"n1_under": dict(soc=lambda u, t: 15.0, load=lambda t: 5500.0),
|
||||
"n1_crossing": dict(soc=crossing, load=lambda t: 5000.0),
|
||||
"n1_above": dict(soc=lambda u, t: 60.0, load=lambda t: 5500.0),
|
||||
"n1_under_banked": dict(soc=lambda u, t: 15.0, load=lambda t: 6000.0,
|
||||
month_peak=4000.0),
|
||||
"n1_charge_low": dict(soc=lambda u, t: 11.0, load=lambda t: 5000.0,
|
||||
store="charge"),
|
||||
"n1_hold": dict(soc=lambda u, t: 99.0, load=lambda t: 5500.0,
|
||||
store="hold", cheap=False,
|
||||
extra={"maintenance_hold_min": 600}),
|
||||
}
|
||||
|
||||
|
||||
def digests() -> dict:
|
||||
out = {}
|
||||
for k, v in SCENARIOS.items():
|
||||
v = dict(v)
|
||||
st = store(v.pop("store", None))
|
||||
out[k] = s84.digest(s84.run(1, dur=DUR, store=st, **v)[0])
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for k, v in digests().items():
|
||||
print(f"{k} {v}")
|
||||
@@ -85,6 +85,15 @@ configuration:
|
||||
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."
|
||||
|
||||
@@ -66,6 +66,15 @@ configuration:
|
||||
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."
|
||||
|
||||
Reference in New Issue
Block a user