Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63043ea219 | ||
|
|
39ca1df232 | ||
|
|
6c0546beb0 | ||
|
|
79dfcde2bb | ||
|
|
de06a2c6f5 | ||
|
|
9a76f1a968 | ||
|
|
0020756aab | ||
|
|
0381fffbe1 | ||
|
|
b700c0be92 | ||
|
|
e79d403560 | ||
|
|
634001cb26 | ||
|
|
94214d609e | ||
|
|
4c0122a8b6 | ||
|
|
7bc8ad264b | ||
|
|
07d23d2eed | ||
|
|
fb4a449591 | ||
|
|
776b84ca20 | ||
|
|
34b81e8aeb | ||
|
|
f3f9483d9c |
@@ -1,5 +1,130 @@
|
||||
# Changelog
|
||||
|
||||
### Not in a release yet
|
||||
|
||||
- During a real peak, a battery in its maintenance charge now stays paused instead of switching between charging and giving, and the batteries give only what keeps the peak under the limit ([[MAINT-28]]).
|
||||
|
||||
## 0.22.0
|
||||
|
||||
**A minor bump: a battery's monthly charge now gets the cheap night, the plan knows about the 7-day wait, a slow meter is followed more gently, and one peak-guard stop no longer costs a night charge its whole period. 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.21.0 also start 0.22.0, and the Supervisor form is the same. It is a minor bump because control acts differently in the cases below; every saved setting still starts.
|
||||
|
||||
**A battery's monthly charge gets the cheap night.** While one battery is in the charge part of its monthly cycle, the other battery no longer grid-charges ahead of it, and the charge aims at this month's highest peak, as the peak guard does, not at the lower limit. In a rig test with the live setup (two batteries, one in its monthly charge), the cycling battery charged at about 1.5 kW in every cheap quarter (13.3 kWh in a night) and the other battery bought nothing; before, it charged at about 225 W or not at all. The peak limit was kept. At the start of a month, before a peak above the limit has been seen, this charge is much slower ([[SAFETY-111]]).
|
||||
|
||||
**The plan knows about the 7-day wait.** After a failed monthly cycle, Home no longer shows a cycle every night. It says the cycle waits, and shows the day the wait ends. The cycle tries again at the first start hour after the wait ([[MAINT-26]]). In a rig test no cycle was drawn during the wait, and none started.
|
||||
|
||||
**One peak-guard stop no longer costs the night charge its period.** With battery modes on, a night grid charge that the peak guard stops early in a 15-minute period used to stay stopped until the period ended. It may now start again after a minute, once per period, and not in the last minute. A real overload is cut as fast as before. In a rig test of the 01:00 case the night's charge was 0.45 to 0.46 kWh, against 0.42 to 0.44 kWh before. The status says "grid charge paused, it tries again in a minute" while it waits.
|
||||
|
||||
**A slow meter is followed more gently.** With a meter whose readings come about 4 s or more apart, such as a 5 s meter, control now takes smaller steps after a big change until the meter shows it. With Home Assistant readings 10 s late, less power goes to the grid when loads switch on and off: 44 % less in a simulation and 29 % less in a rig test. Meters of 1 or 2 s are left alone by design (the same commands in 63 simulation runs). With readings 15 s late the gain is smaller (19 % in a simulation), and a rig test sent more to the grid than before; see the known issues.
|
||||
|
||||
**No change in behaviour:** the comments in the main program file are shorter, and the file is split into smaller files by job. A rig test saw the same start-up, stops and commands.
|
||||
|
||||
Contents of this release, in merge order since 0.21.0 (`5e00cbb`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `56fc41f` | The comments in the main program file are shorter and clearer. There is no change in behaviour ([[MAINT-20]], #224) |
|
||||
| `8508ac3` | The main program file is split into smaller files by job. There is no change in behaviour; a rig test saw the same start-up, stops and commands ([[MAINT-21]], #243) |
|
||||
| `a8ecf17` | With a 5 s meter, control follows the meter more gently after a change. With Home Assistant readings 10 s late, less power goes to the grid when loads switch on and off. Meters of 1 or 2 s are left alone ([[SAFETY-136]], #244) |
|
||||
| `02eac3b` | A night grid charge that the peak guard stops early in a 15-minute period may start again after a minute, once per period, instead of staying stopped until the period ends. A real overload is cut as fast as before. Battery modes only ([[SAFETY-135]], #245) |
|
||||
| `1b5f7c9` | While a battery's monthly cycle charges, the other battery no longer grid-charges ahead of it, and the charge aims at this month's highest peak, so it gets the cheap night. After a failed cycle, the plan no longer shows a cycle every night and says the cycle waits ([[MAINT-24]], [[MAINT-25]], #246) |
|
||||
| `f3f9483`, `4c0122a` | History only: MAINT-28 was merged (#247) and then taken out again (#249). The two cancel out and the code is the same as at `1b5f7c9`. MAINT-28 is not in this release ([[MAINT-28]]) |
|
||||
|
||||
### 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; the peak limit was kept). At the start of a month, before a peak above the limit, that is about 2 kWh a night in a rig test: the cycle can reach its 36 h limit after about 4 nights and fail, and since 0.22.0 the other battery gets no grid charge on those nights ([[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]]).
|
||||
- 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 and over the limit: 3 530 W against a 3 450 W aim and a 3 500 W limit in a rig test with a 2 kW load added 45 s before the end. The peak guard sees the load too late 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. In a test the add-on asked for an 8.7 kW charge (9.6 kW on 0.20.0) ([[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 Leftover solar change in 0.20.1 no longer triggers it in those replays, but the cause stays ([[OPT-37]]).
|
||||
- On a 5 s meter, Home Assistant readings 10 s late are still not caught, and readings 15 s late only after about 4 minutes. Control now follows the meter more gently, so less goes to the grid: in a simulation with a changing load, 340 Wh became 189 Wh at 10 s late and 363 Wh became 296 Wh at 15 s late. In a rig test, 15 s late exported 13 to 19 % more than before ([[SAFETY-136]], [[SAFETY-134]]).
|
||||
- With readings 10 to 15 s late, the hold can end in the middle of the delay and the swing can come back. Seen in rig tests with a 2 s and a 5 s meter ([[SAFETY-134]]).
|
||||
- With readings 10 s late on a 1 s meter, the grid can still move between -422 and +340 W while the batteries are held. With a very long delay (30 to 90 s) the request can swing by about 900 W every few seconds. Seen in review tests ([[SAFETY-130]]).
|
||||
- A battery that answers very slowly (10 s or more) can be taken for late Home Assistant readings and held, and the page then blames Home Assistant. A GoodWe battery settles in about 6 s and this has not been seen on one ([[SAFETY-131]]).
|
||||
- The page row for late readings reads awkwardly in English ("Home Assistant readings show requests about N seconds late, so control holds the batteries"), and the Dutch text misses a word ([[UX-78]]).
|
||||
- When the add-on stops, a write that takes more than 2 s can still land after the 0 W, and the 30 s safety timer on the ESPHome board then sets 0 W. After the Stop button, a cycle that had already decided can send its share (560 to 670 W) just after the 0 W; the next cycle sends 0 W again ([[SAFETY-138]]).
|
||||
- In the last hour of a cheap run, Home can draw a battery's grid charge up to an hour past the time control stops charging it. It only draws too much, never too little ([[UX-79]]).
|
||||
- A tomorrow solar forecast sensor that reports just after midnight with yesterday's value can be used as the new day's forecast for up to 12 hours. This has not been seen on a site ([[OPT-39]]).
|
||||
- During a cheap-hours charge, at some 15-minute boundaries the add-on sends 0 W for one tick ("no trade (headroom)") and then goes on. Seen in a rig test, in 5 of 8 cases; once, with a frozen battery reading, it left a battery at 0 W until 23:45 ([[OPT-40]]).
|
||||
- During a real peak, a battery in its maintenance charge can switch between charging and giving, about 400 times in 45 minutes in rig tests. That means more wear, but no higher peak ([[MAINT-28]]).
|
||||
- While one battery's monthly cycle charges, the other battery's card on Home says "Saves its power for higher prices", and the plan still shows its grid charge in the cheap hours, though it will not charge ([[UX-80]]).
|
||||
- After a failed monthly cycle, the plan line shows the day the wait ends, such as 18 November, not when the cycle tries again, such as 19 November at 19:00 ([[MAINT-26]]).
|
||||
- At the start of a cheap window the peak guard can step once although the period is under the aim, and the step can land on a battery in its monthly charge. In a rig test it gave 1.7 to 2.3 kW for 6 s ([[SAFETY-140]]).
|
||||
- A grid charge at full power in the last seconds of a 15-minute period can run into the next period, where the peak guard cuts it back ([[SAFETY-139]]).
|
||||
|
||||
### 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.21.0
|
||||
|
||||
**A minor bump: control now holds the batteries when Home Assistant readings come late, Home draws the whole night's grid charge, and the shutdown 0 W is sent last. No option changes.**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MAINT-28 - during a real peak the maintenance charge stays paused
|
||||
|
||||
**Status: for review.** Tests: `test_maint28.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`).
|
||||
**Status: fix-forward after the rig FAIL (TEST-23, f3f9483).** Tests: `test_maint28.py`, `test_maint28b.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`).
|
||||
|
||||
## What happened (rig x10, house 6.5 kW, month aim 5.95 kW, A in maintenance CHARGE)
|
||||
`peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW.
|
||||
@@ -8,18 +8,18 @@
|
||||
## The change
|
||||
`peakguard.shaping_held(suspended, held, forecast, aim, house)`: `suspended` is the old `shaping_suspended()`, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up, and only where MAINT-24 moved the aim (a banked month peak above `peak_cap_w`, so the aim is above cap minus margin). Elsewhere the old rule stays, byte for byte: SAFETY-87's N=1 digests (`n1_charge_low`) and SAFETY-119's N=2 charge at 5050 W over a 5000 W cap with no banked peak are pinned, and 1b5f7c9 behaves as 02eac3b there (a chase that was not a MAINT-24 regression).
|
||||
|
||||
Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It stays put when A switches, except for the age of the battery reading (up to 10 s on the rig); the 300 W line is far larger than that lag moves it.
|
||||
Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It does not stay put when a battery steps: see "Fix-forward" below.
|
||||
|
||||
Why 300 W: the house wanders +-50 W; once the charge resumes it settles at (aim - house) / 2, so 300 W gives at least 150 W, three times the noise, and charging less than that is not worth a restart.
|
||||
|
||||
The flag lives only while the charge-only branch is taken: every cycle that reaches the maintenance loop starts it at False, so a maintenance that ended, a mode change or a missing aim cannot carry a stale True into the next charge (test_maint28.py part 1b). `cycle()` has two returns before that loop, and the flag is kept through them on purpose: "inputs missing" and "readings late" (SAFETY-128) are holds of under `stale_after` seconds that write nothing and decide nothing, so the next full cycle sees the same charge and the same peak. A missed poll must not end a peak pause; a reset there would restart the charge on it (`_maint_shape_suspended` is kept through them the same way).
|
||||
|
||||
It only ever keeps the maintenance charge off. No cut-back is delayed or weakened: the guard's claim is filed as before. While the pause is held the loop works as at 02eac3b, so A also gives for the house (quarters then sit under the aim, A's energy goes to the load: 1.67 kWh in the 45 min below, 0.45 kWh on 1b5f7c9). Measured alternative, A capped at 0 W while held and the guard idle (review scratch, same 45 min run): A gives 0.44 kWh in the load instead of 1.66, quarters 5919/5915 W, but A switches give/idle 32 times in 40 min. The uncapped give costs ~1.2 kWh round trip per 45-min peak (a few cents) and ~50 min more maintenance charge, far inside the 36 h timeout, so the steady give is kept.
|
||||
It only ever keeps the maintenance charge off. No cut-back is delayed or weakened: the guard's claim is filed as before. While the pause is held, the pack in CHARGE gives only its share of the guard's floor (0 W if the guard holds it at its reserve), and the batteries together give only what the guard needs (see Fix-forward, F-2 and H-1). The first version let A give for the house; it was dropped.
|
||||
|
||||
## Numbers (sim, real `cycle()`, A rated 2500 W)
|
||||
House 3000 -> 6500 W for 45 min -> 3000 W, +-50 W every 5 s, banked month peak 6000 W (aim 5950 W), forecast = quarter average so far plus the rest at the present grid (`scratch/MAINT-28/dev/m28_repro.py`; `test_maint28.py` runs the same shape for 15 min to keep the suite short).
|
||||
|
||||
| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 |
|
||||
| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 (f3f9483, without the lag) |
|
||||
|---|---|---|---|
|
||||
| A charge/give reversals | 0 | 52 | 1 (the first trip) |
|
||||
| A charging seconds in the load (of 2700) | 0 | 367 | 301 (before the first trip) |
|
||||
@@ -34,3 +34,22 @@ Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac
|
||||
|
||||
## Not changed
|
||||
MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). The pause for a late or missing forecast.
|
||||
|
||||
## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`)
|
||||
The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`.
|
||||
|
||||
**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 4 s after the load (12 s in test_maint28b), not 0: the first settled estimate. No cut-back is touched.
|
||||
|
||||
**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged (replaced by H-1 below). Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim).
|
||||
|
||||
**H-1 (review 2026-11-14, owner decision 2026-11-14): the batteries together give only what the guard needs.** With the cap on A alone, the loop gave for the whole house with B (Cheap hours): in the live-try shape (A 11 % in CHARGE, B 25 %, house 6.5 kW for 90 min) B gave 4.6 kW, went from 25 to 10 % in 43 min, and two quarters were 6502 and 6499 W against the month peak 6000 W. So while the pause holds, `_m28_share()` also files one site limit (source `charge`, `P_MAINT_SHAPE`, per-site `_m28_site`): `hi` = the largest floor of the period, never under this cycle's floor. The guard's claim is not cut: `hi` is at least the floor, `P_PEAK` outranks `P_MAINT_SHAPE`, and `_peak_on_able` still places the whole floor. Outside the hold nothing is filed. Live try, same shape (test_maint28b): quarters 5357 to 5911 W (aim 5950), B 0.86 kWh and ends at 20.6 % (1b5f7c9: 1.0 kWh, 19.8 %), B switches give/idle once, A never. The re-pinned `n2_charge_banked` quarters are 3349 and 3950 W (aim 3950).
|
||||
|
||||
The rule as built (`Controller._m28_share()`): while the pause holds (N>=2, month aim above the cap) the pack gets a unit limit `hi` = the largest part of the guard's floor placed on it in this 15-minute period. Its part in a cycle is the floor less what the other units are placed at in this cycle's split without that limit, so a part the guard's split puts on the other pack is not this pack's. A pack the guard holds at its reserve (`peak_guard.held`) gets 0 W and its part starts again from 0 W. The largest part is kept per unit (`_m28_hi`, keyed by the unit) and dropped in a new period or when the pause ends; only one pack can be in maintenance CHARGE anyway (`maintenance.arbitrate()`, one slot per site).
|
||||
|
||||
Why the largest of the period and not the present part: the guard alone lands the quarter 9.5-10 W over its aim in the sim (SAFETY-126: its own projection ends on 5950.0 W but the meter reading it counts lags the true grid), and with the present part only, test_maint28 part 1 ends at 5960.1 W; the largest of the period lets the loop keep A up and it ends at 5925 W. Why not `hi` = 0 W: with the other pack under its reserve the guard needs this one (test_maint28b, house 12 kW, B at 18 %: A gives 1.07 kWh for the guard).
|
||||
|
||||
Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (it gives for the house): A's limit stays under the period's largest floor by 500 W or more in 740 of 744 paused cycles, and the quarters stay under the aim.
|
||||
|
||||
Sim, rig shape, 45 min, house 6500 W (load from minute 10), A in CHARGE at 49 %, B 30 % on Cheap hours: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 1, quarters 5358 5897 5911 5176 4477 W, charge back 4 s after the load; B gives 0.23 kWh (before H-1 3.04, f3f9483 0.32). test_safety84 n2_charge_banked: banked quarters 3349 and 3950 W against the aim 3950 W.
|
||||
|
||||
Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W); `_batts_settled()` replaced by True at the hold in ctl_cycle.py (the 45-min rig shape in `test_maint28b.py`, one stale release); the site limit dropped (the live try in `test_maint28b.py`: quarters 6502 and 6499 W, B 2.91 kWh to 10.0 %).
|
||||
|
||||
@@ -29,7 +29,7 @@ from . import shadow
|
||||
from .price import Uncertainty
|
||||
from .tariff import next_quarter
|
||||
from .watchdog import loop_is_following
|
||||
from .ctl_trade import pack_energy_of
|
||||
from .ctl_trade import M28_REASON, pack_energy_of
|
||||
from .rows import why_key, why_raised
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
@@ -574,10 +574,11 @@ class CycleMixin:
|
||||
else float(self.grid) + float(self.site_batt_w))
|
||||
_susp = peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc, maint_aim)
|
||||
_m28 = (len(self.inverters) > 1
|
||||
and maint_aim > self.peak_aim_w())
|
||||
self._maint_peak_hold = peakguard.shaping_held(
|
||||
_susp, _peak_held and len(self.inverters) > 1
|
||||
and maint_aim > self.peak_aim_w(),
|
||||
self.peak_fc, maint_aim, house)
|
||||
_susp, _peak_held and _m28, self.peak_fc, maint_aim,
|
||||
house, self._batts_settled())
|
||||
if self._maint_peak_hold:
|
||||
# Edge-logged: level-logging here would write one line per
|
||||
# cycle for as long as the event lasts and flush the 40-line
|
||||
@@ -588,6 +589,18 @@ class CycleMixin:
|
||||
"suspended so the peak claim is not "
|
||||
"contradicted",
|
||||
hint=messages.hint("maint_paused"))
|
||||
# MAINT-28: while held this pack gives at most the most of
|
||||
# the guard's floor placed on it this period
|
||||
# (`_m28_share()`).
|
||||
# DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md
|
||||
shape = ((0.0 if self.peak_guard is not None
|
||||
and inv.name in self.peak_guard.held
|
||||
else max([c.lo for c in claims
|
||||
if c.source == peakguard.SOURCE]
|
||||
+ [self._m28_was(self.actuator_of(
|
||||
"maintenance", inv), now)],
|
||||
default=0.0), M28_REASON)
|
||||
if _m28 else None)
|
||||
else:
|
||||
self._maint_shape_suspended = False
|
||||
hi = 0.0
|
||||
@@ -604,10 +617,12 @@ class CycleMixin:
|
||||
floor = self._charge_headroom(floor, claims)
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
shape = (hi, reason)
|
||||
if shape is not None:
|
||||
# PER-INVERTER, like the SET above and for the same reason -
|
||||
# the shaping is about the pack being charged, which is `inv`.
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
|
||||
reason=reason,
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE,
|
||||
hi=shape[0], reason=shape[1],
|
||||
actuator=self.actuator_of("maintenance",
|
||||
inv)))
|
||||
|
||||
@@ -855,6 +870,9 @@ class CycleMixin:
|
||||
self.loop_clamp_facts = ()
|
||||
self._loop_decision = None
|
||||
|
||||
# MAINT-28: a paused pack gives at most its largest part of the floor
|
||||
# in this period.
|
||||
self._m28_share(claims, now)
|
||||
# SAFETY-28. A new resolution is a new tick, and the previous record is
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that the
|
||||
# record resolve() builds is stamped with the tick it is for.
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from . import arbitrage
|
||||
from .arbiter import (
|
||||
ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET,
|
||||
pre_resolve,
|
||||
pre_resolve, resolve,
|
||||
)
|
||||
from . import allocator
|
||||
from . import dayplan
|
||||
@@ -19,6 +19,7 @@ from . import packmode
|
||||
from . import priceruns
|
||||
from . import solarfc
|
||||
from . import control as _ctl
|
||||
from . import freshness
|
||||
from . import messages
|
||||
from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING
|
||||
from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key
|
||||
@@ -49,6 +50,13 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
HOUSE_PROFILE_KEY = "house_profile"
|
||||
|
||||
|
||||
# MAINT-28: a step of a battery's command, in W, after which its reading is not
|
||||
# current until a changed one arrives (`Controller._batts_settled()`).
|
||||
MAINT_STEP_W = 100.0
|
||||
# MAINT-28: the reason on a paused pack's cap (`Controller._m28_share()`).
|
||||
M28_REASON = "peak hold"
|
||||
|
||||
|
||||
# OPT-34: the level of a night grid charge that has started, so it does not
|
||||
# drop when the forecast goes (`Controller._latch_charge()`).
|
||||
CHARGE_LATCH_KEY = "night_charge_latch"
|
||||
@@ -711,6 +719,80 @@ class TradeMixin:
|
||||
lo = hi = float(inv.batt)
|
||||
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
|
||||
|
||||
def _batts_settled(self) -> bool:
|
||||
"""MAINT-28: False while a battery reading may pre-date a step UP of
|
||||
its command, so `grid + site_batt_w` is a new grid and an old battery.
|
||||
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
|
||||
now, ok = self._now_mono, True
|
||||
if now is None:
|
||||
return True
|
||||
for inv in self.inverters:
|
||||
cmd = float(inv.standing_w())
|
||||
seen = self._m28_step.get(inv.name)
|
||||
if seen is None:
|
||||
seen = self._m28_step[inv.name] = (cmd, None)
|
||||
elif abs(cmd - seen[0]) >= MAINT_STEP_W:
|
||||
seen = self._m28_step[inv.name] = (
|
||||
cmd, now if cmd > seen[0] else seen[1])
|
||||
at = seen[1]
|
||||
if at is None or now - at >= freshness.BATT_OBLIGED_S:
|
||||
continue
|
||||
got = inv.batt_fresh.changed_mono
|
||||
if got is None or got < at + freshness.PLANT_SETTLE_S:
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def _m28_share(self, claims, now) -> None:
|
||||
"""MAINT-28: each paused pack's cap (0 W if the guard holds it at its
|
||||
reserve) set to the largest part of the guard's floor left to it in
|
||||
this 15-minute period: the floor less what the other units are placed
|
||||
at in this cycle's split without the cap. Kept per unit in `_m28_hi`,
|
||||
dropped while the unit is not paused or is held. Also one site limit,
|
||||
the period's largest floor, so the other pack does not give for the
|
||||
house (`_m28_site`).
|
||||
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
|
||||
floor = max((c.lo for c in claims if c.source == peakguard.SOURCE),
|
||||
default=0.0)
|
||||
g = self.peak_guard
|
||||
held = {self.actuator_of("maintenance", inv) for inv in self.inverters
|
||||
if g is not None and inv.name in g.held}
|
||||
q, seen = int(now.timestamp() // 900), set()
|
||||
site = any(c.source == "maintenance" and c.reason == M28_REASON
|
||||
and c.actuator != ACTUATOR_SITE for c in claims)
|
||||
for i, c in enumerate(claims):
|
||||
if c.source != "maintenance" or c.reason != M28_REASON \
|
||||
or c.actuator == ACTUATOR_SITE or c.actuator in held:
|
||||
continue
|
||||
seen.add(c.actuator)
|
||||
shares = resolve(claims[:i] + claims[i + 1:], units=self.units,
|
||||
live=self.live_units(), weights=self.pack_weights,
|
||||
reserve=self._held_back()).shares
|
||||
part = max(0.0, floor - sum(float(w) for u, w in shares.items()
|
||||
if u != c.actuator))
|
||||
hi = max(part, self._m28_was(c.actuator, now))
|
||||
self._m28_hi[c.actuator] = (q, hi)
|
||||
claims[i] = dataclasses.replace(c, hi=hi)
|
||||
# A unit not paused (or held at its reserve) starts again from 0 W.
|
||||
for u in set(self._m28_hi) - seen:
|
||||
del self._m28_hi[u]
|
||||
# Site: while the pause holds, the packs together give at most the
|
||||
# period's largest floor, never under this cycle's (the guard's claim
|
||||
# and P_PEAK outrank this one anyway).
|
||||
if not site:
|
||||
self._m28_site = (None, 0.0)
|
||||
return
|
||||
hi = max(floor, self._m28_site[1] if self._m28_site[0] == q else 0.0)
|
||||
self._m28_site = (q, hi)
|
||||
claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=hi,
|
||||
reason=M28_REASON,
|
||||
actuator=ACTUATOR_SITE))
|
||||
|
||||
def _m28_was(self, u, now) -> float:
|
||||
"""MAINT-28: unit `u`'s cap so far in this 15-minute period, 0 W in
|
||||
a new one (`_m28_share()`)."""
|
||||
q, hi = self._m28_hi.get(u, (None, 0.0))
|
||||
return hi if q == int(now.timestamp() // 900) else 0.0
|
||||
|
||||
def _reseed_drop(self, live) -> None:
|
||||
"""SAFETY-96, N>=2: a battery that drops out of the split (stale link)
|
||||
leaves the loop's start point, so the others take over at one slew step
|
||||
|
||||
@@ -506,6 +506,9 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._peak_needed = False
|
||||
self._maint_shape_suspended = False
|
||||
self._maint_peak_hold = False # MAINT-28
|
||||
self._m28_step = {} # MAINT-28: unit -> (command, step at)
|
||||
self._m28_hi = {} # MAINT-28: unit -> (period, cap)
|
||||
self._m28_site = (None, 0.0) # MAINT-28: (period, site cap)
|
||||
# OPT-11: battery modes, off by default (SAFETY-10 section 1). Each
|
||||
# battery's mode and on/off switch live in the store, not in options,
|
||||
# so a change applies at once (`pack_use`, `pack_mode`).
|
||||
|
||||
@@ -450,18 +450,23 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
return decision is not None and decision.engaged
|
||||
|
||||
|
||||
def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w) -> bool:
|
||||
def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w,
|
||||
settled=True) -> bool:
|
||||
"""MAINT-28. `suspended` (`shaping_suspended()`'s answer), kept on while
|
||||
`held` until the house is `MAINT_RESUME_UNDER_W` under `aim_w`
|
||||
(`peak_forecast_w` stands in for an unread house; neither read: not held,
|
||||
as SAFETY-63). Only ever pauses the maintenance charge, never a cut-back.
|
||||
as SAFETY-63) and `settled` (no battery reading older than its last
|
||||
command step, so `house_w` is not a new grid plus an old battery). Only
|
||||
ever pauses the maintenance charge, never a cut-back.
|
||||
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md."""
|
||||
if suspended:
|
||||
return True
|
||||
if not held:
|
||||
return False
|
||||
level = house_w if house_w is not None else peak_forecast_w
|
||||
return level is not None and level > float(aim_w) - MAINT_RESUME_UNDER_W
|
||||
if level is None:
|
||||
return False
|
||||
return level > float(aim_w) - MAINT_RESUME_UNDER_W or not settled
|
||||
|
||||
|
||||
def cap_floor(decision, cap_w):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.21.0"
|
||||
version: "0.22.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Runnable check for MAINT-28 fix-forward. `python3 test_maint28b.py`
|
||||
|
||||
The rig failed MAINT-28 (TEST-23, f3f9483): the hold of the maintenance pause
|
||||
ended at almost every guard step (F-1), and while it stuck, the pack in
|
||||
maintenance CHARGE gave its full rating for the house below its peak reserve
|
||||
(F-2). test_maint28.py missed both because its battery reading has no lag. Here
|
||||
the battery register is published every 10 s and held (`Lag.batt_period_s`),
|
||||
the meter reading is 2 s old, as on the rig; the rating is the rig's A (5 kW).
|
||||
|
||||
All on the real Controller through `cycle()` (tools/opt20_scen.py): cheap
|
||||
window, cap 3500 / margin 50, a month peak of 6000 W banked (aim 5950 W), a
|
||||
peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then
|
||||
6500 W for 13 min (over a quarter boundary), then 3000 W.
|
||||
|
||||
1. A real peak, A in CHARGE above its peak reserve: A flips charge/give a few
|
||||
times (base 1b5f7c9: dozens), no release of the pause on an estimate that
|
||||
mixes a new grid with an old battery reading, quarters at or under the aim,
|
||||
A's charge back after the load.
|
||||
2. F-2 and the cap (owner 2026-11-13): while the pause holds, A gives at most
|
||||
the largest part of the guard's floor placed on it in the 15-min period,
|
||||
at any SoC (never under its reserve; SoC drop within the owner-accepted
|
||||
give); with both packs able, never over the period's largest floor, and
|
||||
when B is placed part of it A's limit is its own part, not the whole floor;
|
||||
one unit's limit is its own (two packs in CHARGE never share it); with the
|
||||
other pack under its reserve, A still gives for the guard.
|
||||
3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing
|
||||
for the house.
|
||||
4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the
|
||||
same lag on.
|
||||
5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`.
|
||||
6. H-1 (owner 2026-11-14): while the pause holds the batteries together give
|
||||
only what the guard needs. The live try (A 11 %, B 25 %, 90 min at 6.5 kW)
|
||||
and, for the F-1 wiring, the 45-min rig shape.
|
||||
Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import opt20_scen as sc # noqa: E402
|
||||
from app import (ctl_cycle, ctl_trade, freshness, maintenance, # noqa: E402
|
||||
packmode, peakguard, tariff)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
rp.RATING, rp.CAP_KWH = 5000.0, 15.36 # the rig's A
|
||||
LAG = rp.Lag(dead_s=1.0, meter_s=2.0, batt_s=1.0, p1_s=0.35, tick=1.0,
|
||||
tau_s=2.0, ramp_w_s=1e9, batt_period_s=10.0)
|
||||
CH = sc.MAINT_CHARGE
|
||||
MONTH_PEAK = 6000.0
|
||||
AIM = MONTH_PEAK - 50.0
|
||||
LINE = AIM - getattr(peakguard, "MAINT_RESUME_UNDER_W", 300.0)
|
||||
T0 = sc.at(2.0) # a quarter boundary
|
||||
PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs)
|
||||
RESERVE = 20.0 # peak_soc_floor default
|
||||
|
||||
|
||||
def stepped_house(base_w, hi_w, load=LOAD, pre=PRE):
|
||||
rnd = random.Random(28)
|
||||
out = [(sc.UTC0.timestamp() - 10, base_w)]
|
||||
for t in range(0, int(pre + load + POST), 5):
|
||||
w = hi_w if pre <= t < pre + load else base_w
|
||||
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
|
||||
return out
|
||||
|
||||
|
||||
def scen(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0,
|
||||
peak=MONTH_PEAK, dur=PRE + LOAD + POST, fc="quarter", n=2,
|
||||
choice=None):
|
||||
"""`unit` is in maintenance CHARGE (forced result), at `soc`, the other at
|
||||
`other`. Rows: the plants' power and SoC, the pause flag, the add-on's house
|
||||
estimate and the real house."""
|
||||
rows = []
|
||||
q = dict(q=None, s=0.0, last=None, start=None)
|
||||
idx = 0 if unit == "a" else 1
|
||||
o = "b" if unit == "a" else "a"
|
||||
|
||||
def patch(c):
|
||||
c.maints[idx].tick = (
|
||||
lambda now, soc_, blocked_by=None, gate_open=True: CH)
|
||||
if peak is not None:
|
||||
orig_peak = c.peak.peak
|
||||
|
||||
def peak_(now):
|
||||
r = orig_peak(now)
|
||||
return tariff.PeakReading(tariff.OBSERVED,
|
||||
getattr(r, "month", ""), peak)
|
||||
c.peak.peak = peak_
|
||||
orig = c.cycle
|
||||
|
||||
async def cyc():
|
||||
c.cheap = True
|
||||
if fc and c.grid is not None:
|
||||
g = float(c.grid)
|
||||
now = ctl_cycle.datetime.now() # the replay's clock
|
||||
qi = int(now.timestamp() // 900)
|
||||
if q["q"] != qi:
|
||||
q.update(q=qi, s=0.0, last=now, start=now)
|
||||
el = (now - q["start"]).total_seconds()
|
||||
q["s"] += g * (now - q["last"]).total_seconds()
|
||||
q["last"] = now
|
||||
c.peak_fc = (q["s"] + g * (900.0 - el)) / 900.0
|
||||
await orig()
|
||||
c.cycle = cyc
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
est = (None if c.grid is None or c.site_batt_w is None
|
||||
else float(c.grid) + float(c.site_batt_w))
|
||||
g = c.peak_guard
|
||||
floor = (float(g.floor_w) if g is not None and g.engaged
|
||||
and g.floor_w is not None else 0.0)
|
||||
sh = (dict(c.allocation.shares) if c.allocation is not None
|
||||
and c.allocation.shares else {})
|
||||
au = c.actuator_of("maintenance", c.inverters[idx])
|
||||
ao = c.actuator_of("maintenance", c.inverters[1 - idx])
|
||||
rows.append(dict(
|
||||
t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p,
|
||||
o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc,
|
||||
hold=bool(getattr(c, "_maint_peak_hold", False)), est=est,
|
||||
floor=floor, share_u=float(sh.get(au, 0.0)),
|
||||
hi_u=next((float(pl.hi) for pl in (c.allocation.plans
|
||||
if c.allocation else ())
|
||||
if pl.unit == au), None),
|
||||
share_o=float(sh.get(ao, 0.0)),
|
||||
house=h.grid() + sum(p.p for p in h.plants.values())))
|
||||
|
||||
src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)]
|
||||
ch = dict(sc.CH_CH if choice is None else choice)
|
||||
stats, c = rp.run("p3_ch_sell", src, sc.WPTS, T0, dur, check=cb, lag=LAG,
|
||||
n=n, soc0={unit: soc, o: other}, choice=ch,
|
||||
refuse=(), patch=patch,
|
||||
extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows, stats, c
|
||||
|
||||
|
||||
def flips(rs, k="u"):
|
||||
n, s = 0, 0
|
||||
for r in rs:
|
||||
v = 1 if r[k] > 50 else -1 if r[k] < -50 else 0
|
||||
if v and s and v != s:
|
||||
n += 1
|
||||
if v:
|
||||
s = v
|
||||
return n
|
||||
|
||||
|
||||
def releases(rows):
|
||||
"""Cycles in the load where the pause ended: (row, estimate under the
|
||||
release line while the real house was over it)."""
|
||||
out = []
|
||||
for p, r in zip(rows, rows[1:]):
|
||||
if PRE <= r["t"] < PRE + LOAD and p["hold"] and not r["hold"]:
|
||||
out.append((r, r["est"] is not None and r["est"] < LINE
|
||||
and r["house"] >= LINE))
|
||||
return out
|
||||
|
||||
|
||||
def quarters_ok(st):
|
||||
# SAFETY-126: a landing 0-7 W over the aim is not an overrun.
|
||||
return (max(a[0] / a[1] for a in st["quarters"].values())
|
||||
<= AIM + peakguard.ALARM_MISS_W)
|
||||
|
||||
|
||||
def show(tag, rows, st):
|
||||
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
||||
rel = releases(rows)
|
||||
print(f" {tag}: flips {flips(load)}, charge s "
|
||||
f"{sum(1 for r in load if r['u'] < -50)}, releases {len(rel)} "
|
||||
f"(stale {sum(1 for _r, s in rel if s)}), lowest SoC "
|
||||
f"{min(r['soc'] for r in rows):.1f} %, give kWh "
|
||||
f"{sum(r['u'] for r in load if r['u'] > 0) / 3.6e6:.2f}, quarters "
|
||||
f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}")
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a real peak, A in CHARGE at 49 % (above its reserve), the battery "
|
||||
"reading lagging")
|
||||
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
|
||||
show("A 49 %", rows, st)
|
||||
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
||||
rel = releases(rows)
|
||||
after = [r for r in rows if r["t"] >= PRE + LOAD]
|
||||
back = next((i for i, r in enumerate(after) if r["u"] < -50), None)
|
||||
print(f" A's charge back {back} s after the load")
|
||||
check("A flips charge/give 3 times or fewer in the 13 min (1b5f7c9: 60, "
|
||||
"f3f9483: 11)", flips(load) <= 3)
|
||||
check("the pause never ends on an estimate under the release line while the "
|
||||
"real house is over it", not any(s for _r, s in rel))
|
||||
check("every quarter at or under the month aim",
|
||||
quarters_ok(st))
|
||||
check("after the load A's charge is back within 20 cycles (1 s each: a 10 s "
|
||||
"register period plus its settle time, 16 s)",
|
||||
back is not None and back <= 20)
|
||||
|
||||
# M-1 (review 2026-11-14): the F-1 wiring, `_batts_settled()` read at the hold
|
||||
# in ctl_cycle.py. With `settled=True` there the 13-min load above still passes;
|
||||
# the rig shape (the load starts 10 min into the quarter, 45 min long) does not.
|
||||
RIG_PRE, RIG_LOAD = 600.0, 2700.0
|
||||
rows, st, c = scen(house=stepped_house(3000.0, 6500.0, RIG_LOAD, RIG_PRE),
|
||||
dur=RIG_PRE + RIG_LOAD + POST)
|
||||
load45 = [r for r in rows if RIG_PRE <= r["t"] < RIG_PRE + RIG_LOAD]
|
||||
rel45 = [(r, s_) for p, r in zip(rows, rows[1:])
|
||||
if RIG_PRE <= r["t"] < RIG_PRE + RIG_LOAD and p["hold"]
|
||||
and not r["hold"]
|
||||
for s_ in [r["est"] is not None and r["est"] < LINE
|
||||
and r["house"] >= LINE]]
|
||||
print(f" A 49 %, 45 min: flips {flips(load45)}, releases {len(rel45)} "
|
||||
f"(stale {sum(1 for _r, s_ in rel45 if s_)}), quarters "
|
||||
f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}")
|
||||
check("45 min: A flips charge/give 3 times or fewer (f3f9483: 148)",
|
||||
flips(load45) <= 3)
|
||||
check("45 min: no release of the pause on a stale estimate (f3f9483: 83 of 83)",
|
||||
not any(s_ for _r, s_ in rel45))
|
||||
check("45 min: every quarter at or under the month aim", quarters_ok(st))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the cap: while the pause holds, A gives at most the largest part "
|
||||
"of the guard's floor placed on it this period")
|
||||
rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0))
|
||||
show("A 21 %", rows, st)
|
||||
check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)",
|
||||
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.15)
|
||||
check("A never goes under its peak reserve in the peak (rig: to 10 %)",
|
||||
min(r["soc"] for r in rows) >= RESERVE - 0.5)
|
||||
check("every quarter at or under the month aim", quarters_ok(st))
|
||||
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
|
||||
soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0)
|
||||
drop = soc_start - min(r["soc"] for r in rows if r["t"] >= PRE)
|
||||
print(f" A at 49 %: SoC drop {drop:.2f} point")
|
||||
# Owner 2026-11-13 accepted about 0.37 kWh per 45-min peak with the cap
|
||||
# (DECISIONS): 0.37 * 13 / 45 = 0.107 kWh in this load, 0.70 point of
|
||||
# 15.36 kWh. Measured 0.64.
|
||||
check("A at 49 %: its SoC drops no more than the owner-accepted give allows "
|
||||
"(0.70 point in the 13 min; f3f9483 6.1)", drop <= 0.70)
|
||||
rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0))
|
||||
show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st)
|
||||
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
||||
check("the guard's own claim stands: with B under its reserve A gives for "
|
||||
"the peak (over 0.2 kWh; the cap yields to the guard)",
|
||||
sum(r["u"] for r in load if r["u"] > 0) / 3.6e6 > 0.2)
|
||||
g = [("give" if r["u"] > 50 else "idle") for r in load]
|
||||
sw = sum(1 for x, y in zip(g, g[1:]) if x != y)
|
||||
print(f" give/idle switches of A in the load: {sw}")
|
||||
check("...give/idle switches of A at most 10 in the 13 min", sw <= 10)
|
||||
|
||||
def period_max(rows, f):
|
||||
"""Per row: the largest f(row) over the paused rows of its 15-minute
|
||||
period so far (0 W outside the pause), as the owner rule reads."""
|
||||
out, q, m = [], None, 0.0
|
||||
for r in rows:
|
||||
qi = int((T0.timestamp() + r["t"]) // 900)
|
||||
if qi != q or not r["hold"]:
|
||||
q, m = qi, 0.0
|
||||
if r["hold"]:
|
||||
m = max(m, f(r))
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
for tag, ch, hi_w in (("B on Cheap hours", None, 9000.0),
|
||||
("B on Solar first (it gives for the house too)",
|
||||
{"a": sc.CH_CH["a"],
|
||||
"b": (True, packmode.SOLAR_FIRST, False)},
|
||||
12000.0)):
|
||||
rows, st, c = scen(soc=49.0, other=40.0, choice=ch,
|
||||
house=stepped_house(3000.0, hi_w))
|
||||
show(f"A 49 %, B 40 %, both able, {tag}, house {hi_w:.0f} W", rows, st)
|
||||
mf = period_max(rows, lambda r: r["floor"])
|
||||
held_rows = [(r, m) for r, m in zip(rows, mf)
|
||||
if r["hold"] and r["floor"] > 0.0 and r["hi_u"] is not None]
|
||||
n = max(1, len(held_rows))
|
||||
over = sum(1 for r, m in held_rows if r["share_u"] > m + 2.0)
|
||||
# the whole floor, as far as A's rating lets it be A's limit
|
||||
under = sum(1 for r, m in held_rows
|
||||
if r["hi_u"] < min(m, rp.RATING) - 500.0)
|
||||
print(f" {len(held_rows)} paused cycles with a guard floor (mean "
|
||||
f"{sum(r['floor'] for r, _m in held_rows) / n:.0f} W, period's "
|
||||
f"largest {sum(m for _r, m in held_rows) / n:.0f} W): A's limit "
|
||||
f"{sum(r['hi_u'] for r, _m in held_rows) / n:.0f} W, A "
|
||||
f"{sum(r['share_u'] for r, _m in held_rows) / n:.0f} W, B "
|
||||
f"{sum(r['share_o'] for r, _m in held_rows) / n:.0f} W; A over the "
|
||||
f"period's largest floor in {over}; A's limit 500 W or more under it"
|
||||
f" (or A's rating) in {under}")
|
||||
check(f"{tag}: the pause holds with a guard floor for over 5 min",
|
||||
len(held_rows) >= 300)
|
||||
check("...A never gives more than the largest guard floor of the period "
|
||||
"(owner 2026-11-13)", over == 0)
|
||||
if ch is not None:
|
||||
check("...B is placed part of the floor, so A's limit is its own part,"
|
||||
" 500 W or more under the period's largest floor (or A's "
|
||||
"rating) in 9 of 10 cycles (not the whole floor)",
|
||||
under >= 0.9 * len(held_rows))
|
||||
check("...every quarter at or under the month aim", quarters_ok(st))
|
||||
|
||||
rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0))
|
||||
show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st)
|
||||
check("the guard places its claim on the other pack: A under its reserve "
|
||||
"gives under 0.05 kWh and its SoC stays at 18 %",
|
||||
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.05
|
||||
and min(r["soc"] for r in rows) >= 18.0)
|
||||
|
||||
# H-1 (review 2026-11-14, owner decision): while the pause holds, the packs
|
||||
# together give only what the guard needs. The live try: A 11 % in CHARGE (under
|
||||
# its reserve), B 25 % on Cheap hours, 90 min at 6.5 kW.
|
||||
LIVE = 5400.0
|
||||
rp.CAP_KWH = 20.48 # the rig's B; A gives nothing here
|
||||
rows, st, c = scen(soc=11.0, other=25.0,
|
||||
house=stepped_house(3000.0, 6500.0, LIVE, RIG_PRE),
|
||||
dur=RIG_PRE + LIVE + POST)
|
||||
rp.CAP_KWH = 15.36
|
||||
qs = [a[0] / a[1] for a in st["quarters"].values()]
|
||||
load = [r for r in rows if RIG_PRE <= r["t"] < RIG_PRE + LIVE]
|
||||
gb = sum(r["o"] for r in load if r["o"] > 0) / 3.6e6
|
||||
|
||||
|
||||
def switches(k):
|
||||
g = [("give" if r[k] > 50 else "idle") for r in load]
|
||||
return sum(1 for x, y in zip(g, g[1:]) if x != y)
|
||||
|
||||
|
||||
b_low = min((r["soc_o"] for r in rows), default=0.0)
|
||||
print(f" live try: quarters {[round(q) for q in qs]}, B gives {gb:.2f} kWh, "
|
||||
f"B lowest {b_low:.1f} %, give/idle switches A "
|
||||
f"{switches('u')}, B {switches('o')}")
|
||||
check("live try: every quarter at or under the aim + 10 W (07d23d2: 6502 and "
|
||||
"6499 W against the month peak 6000 W)", max(qs) <= AIM + 10.0)
|
||||
check("live try: B gives about its guard part, under 1.5 kWh (1b5f7c9 1.0; "
|
||||
"07d23d2 4.6 kW, 25 -> 10 % in 43 min)", gb < 1.5)
|
||||
check("live try: B's SoC never goes under 19.8 % (1b5f7c9 19.8)",
|
||||
b_low >= 19.8)
|
||||
check("live try: give/idle switches of B at most 40 in the 90 min",
|
||||
switches("o") <= 40)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours")
|
||||
rows, st, c = scen(unit="b", soc=18.2, other=30.0,
|
||||
house=stepped_house(3000.0, 6500.0))
|
||||
show("B 18 %", rows, st)
|
||||
check("B gives nothing for the house: its SoC never goes under 18 %",
|
||||
min(r["soc"] for r in rows) >= 18.0)
|
||||
check("every quarter at or under the month aim", quarters_ok(st))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. unchanged: the MAINT-24 live shape with the lagging reading")
|
||||
rl, sl, cl = scen(house_w=3000.0, dur=600.0)
|
||||
body = rl[len(rl) // 6:]
|
||||
mean_a = sum(r["u"] for r in body) / len(body)
|
||||
mean_b = sum(r["o"] for r in body) / len(body)
|
||||
print(f" A {mean_a:+.0f} W, B {mean_b:+.0f} W, pause "
|
||||
f"{sum(r['hold'] for r in rl)} cycles")
|
||||
check("A charges about 1475 W (-1500..-1440) and the pause never comes",
|
||||
-1500.0 <= mean_a <= -1440.0 and not any(r["hold"] for r in rl))
|
||||
check("B does not trade with the grid (Cheap hours, nothing to charge it)",
|
||||
abs(mean_b) < 100.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. peakguard.shaping_held() with `settled`; Controller._batts_settled()")
|
||||
check("the rule exists (MAINT_STEP_W, _batts_settled)",
|
||||
hasattr(ctl_trade, "MAINT_STEP_W") and hasattr(ctl_trade.TradeMixin,
|
||||
"_batts_settled"))
|
||||
if not hasattr(ctl_trade, "MAINT_STEP_W"):
|
||||
print(f"\n{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
U = peakguard.MAINT_RESUME_UNDER_W
|
||||
idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0,
|
||||
ceiling_from="x", target_w=AIM)
|
||||
|
||||
|
||||
def held(was, fc, house, settled):
|
||||
return peakguard.shaping_held(
|
||||
peakguard.shaping_suspended(idle, fc, AIM), was, fc, AIM, house,
|
||||
settled)
|
||||
|
||||
|
||||
check("held, house under the line but not settled: stays paused",
|
||||
held(True, AIM - 1, AIM - U - 100.0, False))
|
||||
check("held, house under the line and settled: resumes",
|
||||
not held(True, AIM - 1, AIM - U - 100.0, True))
|
||||
check("not held: settled makes no difference",
|
||||
not held(False, AIM - 1, 6500.0, False))
|
||||
check("neither the house nor the forecast read: no pause, settled or not "
|
||||
"(SAFETY-63)",
|
||||
not held(True, None, None, False))
|
||||
|
||||
|
||||
class _Fresh:
|
||||
def __init__(self, changed):
|
||||
self.changed_mono = changed
|
||||
|
||||
|
||||
class _Inv:
|
||||
def __init__(self, name, cmd, changed):
|
||||
self.name, self.cmd = name, cmd
|
||||
self.batt_fresh = _Fresh(changed)
|
||||
|
||||
def standing_w(self):
|
||||
return self.cmd
|
||||
|
||||
|
||||
class _Ctl:
|
||||
_batts_settled = getattr(ctl_trade.TradeMixin, "_batts_settled", None)
|
||||
|
||||
def __init__(self, invs):
|
||||
self.inverters, self._m28_step, self._now_mono = invs, {}, 0.0
|
||||
|
||||
|
||||
S = freshness.PLANT_SETTLE_S
|
||||
a = _Inv("a", -1500.0, 0.0)
|
||||
k = _Ctl([a, _Inv("b", 0.0, 0.0)])
|
||||
check("no step seen yet: settled", k._batts_settled())
|
||||
a.cmd = 500.0
|
||||
k._now_mono = 100.0
|
||||
check("a step up of the command: not settled", not k._batts_settled())
|
||||
k._now_mono = 100.0 + S - 0.5
|
||||
a.batt_fresh.changed_mono = 100.0 + S - 1.0
|
||||
check("...a reading that arrived before step + settle does not count",
|
||||
not k._batts_settled())
|
||||
a.batt_fresh.changed_mono = 100.0 + S
|
||||
k._now_mono = 100.0 + S
|
||||
check("...one that arrived at step + settle does", k._batts_settled())
|
||||
a.cmd = 3000.0
|
||||
k._now_mono = 200.0
|
||||
check("a second step up; the old reading is not current",
|
||||
not k._batts_settled())
|
||||
k._now_mono = 200.0 + freshness.BATT_OBLIGED_S
|
||||
check("...and a reading that never changes is waited for no longer than "
|
||||
"BATT_OBLIGED_S", k._batts_settled())
|
||||
a.cmd = -1500.0
|
||||
k._now_mono = 400.0
|
||||
check("a step down (less give: the estimate errs high) is no step",
|
||||
k._batts_settled())
|
||||
a2 = _Inv("a", -1500.0, 0.0)
|
||||
k2 = _Ctl([a2])
|
||||
k2._batts_settled()
|
||||
a2.cmd, k2._now_mono = -1500.0 + ctl_trade.MAINT_STEP_W - 1.0, 1.0
|
||||
check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled())
|
||||
a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W
|
||||
check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled())
|
||||
|
||||
# Per unit: the cap so far lives under each unit's own key, and only one pack
|
||||
# can be in maintenance CHARGE at all (maintenance.arbitrate(): one slot).
|
||||
au, ab = (cl.actuator_of("maintenance", i) for i in cl.inverters)
|
||||
t = T0 + timedelta(seconds=60)
|
||||
cl._m28_hi = {au: (int(t.timestamp() // 900), 3000.0)}
|
||||
check("the period's cap is kept per unit: A's 3000 W is not B's",
|
||||
cl._m28_was(au, t) == 3000.0 and cl._m28_was(ab, t) == 0.0)
|
||||
check("...and it starts again from 0 W in the next 15-minute period",
|
||||
cl._m28_was(au, t + timedelta(seconds=900)) == 0.0)
|
||||
|
||||
|
||||
class _M:
|
||||
def __init__(self, name):
|
||||
self.name, self.phase = name, maintenance.CHARGE
|
||||
|
||||
|
||||
check("maintenance.arbitrate(): with two packs in CHARGE only the first keeps "
|
||||
"the slot", maintenance.arbitrate([_M("a"), _M("b")], t)[1] != ""
|
||||
and maintenance.arbitrate([_M("a"), _M("b")], t)[0] == "")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -1148,7 +1148,10 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
|
||||
"could do - and no fixture anywhere could see that, because every check "
|
||||
"calls `resolve()` for itself. A method, not a dict, because the "
|
||||
"weighting is direction-dependent and the direction is stage 2's output",
|
||||
len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"])
|
||||
# MAINT-28 added a second call (`_m28_share()`'s split without the
|
||||
# paused pack's cap); it must pass the same method, as the cycle's does.
|
||||
len(_resolve_calls) >= 1
|
||||
and _w_arg == ["self.pack_weights"] * len(_resolve_calls))
|
||||
|
||||
# ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY
|
||||
# HALF OF THAT. The other half is main.py putting it in the action log, which no
|
||||
|
||||
@@ -116,6 +116,7 @@ from math import inf, isinf
|
||||
from pathlib import Path
|
||||
|
||||
from app import messages
|
||||
from app import ctl_cycle as _ctl_cycle
|
||||
from app import peakguard
|
||||
from named_absence import first, given, nth, present
|
||||
|
||||
@@ -2787,9 +2788,22 @@ def cycle_claims(run):
|
||||
return out, (seen[-1] if seen else [])
|
||||
|
||||
|
||||
def run_cycle(ctl):
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
ctl.cycle())
|
||||
def run_cycle(ctl, at=None):
|
||||
"""One `Controller.cycle()`. `at` pins the `now` that cycle() takes
|
||||
(ctl_cycle.py:49) so a fixture laid up to `at` does not age with host load.
|
||||
Other modules still read the real clock. CI-17."""
|
||||
real_dt = _ctl_cycle.datetime
|
||||
if at is not None:
|
||||
class _Pinned(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return at if tz is None else at.astimezone(tz)
|
||||
_ctl_cycle.datetime = _Pinned
|
||||
try:
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
ctl.cycle())
|
||||
finally:
|
||||
_ctl_cycle.datetime = real_dt
|
||||
return ctl
|
||||
|
||||
|
||||
@@ -2801,11 +2815,15 @@ def run_cycle(ctl):
|
||||
EVE_MEAN_W = sum(EVE_W) / len(EVE_W)
|
||||
c_src, _ = built(TMP / "sources.db")
|
||||
c_src.auto, c_src.batt, c_src.soc = True, 0.0, 100.0
|
||||
# CI-17: ONE read of the clock, and every cycle on c_src runs AT it. The
|
||||
# telegrams end here; `cycle()` used to read the real clock again, 1.3 s later
|
||||
# per cycle under --cpus 0.25, so by the 23rd cycle of the sweep the meter's
|
||||
# last telegram was older than `max_hold_s` (30 s), the guard counted the gap at
|
||||
# its ceiling and filed `peak@site` (ctl_cycle.py:49 -> p1.py max_hold_s).
|
||||
_SRC_NOW = datetime.now(timezone.utc)
|
||||
_blk_src = datetime.fromtimestamp(
|
||||
int(datetime.now(timezone.utc).timestamp() // QUARTER_S) * QUARTER_S,
|
||||
timezone.utc)
|
||||
for _i in range(0, int((datetime.now(timezone.utc)
|
||||
- _blk_src).total_seconds()) + 1, 5):
|
||||
int(_SRC_NOW.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc)
|
||||
for _i in range(0, int((_SRC_NOW - _blk_src).total_seconds()) + 1, 5):
|
||||
c_src.p1.submit(sample(_blk_src + timedelta(seconds=_i), EVE_MEAN_W))
|
||||
c_src.grid = EVE_MEAN_W
|
||||
|
||||
@@ -2817,7 +2835,7 @@ SRC_SWEEP = sorted({float(s) for s in range(0, 101, 5)}
|
||||
IDLE_SRC = {}
|
||||
for _soc in SRC_SWEEP:
|
||||
c_src.soc = _soc
|
||||
_, _cl = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
IDLE_SRC[_soc] = frozenset(c.source for c in _cl)
|
||||
IDLE_SETS = set(IDLE_SRC.values())
|
||||
print(f" a real idle cycle at {len(SRC_SWEEP)} states of charge "
|
||||
@@ -3141,7 +3159,7 @@ _b2, _restore_b2 = second_inverter(c_src)
|
||||
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
|
||||
_n2_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
_n2_target, _n2_reason = c_src.target, c_src.reason
|
||||
_n2_events = list(c_src.events)
|
||||
_n2_writes = c_src.hass.per_entity[_n2_writes_before:]
|
||||
@@ -3337,7 +3355,7 @@ check("...and the action log no longer carries the 'no writer' refusal, "
|
||||
_nw, _restore_nw = second_inverter(c_src, name="inverter-c", setpoint="")
|
||||
_nw_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl_nw = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl_nw = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
_nw_target, _nw_reason = c_src.target, c_src.reason
|
||||
_nw_events = list(c_src.events)
|
||||
_nw_writes = c_src.hass.per_entity[_nw_writes_before:]
|
||||
@@ -3382,16 +3400,21 @@ check("...and the 0 W is NOT what the site target happened to be - the same "
|
||||
# method is asked, which is what makes this a check on `live_units()` instead of
|
||||
# a check on a lambda this file wrote.
|
||||
_bs, _restore_bs = second_inverter(c_src)
|
||||
_bs.rejected_since = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=app_main.LINK_STALE_S + 5)
|
||||
# CI-17: from `_SRC_NOW`, the instant the pinned cycles run at; the real clock
|
||||
# is later, so `live_units()` (real clock) and the cycle (pinned) agree.
|
||||
_bs.rejected_since = _SRC_NOW - timedelta(seconds=app_main.LINK_STALE_S + 5)
|
||||
_bs.last_write_ok = False
|
||||
_stale_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
check("CI-17: the pinned clock and the real clock agree that inverter B's "
|
||||
"link is stale, so the cycle and `live_units()` see the same roster",
|
||||
not _bs.is_live(_SRC_NOW)[0]
|
||||
and not _bs.is_live(datetime.now(timezone.utc))[0])
|
||||
check("PRECONDITION: the SHIPPED `live_units()` - not a lambda - drops the "
|
||||
"inverter whose writes have been rejected for a whole layer-1 window, "
|
||||
"and keeps the healthy one",
|
||||
c_src.live_units() == ("inverter-a",))
|
||||
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
_stale_target = c_src.target
|
||||
_stale_alloc = c_src.allocation
|
||||
_stale_events = list(c_src.events)
|
||||
@@ -3433,14 +3456,13 @@ check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and
|
||||
# state `live_units()`'s own docstring warns about - the sole inverter dropping
|
||||
# takes the SITE to 0 W - so it is driven here rather than left as a sentence.
|
||||
_bd, _restore_bd = second_inverter(c_src)
|
||||
_gone = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=app_main.LINK_STALE_S + 5)
|
||||
_gone = _SRC_NOW - timedelta(seconds=app_main.LINK_STALE_S + 5) # CI-17: as above
|
||||
_bd.rejected_since, _bd.last_write_ok = _gone, False
|
||||
c_src.inverters[0].rejected_since = _gone
|
||||
c_src.inverters[0].last_write_ok = False
|
||||
_dead_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
_dead_target, _dead_reason = c_src.target, c_src.reason
|
||||
_dead_alloc = c_src.allocation
|
||||
_dead_events = list(c_src.events)
|
||||
|
||||
@@ -484,7 +484,14 @@ print("\n8. the N=1 and N=2 digests")
|
||||
# MAINT-28 re-pinned n2_charge_banked: in the 5000 W load (over the 3950 W
|
||||
# aim) the maintenance charge no longer restarts at -125 W after each guard
|
||||
# stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone
|
||||
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). The other five are equal.
|
||||
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward
|
||||
# re-pinned it again: while the pause holds A gives at most the largest part
|
||||
# of the guard's floor placed on it this period, so rows 288-479 (t = 1440 s
|
||||
# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Round 5
|
||||
# (H-1) re-pinned it again: one site limit, the period's largest floor, so the
|
||||
# batteries give only what the guard needs. Its banked quarters stay at or
|
||||
# under the aim 3950 W (3349 and 3950 W; f3f9483 3349 and 2527 W), graded
|
||||
# below. The other five are equal.
|
||||
PINNED = {
|
||||
"n1_charge":
|
||||
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
|
||||
@@ -497,8 +504,14 @@ PINNED = {
|
||||
"n2_charge_dear":
|
||||
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
|
||||
"n2_charge_banked":
|
||||
"5d523562219245c135a4e9aa856826bc2939a3157c6ae0320cafc2cd1a8a6cf1",
|
||||
"358ebd2a23fe7c7f52f441b60391e40c6605e32bcfb109fa0ff4502d4070745e",
|
||||
}
|
||||
_nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"])
|
||||
print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}"
|
||||
f" W (aim 3950)")
|
||||
check("...and in the re-pinned n2_charge_banked every banked quarter is at or "
|
||||
"under the aim 3950 W",
|
||||
len(banked(_nb)) >= 2 and all(v <= 3950.0 + 1.0 for v in banked(_nb)))
|
||||
_now = s84.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
|
||||
|
||||
@@ -116,10 +116,15 @@ class Lag:
|
||||
after the reading (the HA source's debounce), so a cycle just after a
|
||||
quarter boundary can still see the old block; `tick` the cycle period;
|
||||
`tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1,
|
||||
no lag or ramp)."""
|
||||
no lag or ramp). `batt_period_s` (MAINT-28): the battery register is
|
||||
published every that many seconds and held between (the rig: 10 s), so its
|
||||
reading is `batt_s` plus up to a period older than the meter's; None keeps
|
||||
the constant age."""
|
||||
|
||||
def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35,
|
||||
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0):
|
||||
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0,
|
||||
batt_period_s=None):
|
||||
self.batt_period_s = batt_period_s
|
||||
self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s
|
||||
self.p1_s, self.tick, self.sub = p1_s, tick, sub
|
||||
self.tau_s, self.ramp_w_s = tau_s, ramp_w_s
|
||||
@@ -211,6 +216,11 @@ class Hass:
|
||||
if e.startswith("sensor.batt"):
|
||||
# A real reading jitters by a few watts; a dead-flat one is a
|
||||
# stale register to the add-on (SAFETY-37), which is not modelled.
|
||||
if self.lag is not None and self.lag.batt_period_s:
|
||||
per = self.lag.batt_period_s
|
||||
ts = math.floor((self.t - self.lag.batt_s) / per) * per
|
||||
j = (ord(u) * 31 + int(ts)) % 7 - 3
|
||||
return float(round(self._past(self.t - ts)[1][u]) + j)
|
||||
j = (ord(u) * 31 + int(self.t)) % 7 - 3
|
||||
p = self.plants[u].p if self.lag is None else \
|
||||
self._past(self.lag.batt_s)[1][u]
|
||||
|
||||
Reference in New Issue
Block a user