Compare commits

...
Author SHA1 Message Date
glenn schrooyenandClaude ec4ba49f45 Release 0.20.1
checks / unit (pull_request) Successful in 28m19s
infra-pin / fresh (pull_request) Successful in 1s
Patch bump: OPT-36 (Leftover solar counts Cheap hours only and Solar first batteries). No option changes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 17:52:41 +01:00
admin 22d09f8fe7 Merge pull request 'OPT-36: Leftover solar counts Cheap hours and Solar first batteries before buying' (#229) from OPT-36 into hotfix-0.20.1 2026-11-02 14:49:45 +01:00
glenn schrooyenandClaude eb9ccc2730 OPT-36 round 4: count only charge control will make; Solar first drain at the night base
checks / unit (pull_request) Successful in 44m14s
infra-pin / fresh (pull_request) Successful in 2s
Review F1: a slot after B's run counts only if no slot from the expensive
hours on is cheaper (control plans B's charge left in the cheapest window
ahead), and x 0.95 of B's charge is stored. Three-price night: 1ac7fe9 left
the expensive hours 2.61 kWh short; now 0.
Review F2: a Solar first battery's drain until the expensive hours reads each
hour at no less than the night base (the profile is net of the sun). Read net,
a dark morning drained the battery the plan still counted; A's level fell,
the cheap run moved and was lost before it started (+0.40 EUR per 3 days).
The two real-price replays now cost what 0.20.0 does.
F3: the ux54 sweep's peak limit is 15 kW (the option's top). F4: the rest
comes from the grid (EN/NL). F5: CHANGELOG and DECISIONS wording.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 12:56:17 +01:00
glenn schrooyenandClaude 1ac7fe98d7 OPT-36 round 3: test_opt36 reads the mid_flat level through nth()
checks / unit (pull_request) Successful in 26m38s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 10:57:22 +01:00
glenn schrooyenandClaude e4b9dee699 OPT-36 round 3: B's charge counts the cheap time left; OPT-34 nights on their own shapes
checks / unit (pull_request) Failing after 36m29s
infra-pin / fresh (pull_request) Successful in 2s
The run is sized at the batteries' ratings, not the peak limit, so a run too
short to fill a Cheap hours battery is planned again later in the night.
Counting that run alone made B's give grow through the night and A's level
fall after A had bought (round 2: 55 -> 69 -> 41 -> 37 %, A to 56 %).
dayplan._cheap_give_kwh() now also counts the later firm slots before the
expensive hours that a run would still be worth charging in (price + the
spread control asks at most the give run's dearest slot); the Controller
passes the slots and the spread.

test_opt34: back to the original house shapes, fixture and expected values.
B is Off in the nights that test A's level (a Cheap hours B now covers
these hours); B's checks run in new `_b` nights; the 03:00 check runs in a
night from midnight; tail starts A at 60 %; late_fresh reads the kept 6.0.
test_opt36: slot cases, a whole night on one figure (level never moves),
F-1 with B counted (raised level reached and held). test_ux54: the run end
is read from the plan and asserted (02:30); the F1/F2 sweep uses a peak
limit that does not bind and reads a level shown as the target as at it.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 10:15:20 +01:00
glenn schrooyenandClaude 3a18475a5a OPT-36 round 2: test_opt34 keeps its tolerances; fresh sensors read the day they are on
checks / unit (pull_request) Successful in 20m36s
infra-pin / fresh (pull_request) Successful in 2s
The silent and fresh nights diverged only because the test's fresh sensors
kept reading the evening's 0.4 kWh 'left today' after midnight, while the
kept figure is the 4th's 6.0 kWh (solarfc.Hold.stand_in, as_today): two
different shortfalls (16.5 and 12.7 kWh) that A's ceiling hid until B was
counted. Fresh sensors now read 6.0 after midnight, so fresh and kept are
the same figure; the 0.3 tolerance is back, the other exact values are
recomputed. test_ux54 literals are recomputed too.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 05:34:32 +01:00
glenn schrooyenandClaude 6a527de851 OPT-36: test_opt34 and test_ux54 scenarios keep a shortfall B does not cover
checks / unit (pull_request) Successful in 27m17s
infra-pin / fresh (pull_request) Successful in 2s
With Cheap hours B counted, A buys only what B cannot reach. The OPT-34
night scenarios use a 1.6 x larger house profile, and the ux54 tab scenarios
a 4.8 kW evening (ux54_scenarios.make(evening=)), so A still charges; the
hard-coded levels become relations.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 03:30:39 +01:00
glenn schrooyenandClaude 60674e45ba OPT-36: test_opt36 reads possibly-missing values through nth()
checks / unit (pull_request) Failing after 35m3s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 02:50:33 +01:00
glenn schrooyenandClaude 490f67924b OPT-36: test a give block before tonight's charge, Solar first refusing and in maintenance
checks / unit (pull_request) Failing after 26m32s
infra-pin / fresh (pull_request) Successful in 4s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 02:43:02 +01:00
glenn schrooyenandClaude 05746c0564 OPT-36: Leftover solar counts what Cheap hours and Solar first batteries will give before buying
checks / unit (pull_request) Failing after 59m31s
infra-pin / fresh (pull_request) Successful in 2s
Live 0.20.0: A (Leftover solar, 60 %) charged to 95 % while B (Cheap hours,
94 %) already covered the expensive hours. dayplan.plan() now takes off the
shortfall what Cheap hours batteries will hold when the hours start (the
ceiling or what their cheap run reaches under the peak aim) and what Solar
first batteries will still hold after the night's use. Home reads a give
block after the planned charge, plan_cover() is the plan's own figure, and
the Why line says other batteries cover.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-02 02:03:04 +01:00
admin 01e88a24f2 Release 0.20.0: release/1.0 -> master (#227) 2026-11-01 18:58:04 +01:00
admin 77fe31b161 Merge pull request 'Release 0.20.0' (#226) from release-0.20.0 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 18m57s
infra-pin / fresh (pull_request) Successful in 1s
2026-11-01 17:22:03 +01:00
glenn schrooyenandClaude Opus 5.5 e9fa11e60d Release 0.20.0 review: CHANGELOG wording (OPT-23 headline, CI-15 time, UX-75 quote)
checks / unit (pull_request) Successful in 19m14s
infra-pin / fresh (pull_request) Successful in 1s
- The OPT-23 headline said a daily-total forecast is now counted right by
  day; OPT-30 (known issue) still over-counts by day. Say what changed.
- CI-15: 16 min was one run; runs took 16 to 24 min.
- UX-75: quote the page text as it reads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:02:17 +01:00
glenn schrooyenandClaude d2f63fe86a Release 0.20.0: version bump and CHANGELOG
checks / unit (pull_request) Successful in 24m4s
infra-pin / fresh (pull_request) Successful in 2s
config.yaml version 0.20.0 (minor; no option change) and a ## 0.20.0
CHANGELOG section that folds in the unreleased block and lists every
first-parent merge since 0.19.2 (f1c9733).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-01 16:12:45 +01:00
admin cdafa2c189 Merge pull request 'OPT-34 fix (rig F-1): a replaced evening forecast does not come back' (#225) from OPT-34-fix into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-11-01 14:21:44 +01:00
glenn schrooyenandClaude 6263a57b6b OPT-34 fix (rig F-1): a replaced evening forecast does not come back
checks / unit (pull_request) Successful in 21m5s
infra-pin / fresh (pull_request) Successful in 2s
After a fresh today reading in the night (02:00 on the rig) went stale
(05:00), Hold.stand_in(as_today=True) served the 17:05 "tomorrow" figure
again until 06:00: the plan went back to 95 %, the latch took it, and
Leftover solar charged back up. read_sum() now skips that stand-in once
every today sensor has given a fresh reading for the day
(Hold.replaced()); the stale newer reading follows the OPT-27 rules.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-01 12:26:05 +01:00
17 changed files with 1512 additions and 76 deletions
+206 -6
View File
@@ -1,13 +1,213 @@
# Changelog
## 0.20.1
**A patch bump: Leftover solar no longer buys from the grid what other batteries will already cover. No option changes.**
No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.20.0 also start 0.20.1. A site with one battery, with battery modes off, or with no Cheap hours only or Solar first battery plans as before.
**Leftover solar counts the other batteries.** At night, a Leftover solar battery used to buy for the whole of the expensive hours as if no other battery would give. It now counts what a Cheap hours only battery will hold when the expensive hours start, and what a Solar first battery will have left after the night's use, and buys only the rest. In a rig test of the live night (Leftover solar at 60 %, Cheap hours only at 94 %) it bought 0.00 kWh where 0.20.0 bought 5.40 kWh. The Batteries screen says "Other batteries cover the expensive hours. It does not charge from the grid."
Contents of this release, in merge order since 0.20.0 (`01e88a2`):
| | |
|---|---|
| `22d09f8` | Leftover solar counts what Cheap hours only and Solar first batteries will still hold when the expensive hours start, and buys from the grid only the rest. The Batteries screen says when other batteries cover those hours, and the "rest" sentence now says it comes from the grid ([[OPT-36]], #229) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
- Control acts on Home Assistant readings that arrive late. With readings 10 s behind, the give run can swing and send up to 1.9 kW to the grid for about 50 s in a rig test; with readings 30 s behind, a battery can charge from the grid in an expensive period. Also in 0.19.2. A fix is made for the next release and is not in 0.20.1 ([[SAFETY-128]]).
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
- In the last minute of a 15-minute period, with Home Assistant readings 30 s late, the period can end about 80 W over the peak aim: 3 530 W against a 3 450 W aim in a rig test with a 2 kW load added 45 s before the end. The peak guard sees the late readings too slowly to hold it back ([[SAFETY-133]]).
- With Home Assistant readings 10 s late, the grid charge in a cheap run can swing by several kW; a peak test on 0.20.0 reached 9.6 kW ([[SAFETY-132]]).
- A plan rebuilt while the battery levels are missing for a moment can drop a cheap run that has not started yet, so the battery does not charge that day. Seen in a replay of real prices, where it cost about EUR 0.40 of extra grid use; the change in this release no longer triggers it in those replays, but the cause stays ([[OPT-37]]).
- Home draws a battery's charge only for the current cheap run. On a flat night tariff the later night hours it will charge show up as the run rolls on, though control keeps charging ([[UX-77]]).
### 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.20.0
**A minor bump: a Forecast tab, a night grid charge sized to what the sun will not give, a kept evening forecast, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because Leftover solar now buys different amounts from the grid and the night charge keeps its level when the forecast goes quiet; every saved setting still starts.
**Existing installs need no option change:** options that start 0.19.2 also
start 0.20.0. The changes below apply with one inverter or two.
**A Forecast tab.** A new tab next to Home and Batteries shows today and tomorrow hour by hour: the solar forecast, the sun measured so far, the house's expected use, and each battery's level with its planned charge and give blocks. It shows what the add-on plans and changes no command.
**Leftover solar buys what the sun will not give.** On a day/night tariff the night grid charge now covers the expensive hours' use less the sun counted for them, where it used to buy for the whole day. A forecast with only a daily total, such as Forecast.Solar, is no longer counted several times over by day, so Leftover solar no longer charges too little because of it.
**The evening forecast is kept for the night.** Forecast.Solar-type sensors stop updating in the evening. The last fresh reading is now kept until 06:00, so the night cheap run is judged with the forecast's price gap and Leftover solar still plans its charge. A newer reading replaces it at once, and a charge that is running keeps its planned level if the figure ends.
**No false peak alarm during the night grid charge.** "Battery stopped limiting the peak", and its notification, no longer appear while the 15-minute period stays within 10 W of the aim.
Contents of this release, in merge order since 0.19.2 (`f1c9733`):
| | |
|---|---|
| `a1d0703` | Home now draws a grid charge only where the add-on will take it, with battery modes on or off; before, it could show the rest of a refused cheap run, or a charge after the last dear period. The price texts say "lowest price left" and "highest price left", and no command changes ([[UX-53]], #209) |
| `f5afb42` | The day planner's hour loop now always ends on the night the clocks go forward. Without time-zone data it could go round forever and freeze the add-on; the add-on's own image has that data ([[SAFETY-123]], #211) |
| `4a1f6cc` | A new Forecast tab shows today and tomorrow hour by hour: the sun, the house and each battery's level, with the charge and give blocks Home would draw. It shows the plan and changes no command; notes say when a part is not known yet ([[UX-54]], #210) |
| `6be7e41` | The add-on's test files now run six at a time in the checks, so a check takes about 15 to 25 minutes, where one by one it ran past an hour. No change in behaviour ([[CI-15]], #215) |
| `b6be312` | The day plan counts the sun right: a forecast with only a daily total is no longer counted several times over. On a day/night tariff the night charge for Leftover solar covers the expensive hours' use less the sun counted for them, shared between batteries by capacity, and the screens say when another battery or the grid covers the rest ([[OPT-23]], [[OPT-28]], #218) |
| `cc9bbeb` | When a solar forecast sensor stops updating in the evening, the night plan uses its last fresh reading, for the day it is for only. One missed reading no longer drops the forecast at once, and the pages say when a figure is kept ([[OPT-27]], #219) |
| `deee98d` | The night grid charge under the peak limit no longer raises a false "Battery stopped limiting the peak" alarm when the 15-minute period stays within 10 W of the aim. A real overrun still alarms; a steadily rising load alarms a little later, and with a peak margin under 10 W a period can end up to 10 W over the limit without it ([[SAFETY-126]], #221) |
| `8afadbd`, `cdafa2c` | The kept evening figure now counts until 06:00, not until 03:00, and a charge that is running keeps its planned level when the figure ends. A newer reading replaces the evening figure at once and for good, so it does not come back later in the night ([[OPT-34]], #222, with its fix #225) |
| `d4c01da` | Comments and docstrings in the peak guard and the price files were cut to a line and a pointer to the decision notes. No change in behaviour: a check shows the code itself is the same ([[MAINT-20]] part 1, #223) |
The merge that brought 0.19.1 and 0.19.2 into this branch (`f1b7244`, #220) adds nothing else; the Forecast tab uses the same price gap as control since it.
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
- Control acts on Home Assistant readings that arrive late. With readings 10 s behind, the give run can swing and send up to 1.9 kW to the grid for about 50 s in a rig test; with readings 30 s behind, a battery can charge from the grid in an expensive period. Also in 0.19.2 ([[SAFETY-128]]).
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
### 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.19.2
### Not in a release yet
- The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]).
- A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, and a charge that has started goes on to its level ([[OPT-34]]).
**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.**
**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.**
No option was added, removed or renamed, and no default changed (86 options).
**Existing installs need no option change:** options that start 0.19.1 also
start 0.19.2. The change below applies with one inverter or two.
@@ -6,6 +6,7 @@
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
- **Cheap hours** still fills up and is not counted in the share; **Solar first** still never charges from the grid. The mode help says so.
- **Not done:** the run length (OPT-25); netting what a Cheap hours or Solar first battery will hold against the shortfall.
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by the grid (OPT-36 counts the other batteries first), as the screen says.
- **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so.
- **OPT-36 (0.20.1):** Cheap hours and Solar first batteries give first; Leftover solar shares only the rest of the shortfall (`dayplan._cheap_give_kwh()`, `_solar_first_give_kwh()`). A Cheap hours battery counts the level its charge reaches: the cheap run, then later slots still worth charging in and no dearer than any slot from the expensive hours on, under the peak aim, x EFF stored. A Solar first battery counts what is left after the house's use until the hours start, each hour at no less than the night base. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Owner, 2026-11-02: Solar first counts too. Test: `test_opt36.py`.
- **Not done:** the run length (OPT-25). On a dynamic tariff the Cheap hours count guesses where control will charge; a counted slot can still go unused, and that gap comes from the grid.
@@ -34,6 +34,13 @@ known, so the add-on does not depend on it.
midnight, so a 09:05 reading, unusable since 21:05, does not come back for five
minutes after midnight. Same-day figures and the daytime rule keep 12 h and 3 h.
4. A fresh figure replaces the kept one at once, as before.
*Rig F-1 fix:* once every today sensor has given a fresh reading after
midnight, the evening figure is no stand-in for that day any more
(`Hold.replaced()`). When that reading goes stale (3 h), it is kept or not
as any today reading (OPT-27), so it is "no forecast" at night and the latch
keeps the level it set; the older 17:05 figure never comes back. Rig: A
stopped at 69.5 % after a brighter 02:00 figure, then charged back to 94.4 %
from 05:15 when the 17:05 figure returned. Test: `test_opt34.py` F-1.
**Step 1 alone is enough for the owner's shape, steps 2-3 are still needed.**
With the window put back to midnight + 3 h the owner's night still ends as a
@@ -59,4 +66,4 @@ A fresh figure that LOWERS the level during a charge still lowers it, as before.
**Mutants** (all red in `test_opt34.py`): no latch; window back to midnight + 3 h;
15 h without a limit; no "usable at midnight" rule; the latch without the narrow
gap, without its save, or not kept through the next run; the window 6 h after
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level.
midnight instead of 06:00 local (DST); 15 h cut to 12 h; the new screen line off or shown without a cheap run; no expiry check; no pruning of a changed mode; no night bound; a left-the-plan battery keeping its level; no `replaced()` check (F-1); `replaced()` with one of several today sensors (any, not all).
+1 -1
View File
@@ -549,7 +549,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
- 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: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. In the expensive hours the rest comes from another battery (*Cheap hours only* or *Solar first*) or from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. *Cheap hours only* and *Solar first* batteries give first: what they will hold when the expensive hours start (a *Cheap hours only* battery full, or as far as it can charge under the peak limit in the cheap hours left; a *Solar first* battery after the night's use) is taken off the shortfall. In the expensive hours the rest comes from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
- *Cheap hours only* ignores the forecast and fills to the top level.
- *Solar first* never charges from the grid.
+124 -9
View File
@@ -22,7 +22,10 @@ OPT-28 for the second):
all of them cannot hold (`Plan.uncovered_kwh`) is not bought: another
battery (Cheap hours, Solar first) or the grid carries it.
* 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
the design (section 1). OPT-36: what it, and a Solar first battery after
the night's use, will give in the expensive hours is taken off `short`
before the Leftover solar batteries share it (`_cheap_give_kwh()`,
`_solar_first_give_kwh()`). 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 from the grid up to the level it needs
for its part of `short`, less its share of the sun before the run:
@@ -45,7 +48,7 @@ import statistics
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST
from .tariff import BILLING_TZ
EFF = 0.95 # charge (and discharge) efficiency
@@ -170,6 +173,14 @@ class Plan:
short_kwh: float = 0.0
cover_kwh: dict = field(default_factory=dict)
uncovered_kwh: float = 0.0
# OPT-36: what Cheap hours batteries will give in those hours (kWh on the
# house's side), and, per Leftover solar battery, the grid kWh it buys
# less because of it.
cheap_kwh: float = 0.0
saved_kwh: dict = field(default_factory=dict)
# ...and the same for Solar first batteries (owner, 2026-11-02): their
# level when the expensive hours start, after the night's use.
solar_first_kwh: float = 0.0
def _hour_slices(t0, t1):
@@ -219,12 +230,100 @@ def _room(p, to_pct):
p.capacity_kwh)
def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
aim_w, until=None, slots=None, spread=None):
"""OPT-36: what the Cheap hours batteries in `packs` give in the
expensive hours, in kWh on the house's side: (the level at the start of
those hours - the floor) x capacity x `EFF`.
That level is the one its cheap charging reaches: the ceiling, or less
when the time left cannot get there. That time is the cheap run from now
(or the whole run ahead of it) and, given the firm price `slots`, each
later slot before the expensive hours (`until`, a give run) still worth
charging in (its price plus the spread control asks, `spread`, at most
the give run's dearest slot, `priceruns.plan()`'s test) and no dearer
than every slot from the start of those hours on: control plans the
charge left in the cheapest window ahead, so a slot with a cheaper one
after it may never be used (round 4, a three-price night). The run is
sized at the batteries' ratings (`priceruns.need_hours()`), not the peak
limit, so a run too short to fill them is planned again in that time;
counting the run alone let A's level fall in the night after A had
bought. In that time the batteries together charge at their ratings,
held to what the peak aim leaves above the house's use that hour
(`aim_w`, None: no limit), shared by rating; x `EFF` of it is stored.
No cheap run worth charging in: the level now.
A battery that refuses a direction counts 0."""
ps = [p for p in packs if p.mode == CHEAP_HOURS and p.refused == 0]
if not ps:
return 0.0
house_w, base_w = house
gain = {p.name: 0.0 for p in ps}
rate = {p.name: abs(float(p.rating_w)) for p in ps}
total = sum(rate.values())
run = runs.cheap
spans = [(max(now, run.start), run.end)]
top = max((eur for s, _e, eur in slots or ()
if until is not None and until.start <= s < until.end),
default=None)
low = min((eur for s, _e, eur in slots or ()
if until is not None and s >= until.start), default=None)
if top is not None and spread is not None:
spans += [(max(s, now, run.end), min(e, until.start))
for s, e, eur in slots
if eur + float(spread) <= top + 1e-9
and (low is None or eur <= low + 1e-9)
and e > run.end and s < until.start]
if runs.charge_useful() and total > 0.0:
for t0, t1 in spans:
for s, e, h in _hour_slices(t0, t1):
w = total
if aim_w is not None:
w = min(w, max(0.0, float(aim_w)
- max(house_w(h), base_w, 0.0)))
hrs = (e - s).total_seconds() / 3600.0
for p in ps:
gain[p.name] += w * rate[p.name] / total * hrs \
/ 1000.0 * EFF
out = 0.0
for p in ps:
cap = float(p.capacity_kwh)
lvl = max(float(p.soc), min(float(ceiling_pct),
float(p.soc) + gain[p.name] / cap * 100.0))
out += max(0.0, lvl - float(floor_pct)) / 100.0 * cap * EFF
return out
def _solar_first_give_kwh(house, now, start, packs, *, floor_pct):
"""OPT-36 (owner, 2026-11-02): what the Solar first batteries in `packs`
will still give when the expensive hours start at `start`, in kWh on the
house's side. They give at any hour, so the house's use from now to
`start` is taken off first, shared by the energy each holds above the
floor, and what is left x `EFF` counts. Each hour is read at no less than
the night base: the profile is net of the sun the house used, and read
net it let a dark day's morning drain a battery that the plan still
counted (round 4, real-price replays). A battery
that runs down to the floor before then counts 0; one that refuses a
direction counts 0."""
ps = [p for p in packs if p.mode == SOLAR_FIRST and p.refused == 0]
if not ps:
return 0.0
house_w, base_w = house
use = sum(max(0.0, house_w(h), base_w) * (e - s).total_seconds() / 3.6e6
for s, e, h in _hour_slices(now, start))
have = {p.name: max(0.0, float(p.soc) - float(floor_pct)) / 100.0
* float(p.capacity_kwh) for p in ps}
took, _ = _spread(use / EFF, have, have)
return sum(have[n] - took[n] for n in have) * EFF
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
ceiling_pct):
ceiling_pct, aim_w=None, slots=None, spread=None):
"""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."""
`modetrade.Pack`s. `aim_w`, `slots` (`priceruns.firm_slots()`) and
`spread` (the price gap control asks, EUR/kWh) size a Cheap hours
battery's charge (`_cheap_give_kwh()`)."""
if forecast is None or house is None or runs is None or not runs.on \
or runs.cheap is None:
return None
@@ -273,11 +372,23 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
- trust * pv)
caps = {p.name: float(p.capacity_kwh) for p in price}
span = max(0.0, float(ceiling_pct) - float(floor_pct))
held, left = _spread(short / EFF,
{n: span / 100.0 * c for n, c in caps.items()},
caps)
# OPT-36: the Cheap hours and Solar first batteries give first;
# Leftover solar holds the rest. A Leftover solar battery's own stored energy counts through its
# level: it buys from where it is to the level it needs.
cheap = _cheap_give_kwh(runs, now, house, usable, floor_pct=floor_pct,
ceiling_pct=ceiling_pct, aim_w=aim_w,
until=until, slots=slots, spread=spread) \
if price else 0.0
cheap = min(cheap, short)
sfirst = _solar_first_give_kwh(house, now, until.start, usable,
floor_pct=floor_pct) if price else 0.0
sfirst = min(sfirst, short - cheap)
others = cheap + sfirst
room_of = {n: span / 100.0 * c for n, c in caps.items()}
held, left = _spread((short - others) / EFF, room_of, caps)
held0 = _spread(short / EFF, room_of, caps)[0] if others > 0.0 else held
targets, need, share, cover = {}, {}, {}, {}
targets, need, share, cover, saved = {}, {}, {}, {}, {}
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
@@ -285,6 +396,9 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
level = max(0.0, top - sh_pct)
targets[p.name] = round(level, 2)
need[p.name] = round(_room(p, level), 3)
top0 = float(floor_pct) + held0[p.name] / caps[p.name] * 100.0
saved[p.name] = round(
_room(p, max(0.0, top0 - sh_pct)) - need[p.name], 3)
share[p.name] = round(sh, 3)
cover[p.name] = round(held[p.name] * EFF, 3)
if sun < sf_room:
@@ -295,7 +409,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
verdict = SUNNY
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
targets, need, share, round(short, 3), cover,
round(left * EFF if price else 0.0, 3))
round(left * EFF if price else 0.0, 3), round(cheap, 3),
saved, round(sfirst, 3))
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
+26 -9
View File
@@ -444,9 +444,16 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
return got if got is not None else (
floor if kind == "grid" else ceiling)
grids = [] # (end, kWh) of the grid blocks drawn
def room(kind, hrs, start):
kwh = inv.max_w / 1000.0 * hrs
lvl = start_pct(kind, start) if ahead else soc
if kind != "grid" and not ahead and cap and lvl is not None:
# OPT-36: a give block reads the level after the grid block
# before it, not the level now.
lvl = min(float(ceiling), lvl + sum(
k for e, k in grids if e <= start) / cap * 100.0)
if cap and lvl is not None:
kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid"
else (lvl - floor)) / 100.0 * cap)
@@ -474,6 +481,8 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
kwh = min(kwh, need)
if kwh < MIN_KWH:
continue
if kind == "grid":
grids.append((run.end, kwh))
price = run.mean_eur_kwh
if kind == "grid":
cov = plan_cover(c, inv.name)
@@ -522,17 +531,16 @@ def plan_cover(c, name):
grid: the level it charges to, and the expensive-hour use of the house
that charge covers and the sun will not. Read from the level control
holds (`plan_targets()`), so Home says what control does. None when the
plan buys nothing for it."""
plan buys nothing for it. OPT-36: the kWh is the plan's own cover figure
(`Plan.cover_kwh`), not one rebuilt from the level."""
dp = getattr(c, "day_plan", None)
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
inv = next((i for i in c.inverters if i.name == name), None)
if dp is None or not targets or name not in targets or inv is None \
or not inv.capacity_kwh or targets[name] <= 0.0:
or not inv.capacity_kwh or targets[name] <= 0.0 \
or name not in dp.cover_kwh:
return None
floor, _ceil = arbitrage.soc_bounds(c.o)
cap = float(inv.capacity_kwh)
top = targets[name] + dp.share_kwh.get(name, 0.0) / cap * 100.0
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
return targets[name], dp.cover_kwh[name]
def _latched_below(c) -> bool:
@@ -931,7 +939,8 @@ def battery_rows(c, shadow, running, held=False):
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."""
battery: `sun_fills`, `others_cover`, `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))
@@ -941,7 +950,14 @@ def day_key(c, name):
need = plan_need(c, name)
if need is None:
return None
return "tops_up" if need >= MIN_KWH else "sun_fills"
if need >= MIN_KWH:
return "tops_up"
# OPT-36: no grid charge because other batteries (Cheap hours only,
# Solar first) cover the hours, not the sun.
dp = getattr(c, "day_plan", None)
if dp is not None and dp.saved_kwh.get(name, 0.0) >= MIN_KWH:
return "others_cover"
return "sun_fills"
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
@@ -1473,7 +1489,8 @@ def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
def line_uncovered(kwh) -> str:
"""OPT-28: the Leftover solar batteries cannot hold the expensive hours'
shortfall. The rest comes from another battery or the grid."""
shortfall. The rest comes from the grid: OPT-36 counts the other
batteries first."""
return _t("solar.uncovered", kwh=_kwh(kwh))
+3 -1
View File
@@ -2375,7 +2375,9 @@ class Controller:
else self.house_profile.estimator(),
priceruns.keep_started(self.mode_runs, runs, now), now, packs,
trust_pct=self.solar_trust_pct, floor_pct=floor_pct,
ceiling_pct=ceiling_pct)
ceiling_pct=ceiling_pct, aim_w=self.peak_aim_w(),
slots=priceruns.firm_slots(plan.read.points, now),
spread=required)
self.day_plan = dp
if dp is None:
self._plan_targets = None
+13 -3
View File
@@ -38,7 +38,8 @@ midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
Never beyond that, and never for another day: a today figure is only today's,
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
old, and it must have been usable at midnight).
old, it must have been usable at midnight, and no fresh today reading may
have replaced it since).
DECISIONS/OPT-27-last-fresh-forecast-for-the-night.md,
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md.
@@ -351,6 +352,13 @@ class Hold:
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
return (day, at, True) if evening else None
def replaced(self, entities, day0) -> bool:
"""OPT-34 F-1: every today sensor has given a fresh reading for
`day0` (so after midnight, newer than any evening figure). The
evening's tomorrow figure is then no stand-in for today any more,
also once those readings are stale."""
return all(("today", e, day0) in self._r for e in entities)
def dump(self) -> dict:
return {"v": 1, "r": [
[kind, ent, d.day0.isoformat(), d.kwh, at.isoformat(),
@@ -492,7 +500,8 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
where `Hold.stand_in()` allows it (the forecast then has `kept` set when
that took a reading older than `FRESH_S`). After midnight, if the today
side is still short, the tomorrow figures read before midnight are
today's, until the bell starts (`stand_in()`). Without a `hold` this is
today's, until the bell starts (`stand_in()`), unless fresh today
readings have replaced them (`Hold.replaced()`). Without a `hold` this is
exactly as before."""
if not today:
return None, NOT_SET, ()
@@ -537,7 +546,8 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
# day; all of them are needed, as for any sum.
subs = [hold.stand_in("tomorrow", ent, day0, now, as_today=True)
for ent, _st in tomorrow]
if all(r is not None for r in subs):
if all(r is not None for r in subs) and not hold.replaced(
[ent for ent, _st in today], day0):
days = [r[0] for r in subs]
ats = [r[1] for r in subs]
gaps = [g for g in gaps if g[0] != "today"]
+2 -1
View File
@@ -357,10 +357,11 @@
"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.",
"others_cover": "Other batteries cover the expensive hours. It does not charge from the grid.",
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
"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}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "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.",
+2 -1
View File
@@ -297,10 +297,11 @@
"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.",
"others_cover": "Andere batterijen dekken de dure uren. Ze laadt niet van het net.",
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
"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}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "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.",
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.19.2"
version: "0.20.1"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
@@ -323,6 +323,9 @@ _cases = [
day="sun_fills"), "sun_fills"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="tops_up"),
"tops_up"),
# OPT-36: a other batteries cover the hours, not the sun.
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="others_cover"),
"others_cover"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="no_forecast"),
"no_forecast"),
(dict(mode=packmode.LEFTOVER_SOLAR, role="charge", day="tops_up"),
+24 -10
View File
@@ -254,9 +254,16 @@ check("B on Solar first never gets a level (never charges from the grid)",
more = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR),
pk(B, packmode.CHEAP_HOURS, cap=CAP_B,
rating=4600.0)])
check("B on Cheap hours fills up and is no part of A's share: A's level is "
"the same, B is not in the plan",
more.targets == res[6.0].targets and B not in more.targets
# OPT-36 (on purpose, was: "B on Cheap hours ... A's level is the same"): a
# Cheap hours battery still fills up and is still not in the plan, but what
# its own cheap run will give in the expensive hours is taken off the
# shortfall before Leftover solar shares it. Here B (10 %, 4.6 kW, the run
# 22:00-07:00) reaches the ceiling and covers all of the 6 kWh case.
check("B on Cheap hours fills up and gives first (OPT-36): A buys nothing, "
"B is not in the plan",
more.need_kwh[A] == 0.0 and more.targets[A] == FLOOR
and more.cheap_kwh > more.short_kwh - 1e-6
and more.uncovered_kwh == 0.0 and B not in more.targets
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, cap=CAP_B), CEIL,
more.targets) == CEIL)
# The profile is net of the sun the house used, so an hour with sun is read
@@ -341,8 +348,8 @@ print(f" 4 kW house, 2 kWh: shortfall {sh4:.1f} kWh; both hold "
f"levels {p4.targets}")
check("more than all of them hold: both go to the ceiling, never above",
p4.targets[A] == CEIL and p4.targets[B] == CEIL)
check("...and the rest is said, not bought: another battery or the grid "
"carries it (`uncovered_kwh` = shortfall less what they cover)",
check("...and the rest is said, not bought: the grid carries it (OPT-36: "
"the other batteries are counted before) (`uncovered_kwh` = shortfall less what they cover)",
abs(p4.uncovered_kwh - (sh4 - rooms * EFF)) < 0.01
and abs(sum(p4.cover_kwh.values()) - rooms * EFF) < 0.01)
# The pass-through itself: a battery that reaches its ceiling passes the rest.
@@ -439,6 +446,10 @@ print("5. the screens say what control does (EN and NL)")
now = DAY0 + timedelta(minutes=18, seconds=5)
c = S.make("dynamic", now, soc=(20.0, 20.0))
c.solar_fc = S.forecast(now, 3.0, 3.0)
# OPT-36: B (Cheap hours) now gives first, so A buys only what B's cheap run
# cannot reach. A 2.5 kW house leaves B 0.95 kW of peak room in the 3.5 h run:
# B gets part of the way, A buys the rest.
c.house_profile = profile_of({h: 2500.0 for h in range(24)})
c._runs_key = None
c._mode_roles(now, None)
tg = c.plan_targets()
@@ -494,8 +505,9 @@ for lang in ("en", "nl"):
check(f"{lang}: '{key}' is at most 2 sentences of at most 12 words",
len(sentences) <= 2 and all(
len(s.split()) <= 12 for s in sentences))
# More than all hold: the Batteries screen says another battery or the grid
# carries the rest (here B is on Cheap hours: "the grid" alone is not true).
# More than all hold: the Batteries screen says the grid carries the rest.
# OPT-36 (on purpose, was "another battery or the grid"): B on Cheap hours is
# counted before, so what is left comes from the grid.
c2 = S.make("dynamic", now, soc=(20.0, 20.0))
c2.solar_fc = S.forecast(now, 1.0, 1.0)
c2.house_profile = profile_of({h: 3500.0 for h in range(24)})
@@ -508,14 +520,16 @@ print(f" a 3.5 kW house: shortfall {dp2.short_kwh:.1f} kWh, unheld "
check("the plan finds more than the battery holds", dp2.uncovered_kwh > 1.0
and c2.plan_targets()[A] == 95.0)
check("EN: the line under the Batteries cards says Leftover solar cannot "
"hold it all and the rest comes from another battery or the grid",
"hold it all and the rest comes from the grid",
"more than Leftover solar can hold" in sol["line"]
and "another battery or the grid" in sol["line"]
and "comes from the grid." in sol["line"]
and "another battery" not in sol["line"]
and homeview._kwh(dp2.uncovered_kwh) in sol["line"])
with homeview.in_lang("nl"):
sol_nl = homeview.solar_block(c2, now)
check("NL: and in Dutch", "meer dan Overschot zonne-energie kan opslaan"
in sol_nl["line"] and "andere batterij of het net" in sol_nl["line"]
in sol_nl["line"] and "komt van het net." in sol_nl["line"]
and "andere batterij" not in sol_nl["line"]
and "{" not in sol_nl["line"])
check("a covered plan has no such sentence",
"can hold" not in homeview.solar_block(c, now)["line"])
+133 -15
View File
@@ -26,7 +26,9 @@ DECISIONS note and the dev report.
solar + B Cheap hours, both 10 % at the start of the night, the night rate
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
processes, about 2.5 minutes each):
processes, about 2.5 minutes each). OPT-36: B is Off in the nights that
test A's level (a Cheap hours B now counts before A buys and would cover
these hours); the `_b` nights keep it on for B's checks:
a. silent from 17:05: both batteries end the night where a fresh figure
ends them (the base left A at 57 % against 87 %), and the screens agree;
b. restarts at 01:00 and 04:00 change nothing;
@@ -36,6 +38,8 @@ DECISIONS note and the dev report.
and before a charge has started, up and down;
e. the last hour, after the kept figure has ended at 06:00: the charge under
way goes on to its level, also over a restart; the screens say so.
f. F-1 (rig): one fresh reading at 02:00, then silent again: when it is
stale (05:00) the 17:05 figure it replaced does not come back.
3. The text, EN and NL.
4. `_latch_charge()` alone: a battery that leaves the plan leaves the latch.
"""
@@ -193,6 +197,47 @@ for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
"17:05 figure again", fr is not None and fr.kept
and fr.today.kwh == 12.0 and fr.at == at(3, 17, 5))
# F-1 (rig): a fresh today reading at 02:00 replaces the 17:05 figure; once
# that reading is stale (05:00) the 17:05 figure must not come back.
for label, kwh in (("brighter", 40.0), ("darker", 1.0)):
hn = hold_of(at(3, 17, 5))
f2 = rs(st(kwh, at(4, 2, 0)), st(kwh, at(4, 2, 0)), at(4, 2, 1), hn)[0]
f4 = rs(GONE, GONE, at(4, 4, 59), hn)[0]
snap = json.loads(json.dumps(hn.dump()))
f5 = [rs(GONE, GONE, t, hn)[0] for t in (at(4, 5, 1), at(4, 5, 59))]
f5s = [rs(st(kwh, at(4, 2, 0)), st(12.0, at(3, 17, 5)), t, hn)[0]
for t in (at(4, 5, 1), at(4, 5, 30))]
check(f"F-1 {label} 02:00 reading: used fresh at 02:01 and kept for "
"a missing sensor to 04:59 (3 h)",
f2 is not None and not f2.kept and f2.today.kwh == kwh
and f4 is not None and f4.today.kwh == kwh and not f4.kept)
check("... and from 05:01 (stale) to 05:59: no forecast, never the "
"17:05 figure (sensors gone, or showing their old readings)",
all(x is None for x in f5 + f5s))
back = solarfc.Hold(snap)
check("... also after a restart at 03:00 (the saved hold keeps the "
"02:00 reading)", rs(GONE, GONE, at(4, 3, 0), back)[0] is not None
and rs(GONE, GONE, at(4, 5, 1), back)[0] is None)
# Two today sensors, only one read fresh: the sum never had a fresh figure,
# so the evening one still stands in (as before).
h2 = solarfc.Hold()
td2 = [("sensor.t1", st(0.2, at(3, 17, 5))),
("sensor.t2", st(0.2, at(3, 17, 5)))]
tm2 = [("sensor.m1", st(6.0, at(3, 17, 5))),
("sensor.m2", st(6.0, at(3, 17, 5)))]
gone2 = [(e, GONE) for e, _s in td2], [(e, GONE) for e, _s in tm2]
solarfc.read_sum(td2, tm2, at(3, 17, 10), h2)
solarfc.read_sum([("sensor.t1", st(9.0, at(4, 2, 0))), td2[1]], tm2,
at(4, 2, 1), h2)
g5 = solarfc.read_sum(*gone2, at(4, 5, 1), h2)[0]
check("two today sensors, only one read fresh at 02:00: the evening sum "
"(12.0) still stands in at 05:01", g5 is not None and g5.kept
and g5.today.kwh == 12.0)
solarfc.read_sum([td2[0], ("sensor.t2", st(9.0, at(4, 2, 10)))], tm2,
at(4, 2, 11), h2)
check("... and once both have read fresh, it does not",
solarfc.read_sum(*gone2, at(4, 5, 20), h2)[0] is None)
# DST: the 25 h autumn day and the 23 h spring day. The window ends at 06:00
# LOCAL, not 6 h after midnight.
for (y, mo, d), label in (((2026, 10, 25), "25 h day 2026-10-25"),
@@ -224,6 +269,10 @@ PROFILE_LATE = ('{"periods": ['
' "to": "22:00", "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
# OPT-36: a Cheap hours B now gives first, and with these house shapes it would
# cover the dear hours alone, so A would not charge. The nights that test A's
# level have B Off (`b_off`, the default; the house is as written); `_b`
# nights keep B on Cheap hours for B's own checks.
LAST = at(3, 17, 5)
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
@@ -258,7 +307,10 @@ def run_night(spec):
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
house_w = spec.get("house_w", 700.0)
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
choice = dict(CHOICE)
if spec.get("b_off", True):
choice["b"] = (False, "cheap_hours", False)
for u, (use, mode, sell) in choice.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
@@ -376,15 +428,24 @@ SCEN = {
marks=[at(4, 4, 30)]),
"silent": dict(start=NIGHT, hours=9,
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
# OPT-36: B on Cheap hours, for B's checks
"fresh_b": dict(start=NIGHT, hours=9, silent=False, b_off=False),
"silent_b": dict(start=NIGHT, hours=9, b_off=False),
# OPT-36: alone, A is full by 02:00 from 22:00; from midnight its charge
# runs through 03:00
"midnight": dict(start=at(4, 0, 0), hours=7),
"restart": dict(start=NIGHT, hours=9,
restarts=[at(4, 1, 0), at(4, 4, 0)],
marks=[at(4, 1, 30), at(4, 4, 30)]),
# c. the charge cannot start before 03:00 (the price dips at 04:00)
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
# OPT-36: the fresh sensors read the kept figure's 6.0 kWh for the 4th.
# Alone, A reaches its level, so the runs must plan on the same sun (with
# B charging too, A reached neither level by 07:00 and they matched).
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
house_w=500.0, soc={"a": 40.0, "b": 70.0},
silent=False),
fresh_from=at(4, 2, 30), fresh=(6.0, 6.0)),
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
# use, so the level has room to rise)
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
@@ -403,8 +464,10 @@ SCEN = {
house_w=500.0, fresh_from=at(4, 2, 40),
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
# d. the last hour, after the kept figure ended at 06:00
# (OPT-36: A at 60 %, not 70 %: alone it charges twice as fast and from
# 70 % is full at 06:38, before the 06:40 mark)
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
soc={"a": 60.0, "b": 70.0}, restarts=[at(4, 6, 30)],
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
@@ -418,12 +481,12 @@ SCEN = {
latch={"until": at(3, 7, 15).isoformat(),
"born": at(2, 23, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
marks=[at(3, 22, 20)], b_off=False),
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
latch={"until": at(4, 7, 15).isoformat(),
"born": at(3, 22, 0).isoformat(),
"levels": {A: 80.0, B: 80.0}},
marks=[at(3, 22, 20)]),
marks=[at(3, 22, 20)], b_off=False),
# the same, B already full and a 1.5 kW limit: the run is sized at the
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
# next slice is planned after 06:00 and is only A's
@@ -431,6 +494,19 @@ SCEN = {
soc={"a": 65.0, "b": 95.0},
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 45)]),
}
# F-1 (rig 4v): silent from 17:05, one fresh reading at 02:00, silent again.
# It is stale at 05:00, before the 06:00 bell: the 17:05 figure it replaced
# must not come back. Brighter (A's level drops), with a restart at 03:00,
# and darker (the level rises).
F1_MARKS = [at(4, 1, 55)] + [at(4, 2, 15) + timedelta(minutes=15 * i)
for i in range(19)]
for k, kwh, rst, hw in (("f1_bright", 30.0, (), 700.0),
("f1_bright_rs", 30.0, (at(4, 3, 0),), 700.0),
("f1_dark", 1.0, (), 500.0)):
SCEN[k] = dict(start=at(4, 1, 30), hours=5.5, house_w=hw,
soc={"a": 50.0, "b": 50.0}, fresh_from=at(4, 2, 0),
fresh_until=at(4, 2, 0), fresh=(kwh, kwh),
restarts=list(rst), marks=F1_MARKS)
t0 = time.time()
with ProcessPoolExecutor(max_workers=min(6, os.cpu_count() or 2),
mp_context=get_context("fork")) as pool:
@@ -450,17 +526,20 @@ for k in R:
fa, si, rr = R["fresh"], R["silent"], R["restart"]
# a. silent from 17:05
fb_, sb_ = R["fresh_b"], R["silent_b"]
check("a fresh figure all night: A ends at its planned level (87 %), B "
"full", abs(fa["soc"][0] - 86.9) < 0.5 and fa["soc"][1] > 94.5)
"(Cheap hours, on in the `_b` night) full",
abs(fa["soc"][0] - 86.9) < 0.5 and fb_["soc"][1] > 94.5)
check("sensors silent from 17:05: A ends where the fresh run ends it "
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
f"stop its slices): {sb_['soc'][1]:.1f} against {fb_['soc'][1]:.1f} %",
abs(sb_["soc"][1] - fb_["soc"][1]) < 0.3)
mn = R["midnight"]
check("A charges through 03:00 without a break (no step at 03:00)",
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
mn["soc_at"]["03:30"][0] > mn["soc_at"]["03:00"][0] + 4.0
and mn["soc_at"]["03:00"][0] > mn["soc_at"]["02:55"][0] + 0.3)
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
"line says so (EN)",
@@ -545,12 +624,12 @@ check("a fresh, brighter figure lowers A's level below its SoC during the "
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
"LOWERED level, so A does not charge back up to the old one "
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
"while B goes on",
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %)"
" (OPT-36: B is Off here; B's slices with no forecast are the `_b` "
"night's check above)",
l2["fc_none"] and l2["target_a"] is not None
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0)
st_, sl_ = R["stale"], R["stale_live"]
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
"inside a cheap run, with no forecast: nothing grid-charges "
@@ -563,6 +642,45 @@ check("(control: the same latch, still valid until tomorrow 07:15, does "
f"charge both: A {sl_['soc'][0]:.1f}, B {sl_['soc'][1]:.1f} %)",
sl_["soc"][0] > 11.0 and sl_["soc"][1] > 11.0
and sl_["marks"]["22:20"]["state"] == "latched")
for k in ("f1_bright", "f1_bright_rs"):
fb = R[k]
mk = fb["marks"]
lo_t = mk["02:15"]["target_a"]
after = [mk[h] for h in sorted(mk) if h >= "02:15"]
print(f" {k}: A level 01:55 {mk['01:55']['target_a']} -> 02:15 "
f"{lo_t}; A {fb['soc_at']['02:15'][0]:.1f} % at 02:15, "
f"{fb['soc_at']['06:55'][0]:.1f} % at 06:55; lines "
+ " | ".join(sorted({x['line'] for x in after})))
check(f"F-1 {k}: a brighter fresh figure at 02:00 lowers A's level "
"below its SoC and A stops",
lo_t is not None and mk["01:55"]["target_a"] is not None
and lo_t < mk["01:55"]["target_a"] - 10.0
and lo_t < mk["02:15"]["soc_a"])
check(f"... after 02:00 the screen never says 'read 17:05' and from "
"05:15 there is no forecast, with the LOWERED level",
all("17:05" not in x["line"] and not (x["kept"] and
not x["fc_none"])
for x in after)
and all(mk[h]["fc_none"] and near(mk[h]["target_a"], lo_t, 0.5)
for h in mk if "05:15" <= h < "07:00"))
check(f"... so A stays at its lowered level to 07:00 "
f"({fb['soc_at']['02:30'][0]:.1f} -> "
f"{fb['soc_at']['06:55'][0]:.1f} %)",
fb["soc_at"]["06:55"][0] < fb["soc_at"]["02:30"][0] + 1.0)
fd = R["f1_dark"]
md = fd["marks"]
print(f" f1_dark: A level 01:55 {md['01:55']['target_a']} -> 02:15 "
f"{md['02:15']['target_a']}; A {fd['soc_at']['06:55'][0]:.1f} % "
"at 06:55")
check("F-1 darker fresh figure at 02:00: the level rises, is held after "
"05:00 with no forecast (never 'read 17:05'), and A charges to it",
md["02:15"]["target_a"] is not None
and md["01:55"]["target_a"] is not None
and md["02:15"]["target_a"] > md["01:55"]["target_a"] + 3.0
and all("17:05" not in md[h]["line"] for h in md if h >= "02:15")
and all(md[h]["fc_none"] for h in md if "05:15" <= h < "07:00")
and near(fd["soc_at"]["06:55"][0], md["02:15"]["target_a"], 1.5))
tb = R["tail_bfull"]
check("B full: the run ends at 06:33 before A is at its level, so the next "
"slice is only A's, planned after the figure has ended with A's "
+919
View File
@@ -0,0 +1,919 @@
"""Runnable check for OPT-36. `python3 test_opt36.py`
Leftover solar counts what Cheap hours only batteries will give in the
expensive hours before it buys from the grid at night (hotfix 0.20.1).
Live 0.20.0, 2026-11-02 01:01: A (Leftover solar, 60 %) went to 95 %
(5.38 kWh) while B (Cheap hours only, 94 %) already covered the expensive
hours. DECISIONS/OPT-23-28 had left "netting what a Cheap hours battery will
hold" out; this adds it.
1. The live figures through the real Controller: A buys 0, nothing is
"uncovered", and Home says the same in EN and NL.
2. `dayplan.plan()` alone: B low with a short cheap run under the peak limit,
A buys only what B cannot reach; the numbers written out. Round 3: B's
charge counts the cheap slots left before the expensive hours, not only
the run (sized at the ratings), so the level does not fall in the night.
3. A Cheap hours battery that is off, refusing, in maintenance, not live or
without capacity counts 0; Solar first counts after the night's use
(owner, 2026-11-02); N=1 and Leftover solar only are unchanged.
4. The screens: the give line reads the level after the planned charge,
`plan_cover()` is the plan's own figure, the Why line says who covers.
5. The real Controller through a whole night (parallel processes, 5 s per
cycle): A's commands follow the new level, which holds all night while
the figure does; OPT-34 F-1 with B counted (a raised level is reached and
held); the OPT-34 latch still holds a started run (a lowered level stays
lowered). Round 4: a three-price night holds the shortfall at 07:00, and
two real-price replays with a Solar first battery cost no more than
0.20.0.
Mutations (MUTATION.md, purged copies): listed in the pull request.
"""
import asyncio
import json
import logging
import os
import sys
import tempfile
import time
from concurrent.futures import ProcessPoolExecutor
from datetime import datetime, timedelta, timezone
from multiprocessing import get_context
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 ux54_scenarios as S # noqa: E402
import opt11p3_replay as rp # noqa: E402
import app.main as m # noqa: E402
from app import dayplan, homeview, modetrade, packmode # noqa: E402
from app import priceruns, solarfc, uitext # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.static_tariff import static_plan # noqa: E402
from app.store import unit_key # 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)
UTC = timezone.utc
EFF = dayplan.EFF
A, B = "inverter-a", "inverter-b"
FLOOR, CEIL = 20.0, 95.0
CAP_A, CAP_B = 15.36, 20.48
# ============================================================================ #
print("1. the live figures through the real Controller")
# ============================================================================ #
NOW = datetime(2026, 11, 2, 1, 1, 26, tzinfo=BILLING_TZ).astimezone(UTC)
def shape_of(scale=1.0):
"""The brief's owner example (0.6 kW at night, 1.2 kW 06-10, 0.9 kW by
day, 1.8 kW 17-22), scaled."""
return {h: scale * (600.0 if h < 6 or h >= 22 else 1200.0 if h < 10
else 900.0 if h < 17 else 1800.0) for h in range(24)}
def profile_of(shape, now=NOW):
d = now.astimezone(BILLING_TZ).date()
return dayplan.HouseProfile(
{(d - timedelta(days=k)).isoformat():
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
def make(shape, soc=(60.0, 94.0), modes=("leftover_solar", "cheap_hours"),
today=10.2, tomorrow=10.0, now=NOW, peak_cap=None):
"""A Controller at `now`: day/night tariff, floor 20 %, ceiling 95 %,
three state-only sensors (10.2 kWh today), its mode roles run once."""
opts = dict(S.BASE)
opts.update(battery_modes_enabled=True,
pv_forecast_entities=[f"{S.FC1}_{i}" for i in range(3)],
pv_forecast_tomorrow_entities=[f"{S.FCT1}_{i}"
for i in range(3)],
static_profile=json.dumps(S.DN), arbitrage_enabled=False,
arbitrage_soc_floor=20, arbitrage_soc_ceiling=95,
cycle_cost_threshold_eur=0.04)
if peak_cap is not None:
opts.update(peak_cap_w=peak_cap)
plan = static_plan(opts, S.day0_of(now), S.day0_of(now) + timedelta(
hours=48), now=now)
c = Controller(opts, S.Hass(), S.Store(), S.Pub(), stop=S.asyncio.Event())
c.auto = True
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i]
c.grid, c.pv_w = 3000.0, 0.0
c.tariff_plan, c.tariff_at = plan, now
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0,
ceiling_from="cap", projected_w=800.0)
c.arb_headroom_w = 3450.0 - 3000.0 + 2000.0
c.solar_fc = S.forecast(now, today, tomorrow, members=3)
c.solar_fc_why = None
c.house_profile = profile_of(shape, now)
c.tariff_at = now
c._mode_roles(now, None)
return c
def screen(c, lang="en", now=NOW):
st = homeview.build(c, level="ok", checks=[], now=now, lang=lang)
rows = {r["name"]: r for r in st["batteries"]}
return st, rows
LIVE = shape_of(0.973) # the scale whose plan matches live (11.3 kWh)
c = make(LIVE)
dp = c.day_plan
tg = c.plan_targets()
print(f" short {dp.short_kwh:.2f} kWh, Cheap hours give {dp.cheap_kwh:.2f}, "
f"A to {tg[A]} %, A buys {dp.need_kwh[A]:.2f} kWh, uncovered "
f"{dp.uncovered_kwh:.2f}")
check("live: A (Leftover solar 60 %) buys nothing while B (Cheap hours 94 %) "
"covers the expensive hours (was: to 95 %, 5.38 kWh)",
dp.need_kwh[A] == 0.0 and tg[A] <= 60.0
and homeview.plan_need(c, A) == 0.0)
check("live: nothing is left uncovered (was 0.4 kWh), and B's give is "
"counted up to the shortfall and no more",
dp.uncovered_kwh == 0.0 and abs(dp.cheap_kwh - dp.short_kwh) < 1e-6)
check("live: control gives A no grid charge at all (the run charges B only)",
[p.name for p in modetrade.chargers(
c._mode_packs(None), CEIL, targets=c.plan_targets())] == [B])
for lang in ("en", "nl"):
st, rows = screen(c, lang)
mine = [r for r in st["plan"]["runs"]
if r["battery"] == 0 and r["kind"] != "house"]
theirs = [r for r in st["plan"]["runs"]
if r["battery"] == 1 and r["kind"] != "house"]
line = st["solar"]["line"]
check(f"{lang}: A has no grid block, only its give block "
f"({[r['kind'] for r in mine]})",
[r["kind"] for r in mine] == ["give"])
check(f"{lang}: A's give block is what A holds (60 % -> 6.1 kWh)",
abs(nth(mine, 0)["kwh"] - (60.0 - FLOOR) / 100.0 * CAP_A) < 0.01)
check(f"{lang}: B's give block reads B after its charge (to 95 %: "
"15.4 kWh, not 15.2 from 94 %)",
abs(nth(theirs, -1)["kwh"] - (CEIL - FLOOR) / 100.0 * CAP_B) < 0.01)
check(f"{lang}: the solar line has no 'the rest' sentence",
"0.4" not in line and "0,4" not in line
and "rest, " not in line and "rest van" not in line
and "rest," not in line)
check(f"{lang}: no line anywhere says Leftover solar charges from the "
"grid to 95",
all("95" not in r["why"] for r in rows.values())
and all("95 %" not in t["what"] for r in rows.values()
for t in r["today"] if "grid" in t["kind"]
and r is rows[A]))
check("live: while B charges, A's Why says another battery charges (it "
"rests)", screen(c, "en")[1][A]["why_key"] == "rests_charge")
# B at 100 % of its run done: the cheap run is over, B only gives. A's Why
# says who covers the hours, not the sun.
c2 = make(LIVE, soc=(60.0, 95.0))
c2._mode_roles(NOW, None)
role_a = c2.mode_tick.roles[A]
key_a = homeview.day_key(c2, A)
print(f" B full: A's role {role_a}, day key {key_a}")
check("B already at the ceiling: A has no charge, and the day key says the "
"Cheap hours battery covers, not the sun",
key_a == "others_cover" and c2.day_plan.need_kwh[A] == 0.0
and c2.day_plan.saved_kwh[A] > 5.0)
for lang in ("en", "nl"):
with homeview.in_lang(lang):
txt = homeview.why_words("others_cover")
sentences = [x for x in txt.split(".") if x.strip()]
check(f"{lang}: the 'others_cover' line is at most 2 sentences of at "
f"most 12 words and has no raw placeholder: {txt!r}",
len(sentences) <= 2 and all(len(x.split()) <= 12
for x in sentences) and "{" not in txt)
check("EN and NL say that other batteries cover, in plain words",
"Other batteries" in uitext.words("en")["home_words"]["why"][
"others_cover"]
and "Andere batterijen" in uitext.words("nl")["home_words"]["why"][
"others_cover"])
# Owner, 2026-11-02: a Solar first battery counts too, at its level when the
# expensive hours start: level now less the house's night use to 07:00
# (01:01 to 07:00: 0.58 kW to 06:00, then 1.17 kW), shared among Solar first
# batteries, x 0.95. A Leftover solar at its floor buys only what is left.
c3 = make(LIVE, soc=(20.0, 94.0), modes=("leftover_solar", "solar_first"))
# Round 4: each hour at no less than the night base (the profile is net of
# the sun the house used).
_hw, base_n = c3.house_profile.estimator()
use_n = sum(max(LIVE[h_], base_n) / 1000.0 * (e - s_).total_seconds() / 3600.0
for s_, e, h_ in dayplan._hour_slices(
NOW, datetime(2026, 11, 2, 7, 0, tzinfo=BILLING_TZ)))
have_b = (94.0 - FLOOR) / 100.0 * CAP_B
give_sf = (have_b - use_n / EFF) * EFF
d3 = c3.day_plan
want_hold = (d3.short_kwh - give_sf) / EFF
print(f" B Solar first 94 %: night use {use_n:.2f}, B gives {give_sf:.2f} "
f"of {d3.short_kwh:.2f}; A (20 %) buys {d3.need_kwh[A]:.3f}")
check("B on Solar first at 94 % counts: its level at 07:00 is 94 % less the "
"night's use, (level - floor) x cap x 0.95",
abs(d3.solar_first_kwh - give_sf) < 0.01 and d3.cheap_kwh == 0.0)
check("...and A (at its floor) buys only the shortfall after that "
"(was 5.38 kWh and to 95 %)",
abs(d3.need_kwh[A] - want_hold) < 0.02 and d3.need_kwh[A] < 1.5
and d3.uncovered_kwh == 0.0)
c3b = make(LIVE, soc=(60.0, 94.0), modes=("leftover_solar", "solar_first"))
check("...and A at 60 % with B Solar first 94 % buys nothing",
c3b.day_plan.need_kwh[A] == 0.0 and c3b.day_plan.uncovered_kwh == 0.0)
# ============================================================================ #
print("2. dayplan.plan() alone: B low, a short cheap run under the peak limit")
# ============================================================================ #
def loc(day, hour, minute=0):
return datetime(2026, 10, day, hour, minute,
tzinfo=BILLING_TZ).astimezone(UTC)
SHAPE = shape_of(1.0)
T = loc(30, 4, 0) # 04:00, three cheap hours left
RUNS = priceruns.RunPlan(
priceruns.Run(loc(29, 22), loc(30, 7), 0.2333),
(priceruns.Run(loc(30, 7), loc(30, 22), 0.2801),
priceruns.Run(loc(31, 7), loc(31, 22), 0.2801)),
None, 0.2801, None, True)
def pk(name, mode, soc, cap, rating=4600.0, **kw):
return modetrade.Pack(name, True, mode, False, rating, soc, cap, **kw)
def fc_of(kwh, now=T):
d0, _d1 = solarfc._bounds(now)
return solarfc.Forecast(solarfc.Day(d0, kwh),
solarfc.Day(d0 + timedelta(days=1), kwh), now)
def mk_plan(packs, *, aim=3450.0, runs=RUNS, now=T, kwh=6.0, shape=SHAPE):
return dayplan.plan(fc_of(kwh, now), profile_of(shape, now).estimator(),
runs, now, packs, trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL, aim_w=aim)
def expect_short(kwh, shape=SHAPE, trust=0.7):
"""The shortfall written out: per hour 07..21 max(0, house - 0.7 x sun),
an hour with sun read at no less than the night base."""
base = sorted(shape[h] for h in (0, 1, 2, 3, 4, 5, 22, 23))
base = (base[3] + base[4]) / 2.0
tot = 0.0
for h in range(7, 22):
pv = kwh * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
use = (max(shape[h], base) if pv > 0 else shape[h]) / 1000.0
tot += max(0.0, use - trust * pv)
return tot
def expect_b_level(soc, cap, gain_kwh):
"""`gain_kwh` drawn from the grid; x 0.95 of it is stored (round 4)."""
return max(soc, min(CEIL, soc + gain_kwh * EFF / cap * 100.0))
SH = expect_short(6.0)
# 04:00-07:00 at the night 0.6 kW (04, 05) and 1.2 kW (06): the peak aim 3450
# leaves 2850, 2850 and 2250 W of the battery's 4600 W.
GAIN = 2.85 + 2.85 + 2.25
LB = expect_b_level(30.0, CAP_B, GAIN)
GIVE_B = (LB - FLOOR) / 100.0 * CAP_B * EFF
A_LVL = FLOOR + (SH - GIVE_B) / EFF / CAP_A * 100.0
p_low = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)])
print(f" short {SH:.2f}; B 30 % + {GAIN:.2f} kWh -> {LB:.1f} % gives "
f"{GIVE_B:.2f}; A to {A_LVL:.1f} % (plan: {p_low.targets[A]} %, B "
f"gives {p_low.cheap_kwh})")
check("the shortfall is the written-out sum", abs(p_low.short_kwh - SH) < 0.005)
check("B's give is (its level at the start of the dear hours - floor) x "
"capacity x 0.95, the level being what 3 h under the peak aim reach",
abs(p_low.cheap_kwh - GIVE_B) < 0.005 and LB < CEIL - 20.0)
check("A buys only what B cannot reach: its level is floor + (shortfall - "
"B's give) / 0.95",
abs(p_low.targets[A] - A_LVL) < 0.05
and abs(p_low.need_kwh[A] - (A_LVL - FLOOR) / 100.0 * CAP_A) < 0.01
and p_low.uncovered_kwh == 0.0)
alone = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A)])
check("...which is less than A would buy with no Cheap hours battery",
alone.need_kwh[A] > p_low.need_kwh[A] + 4.0 and alone.cheap_kwh == 0.0)
check("the grid kWh A saves is reported (`saved_kwh`)",
abs(p_low.saved_kwh[A] - (alone.need_kwh[A] - p_low.need_kwh[A])) < 0.01)
# No peak limit given: the batteries' ratings alone (4.6 kW x 3 h reaches 95).
p_free = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], aim=None)
GIVE_FREE = (expect_b_level(30.0, CAP_B, 4.6 * 3.0) - FLOOR) / 100.0 \
* CAP_B * EFF
check("with no peak aim the run is held by the rating only: 4.6 kW x 3 h "
"x 0.95 stored takes B to 94 %; A buys only what is left",
abs(p_free.cheap_kwh - GIVE_FREE) < 0.005
and abs(p_free.need_kwh[A] - (SH - GIVE_FREE) / EFF) < 0.01
and p_free.uncovered_kwh == 0.0)
# A house at the aim leaves no room: B cannot charge, counts what it holds.
p_full = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], aim=500.0)
check("a house at the peak aim leaves no room: B counts its level now "
"((30 - 20) % x 20.48 x 0.95)",
abs(p_full.cheap_kwh - 10.0 / 100.0 * CAP_B * EFF) < 0.005)
# The run is not worth charging in: B stays where it is.
NOCH = priceruns.RunPlan(RUNS.cheap, RUNS.give, None, RUNS.level_eur_kwh,
None, False)
p_noch = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], runs=NOCH,
aim=None)
check("a cheap run not worth charging in: B counts its level now",
abs(p_noch.cheap_kwh - 10.0 / 100.0 * CAP_B * EFF) < 0.005)
# A cheap run that is already over (now past its end): the level now.
p_over = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 80.0, CAP_B)],
now=loc(30, 7, 0) - timedelta(seconds=1), aim=None)
check("the last second of the run: B counts its level (80 %), no more",
abs(p_over.cheap_kwh - (80.0 - FLOOR) / 100.0 * CAP_B * EFF) < 0.01)
# Two Cheap hours batteries share the peak room by rating.
p_two = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B, rating=3000.0),
pk("inverter-c", packmode.CHEAP_HOURS, 30.0, CAP_B,
rating=1500.0)])
g_b = GAIN * 3000.0 / 4500.0
g_c = GAIN * 1500.0 / 4500.0
give_two = sum((expect_b_level(30.0, CAP_B, g) - FLOOR) / 100.0 * CAP_B * EFF
for g in (g_b, g_c))
# 2.85 kW is under the 4.5 kW of the two together: shared 2:1. Hours 04-05
# take 2.85 of 4.5, hour 06 takes 2.25 of 4.5.
check("two Cheap hours batteries share the peak room by rating",
abs(p_two.cheap_kwh - min(give_two, p_two.short_kwh)) < 0.01)
# Round 3: the run is sized at the ratings, not the peak limit, so a run too
# short to fill B is planned again later in the night. B's charge counts the
# firm price slots after the run, up to the expensive hours, that a run would
# still be worth charging in: price + the spread control asks (`spread`, 0.04
# here) at most the give run's dearest slot (0.2801). Without them A's level
# fell through the night after A had bought.
RUN1 = priceruns.RunPlan(
priceruns.Run(loc(30, 4), loc(30, 5), 0.2333), RUNS.give, None,
RUNS.level_eur_kwh, None, True)
def quarters(t0, t1, eur):
out, t = [], t0
while t < t1:
out.append((t, t + timedelta(minutes=15), eur))
t += timedelta(minutes=15)
return out
NIGHT_Q = quarters(loc(30, 4), loc(30, 7), 0.2333)
DAY_Q = quarters(loc(30, 7), loc(30, 22), 0.2801)
LATE_Q = quarters(loc(30, 22), loc(31, 7), 0.2333)
PK2 = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)]
def mk_slots(slots, runs=RUN1, spread=0.04):
return dayplan.plan(fc_of(6.0, T), profile_of(SHAPE, T).estimator(),
runs, T, PK2, trust_pct=70, floor_pct=FLOOR,
ceiling_pct=CEIL, aim_w=3450.0, slots=slots,
spread=spread)
p_run1 = mk_slots(None)
p_q = mk_slots(NIGHT_Q + DAY_Q + LATE_Q)
GIVE_1H = (expect_b_level(30.0, CAP_B, 2.85) - FLOOR) / 100.0 * CAP_B * EFF
print(f" a 1 h run (04:00-05:00): B gives {p_run1.cheap_kwh:.3f} without "
f"the slots, {p_q.cheap_kwh:.3f} with the night's slots to 07:00")
check("a 1 h run with no slots given: B counts what 1 h reaches (as before)",
abs(p_run1.cheap_kwh - GIVE_1H) < 0.005)
check("...and with the slots but no spread given, the same",
abs(mk_slots(NIGHT_Q + DAY_Q, spread=None).cheap_kwh - GIVE_1H)
< 0.005)
check("the same run with the night's quarters (0.2333) to 07:00: B "
"counts what 04:00-07:00 reach, as the 3 h run does (and A buys the "
"same); the day and the next night are not counted",
abs(p_q.cheap_kwh - GIVE_B) < 0.005
and abs(p_q.targets[A] - p_low.targets[A]) < 0.01)
p_dear = mk_slots(quarters(loc(30, 5), loc(30, 7), 0.2500) + DAY_Q)
check("slots after the run not worth charging in (0.25 + 0.04 over the "
"dearest 0.2801) are not counted: B counts the 1 h run only",
abs(p_dear.cheap_kwh - GIVE_1H) < 0.005)
p_part = mk_slots(quarters(loc(30, 5), loc(30, 6), 0.2400)
+ quarters(loc(30, 6), loc(30, 7), 0.2500) + DAY_Q)
GIVE_2H = (expect_b_level(30.0, CAP_B, 2.85 + 2.85) - FLOOR) / 100.0 \
* CAP_B * EFF
check("...and only those are: 05:00-06:00 at 0.24 (dearer than the run, "
"still worth it) adds one hour (04:00-06:00 reach)",
abs(p_part.cheap_kwh - GIVE_2H) < 0.005)
# ============================================================================ #
print("3. who counts, and who does not")
# ============================================================================ #
base_pk = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A)]
want = mk_plan(base_pk)
for label, b in (
("off", modetrade.Pack(B, False, packmode.CHEAP_HOURS, False, 4600.0,
94.0, CAP_B)),
("refusing to give", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
refused=1)),
("refusing to charge", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
refused=-1)),
("in maintenance", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
idle=False)),
("not live", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B, live=False)),
("without capacity", modetrade.Pack(B, True, packmode.CHEAP_HOURS,
False, 4600.0, 94.0, None)),
("without a level", modetrade.Pack(B, True, packmode.CHEAP_HOURS,
False, 4600.0, None, CAP_B)),
("Solar first refusing to give",
pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B, refused=1)),
("Solar first in maintenance",
pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B, idle=False)),
("Solar first with the night draining it to the floor",
pk(B, packmode.SOLAR_FIRST, 24.0, CAP_B))):
pl = mk_plan(base_pk + [b])
check(f"B {label}: counts 0, A's plan is as with no B",
pl.cheap_kwh == 0.0 and pl.targets == want.targets
and pl.need_kwh == want.need_kwh
and pl.uncovered_kwh == want.uncovered_kwh
and pl.cover_kwh == want.cover_kwh)
on = mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)])
check("(control: the same B, on and idle, counts)",
on.cheap_kwh > 5.0 and on.need_kwh[A] < want.need_kwh[A] - 1.0)
# Solar first at 94 % at 04:00: three night hours (0.6, 0.6, 1.2 kW) are used
# before 07:00, shared with nobody else; the rest counts.
p_sf = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B)])
use_t = 0.6 + 0.6 + 1.2
give_t = ((94.0 - FLOOR) / 100.0 * CAP_B - use_t / EFF) * EFF
check("Solar first at 94 % at 04:00: counts its level at 07:00 "
f"({give_t:.2f} kWh), A buys only the rest",
abs(p_sf.solar_first_kwh - give_t) < 0.005 and p_sf.cheap_kwh == 0.0
and p_sf.need_kwh[A] < want.need_kwh[A] - 4.0)
# Two Solar first batteries share the night's use by the energy they hold.
p_sf2 = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 60.0, CAP_B),
pk("inverter-c", packmode.SOLAR_FIRST, 40.0,
CAP_B)])
tot_h = (60.0 - FLOOR) / 100.0 * CAP_B + (40.0 - FLOOR) / 100.0 * CAP_B
check("two Solar first batteries: the night's use is shared by the energy "
"held; the total is the same sum",
abs(p_sf2.solar_first_kwh - min(
p_sf2.short_kwh, (tot_h - use_t / EFF) * EFF)) < 0.005)
# Both kinds: Cheap hours and Solar first add up (capped at the shortfall).
p_both = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 60.0, CAP_B),
pk("inverter-c", packmode.CHEAP_HOURS, 30.0,
CAP_B)])
check("Cheap hours and Solar first batteries both count",
p_both.cheap_kwh > 5.0 and p_both.solar_first_kwh > 3.0
and abs(p_both.cheap_kwh + p_both.solar_first_kwh
- min(p_both.short_kwh, p_both.cheap_kwh
+ p_both.solar_first_kwh)) < 1e-6)
# A Cheap hours battery with Leftover solar A: when A is not Leftover solar
# (N=1 on Cheap hours alone) the plan has no Leftover solar to size.
solo = mk_plan([pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)])
check("Cheap hours only sites: no Leftover solar battery, nothing is sized",
solo.targets == {} and solo.need_kwh == {} and solo.cheap_kwh == 0.0
and solo.uncovered_kwh == 0.0)
# Everything the plan says about a Leftover solar only site is the same as
# before OPT-36: the new fields are at rest.
two_ls = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.LEFTOVER_SOLAR, 94.0, CAP_B)])
check("Leftover solar only (two batteries): cheap_kwh 0, nothing saved",
two_ls.cheap_kwh == 0.0
and all(v == 0.0 for v in two_ls.saved_kwh.values())
and abs(sum(two_ls.cover_kwh.values()) - two_ls.short_kwh) < 0.01)
# More than the Cheap hours battery holds: A buys the rest, "the rest" is said
# only for what nothing holds.
big = shape_of(2.0)
p_big = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)], shape=big,
aim=None)
want_big = expect_short(6.0, big)
cheap_big = (CEIL - FLOOR) / 100.0 * CAP_B * EFF
check("a shortfall B cannot cover: A holds the rest, to the ceiling; what "
"neither holds is 'uncovered'",
abs(p_big.cheap_kwh - cheap_big) < 0.005
and p_big.targets[A] == CEIL
and abs(p_big.uncovered_kwh - max(
0.0, want_big - cheap_big - (CEIL - FLOOR) / 100.0 * CAP_A * EFF))
< 0.01)
check("the plan is pure: the same call twice gives the same plan",
mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)])
== mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)]))
# ============================================================================ #
print("4. the screens agree with control")
# ============================================================================ #
# A tops up (B low, the house large): the give block reads A after its charge.
cs = make(shape_of(1.5), soc=(20.0, 30.0), now=NOW)
dps = cs.day_plan
tgs = cs.plan_targets()
print(f" A 20 %, B 30 %: B gives {dps.cheap_kwh:.2f}, A to {tgs[A]} %, "
f"buys {dps.need_kwh[A]:.2f}, uncovered {dps.uncovered_kwh:.2f}")
check("(A does buy here: B cannot cover the larger house)",
dps.need_kwh[A] > 1.0 and dps.cheap_kwh > 5.0)
for lang in ("en", "nl"):
st, rows = screen(cs, lang)
runs_a = [r for r in st["plan"]["runs"] if r["battery"] == 0]
grid = next((r for r in runs_a if r["kind"] == "grid"), None)
give = next((r for r in runs_a if r["kind"] == "give"), None)
cov = homeview.plan_cover(cs, A)
with homeview.in_lang(lang):
pct, kwh = homeview._pct(tgs[A]), homeview._kwh(dps.cover_kwh[A])
check(f"{lang}: A tops up: the grid block names the plan's level and "
"the plan's own cover figure",
grid is not None and pct in grid["what"] and kwh in grid["why"]
and cov == (tgs[A], dps.cover_kwh[A]))
check(f"{lang}: the give block is A at the level it charges to "
f"({(tgs[A] - FLOOR) / 100.0 * CAP_A:.2f} kWh), not at 20 % (0)",
give is not None and abs(
give["kwh"] - (tgs[A] - FLOOR) / 100.0 * CAP_A) < 0.02)
# The Batteries line for a battery that holds (`tops_up`) is made from the
# same figures: the plan's level and its own cover figure.
check("(A's day key is tops_up)", homeview.day_key(cs, A) == "tops_up")
for lang in ("en", "nl"):
with homeview.in_lang(lang):
words = homeview.plan_words(cs, A, "tops_up")
txt = homeview.why_words("tops_up", **words)
pct, kwh = homeview._pct(tgs[A]), homeview._kwh(dps.cover_kwh[A])
check(f"{lang}: A's tops_up line names the plan's level ({pct}) and "
f"cover figure ({kwh}): {txt!r}", pct in txt and kwh in txt)
# The controller hands the plan its peak aim (peak_cap_w - peak_margin_w): a
# lower peak limit leaves B less room in its run, so it covers less.
lo_cap = make(shape_of(1.5), soc=(20.0, 30.0), peak_cap=2000)
hi_cap = make(shape_of(1.5), soc=(20.0, 30.0), peak_cap=20000)
print(f" peak limit 2000 W: B gives {lo_cap.day_plan.cheap_kwh:.2f} kWh; "
f"20000 W: {hi_cap.day_plan.cheap_kwh:.2f} kWh")
check("the Controller passes its peak aim: a 2 kW limit leaves B less room "
"than a 20 kW one, and A buys the difference",
lo_cap.day_plan.cheap_kwh < hi_cap.day_plan.cheap_kwh - 2.0
and lo_cap.day_plan.need_kwh[A] > hi_cap.day_plan.need_kwh[A] + 1.0)
# A hysteresis-held level: the cover figure is still the plan's own.
real = cs.plan_targets
cs.plan_targets = lambda: {A: tgs[A] + 0.7}
cv = homeview.plan_cover(cs, A)
check("plan_cover() is the plan's cover figure whatever level control holds "
"(hysteresis), the level being the held one",
cv == (tgs[A] + 0.7, dps.cover_kwh[A]))
cs.plan_targets = real
# A give run before the cheap run (10:00: tomorrow night's charge is after
# today's give): the give block reads the level now.
c10 = make(shape_of(2.0), soc=(40.0, 25.0), modes=("leftover_solar", "solar_first"),
now=datetime(2026, 11, 2, 10, 0, tzinfo=BILLING_TZ).astimezone(UTC))
st10 = homeview.build(c10, level="ok", checks=[],
now=datetime(2026, 11, 2, 10, 0,
tzinfo=BILLING_TZ).astimezone(UTC),
lang="en")
g10 = [r for r in st10["plan"]["runs"]
if r["battery"] == 0 and r["kind"] == "give"]
check("a give run that comes BEFORE the next cheap run is read at the level "
"now (40 % -> 3.1 kWh), not at tonight's charge",
g10 and abs(g10[0]["kwh"] - (40.0 - FLOOR) / 100.0 * CAP_A) < 0.02
and any(r["kind"] == "grid" and r["battery"] == 0
for r in st10["plan"]["runs"]))
# ============================================================================ #
print("5. the real Controller through a whole night")
# ============================================================================ #
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
def at(day, hh, mm=0):
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(UTC)
def st_of(kwh, when, *, unit="kWh"):
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
"last_updated": when.isoformat(),
"last_reported": when.isoformat()}
GONE = {"state": "unavailable", "attributes": {}}
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
PROFILE = ('{"periods": ['
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
' "eur_per_kwh": 0.26},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)}
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
+ [at(3, 21, 55)]
def night_opts(cap, profile=PROFILE):
o = dict(rp.options(True, False, 2))
o.update(cycle_cost_threshold_eur=0.07, static_profile=profile,
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
peak_cap_w=cap)
return o
def run_night(spec):
"""One whole night in this process -> a plain dict. `spec`: start (UTC),
hours, soc, house_w, cap, fresh (today, tomorrow) before `change`,
change (UTC) and the figures after it, silent_from (UTC): the sensors
show their last reading from then on."""
start = spec["start"]
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val
clock = [EVENING[0]]
figs = spec.get("fresh", (1.0, 5.0))
change = spec.get("change")
after = spec.get("after", figs)
silent = spec.get("silent_from")
def sens(now, ent):
f = after if change is not None and now >= change else figs
when = now if silent is None else min(now, silent)
return st_of(f[0] if ent == T_ENT else f[1], when)
class H(rp.Hass):
async def state(self, e):
return sens(clock[0], e)
hass = H(("a", "b"), lambda t: 300.0, spec["soc"])
out = dict(marks={}, soc_at={}, p_a=[])
tmp = tempfile.mkdtemp(prefix="o36-")
c = Controller(dict(night_opts(spec.get("cap", 2500),
spec.get("profile", PROFILE))), hass, store,
rp._Pub(), stop=asyncio.Event(),
peak_db_path=os.path.join(tmp, "tariff.db"),
price_history_path=os.path.join(tmp, "ph.json"))
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
hw = spec.get("house_w", 700.0)
c.house_profile.load({d: {str(hh): hw for hh in range(24)}
for d in ("2026-10-31", "2026-11-01", "2026-11-02")})
async def go():
real_dt, real_hv = m.datetime, homeview.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = homeview.datetime = _DT
try:
for t_pre in EVENING:
clock[0] = t_pre
await c._read_forecast()
marks = sorted(spec.get("marks", ()))
first, t = True, 0.0
hz = spec["hours"] * 3600.0
while t < hz:
now = start + timedelta(seconds=t)
clock[0] = now
if first or int(t) % 600 == 0:
hor = float(m.price_option(c.o, "price_horizon_h"))
c.tariff_plan = await m.resolve_tariff(
c.o, now, now + timedelta(hours=hor), now=now,
history=c.price_history, watch=c.price_watch)
c.tariff_at = now
first = False
hass.t = t
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()
hass.advance(5.0, 10.0)
if int(t) % 300 == 0:
tgt = c.plan_targets()
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
hass.plants["a"].soc, hass.plants["b"].soc,
hass.plants["a"].p, c.solar_fc is None,
None if tgt is None else tgt.get(A))
if int(t) % 60 == 0:
out["p_a"].append(hass.plants["a"].p)
while marks and now >= marks[0]:
marks.pop(0)
cards = homeview.battery_rows(c, False, True)
blk = homeview.solar_block(c, now)
a_card = next(b for b in cards if b["name"] == A)
dpl = c.day_plan
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
line=blk["line"], why_a=a_card["why_key"],
fc_none=c.solar_fc is None,
target_a=(c.plan_targets() or {}).get(A),
need_a=None if dpl is None else dpl.need_kwh.get(A),
cheap=None if dpl is None else dpl.cheap_kwh,
short=None if dpl is None else dpl.short_kwh,
soc_a=hass.plants["a"].soc,
p_a=hass.plants["a"].p)
t += 5.0
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
out["latch"] = getattr(c, "_charge_latch", None) is not None
finally:
m.datetime, homeview.datetime = real_dt, real_hv
return out
return asyncio.run(go())
# Round 4 (review F1): three night prices, the cheapest in the middle.
PROFILE3 = ('{"periods": ['
'{"name": "eve", "days": "all", "from": "22:00", "to": "01:00",'
' "eur_per_kwh": 0.15},'
'{"name": "low", "days": "all", "from": "01:00", "to": "03:00",'
' "eur_per_kwh": 0.10},'
'{"name": "dawn", "days": "all", "from": "03:00", "to": "07:00",'
' "eur_per_kwh": 0.15},'
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
' "eur_per_kwh": 0.34}]}')
def run_replay(variant, fc):
"""One 3-day opt11p4 replay (real 2026 prices, 2026-09-02) -> net EUR."""
import contextlib
import io
import opt11p4_replay as p4
rp.VARIANTS["own_sf"] = (True, False, {
"a": (True, "leftover_solar", False),
"b": (True, "solar_first", False)})
argv = sys.argv
sys.argv = ["p4", "--variant", variant, "--fc", fc, "--prices",
"real2026", "--shape", "periods", "--first", "2", "--days",
"3"]
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
p4.main()
finally:
sys.argv = argv
return json.loads(buf.getvalue().strip().splitlines()[-1])
NIGHT = at(3, 22, 0)
HOURLY = [NIGHT + timedelta(minutes=30 * i) for i in range(18)]
SCEN = {
# The live night: A 60 % Leftover solar, B 94 % Cheap hours, a fresh
# figure all night. A must not charge from the grid.
"live": dict(start=NIGHT, hours=9, soc={"a": 60.0, "b": 94.0},
house_w=700.0, marks=[at(4, 1, 0), at(4, 5, 0)]),
# A mid case: both low (25 %), the house 0.8 kW, a 2.5 kW peak limit
# leaves B 1.65 kW in a 3 h run (the run is sized to the batteries'
# ratings, not the limit), so B counts for part and A buys the rest.
"mid": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0},
house_w=800.0, marks=HOURLY),
# Round 3: the same with one figure for the 4th all night (today 5.0
# after midnight = tomorrow 5.0 before): A's level must not move at all.
# On round 2's code it went 55 -> 69 -> 41 -> 37 % and A bought to 56 %.
"mid_flat": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0},
house_w=800.0, fresh=(5.0, 5.0), marks=HOURLY),
# Round 3, OPT-34 F-1 with B on Cheap hours and counted: a darker fresh
# figure at 02:00, then silent (no forecast from 05:00). The level rises,
# A charges to it, and the latch holds it after 05:00.
# Round 4 (review F1): the three-price night. B's charge after the run is
# planned in the next night's 0.10 hours, not in tonight's 0.15 ones, so
# those may not count. On 1ac7fe9 the plan counted B to 95 %, B ended at
# 67.5 % and the expensive hours were 2.61 kWh short; 0.20.0: none.
"three": dict(start=NIGHT, hours=9, soc={"a": 30.0, "b": 20.0},
house_w=700.0, cap=10000, fresh=(1.0, 1.0),
profile=PROFILE3, marks=HOURLY),
"f1_dark_b": dict(start=at(4, 0, 30), hours=6.5, cap=3500,
soc={"a": 50.0, "b": 50.0}, house_w=900.0,
fresh=(5.0, 5.0), change=at(4, 2, 0),
after=(1.0, 1.0), silent_from=at(4, 2, 0),
marks=[at(4, 1, 55)] + [at(4, 2, 15) + timedelta(
minutes=15 * i) for i in range(19)]),
# OPT-34 with the new level: a fresh, brighter figure at 03:10 lowers A's
# level below its SoC (A stops), then the sensors stay silent: the
# forecast ends and the latch holds the LOWERED level to 07:00.
"lowered": dict(start=at(4, 2, 0), hours=5.0, soc={"a": 25.0, "b": 25.0},
house_w=1200.0, fresh=(1.0, 5.0), change=at(4, 3, 10),
after=(30.0, 30.0), silent_from=at(4, 3, 10),
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
}
t0 = time.time()
with ProcessPoolExecutor(max_workers=min(4, os.cpu_count() or 2),
mp_context=get_context("fork")) as pool:
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
# Round 4 (review F2): the real-price replays with a Solar first battery.
reps = {k: pool.submit(run_replay, *v) for k, v in (
("ownsf_hourly_real", ("own_sf", "perfect")),
("p4_ls_dark_real", ("p4_ls", "zero")))}
R = {k: f.result() for k, f in futs.items()}
REP = {k: f.result() for k, f in reps.items()}
print(f" {len(R)} nights in {time.time() - t0:.0f} s")
lv = R["live"]
print(f" live: A {lv['soc'][0]:.2f} % B {lv['soc'][1]:.2f} % "
f"marks {lv['marks']}")
check("live night: A takes no grid charge: it never charges (its power "
"stays above -200 W) and ends where it started",
min(lv["p_a"]) > -200.0 and abs(lv["soc"][0] - 60.0) < 1.5)
check("live night: B fills to the ceiling as before",
lv["soc"][1] > 94.0)
check("live night: the plan gives A a level at or under its SoC and the "
"screen says nothing about 'the rest'",
all(mk["need_a"] == 0.0 and mk["cheap"] > 0.0
and "rest" not in mk["line"] for mk in lv["marks"].values()))
md = R["mid"]
m1, m2 = md["marks"]["22:30"], md["marks"]["05:00"]
print(f" mid: A level {m1['target_a']} % at 22:30, {m2['target_a']} % at "
f"05:00; A ends {md['soc'][0]:.2f} %, B {md['soc'][1]:.2f} %; B gives "
f"{m1['cheap']}")
check("mid night: the plan counts B's give and A's level is a partial one "
"(not the ceiling, as 0.20.0 gave: 95 %)",
m1["target_a"] is not None and 25.0 < m1["target_a"] < CEIL - 15.0
and m1["cheap"] > 3.0)
check("mid night: B's counted give is the same at every mark (B reaches the "
"ceiling in the night left), and A's level moves only at midnight, "
"when the 4th's figure changes from tomorrow's 5.0 to today's 1.0",
len({mk["cheap"] for mk in md["marks"].values()}) == 1
and len({mk["target_a"] for h, mk in md["marks"].items()
if h >= "12"}) == 1
and len({mk["target_a"] for h, mk in md["marks"].items()
if h < "12"}) == 1)
check("mid night: A's commands follow the levels: it charges to its level "
"and stops there, well under the ceiling (0.20.0: 95 %)",
md["soc"][0] < 85.0 and abs(md["soc"][0] - m2["target_a"]) < 0.5)
check("mid night: B fills as before (to the ceiling)", md["soc"][1] > 94.0)
mf = R["mid_flat"]
lv_f = [mk["target_a"] for mk in mf["marks"].values()]
lv0 = nth(lv_f, 0)
print(f" mid_flat: A level(s) {sorted(set(map(str, lv_f)))}; A ends "
f"{mf['soc'][0]:.2f} %")
check("one figure all night: A's level never moves (round 2: 55 -> 69 -> "
"41 -> 37 %), A charges to it and stops (round 2: 56 %)",
present(lv0) and lv0 is not None and len(set(lv_f)) == 1
and abs(mf["soc"][0] - lv0) < 0.5)
fd = R["f1_dark_b"]
dk = fd["marks"]
held = [dk[h]["target_a"] for h in dk if "05:15" <= h < "07:00"]
print(f" f1_dark_b: A level 01:55 {dk['01:55']['target_a']} -> 02:15 "
f"{dk['02:15']['target_a']}, held {sorted(set(held))}; A "
f"{fd['soc_at']['06:55'][0]:.1f} % at 06:55, B {fd['soc'][1]:.1f} %")
check("F-1 with B counted: a darker fresh figure at 02:00 raises A's level, "
"the level does not move after that (B's give is the same), it is held "
"with no forecast from 05:15, and A charges to it",
dk["01:55"]["target_a"] is not None
and dk["02:15"]["target_a"] > dk["01:55"]["target_a"] + 3.0
and dk["01:55"]["cheap"] > 3.0
and all(dk[h]["target_a"] == dk["02:15"]["target_a"] for h in dk
if h >= "02:15")
and all(dk[h]["fc_none"] for h in dk if "05:15" <= h < "07:00")
and abs(fd["soc_at"]["06:55"][0] - dk["02:15"]["target_a"]) < 1.5
and fd["soc"][1] > 94.0)
th = R["three"]
sh3 = [mk["short"] for mk in th["marks"].values() if mk["short"] is not None]
held3 = sum(max(0.0, x - FLOOR) for x in th["soc"]) / 100.0 \
* rp.CAP_KWH * EFF
sh3_0 = nth(sh3, 0)
print(f" three prices: A {th['soc'][0]:.2f} %, B {th['soc'][1]:.2f} %; "
f"held at 07:00 {held3:.2f} kWh for a shortfall of {sh3_0} kWh "
"(1ac7fe9: 7.20 for 9.81, 0.20.0: 13.89)")
check("three night prices: B's charge is not counted in the 0.15 hours "
"after its run (the next night's 0.10 hours are cheaper), so A and B "
"together hold the expensive hours' shortfall at 07:00 (1ac7fe9: "
"2.61 kWh short, bought at 0.34)",
present(sh3_0) and held3 >= sh3_0 - 0.05)
for k, base_eur in (("ownsf_hourly_real", 4.00), ("p4_ls_dark_real", 3.84)):
r_ = REP[k]
print(f" replay {k}: {r_['net_eur']} EUR (0.20.0: {base_eur}), end "
f"{r_['end_soc']}")
check(f"real 2026 prices, 3 days, a Solar first battery counted ({k}): "
f"it costs no more than 0.20.0 ({base_eur} EUR; 1ac7fe9: "
f"{base_eur + 0.40:.2f})", r_["net_eur"] <= base_eur + 0.005)
lw = R["lowered"]
l0, l1, l2 = (lw["marks"][k] for k in ("03:05", "03:20", "06:30"))
print(f" lowered: A level {l0['target_a']} -> {l1['target_a']} -> "
f"{l2['target_a']}; SoC {l1['soc_a']:.1f} -> {lw['soc'][0]:.1f} %")
check("a fresh, brighter figure lowers A's level below its SoC during the "
"charge, and A stops",
l0["target_a"] is not None and l1["target_a"] is not None
and l1["target_a"] < l0["target_a"] - 5.0
and lw["soc_at"]["03:30"][0] < lw["soc_at"]["03:20"][0] + 0.5)
check("the sensors go quiet and the forecast ends (06:30): the latch holds "
"the LOWERED level, A does not charge back up to the old one",
l2["fc_none"] and l2["target_a"] is not None
and l2["target_a"] < l0["target_a"] - 5.0 and l2["p_a"] > -200.0
and lw["soc_at"]["06:55"][0] < lw["soc_at"]["03:30"][0] + 1.0)
# ============================================================================ #
print("\n" + ("all checks passed" if not fails
else f"FAILED: {len(fails)}"))
for f in fails:
print(" -", f)
sys.exit(1 if fails else 0)
+37 -13
View File
@@ -222,13 +222,21 @@ check("Leftover solar and Cheap hours: a give block exactly on the give run "
all([(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
== [("give", "17:00", "21:00")] for b in dd)
and rp.give[0].start == at(17, 0, 0) and rp.give[0].end == at(21, 0, 0))
c_n = S.make("dynamic", at(0, 18))
# OPT-36: a larger evening (4.8 kW) so that B (Cheap hours) does not cover the
# dear hours alone and A still buys from the grid; A starts at 30 %.
c_n = S.make("dynamic", at(0, 18), soc=(30.0, 40.0), evening=4800.0)
# The run's end, read from the plan; for this house it is 02:30, as in 0.20.0.
END = c_n.mode_runs.cheap.end
END_H = (END - DAY0).total_seconds() / 3600.0
END_S = S.brussels(END).strftime("%H:%M")
check(f"the cheap run is 01:00 to {END_S} (02:30 for this house)",
c_n.mode_runs.cheap.start == at(1, 0, 0) and END == at(2, 30, 0))
nb = tab(c_n, at(0, 18))["days"][0]["batteries"]
check("in the night before the cheap run both charge from the grid in it "
"(01:00 to 02:30), then give at 17:00",
f"(01:00 to {END_S}), then give at 17:00",
all([r["kind"] for r in b["blocks"]] == ["grid", "give"] for b in nb)
and all(b["blocks"][0]["from"] == "01:00"
and b["blocks"][0]["to"] == "02:30" for b in nb))
and b["blocks"][0]["to"] == END_S for b in nb))
# UX-53: no grid block while the gate refuses.
for why in ("peak", "not_worth"):
c_n.mode_gate = why
@@ -246,12 +254,13 @@ def at_h(b, h):
return dict((round(x, 3), v) for x, v in b["levels"]).get(h, float("nan"))
check("the curve charges in a grid block: A (planned level 64 %) and B "
"(Cheap hours, to the end of its 01:30 run) are well above where they "
LVL_A = c_n.plan_targets()[c_n.inverters[0].name]
check(f"the curve charges in a grid block: A (planned level {LVL_A:.0f} %) "
"and B (Cheap hours, to the end of its run) are well above where they "
"were at 01:00, and stay put after the run",
all(at_h(b, 2.5) - at_h(b, 1.0) > 8.0
and abs(at_h(b, 3.0) - at_h(b, 2.5)) < 1e-6
for b in nb) and nb[0]["levels"][0][1] == 55.0)
all(at_h(b, END_H) - at_h(b, 1.0) > 8.0
and abs(at_h(b, END_H + 0.5) - at_h(b, END_H)) < 1e-6
for b in nb) and nb[0]["levels"][0][1] == 30.0)
# Modes off: the verdict's legs.
c_f = S.make("off", at(10, 7))
fb = tab(c_f, at(10, 7))
@@ -681,7 +690,7 @@ check("Dutch: the notes, the totals and the blocks are in Dutch, with a "
and say("estimate", "nl") in on["days"][0]["notes"]
and on["days"][1]["line"].startswith("Verwachte zon morgen: 16,0 kWh")
and oe["days"][0]["line"].startswith("Solar still to come today: 14.4"))
c = S.make("dynamic", at(0, 18))
c = S.make("dynamic", at(0, 18), soc=(30.0, 40.0), evening=4800.0)
nl_b = tab(c, at(0, 18), "nl")["days"][0]["batteries"][0]["blocks"]
check("...and so are the plan's words on a block",
nl_b[0]["what"].startswith("Laadt maximaal") and " %" in nl_b[0]["what"]
@@ -824,14 +833,17 @@ def may_give(inv):
return packshare.envelope(pack_energy_of(inv)).hi > 0.0
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None,
evening=None, peak_w=None):
"""Walk the real control cycle (`_mode_roles()`) every 15 minutes from
`now0` to the end of tomorrow, each battery at the level the tab's curve
has at that time, and return the tab and the role seen per battery and
quarter. A GIVE role on a pack that control's discharge envelope closes
is seen as "closed": the pack is not asked to give. The tab is built
once, at `now0`."""
c = S.make(name, now0, soc=soc)
once, at `now0`. `peak_w`: the site's peak limit (OPT-36, below)."""
c = S.make(name, now0, soc=soc, evening=evening)
if peak_w is not None:
c.o = dict(c.o, peak_cap_w=peak_w)
cut_out_at_floor(c)
c.tariff_plan = S.quarters(now0, night_evening, days=3)
c.tariff_at = now0
@@ -852,6 +864,13 @@ def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
b = day["batteries"][i]
inv.soc = level_at(b, h if h < 24 else h - 24) if b["levels"] \
else inv.soc
# OPT-36: the tab shows a level to 0.1 % (`dayview.LEVEL_DP`); a
# battery whose curve shows its planned level (50.0 for 50.01) is
# at it, or control charges it for the last 0.01 %.
tg = (c.plan_targets() or {}).get(inv.name)
if tg is not None and inv.soc is not None and round(
inv.soc, dayview.LEVEL_DP) == round(tg, dayview.LEVEL_DP):
inv.soc = max(inv.soc, tg)
c.tariff_at = t
tick = c._mode_roles(t, None)
for inv in c.inverters:
@@ -890,7 +909,12 @@ def tomorrow_match(out, seen, d0, role, kind):
t22 = at(22, 0, 5)
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0))
# OPT-36: the plan counts B's charge under the peak limit, the tab's curve
# charges at the batteries' ratings. Walked along the curve, B runs ahead of
# the plan and A's level falls behind the tab's block. A limit that does not
# bind (15 kW, the option's top) makes the curve the charge control plans.
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.0,
peak_w=15000.0)
tm1 = o1["days"][1]["batteries"]
print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1])
check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "
+6 -2
View File
@@ -206,7 +206,8 @@ def measure(c, now, peak_w):
def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
"solar_first")):
"solar_first"),
evening=None):
"""The controller of scenario `name` at `now` (UTC), its cycle run once."""
assert name in SCENARIOS, name
opts = dict(BASE)
@@ -271,7 +272,10 @@ def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first",
members=members, tomorrow=tomorrow)
c.solar_fc_why = None
if name != "learning":
c.house_profile = profile(now)
# OPT-36: `evening` (W) makes the dear hours' use larger than
# what a Cheap hours battery covers, so Leftover solar still buys.
c.house_profile = profile(now) if evening is None \
else profile(now, evening=evening)
if name in ("owner", "dynamic", "sunny", "periods", "learning",
"notomorrow", "cycle"):
measure(c, now, 7000.0 if name != "dynamic" else 2500.0)