Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
634001cb26 | ||
|
|
94214d609e | ||
|
|
4c0122a8b6 | ||
|
|
7bc8ad264b | ||
|
|
f3f9483d9c | ||
|
|
e7706150e8 | ||
|
|
7edf8484d1 | ||
|
|
29a8a05f30 | ||
|
|
f5bd2a3520 | ||
|
|
bc5be4964e | ||
|
|
edf30f345a | ||
|
|
23359a047d | ||
|
|
8640bfcbc1 | ||
|
|
92f0387415 | ||
|
|
4f9aa5223f | ||
|
|
8cd10ee6aa | ||
|
|
1b5f7c9ccb | ||
|
|
33cfc733d3 | ||
|
|
de0790e62d | ||
|
|
b80cfa969f | ||
|
|
6004bfad9a | ||
|
|
b44a648cab | ||
|
|
0f5e513f1d | ||
|
|
54a738dd87 | ||
|
|
02eac3b84e | ||
|
|
4066624cce | ||
|
|
66d8777b39 | ||
|
|
a8ecf171d6 | ||
|
|
0f9c119bcf | ||
|
|
c50e2279dd | ||
|
|
8508ac33f0 |
@@ -1,5 +1,126 @@
|
||||
# Changelog
|
||||
|
||||
## 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 its start hour after that day ([[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 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). Readings 15 s late gain less (19 % in a simulation) and are in 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 and Home Assistant readings 10 to 15 s late, control follows the meter more gently after a change, so 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, and the peak limit was kept. Since 0.22.0 the other battery's grid charge waits while this charge runs. At the start of a month, before a peak above the limit has been seen, this charge can give about 2 kWh a night (a rig test) and need about 4 nights, longer than its 36 h limit, and the other battery loses those cheap 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 may switch between charging and giving; more wear, no higher peak. In one rig test it switched 468 times in 45 minutes ([[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 says the wait ends on a day such as 18 November, but the cycle tries again at its start hour the next day, 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.**
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# MAINT-24 - a started maintenance charge gets the cheap night
|
||||
|
||||
**Status: for review.** Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13
|
||||
timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3).
|
||||
|
||||
**What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance
|
||||
CHARGE phase for more than 75 h and stalled at 49-50 %. At night B's battery-mode
|
||||
grid charge took the cheap run: A is not idle, so `modetrade.chargers()` never
|
||||
lists it, B is the only charger, and the allocator hands the whole negative
|
||||
target to B. A's own shaping then paused or shrank (the check compared the peak
|
||||
forecast with `cap - margin` = 3450 W, while B's trade and the guard aim at this
|
||||
month's peak, about 4.9 kW), and A gave power back on guard stops against a peak
|
||||
that B's own charge made. The timeout (MAINT-13) counted that starved time as
|
||||
charging time and ended the cycle. Report: `reports/MAINT-24-investigation-report.md`.
|
||||
|
||||
## 1. The maintenance charge goes first in the cheap run
|
||||
|
||||
`modetrade.decide(maint_charging=)`, set in `_mode_roles()` when any pack is in
|
||||
its maintenance CHARGE phase. It is the mirror of `draining` (SAFETY-78): no
|
||||
grid-charge trade for the other packs, why `cycle`, the same Tick. The other
|
||||
packs keep their solar roles, so they may still take spare solar. A refusal probe
|
||||
(SAFETY-73, PROBE_W alone, OPT-20) still goes on: it places no charge on the
|
||||
pack in maintenance, and `test_opt20.py` g7 pins it. The drain still blocks it. B's night
|
||||
charge waits for the nights A needs. At N=1 the only pack is the one in
|
||||
maintenance, so nothing changes.
|
||||
|
||||
## 2. The shaping aims where the guard aims (SAFETY-80)
|
||||
|
||||
The pause (`shaping_suspended()`) and the floor (`maintenance_charge_floor()`)
|
||||
get `peakguard.quarter_target_w(cap, margin, month reading)` instead of
|
||||
`peak_cap_w - peak_margin_w`. With no banked peak above the cap that is the same
|
||||
number. SAFETY-63 (no forecast: no pause, configured rate) and SAFETY-125 (late
|
||||
reading: `charge_room_w`) are untouched. `peak_aim_w()` stays for the day plan.
|
||||
|
||||
## 3. SAFETY-111 is not folded in
|
||||
|
||||
`peak_fc` holds the pack's own charge, so the floor `aim - peak_fc` settles at
|
||||
(aim - house) / 2, here 1475 W instead of 2000 W. Taking the own charge off the
|
||||
forecast was tried: it lifts A to 2000 W, but it changes the N=1 charge that
|
||||
`test_safety119.py` pins (a static 800 W forecast then no longer shapes the
|
||||
charge), so it is a change of its own. 1475 W fills a 10 kWh pack inside the
|
||||
night (sim below). It stays on its card.
|
||||
|
||||
## 4. A gives nothing while no real peak exists
|
||||
|
||||
With 1 and 2 no grid charge of B makes a peak against the lower aim, and the pause
|
||||
comes only when the forecast is over the month aim, so A is not made the only
|
||||
battery that gives while B charges. A guard stop on a real peak
|
||||
still lands on A (SAFETY-07 / MAINT-02, unchanged).
|
||||
|
||||
## Numbers (sim, house 3000 W, cap 3500, margin 50, month peak 6000 W)
|
||||
|
||||
| | base 02eac3b | MAINT-24 |
|
||||
|---|---|---|
|
||||
| A charge | 0 W (B charges 2500 W) | 1475 W = (5950 - 3000) / 2 |
|
||||
| B grid trade | yes, quarters 5500 W | none, why `cycle` |
|
||||
| quarter peak | 5500 W | 4475 W (aim 5950 W) |
|
||||
| A at house 3800 W | 0 W | 1075 W, quarters 4876 W |
|
||||
|
||||
Whole night (sim, `tools`-style replay, N=2, cheap 21:00-06:00, A from 10 %, B
|
||||
from 30 %, 9 h): base A takes 1.41 kWh (10 -> 23 %), B takes 6.84 kWh by grid
|
||||
trade; MAINT-24 A takes 9.47 kWh and is full (100 %) inside the night, B takes
|
||||
no grid charge. Worst 15-minute average 5500 W on base, 4475 W now, A never
|
||||
gives.
|
||||
|
||||
## Not changed
|
||||
|
||||
The MAINT-13 timeout, the peak aim itself, the month peak, `P_MAINT_SHAPE` and
|
||||
`P_ARBITRAGE`. The Home card for B does not say "the other battery is cycling"
|
||||
for a CHARGE phase (`other_cycle` reads "emptying"); that is a wording card.
|
||||
|
||||
MAINT-25 (the Home plan and the back-off) has its own note: `MAINT-25-plan-backoff.md`.
|
||||
@@ -0,0 +1,10 @@
|
||||
# MAINT-25 - the Home plan honours the 7-day back-off
|
||||
|
||||
**Status: for review.** Added to the MAINT-24 PR by the owner (2026-11-12).
|
||||
Tests: `test_maint25.py`. After a failed cycle `wants_start()` waits 7 days
|
||||
(MAINT-13, `backoff_until()`), but `homeview.maintenance_runs()` only asked
|
||||
`due()`, so the plan drew a cycle at the start hour every night of the wait.
|
||||
During the back-off it now draws no run for that battery and shows one line
|
||||
instead: "Battery A: the monthly cycle waits until 19 November." (Dutch: "...
|
||||
wacht tot 19 november."). A cycle completed after the failure ends it, as in
|
||||
`backoff_until()`. The other battery is planned as before.
|
||||
@@ -71,7 +71,8 @@ The fits run all the time; the pull request has the counts.
|
||||
**What it costs.**
|
||||
- Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late
|
||||
is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s,
|
||||
SAFETY-129) with the export above -200 W, and 10 s late is not found. Until
|
||||
SAFETY-129) with the export above -200 W, and 10 s late is not found (SAFETY-136 calms
|
||||
the loop at a slow meter instead). Until
|
||||
then control is as before. In a quiet house the "readings late" row stays
|
||||
about 2 minutes longer after the lag ends (126 s against 32 s on base); no
|
||||
harm measured, and it clears as early as on base when the load changes.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# SAFETY-135 - one guard stop does not cost the night charge the whole period
|
||||
|
||||
**Status: for review, round 2.** Code: `Controller._mode_roles()` and
|
||||
`Controller._give_falling()` (`app/ctl_trade.py`), `modetrade.decide(wait=)`,
|
||||
`RESTART_S`, `YOUNG_S`, `GAVE_S`, `WAIT_WORDS` (`app/modetrade.py`). Tests:
|
||||
`test_safety135.py`. Battery modes only. No option, no change to the peak
|
||||
guard.
|
||||
|
||||
**Problem.** A grid charge the peak guard stops stays off to the period's end
|
||||
(the OPT-11 latch). Three cases seen on TEST-23:
|
||||
- *A spike at a period's start* (UX-77 I-1, x10): a 1-3 s spike, or a charge
|
||||
still at full power from the end of the last period, makes the guard project
|
||||
4.3-6.6 kW and cut the charge. The cut is right, but one 2 s spike cost 15
|
||||
minutes of charge, about 0.16 kWh per trip on the rig.
|
||||
- *A start at the tariff edge* (sim, 5 s meter): a give ends at 01:00 and the
|
||||
cheap run starts. The battery reading drops at once, the meter a few
|
||||
seconds later. In between, grid + batteries reads the house too low, the
|
||||
room too big: the charge starts and the guard cuts it within seconds.
|
||||
- *An oven cycling under a cheap-hours charge* (SAFETY-136 rig run C/s5b, x1):
|
||||
the charge started at 01:00:32 in an oven pause and the guard cut it at
|
||||
01:01:31 when the oven came back on (5.3 kW projected). The cut is right,
|
||||
but the charge stayed off to 01:15.
|
||||
|
||||
**Decision.**
|
||||
1. *The first guard stop in a period does not latch.* A charge the peak guard
|
||||
stops (or a measured headroom stops in the period's first `YOUNG_S` = 60 s)
|
||||
may start again `RESTART_S` = 60 s after that stop,
|
||||
under the normal start rule (guard clear, headroom over `START_W`), but not
|
||||
in the period's last 60 s: there the room is the deficit times 900/s left,
|
||||
and a restart overshot by 15 W in the owner-shape night (N=2, 01:00). A
|
||||
second stop latches to the period's end, so there is at most one restart
|
||||
per period and the OPT-11 flip guard keeps its job. A measured headroom
|
||||
stop later in the period latches at once, and an unmeasured headroom
|
||||
(OPT-15) on its second stop, as before. Why only guard stops later on: all
|
||||
three rig cases are guard stops, while a headroom that falls to 0 W mid
|
||||
period is the slow loss of room the OPT-11 latch was made for (the
|
||||
2026-09-09 flips), so freeing it buys no case and risks that flip.
|
||||
Three existing checks pinned "one start per period" and move with this:
|
||||
`test_modes_p3.py` (a house swinging 2.0/3.9 kW each minute: now at most
|
||||
two starts per period), `test_price_run_fixes.py` 1c (a 1-minute 3.7 kW
|
||||
load: one restart after 60 s), and the `n1_ch` digest in
|
||||
`test_modes_p4.py` (only the waiting reason word moved, "(peak)" for
|
||||
"(headroom)"; with this card off it is OPT-15's pin again).
|
||||
2. *No start while a give is still falling.* In a period's first `YOUNG_S`,
|
||||
while the batteries give over `START_W` less than they did in the
|
||||
last `GAVE_S` = 15 s, no charge starts: the meter may not show that drop
|
||||
yet. A smaller drop does not hold the start.
|
||||
|
||||
The guard is not touched (claim, floor, projection), so a real over-aim load
|
||||
is cut on the same cycle as before, and a running charge is never held. While
|
||||
a start waits, the status row says "grid charge paused, it tries again in a
|
||||
minute" or "grid charge waits for the meter to catch up".
|
||||
|
||||
**Options weighed.**
|
||||
- *Round 1: only a stop in the first 60 s does not latch.* Missed the rig
|
||||
oven case, cut at 91 s (review M-1).
|
||||
- *Re-evaluation each cycle with no latch*: brings back the 13 flips in 4
|
||||
minutes the latch was made for (2026-09-09 replay).
|
||||
- *Restart at once after the first stop*: two flips in 20 s with spikes at
|
||||
0/20 s (`test_safety135.py` part 5 fails on it).
|
||||
- *No start in a young period within 60 s of a give or a guard action*:
|
||||
0.018 kWh (N=1) and 0.003 kWh (N=2) less than base on the owner night.
|
||||
- *Cutting the young room by the recent give*: double-counts once the meter
|
||||
catches up and flapped on a 1 s meter where base did not.
|
||||
- *A hysteresis band*: the edge error is the readings' age, not a small room.
|
||||
|
||||
**Numbers** (`scratch/SAFETY-135/dev/s135_sim.py`, real `cycle()`, TEST-23-like
|
||||
lag, 1 s cycle, two 2.5 kW batteries unless said, cap 3 500 W, aim 3 450 W;
|
||||
the sim's grid average per period):
|
||||
|
||||
| Scenario | Base: paused s, kWh, flaps, top period | Head |
|
||||
|---|---|---|
|
||||
| (a) 4 s spike at 01:30 and 01:45, house 2.8 kW | 1 197 s, 0.330 kWh, 1, 3 458 W | 139 s, 0.537 kWh, 0, 3 458 W |
|
||||
| (b) 01:00 edge after a give, 5 s meter, house 3.0 kW | 882 s, 0.006 kWh, 1, 3 009 W | 21 s, 0.119 kWh, 0, 3 464 W |
|
||||
| (b) the same, house 2.4 kW | 0 s, 0.266 kWh, 0, 3 461 W | 11 s, 0.266 kWh, 0, 3 461 W |
|
||||
| Rig s5b (5 kW + 4.6 kW, oven), 1 s / 5 s meter | 838 / 886 s, 0.054 / 0.035 kWh, 2 224 / 2 123 W | 793 / 793 s, 0.075 / 0.084 kWh, 2 299 / 2 286 W |
|
||||
| (c) +4 kW for 5 min from 01:30:20 | cut 01:30:22, 0.387 kWh | cut 01:30:22, 0.492 kWh |
|
||||
| Owner-shape night, N=1 / N=2 | 5.510 / 6.048 kWh, top 3 454 / 3 453 W | 5.510 / 6.169 kWh, 3 454 / 3 453 W |
|
||||
| Owner-shape day, N=1 / N=2 | top 331 / 145 W | the same |
|
||||
|
||||
In s5b the restart at 01:02:32 is cut again by the oven at 01:03:17 and then
|
||||
latches: one restart, as designed. A +4 kW load from second 30, 57, 59, 60 or
|
||||
61 (N=1 and N=2) is cut on the same cycle as base, the charge restarts only
|
||||
after the load ends, and the period lands at 3 450.0 W (base 3 037-3 350 W).
|
||||
The add-on's own period averages in `test_safety135.py` are at or under the
|
||||
aim + 2 W in every part.
|
||||
|
||||
**Sensitivity** (one constant moved, the others as shipped; head):
|
||||
|
||||
| Constant | Spike (a): paused s, kWh | Edge (b) 5 s meter 3.0 kW: paused s, flaps | Rig s5b 1 s: kWh, period W | Owner night N=2: kWh, top W |
|
||||
|---|---|---|---|---|
|
||||
| `RESTART_S` 30 | 79, 0.541 | 21, 0 | 0.082, 2 321 | 6.206, **3 459** |
|
||||
| `RESTART_S` 60 (shipped) | 139, 0.537 | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
|
||||
| `RESTART_S` 90 | 199, 0.532 | 21, 0 | 0.061, 2 250 | 6.169, 3 453 |
|
||||
| `RESTART_S` 120 | 259, 0.528 | 21, 0 | 0.085, 2 338 | 6.169, 3 453 |
|
||||
| `GAVE_S` 5 | - | 75, **1** | 0.075, 2 299 | 6.170, 3 453 |
|
||||
| `GAVE_S` 10 | - | 15, 0 | 0.075, 2 299 | 6.169, 3 453 |
|
||||
| `GAVE_S` 15 (shipped) | - | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
|
||||
| `GAVE_S` 30 | - | 35, 0 | 0.073, 2 290 | 6.168, 3 453 |
|
||||
| `YOUNG_S` 30 / 90 | - | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
|
||||
|
||||
`RESTART_S` 30 restarts the owner night in a period's last 30 s and lands
|
||||
9 W over base's top period; 60 is the shortest value without that. In s5b
|
||||
the value only moves where the restart meets the oven cycle. `GAVE_S` 5 s is
|
||||
shorter than a 5 s meter's age and flaps again; 10-30 s only move the wait.
|
||||
`YOUNG_S` does not move these runs: the give falls in the first seconds.
|
||||
|
||||
On the synthetic night where the house alone is 950 W over the aim (N=1,
|
||||
heat pump 4.4 kW), head's top period is 4 403 W against base's 4 401 W: a
|
||||
charge restarted in the period before runs into the heat pump's start (the
|
||||
carry-over below). Base charges 1.142 kWh there, head 1.560 kWh.
|
||||
|
||||
**Not changed.** With battery modes off, `arbitrage.decide()` has no period
|
||||
latch and sizes the charge to the headroom each cycle (SAFETY-45). A charge at
|
||||
full power in a period's last seconds still runs into the next period (the
|
||||
charge side of SAFETY-124); with rule 1 that costs 60 s, not the period.
|
||||
@@ -0,0 +1,81 @@
|
||||
# SAFETY-136 - a slow meter gets a gentler loop until the last move shows
|
||||
|
||||
**Status: for review.** Code: `app/settle.py`, a call in `Controller.cycle()`
|
||||
(`app/ctl_cycle.py`). Tests: `test_safety136.py`. No option, no text change.
|
||||
|
||||
**Problem.** On a 5 s meter, Home Assistant readings 10-15 s late are not
|
||||
found (SAFETY-129): they look like a slow battery (R2-1), and a hold does not
|
||||
help (a perfect one cut the export of the oven by 7 %, SAFETY-129 review).
|
||||
With moving loads the loop answers the same stale error at each cycle and
|
||||
overshoots: an oven, a kettle and hob, the owner's day or a step export 3-10
|
||||
times what they do with no lag. The fix is a calmer loop, not a detector.
|
||||
|
||||
**Decision.** After a move of 150 W or more, a loop on a slow meter runs at half
|
||||
its gain until the meter has fallen by half of that move, or for 2 cycles.
|
||||
Slow is judged from the readings that arrive: the usual gap
|
||||
between them, over the last 60 s and at least 5 readings, is over 3.75 s (the
|
||||
limit of SAFETY-128's fast meter fit). It counts arrivals, not changes of
|
||||
value: on the recorded day at 1-2 s the value-based test (`MeterFit.slow()`)
|
||||
called 24 % of the cycles slow, because the recorded house repeats a value
|
||||
for 5 s or more, and the commands then differed from base. So there is no
|
||||
option. At a meter with a usual gap of 3.75 s or less (1-3 s) it is false, the tuning is never touched and the
|
||||
commands are those of the tree before. A meter that Home Assistant passes on
|
||||
only when its value changes looks as slow as its changes are far apart, as it
|
||||
does to SAFETY-128's fits: a 1-2 s meter with a value that holds for 5 s or
|
||||
more is then treated as slow, and the loop is gentler for it (the cost below).
|
||||
With no lag the meter shows a move at the next reading, so the wait ends at
|
||||
once.
|
||||
|
||||
**Candidates** (SAFETY-129 sim, real `cycle()`, 5 s meter, sum over oven,
|
||||
kettle and hob, owner shape over the oven and over the heat pump, step down,
|
||||
step up, flat; export Wh, lag 0 / 10 s / 15 s; base 64 / 340 / 363):
|
||||
|
||||
| Candidate, only at a slow meter | 0 s | 10 s | 15 s | Import Wh, 0 / 10 / 15 s |
|
||||
|---|---|---|---|---|
|
||||
| gain 0.4 | 97 | 201 | 299 | 97 / 224 / 301 |
|
||||
| gain 0.3 | 128 | 185 | 322 | 132 / 232 / 293 |
|
||||
| step limit 500 W | 118 | 285 | 293 | 110 / 325 / 324 |
|
||||
| cut fast, raise slowly (0.6 / 0.3) | 55 | 140 | 226 | 140 / 332 / 345 |
|
||||
| ignore 1 cycle after a move | 93 | 216 | 325 | 90 / 240 / 291 |
|
||||
| ignore 2 cycles after a move, unless the meter showed half | 69 | 206 | 277 | 62 / 240 / 354 |
|
||||
| **half gain for 2 cycles after a move, unless the meter showed half** | **67** | **189** | **296** | **62 / 223 / 288** |
|
||||
|
||||
A lower gain for good, a step limit and a fixed ignore all make a 5 s meter
|
||||
with no lag worse (export +30-110 Wh, quarter peak up to 150 W): that is the
|
||||
common case. Cutting fast and raising slowly moves export to import and peak.
|
||||
Waiting only while the last move has not shown costs nothing with no lag.
|
||||
Ignoring the meter (the 2-cycle line) was worse than half gain where the plant
|
||||
is slow, not the readings late: a 12 s dead time with one inverter went from
|
||||
250 to 841 Wh import and a quarter peak of 206 to 1 509 W, so the loop keeps
|
||||
following at half gain.
|
||||
|
||||
**What it gives** (5 s meter; the pull request and the report have each
|
||||
scenario). Export summed over the seven houses: 10 s late 340 -> 189 Wh
|
||||
(-44 %, import 363 -> 223), 15 s late 363 -> 296 Wh (-19 %, import 281 ->
|
||||
288), together -31 %. At 15 s the oven and the owner's oven are not better
|
||||
(92 / 39 Wh against 91 / 37), the kettle and the steps are. No lag: export
|
||||
64.4 -> 66.5 Wh (kettle +2), import and peak the same.
|
||||
|
||||
**What it costs.** The loop follows a real step more slowly: half gain for up
|
||||
to two cycles after each move of 150 W or more. Of the 136 sim runs (every
|
||||
SAFETY-129 review scenario and its 15 harm runs), 56 at 4-10 s meters differ
|
||||
from base. Together their export is 10 363 -> 9 559 Wh (-8 %) and import
|
||||
8 389 -> 7 835 Wh (-7 %). Only 5 export more than 1 Wh and 14 import more:
|
||||
export by at most 11.5 Wh (a 10 s battery lag, +10 %), import by at most
|
||||
17 Wh (a 12 s dead time, one inverter, +7 %), and the quarter average by at
|
||||
most 35 W, except two import quarters: +73 W (10 s meter, 10 s dead time,
|
||||
export -59 Wh) and +40 W (`dead10_p6`, -6 to 34 W, export -68 Wh). Slow
|
||||
batteries with a dead time of 8-10 s export 5-39 % less; a 12 s dead time is
|
||||
the same. `READINGS_LATE` holds are the same seconds in every run.
|
||||
|
||||
**Live, a 1-2 s meter can be judged slow.** Through Home Assistant on change,
|
||||
a value that holds for 4-10 s makes a 1 s meter slow. In 20 rig scenarios the
|
||||
commands were those of base. On the recorded day (1 s, N=1, 1 day, repeats
|
||||
dropped; its values hold about 5 s) it was slow in 33 % of cycles, half gain
|
||||
in 83 cycles, 15 422 commands in both trees, 4 016 with another value (at
|
||||
most 190 W apart), cost 0.61 EUR in both (review of SAFETY-136).
|
||||
|
||||
**Does not change.** Meters of 1-2 s: the same commands in all 63 runs (34
|
||||
at 1 s, 29 at 2 s), and the recorded-day digests (see the report). The late
|
||||
verdict, the hold and the freeze tests. 15 s late is only partly helped, and
|
||||
30 s late is the hold's (SAFETY-129).
|
||||
@@ -559,8 +559,13 @@ class CycleMixin:
|
||||
# peakguard.shaping_suspended(), NOT HERE. One suspension flag
|
||||
# per SITE (`arbitrate()` allows one pack in CHARGE).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
# MAINT-24: the guard's aim, month peak included (SAFETY-80).
|
||||
# DECISIONS/MAINT-24-charge-first.md
|
||||
maint_aim = peakguard.quarter_target_w(
|
||||
float(self.o.get("peak_cap_w", 3500)),
|
||||
self.peak_margin_w, _reading)
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc, self.peak_aim_w()):
|
||||
self.peak_guard, self.peak_fc, maint_aim):
|
||||
# Edge-logged: level-logging here would write one line per
|
||||
# cycle for as long as the event lasts and flush the 40-line
|
||||
# action log of the very context that explains it.
|
||||
@@ -582,7 +587,7 @@ class CycleMixin:
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
floor = maintenance_charge_floor(
|
||||
self.maint.cfg.charge_w,
|
||||
self.peak_fc, self.peak_aim_w())
|
||||
self.peak_fc, maint_aim)
|
||||
floor = self._charge_headroom(floor, claims)
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
@@ -734,15 +739,21 @@ class CycleMixin:
|
||||
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
|
||||
# AGAINST. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
loop_prev = self.target
|
||||
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||
# OPT-15: `loop_grid` is the grid, or the target while a run
|
||||
# edge is held.
|
||||
grid_in = (self._drain_grid_w(_live96) if self._drain_tail
|
||||
else loop_grid)
|
||||
# SAFETY-136: on a slow meter, a gentler loop until the last move
|
||||
# shows.
|
||||
slow_meter = self.settle.slow()
|
||||
if moved or mode_trade:
|
||||
self.settle.reset()
|
||||
decision = compute(
|
||||
prev_w=loop_prev,
|
||||
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||
# OPT-15: `loop_grid` is the grid, or the target while a run
|
||||
# edge is held.
|
||||
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
|
||||
else loop_grid),
|
||||
grid_w=grid_in,
|
||||
actual_w=site_batt,
|
||||
tuning=self.tuning,
|
||||
tuning=self.settle.tuning(self.tuning, grid_in, slow_meter),
|
||||
sat_count=self.sat_count,
|
||||
i_w=self.i_w,
|
||||
# SAFETY-52 at one inverter only: with several, OPT-09's pack
|
||||
@@ -772,6 +783,7 @@ class CycleMixin:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
||||
f"{site_batt:.0f} W)",
|
||||
hint=messages.hint("not_keeping_up"))
|
||||
self.settle.moved(grid_in, loop_prev, decision.target_w, slow_meter)
|
||||
self.sat_count = decision.sat_count
|
||||
self.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
@@ -811,6 +823,7 @@ class CycleMixin:
|
||||
else:
|
||||
# Stopped or blind: no accumulation may survive the outage.
|
||||
self.i_w = 0.0
|
||||
self.settle.reset()
|
||||
self.loop_w = None
|
||||
self._live_was = frozenset(_live96)
|
||||
# TEL-10, and it is the same zero the line above writes rather than
|
||||
|
||||
@@ -197,6 +197,7 @@ class InputsMixin:
|
||||
# correlates 0.998 with battery power and 0.09 with the real meter
|
||||
# (a controller would regulate against its own output).
|
||||
self.grid = self.p1.net_w
|
||||
self.settle.arrive(now_mono, self.p1.samples) # SAFETY-136
|
||||
else:
|
||||
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
||||
bool(o.get("meter_invert")))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import collections
|
||||
import dataclasses
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -401,6 +402,18 @@ class TradeMixin:
|
||||
quarter = int(now.timestamp() // 900)
|
||||
was = self.mode_tick is not None and \
|
||||
self.mode_tick.action == modetrade.CHARGE
|
||||
# SAFETY-135: the first guard stop in a period (or measured headroom
|
||||
# stop in a young one) does not latch; the charge may start again
|
||||
# RESTART_S after it, but not in the period's last RESTART_S. No
|
||||
# start in a young period while a give falls.
|
||||
# DECISIONS/SAFETY-135-young-period.md
|
||||
st = getattr(self, "_charge_stop", None)
|
||||
paused = (st is not None and st[0] == quarter
|
||||
and (now.timestamp() - st[1] < modetrade.RESTART_S
|
||||
or (quarter + 1) * 900 - now.timestamp()
|
||||
< modetrade.RESTART_S))
|
||||
wait = ("wait" if paused else
|
||||
"settle" if self._give_falling(now) else None)
|
||||
self.mode_tick = modetrade.decide(
|
||||
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
|
||||
trade_why=trade_why, price_now=price_now,
|
||||
@@ -408,7 +421,10 @@ class TradeMixin:
|
||||
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()),
|
||||
wait=wait,
|
||||
maint_charging=any(r.phase == CHARGE
|
||||
for r in maint_results or ()))
|
||||
self._latch_charge(now, runs, targets, packs)
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
@@ -419,11 +435,38 @@ class TradeMixin:
|
||||
gap = why == "headroom" and self.arb_headroom_w is None
|
||||
if why == "peak" or (why == "headroom" and not gap) or (
|
||||
gap and self._charge_gap_q == quarter):
|
||||
self._charge_blocked_q = quarter
|
||||
young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S
|
||||
if (why == "peak" or (young and not gap)) and (
|
||||
st is None or st[0] != quarter):
|
||||
self._charge_stop = (quarter, now.timestamp())
|
||||
else:
|
||||
self._charge_blocked_q = quarter
|
||||
elif gap:
|
||||
self._charge_gap_q = quarter
|
||||
return self.mode_tick
|
||||
|
||||
def _give_falling(self, now) -> bool:
|
||||
"""SAFETY-135: True in a young period while the batteries give over
|
||||
START_W less than they did in the last GAVE_S s: the meter may not
|
||||
show that yet, so a charge room read now is too big.
|
||||
DECISIONS/SAFETY-135-young-period.md"""
|
||||
t = now.timestamp()
|
||||
gave = getattr(self, "_gave", None)
|
||||
if gave is None:
|
||||
gave = collections.deque()
|
||||
self._gave = gave
|
||||
if gave and t < gave[-1][0]:
|
||||
gave.clear() # the clock stepped back
|
||||
if self.site_batt_w is not None:
|
||||
gave.append((t, max(0.0, float(self.site_batt_w))))
|
||||
while gave and t - gave[0][0] >= modetrade.GAVE_S:
|
||||
gave.popleft()
|
||||
if self.site_batt_w is None or not gave \
|
||||
or t % 900 >= modetrade.YOUNG_S:
|
||||
return False
|
||||
drop = max(w for _t, w in gave) - max(0.0, float(self.site_batt_w))
|
||||
return drop > modetrade.START_W
|
||||
|
||||
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
||||
"""The batteries that left run `run` (full, or at the floor); they
|
||||
stay out until it ends, so a level edge does not re-arm them.
|
||||
@@ -475,7 +518,8 @@ class TradeMixin:
|
||||
None, 0.0, "size")
|
||||
if size == 0.0 or runs is None:
|
||||
reason = ("battery modes: no price run now" if tick.why is None
|
||||
else f"battery modes: no trade ({tick.why})")
|
||||
else modetrade.WAIT_WORDS.get(tick.why)
|
||||
or f"battery modes: no trade ({tick.why})")
|
||||
self.arb = arbitrage.ArbitrageDecision(
|
||||
action=None, power_w=None, reason=reason,
|
||||
spread_eur_kwh=None, required_eur_kwh=None,
|
||||
|
||||
@@ -630,6 +630,11 @@ def maintenance_runs(c, day0, day1, now, shadow):
|
||||
run_cycle_why(shadow),
|
||||
run_cycle_amount(hours)))
|
||||
continue
|
||||
until = m.backoff_until(now)
|
||||
if until is not None:
|
||||
# MAINT-25: a failed cycle waits (MAINT-13); no run is planned.
|
||||
lines.append(line_cycle_backoff(inv.name, until))
|
||||
continue
|
||||
if not m.due(now) and (m.next_due(now) is None
|
||||
or m.next_due(now) >= day1):
|
||||
continue
|
||||
@@ -1529,6 +1534,13 @@ def line_cycle_waits(name) -> str:
|
||||
return _t("cycle_waits", name=name)
|
||||
|
||||
|
||||
def line_cycle_backoff(name, until) -> str:
|
||||
"""MAINT-25: the failed cycle waits until `until` (MAINT-13)."""
|
||||
local = brussels(until)
|
||||
return _t("cycle_backoff", name=name, day=local.day,
|
||||
month=month_name(local))
|
||||
|
||||
|
||||
def line_no_prices(since, now) -> str:
|
||||
"""PRICE-01: no price covers now. `since` is an instant or None."""
|
||||
if since is None:
|
||||
@@ -1669,6 +1681,8 @@ def _samples() -> list:
|
||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||
("plan", run_house_amount()),
|
||||
("plan", line_cycle_waits("Battery B")),
|
||||
("plan", line_cycle_backoff("Battery B", datetime(
|
||||
2026, 11, 19, 12, 0, tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
|
||||
|
||||
@@ -22,6 +22,7 @@ ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and
|
||||
ctl_tasks (MAINT-21), the status-row functions in rows."""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
@@ -63,6 +64,7 @@ from .clamp import (
|
||||
)
|
||||
from . import freshness
|
||||
from . import lateness
|
||||
from .settle import Settle
|
||||
from . import dayview, homeview
|
||||
from .daycurve import SolarToday
|
||||
from . import handback
|
||||
@@ -527,6 +529,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
|
||||
self._charge_stop = None # SAFETY-135: (quarter, t) first stop
|
||||
self._gave = collections.deque() # SAFETY-135: (t, W given), GAVE_S
|
||||
self._ref_cheap = None
|
||||
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
|
||||
# last battery reading).
|
||||
@@ -741,6 +745,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._arb_action = None
|
||||
self.inputs_bad_since = None
|
||||
self.answer = lateness.Answer() # SAFETY-128
|
||||
self.settle = Settle() # SAFETY-136
|
||||
self._late_logged = self._late_zero = False
|
||||
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
|
||||
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
|
||||
|
||||
@@ -36,6 +36,10 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
makes the other batteries stop giving power, so a few watts of charge
|
||||
flipping on and off would swing the house between them and the grid
|
||||
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
||||
SAFETY-135: the first such stop in a period holds the charge RESTART_S
|
||||
only, a second one to the period's end (at most one restart), and in a
|
||||
period's first YOUNG_S s no charge starts while the batteries' give is
|
||||
still falling (`wait`);
|
||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||
@@ -52,7 +56,9 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
the probe (none: PROBE_W alone), so a probe never places charge on a
|
||||
battery in a maintenance phase or on one that rests;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
(`draining`, SAFETY-78): it would charge against the drain; nor while
|
||||
one is in its maintenance CHARGE (`maint_charging`, MAINT-24): the cheap
|
||||
night's room is that pack's.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -69,6 +75,18 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
# would stop covering the house for less than they give.
|
||||
START_W = 500.0
|
||||
|
||||
# SAFETY-135: how long the first stop in a period holds the charge, how long
|
||||
# a period is young, and how far back a give may not be on the meter yet.
|
||||
# DECISIONS/SAFETY-135-young-period.md
|
||||
RESTART_S = 60.0
|
||||
YOUNG_S = 60.0
|
||||
GAVE_S = 15.0
|
||||
# SAFETY-135: the plain status words for `wait` (UX-08).
|
||||
WAIT_WORDS = {
|
||||
"wait": "battery modes: grid charge paused, it tries again in a minute",
|
||||
"settle": "battery modes: grid charge waits for the meter to catch up",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pack:
|
||||
@@ -164,7 +182,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
|
||||
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
||||
blocked=False, done_charge=(), done_sell=(),
|
||||
targets=None, draining=False) -> Tick:
|
||||
targets=None, draining=False, wait=None,
|
||||
maint_charging=False) -> Tick:
|
||||
"""This tick's ranges, roles and site ask.
|
||||
|
||||
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
||||
@@ -177,7 +196,11 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
it going); `blocked` that the guard or the headroom stopped a charge in
|
||||
this 15-minute period. `targets` is the day plan (`charge_to()`).
|
||||
`draining` says a battery drains for its monthly cycle, so none may
|
||||
charge from the grid (why "cycle").
|
||||
charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
|
||||
why) says no charge may start yet (SAFETY-135). `maint_charging` says a
|
||||
battery is in its maintenance CHARGE phase: no grid charge for the others
|
||||
either, same why, but a refusal probe goes on (MAINT-24,
|
||||
DECISIONS/MAINT-24-charge-first.md).
|
||||
"""
|
||||
plan_on = runs is not None and runs.on
|
||||
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
||||
@@ -189,6 +212,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
if in_cheap else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
elif ch and maint_charging:
|
||||
# MAINT-24: a refusal probe (PROBE_W alone, OPT-20) may go on.
|
||||
ch = [p for p in ch if p.refused < 0]
|
||||
why = None if ch else "cycle"
|
||||
charging = False
|
||||
if ch:
|
||||
need = 0.0 if charging_now else START_W
|
||||
@@ -198,6 +225,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
why = trade_why
|
||||
elif headroom_w is None or not headroom_w > need:
|
||||
why = "headroom"
|
||||
elif wait and not charging_now:
|
||||
why = wait
|
||||
else:
|
||||
charging = True
|
||||
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""SAFETY-136: on a slow meter, follow the meter gently until the last move shows.
|
||||
|
||||
A meter whose usual gap between readings is over 3.75 s (a 4-5 s meter), read
|
||||
through Home Assistant, can be 10-15 s late. The loop then answers the same error again at each cycle
|
||||
and overshoots. After a move of MOVE_W or more the loop therefore runs at
|
||||
WAIT_SHARE of its gain until the meter has fallen SHOWN of the move, or for at
|
||||
most WAIT_CYCLES cycles. Off for a meter with a usual gap of 3.75 s or less, so those sites run as
|
||||
before. DECISIONS/SAFETY-136-slow-meter-settle.md.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import replace
|
||||
|
||||
from .lateness import M_FIT_S, M_ROWS
|
||||
|
||||
# A meter is slow when its readings come further apart than this (the usual
|
||||
# gap, over the last 60 s and at least 5 readings): the fast meter fit's own
|
||||
# limit (SAFETY-128), 3.75 s.
|
||||
SLOW_GAP_S = M_FIT_S / M_ROWS
|
||||
SLOW_ROWS = 5
|
||||
# A move this big is waited for (above the house noise of about 80 W).
|
||||
MOVE_W = 150.0
|
||||
# The meter has shown a move once it fell by this share of it.
|
||||
SHOWN = 0.5
|
||||
# Meanwhile the loop runs at this share of its gain.
|
||||
WAIT_SHARE = 0.5
|
||||
# Wait at most this many cycles (10 s at a 5 s meter).
|
||||
WAIT_CYCLES = 2
|
||||
|
||||
|
||||
class Settle:
|
||||
def __init__(self):
|
||||
self.pend = None # (meter at the move, the move in W, cycles waited)
|
||||
self._n = None # readings taken in so far
|
||||
self._at = deque() # when the last ones arrived, over M_FIT_S
|
||||
|
||||
def arrive(self, now, n):
|
||||
"""Each read: `n` is the count of meter readings taken in. A reading
|
||||
counts when it arrives, not when its value changes, so a steady load on
|
||||
a 1 s meter is not a slow meter."""
|
||||
if n != self._n:
|
||||
self._n = n
|
||||
self._at.append(now)
|
||||
while self._at and self._at[0] < now - M_FIT_S:
|
||||
self._at.popleft()
|
||||
|
||||
def slow(self):
|
||||
"""The usual gap between readings is over SLOW_GAP_S."""
|
||||
ts = list(self._at)
|
||||
if len(ts) < SLOW_ROWS:
|
||||
return False
|
||||
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
|
||||
return gaps[len(gaps) // 2] > SLOW_GAP_S
|
||||
|
||||
def reset(self):
|
||||
self.pend = None
|
||||
|
||||
def tuning(self, tuning, grid_w, slow):
|
||||
"""The tuning for this cycle: `tuning`, or with the gain cut while the
|
||||
last move has not shown on the meter (`grid_w`)."""
|
||||
p = self.pend
|
||||
if p is None:
|
||||
return tuning
|
||||
g0, move, waited = p
|
||||
if not slow or (g0 - grid_w) / move >= SHOWN or waited >= WAIT_CYCLES:
|
||||
self.pend = None
|
||||
return tuning
|
||||
self.pend = (g0, move, waited + 1)
|
||||
return replace(tuning, gain=tuning.gain * WAIT_SHARE)
|
||||
|
||||
def moved(self, grid_w, prev_w, new_w, slow):
|
||||
"""After a cycle: start waiting if the loop moved by MOVE_W or more."""
|
||||
if slow and self.pend is None and abs(new_w - prev_w) >= MOVE_W:
|
||||
self.pend = (float(grid_w), float(new_w - prev_w), 0)
|
||||
@@ -430,6 +430,7 @@
|
||||
"give": "Tomorrow: gives power at {at}, {eur} per kWh.",
|
||||
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
||||
},
|
||||
"cycle_backoff": "{name}: the monthly cycle waits until {day} {month}.",
|
||||
"cycle_waits": "{name} waits its turn for the monthly cycle.",
|
||||
"no_prices_yet": "No prices yet, maintenance waits.",
|
||||
"no_prices_since": "No prices since {at}, maintenance waits.",
|
||||
|
||||
@@ -365,6 +365,7 @@
|
||||
"no_level": "Nu geen handel op prijs: een laadniveau is onbekend."
|
||||
},
|
||||
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
|
||||
"cycle_backoff": "{name}: de maandelijkse cyclus wacht tot {day} {month}.",
|
||||
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
|
||||
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
|
||||
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
|
||||
|
||||
@@ -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,257 @@
|
||||
"""Runnable check for MAINT-24. `python3 test_maint24.py`
|
||||
|
||||
A started maintenance charge at N=2 must get the cheap night. On the live site
|
||||
(2026-11-08..11) battery B's grid charge (battery modes) took the night, and
|
||||
A's own charge was switched off or sized at 200-450 W: its pause and its floor
|
||||
compared the peak forecast with `cap - margin`, not with this month's peak
|
||||
that the guard and B's trade aim at (SAFETY-80), and its floor counted its own
|
||||
charge in the forecast (SAFETY-111).
|
||||
|
||||
All on the real Controller through `cycle()` (the lagged N=2 replay, tools/
|
||||
opt20_scen.py): cheap window, house 3000 W, cap 3500 / margin 50, a month peak
|
||||
of 6000 W banked (aim 5950 W), a peak forecast that follows the grid.
|
||||
|
||||
1. A in maintenance CHARGE, B a Cheap hours charger: A charges at the room under
|
||||
the month aim (half of it: the forecast holds A's own charge, SAFETY-111
|
||||
stays on its card), B files no grid trade, B's why is "cycle", every quarter is at or
|
||||
under the month aim.
|
||||
2. Forecast above 3450 W but under the month aim: shaping is not paused and A
|
||||
gives nothing. A real peak (house over the month aim) still pauses it.
|
||||
3. The mirror: B in maintenance CHARGE, A the charger.
|
||||
4. modetrade.decide(): `maint_charging` files no charge, why "cycle"; solar
|
||||
roles stay.
|
||||
5. Unchanged: N=1 with no forecast entity, the SAFETY-78 drain, battery modes
|
||||
off (digests printed on release/1.0 02eac3b).
|
||||
Mutations: DECISIONS/MAINT-24-charge-first.md.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 modetrade, packmode, tariff # noqa: E402
|
||||
from app.maintenance import DRAIN, MaintResult # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = sc.A, sc.B
|
||||
MONTH_PEAK = 6000.0
|
||||
AIM = MONTH_PEAK - 50.0 # the guard's aim this month
|
||||
DRAIN_R = MaintResult(DRAIN, True, 2000.0, False, [])
|
||||
|
||||
|
||||
def scen(*, a=None, b=None, soc=(49.0, 30.0), house_w=3000.0, peak=MONTH_PEAK,
|
||||
fc=True, n=2, variant="p3_ch_sell", choice=None, hours=0.75):
|
||||
"""`a` / `b`: that pack's forced maintenance result (or None). `peak`: the
|
||||
banked month peak or None. `fc`: a peak forecast entity that follows the
|
||||
grid. Returns (rows, stats, controller)."""
|
||||
rows = []
|
||||
extra = {"cycle_cost_threshold_eur": 0.10}
|
||||
|
||||
def patch(c):
|
||||
for i, res in enumerate((a, b)):
|
||||
if res is not None and i < len(c.maints):
|
||||
c.maints[i].tick = (
|
||||
lambda now, soc_, blocked_by=None, gate_open=True,
|
||||
res=res: res)
|
||||
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
|
||||
susp = []
|
||||
c._m24_susp = susp
|
||||
|
||||
async def cyc():
|
||||
c.cheap = True
|
||||
if fc and c.grid is not None:
|
||||
c.peak_fc = float(c.grid)
|
||||
await orig()
|
||||
susp.append(bool(c._maint_shape_suspended))
|
||||
c.cycle = cyc
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None \
|
||||
and c.allocation.shares else {}
|
||||
rows.append(dict(
|
||||
now=now, grid=h.grid(), shares=sh,
|
||||
action=getattr(c.arb, "action", None),
|
||||
reason=getattr(c.arb, "reason", None) or "",
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
why=c.mode_tick.why if c.mode_tick else None,
|
||||
p={u: p.p for u, p in h.plants.items()}))
|
||||
|
||||
stats, c = rp.run(variant, [(sc.UTC0.timestamp() - 10, house_w)], sc.WPTS,
|
||||
sc.at(2.0), 3600 * hours, check=cb, lag=sc.LAG, n=n,
|
||||
soc0={"a": soc[0], "b": soc[1]},
|
||||
choice=choice or sc.CH_CH, refuse=(), patch=patch,
|
||||
extra=extra)
|
||||
return rows, stats, c
|
||||
|
||||
|
||||
def mean(rows, u):
|
||||
r = rows[len(rows) // 6:]
|
||||
return sum(x["p"][u] for x in r) / len(r)
|
||||
|
||||
|
||||
def worst_quarter(stats):
|
||||
return max(a[0] / a[1] for a in stats["quarters"].values())
|
||||
|
||||
|
||||
CH = sc.MAINT_CHARGE
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. A in maintenance CHARGE, B a charger in the cheap window")
|
||||
rows, st, c = scen(a=CH)
|
||||
ma, mb = mean(rows, "a"), mean(rows, "b")
|
||||
last = rows[-1] if rows else {}
|
||||
print(f" A {ma:+.0f} W, B {mb:+.0f} W, worst quarter {worst_quarter(st):.0f}"
|
||||
f" W, why {last.get('why')}, roles {last.get('roles')}")
|
||||
check("A charges at (5950 - 3000) / 2 = 1475 W, not 0 or a few hundred W",
|
||||
-1500.0 <= ma <= -1440.0)
|
||||
check("B files no grid trade: no trade action in the run, B near 0 W",
|
||||
all("CHARGE" not in str(r["action"]) for r in rows) and mb > -50.0)
|
||||
check("roles: B does not charge, and the why is 'cycle'",
|
||||
all(r["roles"].get(B) != "charge" for r in rows)
|
||||
and last.get("why") == "cycle")
|
||||
check("every quarter at or under the month aim",
|
||||
worst_quarter(st) <= AIM + 1.0)
|
||||
check("A never gives back while there is no real peak",
|
||||
max(r["p"]["a"] for r in rows) <= 0.0)
|
||||
check("the shaping was never paused", not any(c._m24_susp))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. forecast between 3450 W and the month aim; a real peak")
|
||||
rows2, st2, c2 = scen(a=CH, house_w=3800.0)
|
||||
ma2 = mean(rows2, "a")
|
||||
print(f" house 3800: A {ma2:+.0f} W, worst quarter "
|
||||
f"{worst_quarter(st2):.0f} W")
|
||||
check("forecast 4900 W (over 3450, under 5950): no pause, A charges "
|
||||
"(5950 - 3800) / 2 = 1075 W and gives nothing",
|
||||
not any(c2._m24_susp) and -1100.0 <= ma2 <= -1040.0
|
||||
and max(r["p"]["a"] for r in rows2) <= 0.0
|
||||
and worst_quarter(st2) <= AIM + 1.0)
|
||||
rows3, st3, c3 = scen(a=CH, house_w=6500.0)
|
||||
print(f" house 6500: paused {sum(c3._m24_susp)}/{len(c3._m24_susp)} "
|
||||
f"cycles, A max {max(r['p']['a'] for r in rows3):+.0f} W")
|
||||
check("a real peak (house over the month aim) still pauses the shaping "
|
||||
"(SAFETY-07)", sum(c3._m24_susp) > len(c3._m24_susp) // 2)
|
||||
rows4, st4, c4 = scen(a=CH, house_w=3000.0, peak=None)
|
||||
print(f" no banked peak: A {mean(rows4, 'a'):+.0f} W, worst quarter "
|
||||
f"{worst_quarter(st4):.0f} W")
|
||||
check("no banked peak: the aim is cap - margin (3450 W) as before, so A "
|
||||
"charges (3450 - 3000) / 2 = 225 W, B does not trade, the quarter "
|
||||
"stays under 3450 W",
|
||||
-240.0 <= mean(rows4, "a") <= -210.0 and not any(c4._m24_susp)
|
||||
and all("CHARGE" not in str(r["action"]) for r in rows4)
|
||||
and worst_quarter(st4) <= 3450.0 + 1.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the mirror: B in maintenance CHARGE, A the charger")
|
||||
rowsm, stm, cm = scen(b=CH, soc=(30.0, 49.0))
|
||||
mam, mbm = mean(rowsm, "a"), mean(rowsm, "b")
|
||||
lastm = rowsm[-1] if rowsm else {}
|
||||
print(f" A {mam:+.0f} W, B {mbm:+.0f} W, worst quarter "
|
||||
f"{worst_quarter(stm):.0f} W, why {lastm.get('why')}")
|
||||
check("B charges at 1475 W, A files no grid trade, why 'cycle'",
|
||||
-1500.0 <= mbm <= -1440.0 and mam > -50.0
|
||||
and all("CHARGE" not in str(r["action"]) for r in rowsm)
|
||||
and lastm.get("why") == "cycle"
|
||||
and all(r["roles"].get(A) != "charge" for r in rowsm))
|
||||
check("...every quarter at or under the month aim",
|
||||
worst_quarter(stm) <= AIM + 1.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. modetrade.decide(maint_charging=)")
|
||||
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
|
||||
charge_useful=lambda: True, give_at=lambda n: None,
|
||||
sell=None)
|
||||
|
||||
|
||||
def _dec(packs, **kw):
|
||||
args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1,
|
||||
headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0,
|
||||
house_w=800.0)
|
||||
args.update(kw)
|
||||
return modetrade.decide(_runs, sc.UTC0, packs, **args)
|
||||
|
||||
|
||||
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0,
|
||||
idle=False)
|
||||
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0)
|
||||
_off = _dec([_pa, _pb])
|
||||
_on = _dec([_pa, _pb], maint_charging=True)
|
||||
_dr = _dec([_pa, _pb], draining=True)
|
||||
check("without it B is the charger (as before)",
|
||||
_off.action == modetrade.CHARGE and _off.roles[B] == modetrade.CHARGE)
|
||||
check("with it: no charge, why 'cycle', B keeps a hold role, no ask",
|
||||
_on.action is None and _on.why == "cycle" and _on.ask_w == 0.0
|
||||
and _on.roles[B] == modetrade.HOLD)
|
||||
check("same answer as the SAFETY-78 drain", _on == _dr)
|
||||
_pr = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
|
||||
refused=-1, probing=True)
|
||||
_tp = _dec([_pa, _pr], maint_charging=True)
|
||||
check("a refusal probe beside the maintenance charge goes on: PROBE_W alone "
|
||||
"(OPT-20); beside a drain it does not",
|
||||
_tp.action == modetrade.CHARGE and _tp.ask_w == -modetrade.PROBE_W
|
||||
and _dec([_pa, _pr], draining=True).action is None)
|
||||
check("a probe does not carry a normal charger along",
|
||||
_dec([_pa, _pr, modetrade.Pack("inverter-c", True, packmode.CHEAP_HOURS,
|
||||
False, 2500.0, 30.0, 10.0)],
|
||||
maint_charging=True).ask_w == -modetrade.PROBE_W)
|
||||
check("it files nothing outside a cheap run (why None)",
|
||||
_dec([_pa, _pb], maint_charging=True,
|
||||
).why == "cycle" and modetrade.decide(
|
||||
SimpleNamespace(on=False), sc.UTC0, [_pa, _pb], charge_ok=True,
|
||||
sell_ok=True, trade_why=None, price_now=0.1, headroom_w=3000.0,
|
||||
floor_pct=20.0, ceiling_pct=95.0, house_w=800.0,
|
||||
maint_charging=True).why is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. unchanged: N=1 (no forecast entity), the drain, modes off")
|
||||
d1 = sc.digest(scen(a=CH, n=1, fc=False, peak=None, soc=(49.0, 30.0))[0])
|
||||
dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=False)[0])
|
||||
dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=False)[0])
|
||||
AT_BASE = {
|
||||
"n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4",
|
||||
"drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748",
|
||||
"off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b",
|
||||
}
|
||||
got = {"n1": d1, "drain": dd, "off": dm}
|
||||
for k in AT_BASE:
|
||||
print(f" {k} {'same' if got[k] == AT_BASE[k] else 'DIFFERS'} "
|
||||
f"{got[k][:16]}")
|
||||
check("N=1 with A in maintenance charge and no forecast entity: shares, "
|
||||
"grid, trade, roles and plant powers equal release/1.0 02eac3b",
|
||||
got["n1"] == AT_BASE["n1"])
|
||||
check("the SAFETY-78 drain beside a charger: equal to 02eac3b",
|
||||
got["drain"] == AT_BASE["drain"])
|
||||
check("battery modes off: equal to 02eac3b", got["off"] == AT_BASE["off"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Runnable check for MAINT-25. `python3 test_maint25.py`
|
||||
|
||||
After a failed monthly cycle the add-on waits 7 days (MAINT-13, `backoff_until`).
|
||||
The Home plan ignored that and drew a cycle at the start hour every night of
|
||||
the wait. Through the plan builder (`status()` as in test_home.py), N=1 and N=2:
|
||||
during the back-off no cycle run is drawn and a short line says when it waits
|
||||
until, in English and Dutch; after 7 days the run is drawn again; a cycle
|
||||
completed after the failure ends the back-off.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from app import homeview # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": True, "maintenance_start_hour": 12}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"capacity_kwh": 9.6},
|
||||
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"capacity_kwh": 5.4}])
|
||||
|
||||
|
||||
def make(opts, failed=None, done=None):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.soc, inv.batt, inv.last_write = 50.0, 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = 10.0
|
||||
m = c.maints[0]
|
||||
if failed is not None:
|
||||
m.store.set_time("last_failed", failed)
|
||||
if done is not None:
|
||||
m.store.set_time("last_completed", done)
|
||||
return c
|
||||
|
||||
|
||||
def plan(c, lang=None):
|
||||
s = c.status_payload()
|
||||
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=NOW,
|
||||
**({} if lang is None else {"lang": lang})))
|
||||
p = s["plan"]
|
||||
return [r for r in p["runs"] if r["kind"] == "cycle"], p["lines"]
|
||||
|
||||
|
||||
D = timedelta(days=1)
|
||||
print("1. a cycle failed 1 day ago (N=2: Battery A failed, B never ran)")
|
||||
runs, lines = plan(make(TWO, failed=NOW - D))
|
||||
print(" ", [r["from"] for r in runs], lines)
|
||||
check("the run tonight is B's, not A's (A waits out the back-off)",
|
||||
len(runs) == 1 and not any("Battery A waits its turn" in x for x in lines))
|
||||
check("a plain line says Battery A waits until 3 October",
|
||||
any("Battery A: the monthly cycle waits until 3 October." == x
|
||||
for x in lines))
|
||||
|
||||
print("\n2. N=1")
|
||||
runs1, lines1 = plan(make(ONE, failed=NOW - D))
|
||||
check("no cycle run is drawn, one line says when it waits until",
|
||||
runs1 == [] and any("waits until 3 October" in x for x in lines1))
|
||||
runs1b, lines1b = plan(make(ONE))
|
||||
check("without a failure the run is drawn (non-vacuous)",
|
||||
len(runs1b) == 1 and not any("waits until" in x for x in lines1b))
|
||||
|
||||
print("\n3. after the 7 days, and after a cycle that completed")
|
||||
runs2, lines2 = plan(make(ONE, failed=NOW - timedelta(days=7, hours=1)))
|
||||
check("7 days and an hour after the failure the run is drawn again, no line",
|
||||
len(runs2) == 1 and not any("waits until" in x for x in lines2))
|
||||
runs3, lines3 = plan(make(ONE, failed=NOW - D, done=NOW - D / 2))
|
||||
check("a cycle completed after the failure ends the back-off (the run is "
|
||||
"not drawn only if it is no longer due)",
|
||||
not any("waits until" in x for x in lines3))
|
||||
runs3b, lines3b = plan(make(ONE, failed=NOW - 2 * D, done=NOW - 3 * D))
|
||||
check("a completion BEFORE the failure does not end it",
|
||||
runs3b == [] and any("waits until" in x for x in lines3b))
|
||||
|
||||
print("\n4. Dutch")
|
||||
_, lnl = plan(make(ONE, failed=NOW - D), lang="nl")
|
||||
print(" ", lnl)
|
||||
check("the Dutch line says it plainly",
|
||||
any("de maandelijkse cyclus wacht tot 3 oktober" in x for x in lnl))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -472,15 +472,36 @@ _sw = [(UTC0.timestamp() + 60 * k, 2000.0 if k % 2 else 3900.0)
|
||||
ROWS_W, _, _ = run_day("p3_ch_sell", hours=1.5, house=_sw,
|
||||
soc0={"a": 60.0, "b": 30.0})
|
||||
_per_q = {}
|
||||
_ev_q = {} # SAFETY-135: per period, the charge's starts and stops
|
||||
for a_, b_ in zip(ROWS_W, ROWS_W[1:]):
|
||||
if a_["action"] != arbitrage.CHARGE and b_["action"] == arbitrage.CHARGE:
|
||||
on_a, on_b = (a_["action"] == arbitrage.CHARGE,
|
||||
b_["action"] == arbitrage.CHARGE)
|
||||
if on_a != on_b:
|
||||
q = int(b_["now"].timestamp() // 900)
|
||||
_ev_q.setdefault(q, []).append((on_b, b_["now"].timestamp()))
|
||||
if not on_a and on_b:
|
||||
q = int(b_["now"].timestamp() // 900)
|
||||
_per_q[q] = _per_q.get(q, 0) + 1
|
||||
|
||||
|
||||
def _one_restart(ev):
|
||||
"""At most one start after a stop in the period, RESTART_S or more after
|
||||
it; a second stop is the last change of the period."""
|
||||
re = [(t0, t1) for (s0, t0), (s1, t1) in zip(ev, ev[1:])
|
||||
if not s0 and s1]
|
||||
stops, late = 0, False
|
||||
for on, _t in ev:
|
||||
stops += 0 if on else 1
|
||||
late = late or (on and stops >= 2)
|
||||
return (len(re) <= 1 and not late
|
||||
and all(t1 - t0 >= modetrade.RESTART_S - 1 for t0, t1 in re))
|
||||
print(f" house swinging 2.0/3.9 kW each minute: charge starts per period "
|
||||
f"{sorted(_per_q.values())}")
|
||||
check("a charge the peak guard or the headroom stopped stays off for the "
|
||||
"rest of that 15-minute period (at most one start per period)",
|
||||
_per_q and max(_per_q.values()) == 1)
|
||||
"rest of that 15-minute period, after at most one restart RESTART_S or "
|
||||
"more after the first stop; a second stop latches (SAFETY-135)",
|
||||
_per_q and max(_per_q.values()) <= 2
|
||||
and all(_one_restart(ev) for ev in _ev_q.values()))
|
||||
|
||||
# At the floor: B starts the sale just above it.
|
||||
ROWS_F, _, _ = run_day("p3_ch_sell", start_h=18.75, hours=1.5,
|
||||
|
||||
@@ -65,11 +65,16 @@ print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
||||
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
|
||||
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
|
||||
# (no charge) never moved.
|
||||
# SAFETY-135: n1_ch re-pinned. A first guard stop no longer latches, so while
|
||||
# the charge waits its reason reads "(peak)" where the latch said
|
||||
# "(headroom)"; no command moves. With SAFETY-135 off it is OPT-15's pin
|
||||
# again (checked below).
|
||||
N1_CH_OPT15 = "97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02"
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
@@ -81,7 +86,7 @@ BASE_DIGESTS = {
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
@@ -98,6 +103,38 @@ for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
|
||||
got.get(k) == v)
|
||||
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
||||
|
||||
|
||||
class _Every:
|
||||
"""SAFETY-135 off: a stop period equal to every period, so every guard
|
||||
stop latches as before."""
|
||||
|
||||
def __eq__(self, other):
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
def _s135_off_digest(scn):
|
||||
from app import main as _m
|
||||
real = _m.Controller.__init__
|
||||
|
||||
def init(self, *a, **k):
|
||||
real(self, *a, **k)
|
||||
self._charge_stop = (_Every(), float("-inf"))
|
||||
young, restart = modetrade.YOUNG_S, modetrade.RESTART_S
|
||||
_m.Controller.__init__ = init
|
||||
modetrade.YOUNG_S = modetrade.RESTART_S = 0.0
|
||||
try:
|
||||
return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0])
|
||||
finally:
|
||||
_m.Controller.__init__ = real
|
||||
modetrade.YOUNG_S, modetrade.RESTART_S = young, restart
|
||||
|
||||
|
||||
check("n1_ch with SAFETY-135 off is OPT-15's pin: only the reason word moved",
|
||||
_s135_off_digest("n1_ch") == N1_CH_OPT15)
|
||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||
got[k] == got[k[:-len('_fc_down')]])
|
||||
|
||||
@@ -248,11 +248,14 @@ print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
|
||||
# now counts those seconds, so ch_ch's quarters land on 3450.0 W where they
|
||||
# landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first,
|
||||
# partial quarter charges a little less at its end (bf9019e: e46e3c66...).
|
||||
# MAINT-24 moved ch_ch_maint again: B no longer grid-charges beside A's
|
||||
# maintenance charge, and A's charge is no longer paused (release/1.0 02eac3b:
|
||||
# e56eddab...).
|
||||
AT_BASE = {
|
||||
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
|
||||
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
|
||||
"ch_ch_maint":
|
||||
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9",
|
||||
"df86a451a3b2d365ca5d35b772cd1eb8502d164e6b63e6844b5cfb82f15aeb46",
|
||||
}
|
||||
_got = sc.no_probe_digests()
|
||||
for _k in AT_BASE:
|
||||
@@ -260,8 +263,8 @@ for _k in AT_BASE:
|
||||
f"{_got[_k][:16]}")
|
||||
check("with no refusal every share, grid, trade sentence, role and plant "
|
||||
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 moved "
|
||||
"them)", _got == AT_BASE)
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 and "
|
||||
"MAINT-24 moved them)", _got == AT_BASE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -245,14 +245,16 @@ print(f" decide(cap_w={kw(_d, 'cap_w')}, margin_w={kw(_d, 'margin_w')})")
|
||||
print(f" shaping_suspended(..., {arg(_s, 2)})")
|
||||
print(f" maintenance_charge_floor(..., {arg(_m, 2)})")
|
||||
print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})")
|
||||
# MAINT-24: the maintenance forecast checks get the guard's own aim, cap -
|
||||
# margin with this month's peak in (`maint_aim`), not the plain cap - margin.
|
||||
check("test_call_sites: the guard gets the real cap AND the margin; the "
|
||||
"forecast checks get cap - margin; the month-peak tracker gets the "
|
||||
"real cap",
|
||||
"forecast checks get the guard's aim, cap - margin and the month "
|
||||
"peak (MAINT-24); the month-peak tracker gets the real cap",
|
||||
len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w")
|
||||
and kw(_d, "margin_w") == "self.peak_margin_w"
|
||||
and len(_shape) == 1 and len(_maint) == 1
|
||||
and arg(_s, 2) == "self.peak_aim_w()"
|
||||
and arg(_m, 2) == "self.peak_aim_w()"
|
||||
and arg(_s, 2) == "maint_aim" and arg(_m, 2) == "maint_aim"
|
||||
and "maint_aim = peakguard.quarter_target_w(" in _src
|
||||
and len(_tracker) == 1
|
||||
and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))')
|
||||
check("test_aim_w: `peak_aim_w()` is cap - margin, and the forecast sizing "
|
||||
|
||||
@@ -6,7 +6,8 @@ with the lagged model (`Lag`: command dead time, meter and battery reading
|
||||
age, the P1 debounce, 1 s cycles), which reproduces all three on 5a4cdc2.
|
||||
|
||||
1. F1: a quarter edge while charging does not stop the charge (the guard
|
||||
projects the quarter `now` is in), and a real guard stop still latches.
|
||||
projects the quarter `now` is in), and a real guard stop still holds it
|
||||
(SAFETY-135: one restart a minute later, then a second stop latches).
|
||||
At two inverters SAFETY-91's `next_quarter()` already does that for the
|
||||
guard; at one inverter only the rolled projection does.
|
||||
2. F2: the export overshoot at a charge end stays within 0.15 x rating in
|
||||
@@ -252,7 +253,8 @@ check("two inverters: SAFETY-91 already holds the edge (no stop with the old "
|
||||
"projection), and rolling it changes no banked quarter",
|
||||
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1))
|
||||
|
||||
# 1c. A real guard stop still latches, and the 500 W minimum stays.
|
||||
# 1c. A real guard stop holds the charge (SAFETY-135: one restart after
|
||||
# RESTART_S, a second stop latches), and the 500 W minimum stays.
|
||||
# House: 800 W; 3700 W at 02:20-02:21; 3000 W from 02:26 (headroom 450 W
|
||||
# in the next quarter); 2850 W from 02:40 (600 W).
|
||||
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[
|
||||
@@ -266,11 +268,24 @@ _after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
|
||||
print(f" guard stop at "
|
||||
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
|
||||
f" ({_first['reason'][:40] if _first else ''})")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays "
|
||||
"off for the rest of that quarter, the house back at 800 W",
|
||||
# SAFETY-135: the first guard stop in a quarter holds the charge RESTART_S,
|
||||
# then it may start once more; a second stop latches to the quarter's end.
|
||||
_hold2 = [r for r in _after2
|
||||
if (r["now"] - _first["now"]).total_seconds() < modetrade.RESTART_S]
|
||||
_on2 = [b for a, b in zip(_after2, _after2[1:])
|
||||
if a["action"] != arbitrage.CHARGE and b["action"] == arbitrage.CHARGE]
|
||||
_off2 = [b for a, b in zip(_after2, _after2[1:])
|
||||
if a["action"] == arbitrage.CHARGE and b["action"] != arbitrage.CHARGE]
|
||||
print(f" after it: {len(_on2)} restart(s), {len(_off2)} more stop(s)")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak; it stays off "
|
||||
"for RESTART_S, then starts again at most once that quarter, and a "
|
||||
"second stop holds it off to the quarter's end (SAFETY-135)",
|
||||
_first is not None and _first["now"].astimezone(L).minute == 20
|
||||
and ("peak" in _first["reason"] or "headroom" in _first["reason"])
|
||||
and _after2 and all(r["action"] is None for r in _after2))
|
||||
and _hold2 and all(r["action"] is None for r in _hold2)
|
||||
and len(_on2) <= 1 and len(_off2) <= 1
|
||||
and not any(r["action"] == arbitrage.CHARGE for r in _after2
|
||||
if _off2 and r["now"] >= _off2[0]["now"]))
|
||||
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
|
||||
if a["action"] != arbitrage.CHARGE
|
||||
and b["action"] == arbitrage.CHARGE]
|
||||
|
||||
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
|
||||
import safety128_rig as R # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import lateness # noqa: E402
|
||||
from app import settle # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
@@ -58,6 +59,17 @@ def no_gate(fn):
|
||||
app_main.Controller._readings_late = real
|
||||
|
||||
|
||||
def no_settle(fn):
|
||||
"""`fn()` without SAFETY-136's gentler loop: the tree this card was written
|
||||
against (with it the same run exports -645 W, not -1 325 W)."""
|
||||
real = settle.Settle.moved
|
||||
settle.Settle.moved = lambda self, *a, **k: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
settle.Settle.moved = real
|
||||
|
||||
|
||||
def run_late(**kw):
|
||||
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
|
||||
dur = kw.pop("dur")
|
||||
@@ -91,7 +103,7 @@ for units, house in ((AB, 1800.0), (A, 1500.0)):
|
||||
if len(units) == 2:
|
||||
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0,
|
||||
units=units, p1_s=5.0)
|
||||
no_gate(lambda: old.run(ON + 300.0))
|
||||
no_settle(lambda: no_gate(lambda: old.run(ON + 300.0)))
|
||||
check(f"{n}: without the gate the export goes below -1 kW",
|
||||
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
|
||||
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Runnable check for SAFETY-135: a young 15-minute period does not cost the
|
||||
night charge the whole period. `python3 test_safety135.py`
|
||||
|
||||
The REAL Controller (tools/opt11p3_replay.py, read_inputs + cycle, battery
|
||||
modes, the peak guard on a P1 meter) with TEST-23-like lag: 1 s cycles,
|
||||
readings 1 s old, a 5 s old meter in part 2. "Off" is the same run with
|
||||
every measured stop latching and no wait for a falling give: the tree before
|
||||
this card.
|
||||
DECISIONS/SAFETY-135-young-period.md.
|
||||
|
||||
1. A peak-bound night charge; a period starts with a 4 s kettle spike. The
|
||||
guard cuts at once, and the charge resumes in that period (off: it stays
|
||||
off to the period's end). The period stays at or under the aim.
|
||||
2. A cheap run starts at 01:00 right after a give run, with a 5 s meter: no
|
||||
start-then-cut within 60 s (off: one), and no period over the aim.
|
||||
3. A real over-aim load: the guard and the charge stop on the same cycle as
|
||||
off, and the charge stays off while the load is on.
|
||||
4. The SAFETY-136 rig run C/s5b (house 1 800 W, an oven 2 kW 45 s in every
|
||||
105 s, A Leftover solar 5 kW, B Cheap hours 4.6 kW): the charge starts at
|
||||
about 01:00:32 and the guard cuts it about 60 s later on the oven. It
|
||||
starts again 60 s after that cut (off: never in that period).
|
||||
5. Repeated spikes in one period: at most one restart, and none sooner than
|
||||
RESTART_S after the first stop.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, modetrade, packmode # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1))
|
||||
D0 = datetime(2026, 11, 3, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
# Local quarters: dear 00:00-01:00 (a give run), cheap 01:00-07:00, 0.30 else.
|
||||
PRICES = [0.40 if q < 4 else 0.10 if q < 28 else 0.30 for q in range(96)] * 2
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(PRICES)]
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
CH = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.CHEAP_HOURS, False)}
|
||||
T1 = D0 + timedelta(hours=1) # 01:00 local, the cheap edge
|
||||
RESTART = getattr(modetrade, "RESTART_S", 60.0) # 60.0 on older trees
|
||||
|
||||
|
||||
class _Every:
|
||||
"""A period that equals every period: `_charge_stop` set to it makes
|
||||
every measured stop latch, as before this card."""
|
||||
|
||||
def __eq__(self, other):
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
def _off(c):
|
||||
# With RESTART_S 0 (see `off()`) nothing waits; every guard stop latches.
|
||||
c._charge_stop = (_Every(), float("-inf"))
|
||||
|
||||
|
||||
def off(fn):
|
||||
"""`fn()` with SAFETY-135 off: every measured stop latches and no start
|
||||
waits for a falling give, as on the tree before this card."""
|
||||
names = [n for n in ("YOUNG_S", "RESTART_S") if hasattr(modetrade, n)]
|
||||
real = {n: getattr(modetrade, n) for n in names}
|
||||
OFF[0] = True
|
||||
for n in names: # RESTART_S 0: no wait at a period's end
|
||||
setattr(modetrade, n, 0.0)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
OFF[0] = False
|
||||
for n in names:
|
||||
setattr(modetrade, n, real[n])
|
||||
|
||||
|
||||
OFF = [False]
|
||||
|
||||
|
||||
def run(house_w, start, secs, meter_s=1.0, *, choice=None, soc0=None,
|
||||
limits=None, rating=None):
|
||||
"""Rows per cycle and the Controller, for `house_w(seconds since 01:00)`."""
|
||||
t0 = start.timestamp()
|
||||
house = [(t0 + k, house_w(t0 + k - T1.timestamp()))
|
||||
for k in range(int(secs) + 5)]
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
g = c.peak_guard
|
||||
rows.append(dict(now=now, s=now.timestamp() - T1.timestamp(),
|
||||
grid=h.grid(),
|
||||
charge=getattr(c.arb, "action", None)
|
||||
== arbitrage.CHARGE,
|
||||
engaged=g is not None and g.engaged,
|
||||
reason=getattr(c.arb, "reason", None) or ""))
|
||||
was = rp.RATING
|
||||
if rating is not None:
|
||||
rp.RATING = rating
|
||||
try:
|
||||
_st, c = rp.run("p3_ch_sell", house, WPTS,
|
||||
start.astimezone(timezone.utc), secs, check=cb,
|
||||
soc0=soc0 or {"a": 30.0, "b": 30.0},
|
||||
choice=choice or CH, limits=limits,
|
||||
n=len(choice or CH),
|
||||
patch=_off if OFF[0] else None,
|
||||
extra={"slew_w": 0, "cycle_cost_threshold_eur": 0.03},
|
||||
lag=rp.Lag(meter_s=meter_s))
|
||||
finally:
|
||||
rp.RATING = was
|
||||
return rows, c
|
||||
|
||||
|
||||
def banked(c, frm):
|
||||
"""The add-on's own P1 period averages from `frm` (seconds after 01:00)."""
|
||||
return [b.offtake_avg_w for b in c.p1.blocks
|
||||
if b.start.timestamp() - T1.timestamp() >= frm]
|
||||
|
||||
|
||||
def flaps(rows, frm, to):
|
||||
"""Charge starts in [frm, to) s that stopped again within 60 s."""
|
||||
out = []
|
||||
for i in range(1, len(rows)):
|
||||
r = rows[i]
|
||||
if frm <= r["s"] < to and r["charge"] and not rows[i - 1]["charge"]:
|
||||
if any(not x["charge"] for x in rows[i:]
|
||||
if x["s"] - r["s"] <= 60):
|
||||
out.append(r["s"])
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a 4 s spike at the start of a peak-bound night charge's period")
|
||||
|
||||
|
||||
def spike(s):
|
||||
return 2800.0 + (2500.0 if s >= 1800 and (s - 1800) % 900 < 4 else 0.0)
|
||||
|
||||
|
||||
R1, C1 = run(spike, T1 + timedelta(minutes=20), 1560)
|
||||
R1o, C1o = off(lambda: run(spike, T1 + timedelta(minutes=20), 1560))
|
||||
|
||||
|
||||
def period(rows, frm):
|
||||
return [r for r in rows if frm <= r["s"] < frm + 900]
|
||||
|
||||
|
||||
for tag, rows, c in (("on", R1, C1), ("off", R1o, C1o)):
|
||||
p = period(rows, 1800)
|
||||
cut = next((r["s"] - 1800 for r in p if r["engaged"]), None)
|
||||
on = sum(r["charge"] for r in p)
|
||||
print(f" {tag}: guard cut at +{cut} s, charging {on} of {len(p)} s, "
|
||||
f"period {banked(c, 1800)[:1]}")
|
||||
_p = period(R1, 1800)
|
||||
_cut = next((i for i, r in enumerate(_p) if r["engaged"]), None)
|
||||
check("the spike trips the guard in the period's first seconds and the "
|
||||
"charge stops there",
|
||||
_cut is not None and _p[_cut]["s"] - 1800 <= 5
|
||||
and not _p[_cut]["charge"])
|
||||
_back = [r["s"] - 1800 for r in _p[(_cut or 0):] if r["charge"]]
|
||||
check("the charge resumes in the same period, within 90 s of its start",
|
||||
_back and _back[0] <= 90)
|
||||
_po = period(R1o, 1800)
|
||||
check("off, the same spike keeps the charge off to the period's end (the "
|
||||
"defect this card fixes)",
|
||||
any(r["engaged"] for r in _po[:5])
|
||||
and not any(r["charge"] for r in _po[10:]))
|
||||
check("every period with the charge is at or under the aim (lag allows 2 W)",
|
||||
banked(C1, 0) and max(banked(C1, 0)) <= AIM + 2.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a cheap run at 01:00 right after a give run, 5 s meter")
|
||||
for house in (2400.0, 3000.0):
|
||||
def flat(s, w=house):
|
||||
return w
|
||||
R2, C2 = run(flat, T1 - timedelta(minutes=5), 1260, meter_s=5.0)
|
||||
R2o, C2o = off(lambda: run(flat, T1 - timedelta(minutes=5), 1260,
|
||||
meter_s=5.0))
|
||||
f2, f2o = flaps(R2, 0, 900), flaps(R2o, 0, 900)
|
||||
p2, p2o = banked(C2, 0)[:1], banked(C2o, 0)[:1]
|
||||
on2 = sum(r["charge"] for r in period(R2, 0))
|
||||
on2o = sum(r["charge"] for r in period(R2o, 0))
|
||||
print(f" house {house:.0f} W: on flaps {f2} charging {on2} s period "
|
||||
f"{p2}; off flaps {f2o} charging {on2o} s period {p2o}")
|
||||
check(f"house {house:.0f} W: no charge start is cut within 60 s in the "
|
||||
f"01:00 period", not f2)
|
||||
check(f"house {house:.0f} W: the 01:00 period is at or under the aim "
|
||||
f"(lag allows 2 W)", p2 and p2[0] <= AIM + 2.0)
|
||||
check(f"house {house:.0f} W: the charge still runs in the 01:00 period",
|
||||
on2 >= 800)
|
||||
if house == 3000.0:
|
||||
check("off, the 3 000 W house starts on the young period and is cut "
|
||||
"within 60 s (the defect this card fixes)", bool(f2o))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. a real over-aim load still cuts on the same cycle")
|
||||
|
||||
|
||||
def load(s):
|
||||
return 2800.0 + (4000.0 if 1820 <= s < 2120 else 0.0)
|
||||
|
||||
|
||||
R3, C3 = run(load, T1 + timedelta(minutes=20), 1500)
|
||||
R3o, C3o = off(lambda: run(load, T1 + timedelta(minutes=20), 1500))
|
||||
|
||||
|
||||
def first(rows, key, val, frm):
|
||||
return next((r["s"] for r in rows if r["s"] >= frm and r[key] == val),
|
||||
None)
|
||||
|
||||
|
||||
_g, _go = first(R3, "engaged", True, 1800), first(R3o, "engaged", True, 1800)
|
||||
_s, _so = first(R3, "charge", False, 1800), first(R3o, "charge", False, 1800)
|
||||
print(f" guard cut at {_g} s (off {_go} s), charge stop at {_s} s "
|
||||
f"(off {_so} s)")
|
||||
check("the guard cuts on the same cycle as off, and the charge stops on it",
|
||||
_g is not None and _g == _go and _s == _so == _g)
|
||||
check("the charge stays off while the load is on",
|
||||
not any(r["charge"] for r in R3 if _g <= r["s"] < 2120))
|
||||
check("every period is at or under the aim (lag allows 2 W)",
|
||||
banked(C3, 0) and max(banked(C3, 0)) <= AIM + 2.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the SAFETY-136 rig run C/s5b: start at 01:00:32, oven cut a minute "
|
||||
"later")
|
||||
# The rig's house (s5b.ctl.json), in seconds from 01:00: 1 800 W, an oven of
|
||||
# 2 000 W for 45 s in every 105 s from 00:30 to 01:05:45, 3 000 W 00:56-00:58.
|
||||
|
||||
|
||||
def s5b(s):
|
||||
w = 1800.0
|
||||
if -1800 <= s < 345 and (s + 1800) % 105 < 45:
|
||||
w += 2000.0
|
||||
if -240 <= s < -120:
|
||||
w += 3000.0
|
||||
return w
|
||||
|
||||
|
||||
S5B = dict(choice={"a": (True, packmode.LEFTOVER_SOLAR, False),
|
||||
"b": (True, packmode.CHEAP_HOURS, False)},
|
||||
soc0={"a": 53.6, "b": 27.4}, limits={"b": (-4600.0, 4600.0)},
|
||||
rating=5000.0)
|
||||
R4, C4 = run(s5b, T1 - timedelta(minutes=5), 1260, **S5B)
|
||||
R4o, C4o = off(lambda: run(s5b, T1 - timedelta(minutes=5), 1260, **S5B))
|
||||
|
||||
|
||||
def edges(rows, frm):
|
||||
"""(start s, stop s) lists of the charge in the period from `frm`."""
|
||||
p = period(rows, frm)
|
||||
on = [round(b["s"] - frm) for a, b in zip(p, p[1:])
|
||||
if b["charge"] and not a["charge"]]
|
||||
of = [round(b["s"] - frm) for a, b in zip(p, p[1:])
|
||||
if a["charge"] and not b["charge"]]
|
||||
return on, of
|
||||
|
||||
|
||||
_on4, _of4 = edges(R4, 0)
|
||||
_on4o, _of4o = edges(R4o, 0)
|
||||
print(f" on: starts {_on4}, stops {_of4}, period {banked(C4, 0)[:1]}; "
|
||||
f"off: starts {_on4o}, stops {_of4o}, period {banked(C4o, 0)[:1]}")
|
||||
check("off, as on the rig: the charge starts about 30 s in, the guard cuts "
|
||||
"it about a minute later, and it stays off to the period's end",
|
||||
len(_on4o) == 1 and 25 <= _on4o[0] <= 40 and len(_of4o) == 1
|
||||
and 80 <= _of4o[0] <= 100)
|
||||
check("the same start and the same cut as off (the cut is not delayed)",
|
||||
_on4[:1] == _on4o[:1] and _of4[:1] == _of4o[:1])
|
||||
_cut4 = nth(_of4, 0, "the guard's cut in s5b")
|
||||
check("the charge starts again in that period, 60 s or more after the cut, "
|
||||
"once only",
|
||||
present(_cut4) and len(_on4) == 2 and _on4[1] - _cut4 >= RESTART - 1)
|
||||
check("the period is at or under the aim (lag allows 2 W)",
|
||||
banked(C4, 0) and banked(C4, 0)[0] <= AIM + 2.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. repeated spikes in one period: one restart, not too soon")
|
||||
_SP = (0, 20, 40, 62, 80, 300, 500)
|
||||
|
||||
|
||||
def spikes(s):
|
||||
return 2800.0 + (2500.0 if any(0 <= s - 1800 - k < 4 for k in _SP)
|
||||
else 0.0)
|
||||
|
||||
|
||||
for n_ch in (CH, {"a": CH["a"]}):
|
||||
R5, C5 = run(spikes, T1 + timedelta(minutes=20), 1560, choice=n_ch,
|
||||
soc0={k: 30.0 for k in n_ch})
|
||||
_on5, _of5 = edges(R5, 1800)
|
||||
print(f" N={len(n_ch)}: starts {_on5}, stops {_of5}, "
|
||||
f"period {banked(C5, 1800)[:1]}")
|
||||
check(f"N={len(n_ch)}: at most one restart in the period, none sooner "
|
||||
f"than RESTART_S after the first stop, and the last stop latches",
|
||||
_of5 and len(_on5) <= 1 and len(_of5) == len(_on5) + 1
|
||||
and all(t - _of5[0] >= RESTART - 1 for t in _on5))
|
||||
check(f"N={len(n_ch)}: the period is at or under the aim (lag allows "
|
||||
f"2 W)", banked(C5, 1800) and banked(C5, 1800)[0] <= AIM + 2.0)
|
||||
_w = [r["reason"] for r in period(R5, 1800) if not r["charge"]]
|
||||
check(f"N={len(n_ch)}: the wait says it in plain words (UX-08)",
|
||||
any("tries again in a minute" in x for x in _w)
|
||||
and not any("(young)" in x or "(wait)" in x for x in _w))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
sys.exit(1)
|
||||
print("all SAFETY-135 checks passed")
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Runnable check for SAFETY-136: a calmer loop on a slow meter.
|
||||
`python3 test_safety136.py`
|
||||
|
||||
With a 5 s meter and Home Assistant readings 10-15 s late the loop answers the
|
||||
same error again at each cycle and overshoots: with an oven, a kettle and hob,
|
||||
or a step the export was 3-10 times that of a meter with no lag. After a move
|
||||
of 150 W or more `app/settle.py` now runs the loop at half its gain (at most
|
||||
2 cycles) until the meter shows half of it. It is off for a meter under 4 s.
|
||||
DECISIONS/SAFETY-136-slow-meter-settle.md.
|
||||
|
||||
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Off" is the
|
||||
same run with `Settle.moved` doing nothing: the tree before this card.
|
||||
|
||||
1. `Settle` alone.
|
||||
2. 5 s meter, readings 10 s late: oven, kettle and hob, a step down and a step
|
||||
up export clearly less than with it off.
|
||||
3. 5 s meter, no lag: the same houses are no worse than a small margin.
|
||||
4. 1 s and 2 s meters: the commands are those of the run with it off, and the
|
||||
loop never waits.
|
||||
5. The slow meter is told by its observed interval, at 4 s and 5 s.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import safety128_rig as R # noqa: E402
|
||||
from app.control import Tuning # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
from app import settle # noqa: E402
|
||||
from app.lateness import MeterFit # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def off(fn):
|
||||
"""`fn()` with the settle wait off: the tree before this card."""
|
||||
real = settle.Settle.moved
|
||||
settle.Settle.moved = lambda self, *a, **k: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
settle.Settle.moved = real
|
||||
|
||||
|
||||
def oven(t):
|
||||
return 1800.0 + (2000.0 if (t >= 300.0 and (t - 300.0) % 105 < 45) else 0.0)
|
||||
|
||||
|
||||
def kettle(t):
|
||||
w = 600.0
|
||||
if t % 400 < 120:
|
||||
w += 2200
|
||||
if t >= 900 and t % 90 < 30:
|
||||
w += 1500
|
||||
return w
|
||||
|
||||
|
||||
def step(t):
|
||||
return 1800.0 if t < 320.0 else 300.0
|
||||
|
||||
|
||||
def stepup(t):
|
||||
return 300.0 if t < 320.0 else 2500.0
|
||||
|
||||
|
||||
# name: (house, noise, run length, lag end)
|
||||
HOUSES = {"oven": (oven, 80.0, 900.0, 900.0),
|
||||
"kettle": (kettle, 80.0, 1500.0, 1500.0),
|
||||
"step": (step, 50.0, 700.0, 600.0),
|
||||
"stepup": (stepup, 50.0, 700.0, 600.0)}
|
||||
ON = 300.0
|
||||
|
||||
|
||||
def wh(rows, t0, t1, sign):
|
||||
"""Export (sign -1) or import (sign +1) energy in Wh over [t0, t1)."""
|
||||
return sum((-min(r[1], 0.0) if sign < 0 else max(r[1], 0.0))
|
||||
for r in rows if t0 <= r[0] < t1) / 3600.0
|
||||
|
||||
|
||||
def run(name, lag, p1_s=5.0, units=("a", "b"), dur=None):
|
||||
house, noise, length, lag_off = HOUSES[name]
|
||||
r = R.Rig(house=house, noise=noise, p1_s=p1_s, units=units, lag_s=lag,
|
||||
lag_on=ON, lag_off=lag_off)
|
||||
waits = []
|
||||
r.run(dur or length + 20.0,
|
||||
lambda rig, c, t: waits.append(1) if c.settle.pend else None)
|
||||
return r, len(waits)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. Settle alone")
|
||||
T = Tuning()
|
||||
s = settle.Settle()
|
||||
check("no move: the tuning is untouched", s.tuning(T, 500.0, True) is T)
|
||||
s.moved(500.0, 0.0, 100.0, True)
|
||||
check("a move under 150 W is not waited for", s.pend is None)
|
||||
s.moved(500.0, 0.0, 600.0, False)
|
||||
check("a meter that is not slow is not waited for", s.pend is None)
|
||||
s.moved(500.0, 0.0, 600.0, True)
|
||||
check("a move of 600 W is waited for: half the gain",
|
||||
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
|
||||
check("the meter fell 200 W of 600 W: still half the gain",
|
||||
s.tuning(T, 300.0, True).gain == T.gain * 0.5)
|
||||
check("two cycles waited: the third has the whole gain",
|
||||
s.tuning(T, 300.0, True) is T and s.pend is None)
|
||||
s.moved(500.0, 0.0, 600.0, True)
|
||||
check("the meter went the other way by half the move: the wait stands",
|
||||
s.tuning(T, 800.0, True).gain == T.gain * 0.5)
|
||||
s.reset()
|
||||
s.moved(500.0, 0.0, -600.0, True)
|
||||
check("a move down, the meter rose half the move: the whole gain",
|
||||
s.tuning(T, 800.0, True) is T)
|
||||
s.reset()
|
||||
s.moved(500.0, 0.0, 600.0, True)
|
||||
check("the meter fell half the move: the whole gain at once",
|
||||
s.tuning(T, 200.0, True) is T and s.pend is None)
|
||||
s.moved(500.0, 0.0, -600.0, True)
|
||||
check("a move down is waited for too",
|
||||
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
|
||||
s.reset()
|
||||
check("reset ends the wait", s.pend is None)
|
||||
s.moved(500.0, 0.0, 600.0, True)
|
||||
check("a meter that turns out not to be slow ends the wait",
|
||||
s.tuning(T, 500.0, False) is T and s.pend is None)
|
||||
check("the numbers are pinned",
|
||||
(settle.MOVE_W, settle.SHOWN, settle.WAIT_SHARE, settle.WAIT_CYCLES)
|
||||
== (150.0, 0.5, 0.5, 2))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. 5 s meter, readings 10 s late")
|
||||
tot_on = tot_off = 0.0
|
||||
for name, (_h, _n, length, lag_off) in HOUSES.items():
|
||||
a, waited = run(name, 10.0)
|
||||
b, _w = off(lambda: run(name, 10.0))
|
||||
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
|
||||
tot_on, tot_off = tot_on + ea, tot_off + eb
|
||||
print(f" {name}: export {ea:.1f} Wh, off {eb:.1f} Wh, waited {waited} s")
|
||||
check(f"{name}: waits at some point", waited > 0)
|
||||
check(f"{name}: exports at most 75 % of the run with it off",
|
||||
ea <= 0.75 * eb)
|
||||
print(f" sum: {tot_on:.1f} Wh, off {tot_off:.1f} Wh")
|
||||
check("the four together export at most 65 % of the run with it off",
|
||||
tot_on <= 0.65 * tot_off)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2b. Another writer overrides the loop: the wait ends")
|
||||
halved = []
|
||||
real_tuning = settle.Settle.tuning
|
||||
|
||||
|
||||
def watch_tuning(self, tuning, grid_w, slow):
|
||||
r = real_tuning(self, tuning, grid_w, slow)
|
||||
halved.append(r is not tuning)
|
||||
return r
|
||||
|
||||
|
||||
settle.Settle.tuning = watch_tuning
|
||||
try:
|
||||
mark = []
|
||||
|
||||
def bump(rig, c, t):
|
||||
# right after a cycle that started a wait, another writer moves the
|
||||
# command, so the next cycle sees `target != loop_w`
|
||||
if not mark and c.settle.pend and t > 330.0:
|
||||
mark.append(len(halved))
|
||||
c.target = c.target + 300.0
|
||||
|
||||
r = R.Rig(house=oven, noise=80.0, p1_s=5.0, lag_s=10.0, lag_on=ON,
|
||||
lag_off=900.0)
|
||||
r.run(500.0, bump)
|
||||
finally:
|
||||
settle.Settle.tuning = real_tuning
|
||||
nxt = nth(halved, mark[0] if mark else 10 ** 6)
|
||||
check("the cycle after an override has the whole gain",
|
||||
bool(mark) and present(nxt) and nxt is False)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. 5 s meter, no lag")
|
||||
for name in ("oven", "kettle", "step", "stepup"):
|
||||
_h, _n, length, lag_off = HOUSES[name]
|
||||
a, _w = run(name, 0.0)
|
||||
b, _w = off(lambda: run(name, 0.0))
|
||||
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
|
||||
ia, ib = wh(a.rows, ON, lag_off, 1), wh(b.rows, ON, lag_off, 1)
|
||||
print(f" {name}: export {ea:.1f} Wh (off {eb:.1f}), import {ia:.1f} Wh "
|
||||
f"(off {ib:.1f})")
|
||||
check(f"{name}: export at most 6 Wh over the run with it off",
|
||||
ea <= eb + 6.0)
|
||||
check(f"{name}: import at most 1 Wh over the run with it off",
|
||||
ia <= ib + 1.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. 1 s and 2 s meters")
|
||||
for p1 in (1.0, 2.0):
|
||||
for lag in (0.0, 10.0):
|
||||
for units in (("a", "b"), ("a",)):
|
||||
a, waited = run("oven", lag, p1_s=p1, units=units, dur=700.0)
|
||||
b, _w = off(lambda: run("oven", lag, p1_s=p1, units=units,
|
||||
dur=700.0))
|
||||
same = ([(r[0], r[1], r[2]) for r in a.rows]
|
||||
== [(r[0], r[1], r[2]) for r in b.rows])
|
||||
check(f"{p1:.0f} s meter, {lag:.0f} s late, N={len(units)}: "
|
||||
f"same commands, never waits", same and waited == 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. The slow meter is told by its observed interval")
|
||||
s = settle.Settle()
|
||||
for k in range(30):
|
||||
s.arrive(float(k), k)
|
||||
check("a reading each second is not a slow meter", not s.slow())
|
||||
s = settle.Settle()
|
||||
for k in range(30):
|
||||
s.arrive(5.0 * k, k)
|
||||
check("a reading every 5 s is a slow meter", s.slow())
|
||||
s = settle.Settle()
|
||||
for k in range(4):
|
||||
s.arrive(5.0 * k, k)
|
||||
check("fewer than 5 readings in a minute tell nothing", not s.slow())
|
||||
s = settle.Settle()
|
||||
for k in range(30):
|
||||
s.arrive(5.0 * k, 0 if k else 1)
|
||||
check("the same count again is no new reading", not s.slow())
|
||||
for p1, expect in ((1.0, False), (2.0, False), (3.0, False), (4.0, True),
|
||||
(5.0, True), (10.0, True)):
|
||||
r = R.Rig(house=1800.0, p1_s=p1)
|
||||
seen = []
|
||||
r.run(120.0, lambda rig, c, t: seen.append(c.settle.slow()))
|
||||
last = nth(seen, -1)
|
||||
check(f"{p1:.0f} s meter: slow is {expect}",
|
||||
present(last) and last is expect)
|
||||
# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5.
|
||||
# Where every reading arrives (the rig, a polled meter) it is not slow; where
|
||||
# Home Assistant sends a reading only when the value changes (ha_signed) the
|
||||
# same meter is slow (T3 of the review; DECISIONS).
|
||||
s, m, h = settle.Settle(), MeterFit(), settle.Settle()
|
||||
for k in range(60):
|
||||
s.arrive(float(k), k)
|
||||
h.arrive(float(k), k // 5)
|
||||
m.add(float(k), 0.0, 1800.0 + 100.0 * (k // 5))
|
||||
check("every reading arrives, value changes every 5 s: not slow (the value "
|
||||
"fit says slow)", not s.slow() and m.slow())
|
||||
check("the same meter through Home Assistant on change (a reading per "
|
||||
"change): slow", h.slow())
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
Reference in New Issue
Block a user