Compare commits

...
11 Commits
Author SHA1 Message Date
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
admin d4c01daca5 Merge pull request 'MAINT-20 part 1: comments and docstrings in peakguard and the price files' (#223) from MAINT-20-1 into release/1.0
infra-pin / fresh (push) Successful in 1s
MAINT-20 part 1: comments and docstrings in peakguard.py and the price files cut to one line + a DECISIONS pointer (#223)
2026-11-01 03:24:54 +01:00
17 changed files with 1133 additions and 51 deletions
+112 -4
View File
@@ -1,13 +1,121 @@
# Changelog
## 0.19.2
## 0.20.0
### 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]]).
- Leftover solar no longer charges from the grid for hours that Cheap hours only or Solar first batteries will cover. It counts what they will hold when the expensive hours start (a Solar first battery after the night's use), and buys only the rest. The Batteries screen says which battery covers, and the "gives up to" figure is read after the planned charge.
**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 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
**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.
@@ -7,5 +7,6 @@
- **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.
- **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so.
- **OPT-36 (0.20.1):** what Cheap hours batteries will give in the expensive hours is taken off the shortfall before Leftover solar shares it (`dayplan._cheap_give_kwh()`: its level when they start, the ceiling or what its cheap run reaches under the peak aim, minus the floor, x capacity x EFF). Owner, 2026-11-02: Solar first batteries count too (`_solar_first_give_kwh()`): their level now less the house's night use until the hours start, shared by the energy held, then the same sum; a battery the night drains to the floor counts 0. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Test: `test_opt36.py`.
- **Not done:** the run length (OPT-25).
@@ -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 its cheap run reaches under the peak limit; 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.
+92 -8
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,8 +230,66 @@ def _room(p, to_pct):
p.capacity_kwh)
def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
aim_w):
"""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 own cheap run reaches: the ceiling, or less when
the run cannot get there. From now to the run's end (or the whole run
ahead of it) 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. 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())
if runs.charge_useful() and total > 0.0:
for s, e, h in _hour_slices(max(now, runs.cheap.start),
runs.cheap.end):
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
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` (the plan's own profile) is taken off first, shared by the energy
each holds above the floor, and what is left x `EFF` counts. 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 = house
use = sum(max(0.0, house_w(h)) * (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):
"""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
@@ -273,11 +342,22 @@ 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) \
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 +365,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 +378,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:
+24 -8
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,
+1 -1
View File
@@ -2375,7 +2375,7 @@ 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())
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"]
+1
View File
@@ -357,6 +357,7 @@
"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."
},
+1
View File
@@ -297,6 +297,7 @@
"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."
},
+1 -1
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.19.2"
version: "0.20.0"
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"),
+14 -3
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
@@ -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()
+106 -6
View File
@@ -36,6 +36,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 +195,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"),
@@ -256,7 +299,10 @@ def run_night(spec):
hours, silent, fresh_from, fresh, restarts, soc, load_until_s, marks."""
start = spec["start"]
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
house_w = spec.get("house_w", 700.0)
# OPT-36: Cheap hours B now covers part of the dear hours, so the house the
# plan expects is 1.6 x larger than it was written for (700 W -> 1.1 kW):
# A still has a shortfall to buy and the night charge is still long.
house_w = 1.6 * spec.get("house_w", 700.0)
store = rp._Store()
for u, (use, mode, sell) in CHOICE.items():
for k, val in (("pack_use", use), ("pack_mode", mode),
@@ -431,6 +477,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,11 +509,12 @@ for k in R:
fa, si, rr = R["fresh"], R["silent"], R["restart"]
# a. silent from 17:05
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)
check("a fresh figure all night: A ends high (OPT-36: the ceiling, 87 % "
"before B was counted), B full", fa["soc"][0] > 80.0 and fa["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)
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %; OPT-36: the "
"plans are equal at 04:30 below, the path to them is not)",
abs(si["soc"][0] - fa["soc"][0]) < 15.0 and si["soc"][0] > 75.0)
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)
@@ -470,7 +530,7 @@ check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
and m4["target_a"] is not None
and near(m4["target_a"], fa["marks"]["04:30"]["target_a"], 1e-9))
check("... and the level is the fresh run's (the kept figure counts as a "
"fresh one)", near(m4["target_a"], 86.87, 0.2))
"fresh one)", m4["target_a"] > 50.0)
m6 = si["marks"]["06:30"]
check("06:30: the kept figure has ended (the bell started) and the screen "
"does not say 'kept' (the charge was done by then in this shape)",
@@ -563,6 +623,46 @@ 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 fd["soc_at"]["06:55"][0] > fd["soc_at"]["02:15"][0] + 8.0
and 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 "
+730
View File
@@ -0,0 +1,730 @@
"""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.
3. A Cheap hours battery that is off, refusing, in maintenance, not live or
without capacity counts 0; Solar first is not counted; 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, and the OPT-34 latch still
holds a started run (a lowered level stays lowered).
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 # 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.
use_n = sum(LIVE[h_] / 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
c3 = make(LIVE, soc=(20.0, 94.0), modes=("leftover_solar", "solar_first"))
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):
return max(soc, min(CEIL, soc + gain_kwh / 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)
check("with no peak aim the run is held by the rating only: B reaches the "
"ceiling and gives all it can; A buys only the 0.16 kWh left",
abs(p_free.cheap_kwh - (CEIL - FLOOR) / 100.0 * CAP_B * EFF) < 0.005
and 0.0 < p_free.need_kwh[A] < 0.3 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)
# ============================================================================ #
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):
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))), 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,
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())
NIGHT = at(3, 22, 0)
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=[at(3, 22, 30), at(4, 5, 0)]),
# 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()}
R = {k: f.result() for k, f in futs.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: A's commands follow the levels: it stops charging at "
"its level and ends well under the ceiling (0.20.0: 95 %), and not "
"under the level the plan ends the night with",
md["soc"][0] < 85.0 and md["soc"][0] >= m2["target_a"] - 1.0)
check("mid night: B fills as before (to the ceiling)", md["soc"][1] > 94.0)
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)
+17 -11
View File
@@ -222,13 +222,18 @@ 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)
RUN_END = c_n.mode_runs.cheap.end
END_S = RUN_END.astimezone(S.brussels(RUN_END).tzinfo).strftime("%H:%M")
END_H = S.brussels(RUN_END).hour + S.brussels(RUN_END).minute / 60.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
@@ -247,11 +252,11 @@ def at_h(b, h):
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 "
"(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 + 1.0) - 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 +686,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 +829,15 @@ 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):
"""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)
c = S.make(name, now0, soc=soc, evening=evening)
cut_out_at_floor(c)
c.tariff_plan = S.quarters(now0, night_evening, days=3)
c.tariff_at = now0
@@ -890,7 +896,7 @@ def tomorrow_match(out, seen, d0, role, kind):
t22 = at(22, 0, 5)
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0))
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.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)