Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f0b8477e6 | ||
|
|
58b6fa35f5 | ||
|
|
902da3f723 | ||
|
|
27a867f34b | ||
|
|
66952beb53 | ||
|
|
d3504fbbe7 | ||
|
|
3939211176 | ||
|
|
f7c2d45957 | ||
|
|
058ecb7caa | ||
|
|
fb0730fe6f | ||
|
|
1b6bb05fb4 | ||
|
|
0d49027daa | ||
|
|
56fc41fffa | ||
|
|
bbbc89b235 | ||
|
|
f8df7cd860 | ||
|
|
f5714248f6 | ||
|
|
40ef27c367 | ||
|
|
b05ad2b273 | ||
|
|
31d13ee8bb | ||
|
|
47e99a37bc | ||
|
|
d298f8aa4f | ||
|
|
391c4b4411 | ||
|
|
6f94944e44 | ||
|
|
56d5532b83 | ||
|
|
9ec0a84129 | ||
|
|
6492d179d6 | ||
|
|
f114a19dcc | ||
|
|
a5fec377ad | ||
|
|
8fc5f15b03 | ||
|
|
c2b4302dcf | ||
|
|
16029d7cb8 | ||
|
|
8e26574b03 | ||
|
|
0f208a0500 | ||
|
|
b3cfcf2df4 | ||
|
|
8953be0362 | ||
|
|
dbdc37b959 | ||
|
|
6cae5a2f8f | ||
|
|
313503724d | ||
|
|
d26cc573fe | ||
|
|
a1be9c48b3 | ||
|
|
57c8d1b912 | ||
|
|
c015230452 | ||
|
|
0b20f53cf8 | ||
|
|
e56eeef8f0 | ||
|
|
23ff9d0920 | ||
|
|
0bacafadf0 | ||
|
|
c43873b142 | ||
|
|
b291262ee1 | ||
|
|
c70eeb6916 | ||
|
|
03541a1eee | ||
|
|
85433edac6 | ||
|
|
2667cf0dda | ||
|
|
5b6cb0bf08 | ||
|
|
c66aa7f69a | ||
|
|
6d2b9b5335 | ||
|
|
47c89a13d0 | ||
|
|
45535e19d7 | ||
|
|
af2d0d2f61 | ||
|
|
4f96397a60 | ||
|
|
d53f70f93e | ||
|
|
3219aea9a0 | ||
|
|
2fa14e23dc | ||
|
|
cfe1f06286 | ||
|
|
95bb214155 | ||
|
|
01eba51dd7 | ||
|
|
ec4ba49f45 | ||
|
|
22d09f8fe7 | ||
|
|
eb9ccc2730 | ||
|
|
1ac7fe98d7 | ||
|
|
e4b9dee699 | ||
|
|
371961e51c | ||
|
|
3a18475a5a | ||
|
|
7de77a5a09 | ||
|
|
d3f4f9f48b | ||
|
|
6a527de851 | ||
|
|
60674e45ba | ||
|
|
490f67924b | ||
|
|
05746c0564 | ||
|
|
09e1fbb898 | ||
|
|
01e88a24f2 | ||
|
|
77fe31b161 | ||
|
|
e9fa11e60d | ||
|
|
d2f63fe86a | ||
|
|
cdafa2c189 | ||
|
|
2bbf88862a | ||
|
|
3d31b679eb | ||
|
|
6889667fcd | ||
|
|
0fb4c2b5e2 |
@@ -1,13 +1,332 @@
|
||||
# Changelog
|
||||
|
||||
## 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.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.20.1 also start 0.21.0, and the Supervisor form is the same. It is a minor bump because control acts differently when readings come late and when the add-on stops; every saved setting still starts.
|
||||
|
||||
**Late Home Assistant readings are held, not followed.** When Home Assistant passes readings on late, control used to correct a mistake its last command had already fixed, so the give run could swing and send power to the grid. It now waits until the battery shows its last power request, and the peak limit no longer asks for full power on late readings. A row on the page says so, and if the readings stay too late every inverter goes to 0 W. With readings on time, the commands are the same as before.
|
||||
- With a 2 s meter, readings 10 s late were found in 43 s in a rig test.
|
||||
- With a 5 s meter, readings 30 s late are now found in about 90 s (88 s in a simulation, 75 s in a rig test), where they took about 3 minutes. Readings 10 s late at a 5 s meter are not found ([[SAFETY-136]]).
|
||||
|
||||
**Home draws the whole night's grid charge.** Home used to draw a battery's grid charge only for the cheap run under way. It now draws every hour the add-on will charge it tonight, and shares the peak room between batteries as control does. It shows the plan and changes no command.
|
||||
|
||||
**Tomorrow's forecast stays in the afternoon.** If one of the tomorrow solar forecast sensors stops updating, tomorrow's forecast now stays instead of vanishing until 20:00.
|
||||
|
||||
**The shutdown 0 W is sent last.** When the add-on stops, the 0 W now waits up to 2 s for a command already on its way, so it is the last thing each inverter hears. A command slower than that can still land after it ([[SAFETY-138]]).
|
||||
|
||||
Contents of this release, in merge order since 0.20.1 (`d53f70f`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `371961e` | When Home Assistant passes readings on late, control no longer corrects a mistake its last command has already fixed. It waits until the battery shows its last power request, and the peak limit no longer asks for full power on late readings. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]], #228) |
|
||||
| `af2d0d2` | History only: brings 0.20.1, already live, into this branch. Nothing else is new (#233) |
|
||||
| `47c89a1`, `c66aa7f` | Home draws a battery's grid charge for every hour the add-on will charge it tonight, not only the cheap run under way, and shares the peak room between batteries as control does. It shows the plan and changes no command ([[UX-77]], #234, with its second round #235) |
|
||||
| `b291262` | With a 5 s meter, readings 30 s late are found in about 90 s, not about 3 minutes. Readings 10 s late are not found at a 5 s meter; the owner accepted this and it has its own card ([[SAFETY-129]], #236, [[SAFETY-136]]) |
|
||||
| `23ff9d0` | If one of the tomorrow solar forecast sensors stops updating in the afternoon, tomorrow's forecast now stays on the Forecast tab instead of vanishing until 20:00. The night plan can use it in that time ([[OPT-38]], #237) |
|
||||
| `57c8d1b` | Each inverter is now reached through a driver. This prepares support for other inverter makes; there is no change for GoodWe sites ([[SMA-2]], #238) |
|
||||
| `dbdc37b` | When the add-on stops, an inverter that can steer itself can be handed back instead of held at 0 W. This prepares support for other inverter makes; there is no change for GoodWe sites ([[SMA-10]], #239) |
|
||||
| `b3cfcf2` | When the add-on stops, the 0 W now waits up to 2 s for a command already on its way, so it is the last thing each inverter hears. In a rig test, 0 of 111 stops ended on a value other than 0 W, against 4 of 110 before. A slower command can still land after it ([[SAFETY-137]], #240, [[SAFETY-138]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
|
||||
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
|
||||
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
|
||||
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
|
||||
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
|
||||
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
|
||||
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
|
||||
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
|
||||
- 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 not caught, and readings 15 s late only after about 4 minutes. With a changing load such as an oven or a kettle, 21 to 142 Wh can go to the grid in a simulation, against 2 to 27 Wh with readings on time ([[SAFETY-136]]).
|
||||
- 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]]).
|
||||
- On a night grid charge held to the peak limit, the peak guard can stop the charge 1 to 3 s into a 15-minute period and keep it paused for that whole period. In a rig test this cost up to about 0.27 kWh of grid charge per run; it may come from the sped-up rig clock ([[SAFETY-135]]).
|
||||
- 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]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
|
||||
## 0.20.1
|
||||
|
||||
**A patch bump: Leftover solar no longer buys from the grid what other batteries will already cover. No option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.20.0 also start 0.20.1. A site with one battery, with battery modes off, or with no Cheap hours only or Solar first battery plans as before.
|
||||
|
||||
**Leftover solar counts the other batteries.** At night, a Leftover solar battery used to buy for the whole of the expensive hours as if no other battery would give. It now counts what a Cheap hours only battery will hold when the expensive hours start, and what a Solar first battery will have left after the night's use, and buys only the rest. In a rig test of the live night (Leftover solar at 60 %, Cheap hours only at 94 %) it bought 0.00 kWh where 0.20.0 bought 5.40 kWh. The Batteries screen says "Other batteries cover the expensive hours. It does not charge from the grid."
|
||||
|
||||
Contents of this release, in merge order since 0.20.0 (`01e88a2`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `22d09f8` | Leftover solar counts what Cheap hours only and Solar first batteries will still hold when the expensive hours start, and buys from the grid only the rest. The Batteries screen says when other batteries cover those hours, and the "rest" sentence now says it comes from the grid ([[OPT-36]], #229) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
|
||||
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
|
||||
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
|
||||
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
|
||||
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
|
||||
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
|
||||
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
|
||||
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
|
||||
- Control acts on Home Assistant readings that arrive late. With readings 10 s behind, the give run can swing and send up to 1.9 kW to the grid for about 50 s in a rig test; with readings 30 s behind, a battery can charge from the grid in an expensive period. Also in 0.19.2. The fix is not in 0.20.1; it ships in 0.21.0 ([[SAFETY-128]]).
|
||||
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
|
||||
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
|
||||
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
|
||||
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
|
||||
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
|
||||
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
|
||||
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
|
||||
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
|
||||
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
|
||||
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
|
||||
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
|
||||
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
|
||||
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
|
||||
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
|
||||
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
|
||||
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
|
||||
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
|
||||
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
|
||||
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
|
||||
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
|
||||
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
|
||||
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
|
||||
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
|
||||
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
|
||||
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
|
||||
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
|
||||
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
|
||||
- In the last minute of a 15-minute period, with Home Assistant readings 30 s late, the period can end about 80 W over the peak aim 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 on 0.20.0 the add-on asked for a 9.6 kW charge ([[SAFETY-132]]).
|
||||
- A plan rebuilt while the battery levels are missing for a moment can drop a cheap run that has not started yet, so the battery does not charge that day. Seen in a replay of real prices, where it cost about EUR 0.40 of extra grid use; the change in this release no longer triggers it in those replays, but the cause stays ([[OPT-37]]).
|
||||
- Home draws a battery's charge only for the current cheap run. On a flat night tariff the later night hours it will charge show up as the run rolls on, though control keeps charging ([[UX-77]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
|
||||
## 0.20.0
|
||||
|
||||
**A minor bump: a Forecast tab, a night grid charge sized to what the sun will not give, a kept evening forecast, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). It is a minor bump because Leftover solar now buys different amounts from the grid and the night charge keeps its level when the forecast goes quiet; every saved setting still starts.
|
||||
**Existing installs need no option change:** options that start 0.19.2 also
|
||||
start 0.20.0. The changes below apply with one inverter or two.
|
||||
|
||||
**A Forecast tab.** A new tab next to Home and Batteries shows today and tomorrow hour by hour: the solar forecast, the sun measured so far, the house's expected use, and each battery's level with its planned charge and give blocks. It shows what the add-on plans and changes no command.
|
||||
|
||||
**Leftover solar buys what the sun will not give.** On a day/night tariff the night grid charge now covers the expensive hours' use less the sun counted for them, where it used to buy for the whole day. A forecast with only a daily total, such as Forecast.Solar, is no longer counted several times over by day, so Leftover solar no longer charges too little because of it.
|
||||
|
||||
**The evening forecast is kept for the night.** Forecast.Solar-type sensors stop updating in the evening. The last fresh reading is now kept until 06:00, so the night cheap run is judged with the forecast's price gap and Leftover solar still plans its charge. A newer reading replaces it at once, and a charge that is running keeps its planned level if the figure ends.
|
||||
|
||||
**No false peak alarm during the night grid charge.** "Battery stopped limiting the peak", and its notification, no longer appear while the 15-minute period stays within 10 W of the aim.
|
||||
|
||||
Contents of this release, in merge order since 0.19.2 (`f1c9733`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `a1d0703` | Home now draws a grid charge only where the add-on will take it, with battery modes on or off; before, it could show the rest of a refused cheap run, or a charge after the last dear period. The price texts say "lowest price left" and "highest price left", and no command changes ([[UX-53]], #209) |
|
||||
| `f5afb42` | The day planner's hour loop now always ends on the night the clocks go forward. Without time-zone data it could go round forever and freeze the add-on; the add-on's own image has that data ([[SAFETY-123]], #211) |
|
||||
| `4a1f6cc` | A new Forecast tab shows today and tomorrow hour by hour: the sun, the house and each battery's level, with the charge and give blocks Home would draw. It shows the plan and changes no command; notes say when a part is not known yet ([[UX-54]], #210) |
|
||||
| `6be7e41` | The add-on's test files now run six at a time in the checks, so a check takes about 15 to 25 minutes, where one by one it ran past an hour. No change in behaviour ([[CI-15]], #215) |
|
||||
| `b6be312` | The day plan counts the sun right: a forecast with only a daily total is no longer counted several times over. On a day/night tariff the night charge for Leftover solar covers the expensive hours' use less the sun counted for them, shared between batteries by capacity, and the screens say when another battery or the grid covers the rest ([[OPT-23]], [[OPT-28]], #218) |
|
||||
| `cc9bbeb` | When a solar forecast sensor stops updating in the evening, the night plan uses its last fresh reading, for the day it is for only. One missed reading no longer drops the forecast at once, and the pages say when a figure is kept ([[OPT-27]], #219) |
|
||||
| `deee98d` | The night grid charge under the peak limit no longer raises a false "Battery stopped limiting the peak" alarm when the 15-minute period stays within 10 W of the aim. A real overrun still alarms; a steadily rising load alarms a little later, and with a peak margin under 10 W a period can end up to 10 W over the limit without it ([[SAFETY-126]], #221) |
|
||||
| `8afadbd`, `cdafa2c` | The kept evening figure now counts until 06:00, not until 03:00, and a charge that is running keeps its planned level when the figure ends. A newer reading replaces the evening figure at once and for good, so it does not come back later in the night ([[OPT-34]], #222, with its fix #225) |
|
||||
| `d4c01da` | Comments and docstrings in the peak guard and the price files were cut to a line and a pointer to the decision notes. No change in behaviour: a check shows the code itself is the same ([[MAINT-20]] part 1, #223) |
|
||||
|
||||
The merge that brought 0.19.1 and 0.19.2 into this branch (`f1b7244`, #220) adds nothing else; the Forecast tab uses the same price gap as control since it.
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
|
||||
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
|
||||
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
|
||||
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
|
||||
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
|
||||
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
|
||||
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
|
||||
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
|
||||
- Control acts on Home Assistant readings that arrive late. With readings 10 s behind, the give run can swing and send up to 1.9 kW to the grid for about 50 s in a rig test; with readings 30 s behind, a battery can charge from the grid in an expensive period. Also in 0.19.2 ([[SAFETY-128]]).
|
||||
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
|
||||
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
|
||||
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
|
||||
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
|
||||
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
|
||||
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
|
||||
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
|
||||
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
|
||||
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
|
||||
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
|
||||
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
|
||||
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
|
||||
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
|
||||
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
|
||||
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
|
||||
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
|
||||
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
|
||||
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
|
||||
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
|
||||
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
|
||||
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
|
||||
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
|
||||
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
|
||||
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
|
||||
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
|
||||
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
|
||||
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
## 0.19.2
|
||||
|
||||
### Not in a release yet
|
||||
|
||||
- The night grid charge no longer raises "Battery stopped limiting the peak" when the 15-minute period stays on the aim. A steadily rising load now alarms a little later, and with a peak margin of 0 a period can end up to 10 W over the limit without this alarm ([[SAFETY-126]]).
|
||||
- A night grid charge for *Leftover solar* no longer stops at 03:00 when the solar forecast sensors went quiet in the evening. The evening's figure is used until 06:00, unless a newer reading has replaced it that night, and a charge that has started goes on to its level ([[OPT-34]]).
|
||||
|
||||
**A patch bump: two fixes, one for charging from the grid under the peak limit and one for the night charge of Leftover solar, and no option changes.**
|
||||
**A patch bump: one fix for charging from the grid under the peak limit, and no option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options).
|
||||
**Existing installs need no option change:** options that start 0.19.1 also
|
||||
start 0.19.2. The change below applies with one inverter or two.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
# MAIN - why app/main.py is built the way it is
|
||||
|
||||
**Status: SHIPPED.** Written down by MAINT-20 part 2 from the long comments and
|
||||
docstrings of `app/main.py`, so the module can keep one line and a pointer. The
|
||||
comments that were shortened but not rewritten here are kept word for word in
|
||||
[[MAIN-orchestration-archive]], by their line in `app/main.py` at release/1.0
|
||||
`d4c01da`. Since MAINT-21 the Controller's methods are split by job into
|
||||
`app/main.py` and `app/ctl_*.py`; a comment keeps its text and moves with its code. Related: [[SAFETY-07-peak-guard]], [[FCST-04-the-day-that-is-carried]],
|
||||
[[SAFETY-30-per-unit-sign]], [[SAFETY-73-refusal-per-inverter]],
|
||||
[[SAFETY-121-bound-every-quarter]], [[TARIFF-10-fetching-is-not-trading]],
|
||||
[[TARIFF-24-modes-need-the-tariff-task]], [[OPT-34-the-night-charge-keeps-its-level]].
|
||||
|
||||
## Controller construction
|
||||
|
||||
- **Per-write budget (DEV-02).** Each write is dispatched concurrently and bounded, so one hung `hass.call()` cannot eat a sibling's layer-1 budget (the ESP32 zeroes after about 30 s, and a fault on A could zero a healthy B). The number is derived: `heartbeat_s` max 25 s, layer-1 veto `validate.FIRMWARE_INGEST_VETO_S` 30 s, so 25 + 4 < 30. test_inverter.py grades the inequality, so raising the heartbeat ceiling goes red instead of taking an inverter to zero. Below hass.py's 10 s HTTP timeout on purpose: abandon the write and re-issue it next tick, since `last_write` moves only on an accepted write.
|
||||
- **Tariff refresh (OPT-04).** `resolve_tariff()` may do an HTTPS fetch. The tick feeds the ESP32 heartbeat, so a supplier's TLS timeout on the tick would push the add-on into layer 1. The tick never awaits a fetch. The 30 min cadence is not about price change (day-ahead publishes once at 14-16 h CET and 8.3's carried hours go firm then); it picks the switch up within the hour at 48 fetches a day. The age bound answers another question: a failed refresh still stores a plan (the 11.4 static fallback), the bound covers the refresh task dying while a plan from hours ago stands as current. Three missed refreshes, for `p1.max_age_s`'s reason.
|
||||
- **Status rows (TARIFF-11).** The Tariff row used to be `static_status()`'s sentence alone and never saw the provider plan (read 'no static profile' at all 802 samples of a 40 h soak with a live feed). `tick_words` and `plan` are two clocks and the row says so; the plan is stamped with its own age from `tariff_at` (TARIFF-12 does not move it on a kept plan), and `age_s` is the caller's measurement. The Arbitrage row used to be `"off (arbitrage_enabled)" if self.arb is None`, under a comment saying `arb is None` iff trading was off; false, since `cycle()` returns at the input hold before `self.arb = None`: `arbitrage_enabled: true` plus a sensor answering None rendered 'off (arbitrage_enabled)', naming an option that is on, and past the first cycle the previous decision stood as current. Three states: off, armed with no decision this cycle, a decision with its age (always printed: a row that reports staleness only once stale cannot be told from one that never does).
|
||||
- **`stop` is required (review catch).** `stop=None` made a Controller that cannot stop itself the default: on the late-refusal path in `run_control()` it logged the banner, returned and left the process alive forever with no loop and no output.
|
||||
- **Action log first.** The store migration can log to it (15: how an operator finds a latch was re-armed); an AttributeError on the way to saying so would stop the add-on booting.
|
||||
- **Roster and migrations (DEV-02, SAFETY-44, MAINT-05).** Every per-device value lives on the roster objects (app/inverter.py); a `Tuning` and an integrator are deliberately not per device (6.2: one integrator on P1 above the split; a second regulating the same meter is the two-writers failure). The SAFETY-02 key migration runs before the first `Inverter` reads its latch, else a tripped install comes up armed with a green banner. SAFETY-44: it is handed `roster_names(opts)[0]`, never `UNIT_A` (on a declared roster the flat latch landed under `unit/inverter-a/`, read back under the name it was handed, stamping the marker); `roster_names()` returns `[UNIT_A]` on the flat shape so today's path is byte-identical. `misdirected` is unreachable from that call site (`unit` is a roster member by construction; dropping the `roster` argument survives 28/28), so the claim that the call site buys the refusal is gone; the argument stays for new callers and is graded in test_maintenance.py. The maintenance migration targets `self.inverters[0].name`, not `UNIT_A`: with `UNIT_A` the history would sit under a name on no roster (a nameless entry falls back to a positional `inverter-1`). Every two-pack fixture once named its first entry `inverter-a`, so swapping them left 19 files green; `migrate_maint_keys()` now grades the namespace it wrote against the names the machines read, and test_maintenance.py sections 9 and 11 use `["garage", "shed"]`.
|
||||
- **CFG-04 disclosure.** A declared `inverters:` roster ignores `soc_entity`, `batt_entity`, `batt_invert` and `max_w`, which the UI still shows. Rule 9(e) refuses the dangerous one (a flat `setpoint_entity`); the rest cannot be refused (the schema has no blank value) so one action-log line at every start names the roster and the ignored keys.
|
||||
- **Site bound.** Summed from the roster, not `opts["max_w"]`: it is the integrator bound and rail of the site loop, then split. Identical at N=1; a per-unit `max_w` binds each share through its `supervised` claim.
|
||||
- **SAFETY-39 slew fallback** is 0 ("follow the rail"); it was 1000, a stale third copy. `control.slew_limit_w()` is the one reader.
|
||||
- **CFG-03 in main.py.** Eight `opts.get(key, <literal>)` calls used to live in the maintenance setup and two disagreed with config.yaml (2500 against 2000). `MaintConfig.from_options()` reads them. A ninth key goes in config.yaml `options:`/`schema:` and `maintenance.MAINT_OPTION_DEFAULTS` only.
|
||||
- **Watchdog thresholds (SAFETY-30).** The per-unit pair is re-derived at two inverters, not carried (it grades a command against one unit's own power). The site pair is used only at N>=2: the 300 W threshold carries (meter noise, deadband), the window does not (110 s at one inverter, 215 s at two, against a 180 s hold).
|
||||
- **Latches (SAFETY-02, -31, -34, -42, -18).** SAFETY-02: one per actuator, restored from namespaced keys; graded on every start (a latch that failed to move namespace, or was taken by an older add-on in a rollback, is a disarmed guard, so a disagreement re-arms by hand and shouts). SAFETY-31: site-level, flat keys (circulating power is defined over the roster), restored because it acts. SAFETY-42's delivery latch does not act, so it is restored only so the ALARM survives (the 2026-09-02 outage went unseen 6 h 25 m with the detector correct). SAFETY-18 is not restored (does not act). SAFETY-30's site check is armed only at N>=2.
|
||||
- **PV (TEL-11).** Instrumentation only; wiring it in would be an unrequested behaviour change and would destroy the measurement.
|
||||
- **Peak guard setup (SAFETY-07, TARIFF-01, FCST-04).** See SAFETY-07-peak-guard.md and FCST-04. The windowing caveat for FCST-04: hours already elapsed leave the spread, so a cheap night that has gone no longer sets the day's cheapest hour.
|
||||
|
||||
## Cycle state and latch repair
|
||||
|
||||
- **Allocation record (SAFETY-28).** The tick that mints a record increments once per resolution, not per loop iteration. A cycle with no resolution (the "inputs missing, not yet stale" hold) must not bump it: that hold returns before the allocator so one missed poll does not zero the inverter, and the heartbeat re-affirms `self.target` through the write path; bumping would make that heartbeat unauthorised and drop the site to 0 W. The record is superseded, never expired.
|
||||
- **`self.arb` (TARIFF-11).** See "Status rows" above; `arb_at < cycle_at` is the derived "the latest cycle did not reach the arbitrage step".
|
||||
- **Shadow (SAFETY-10, SAFETY-47).** `shadow_would_w` is what a live controller would have put on the wire; `self.target` is what is standing on the wire (held at `shadow.WIRE_W`, re-seeded each shadow cycle). Folding them shows an operator a number nothing is doing. The "sized from the billed-quarter meter" flag is recorded where the decision was taken (the page runs on its own task); False, not None, before the first shadow cycle.
|
||||
- **ARB-03.** `units` is a property derived from the roster so a site cannot claim two actuators while holding one writer. At N=1 (6.2) the grid connection and the only inverter are one actuator; a second entry makes per-inverter claims stop being site claims (ALLOC-01).
|
||||
- **DEV-02 latch guard (`_check_sign_latch...`).** Runs on every start: gating on `rep.ran` swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip in the window, upgrade; the controller came up untripped while state.json said `sign_tripped: true`; a firmware rollback path was performed on this site on 2026-09-02). The gate could go because `handle_action()` clears the flat keys with the namespaced ones and the trip mirrors them. SAFETY-44 second half: the question is whether a namespaced latch THAT A MACHINE READS is set. An earlier version accepted any `unit/*/sign_tripped`, which blinded the guard on exactly the installs the broken migration had put the latch under `unit/inverter-a/` (a key nothing reads). The first blunt fix (every off-roster latch is litter) re-armed a healthy A off B's mirrored flat key (test_inverter.py DEV-02 restart case). `UNIT_A` is the discriminator: the only name the broken migration wrote. A single flag written for two intents was defeated by one removed unit's key (roster ['garage','shed'] with `unit/old-shed/sign_tripped` drove on a state file reading `sign_tripped: true`; `tools/rehearse_roster.py` exit 1); the fixtures now hold both arrangements. Residual: a first unit really named `inverter-a`, latched and removed from the roster is re-armed on `inverters[0]` (conservative). The printed reason is the flat mirror's (whatever tripped last), so on a multi-unit site one inverter's evidence may appear under another's name; test_maintenance.py's check that the reason is not the removed unit's depends on that fixture's flat string, not on this code.
|
||||
- **Maintenance views (MAINT-05).** `maints` is one FSM per roster entry; `maint`, `maint_unit` and the button target are views of the one that holds the slot (`maintenance.arbitrate()` allows at most one out of IDLE). validate.py rule 6 grades `maintenance_charge_w` against the weakest cap on the roster.
|
||||
- **N=1 views.** Properties, not fields, and they refuse at N>=2 (6.2's identity is false there). Nothing inside main.py may read them (test_inverter.py AST scan).
|
||||
- **`_arm_sign_watchdogs` (SAFETY-30).** Called every cycle because fixtures append an `Inverter` after construction; a detector frozen at construction would be graded against the meter at a roster of two. `arm()` touches no timer or latch.
|
||||
- **Per-inverter refusal (SAFETY-73).** The probe ask is PROBE_W, not `saturation_w`: a unit's share is often small (a 400 W night load split two ways) and SAFETY-43 would name the empty battery as not delivering. The site target is restarted from the watts on the wire when a unit reopens (phase A: -1059 W).
|
||||
|
||||
## Write path and cycle
|
||||
|
||||
- **The write gate (SAFETY-28).** specs 1.5 makes two writers on one actuator a non-goal. `len(validate.ACTUATOR_WRITE_KEYS) == 1` is a fact about the options schema, not the writes, and goes false when CFG-04 adds per-inverter config. So the enforcement is on the write: a number reaches the wire only if the single allocator placed it, on this tick, for this unit (`Allocation.authorises()`); a second writer must file a Claim. 0 W is always authorised and the test for zero comes first and reads nothing: the failsafe branches write 0 W while the allocator has refused (`Allocation.ok` False, no shares), and a gate asking its permission would deadlock the failsafe behind the component it answers; at S = 0 broadcast and allocation coincide, so there is no over-command (the hazard is N-times loop gain from broadcast, TEST-19's `N=2 gain 1.0` limit cycle). The predicate is `value == 0.0`, not a `failsafe=True` flag, which a second writer would pass; -0.0 == 0.0 and a nan never gets here. DEV-02: the unit is an argument, not the N=1 view; the default refuses at N>=2. The refusal sits above the clamp and below the non-finite coercion.
|
||||
- **Clamp (SAFETY-03)** is on the only path to the wire, so no optimizer, maintenance or manual path routes around it. A non-finite value is loss of input (11.1), coerced to 0 W. Every engagement goes to the action log undeduplicated; the notification is deduplicated.
|
||||
- **Shadow (SAFETY-10)** suppresses in the same method, below the gate and the clamp, as a substitution not a return. It is not a second writer.
|
||||
- **Reads (DEV-02, TEL-12).** Each inverter's readings are gathered concurrently across units and sequentially within one; PV entities ride in the same gather (a slow sensor must not push a healthy battery toward layer 1's veto). P1 has no inverter-side fallback: the inverter's AC power correlates 0.998 with battery power and 0.09 with the real meter.
|
||||
- **Stale reading (SAFETY-37/56/57).** The power reading goes through the freshness gate, the SoC does not (the power feeds the sign and delivery checks, which a frozen register fools). SAFETY-57 rewrote the escape clause: "it clears itself when the register reports a different value" was false for the state announced, because the 0 W it announces is what stops the value changing. The release is reported, never silent (it withdraws a SITE STOP).
|
||||
- **TARIFF-03.** The one shiftable load is the maintenance cycle's cheap-window top-up; the static window is synchronous and I/O-free, and validate.py refuses `static_profile` together with the price entities.
|
||||
- **Cycle ordering (SAFETY-02/31/42).** The watchdog evaluations run every cycle, before the input hold, so a run of held cycles cannot leave their timers resuming from a start that predates the gap. Reason first, flag second (each `set` writes the file): a crash between persists a reason nothing reads, not a latch with no explanation. The per-unit trip mirrors the legacy flat keys (store.py rule 2 covered only the latch present at migration time). The site check is flat-keyed, armed only at N>=2. SAFETY-31's input is MEASURED power (`inv.batt`), never the allocation record, which has sign-coherence by construction.
|
||||
- **Maintenance FSMs (MAINT-05).** Each pack is ticked with its own SoC (a minimum would abort a cycle on a sibling's state, an average would declare a pack full that is not); `arbitrate()` runs first. A deferral is carried to the status page because the edge-logged line scrolls out of the 40-row log.
|
||||
- **Aggregates.** SUM for the site bound, achievable rating and the battery power the site loop regulates against (`compute()`'s saturation test compares against the site target; one unit's power trips the freeze permanently, up to 510 W of split-dependence at gain 0.2, test_alloc.py part 8). MINIMUM for the discharge permission (the emptiest pack). MEASURED power for circulating power. Unknown bounds stay unknown when any unit is silent.
|
||||
- **Trade sizing (OPT-09).** `trade_sizing` uses arbitrage's own SoC bounds, not the BMS cut-out (SAFETY-25): sizing is the trade's business, the pack envelope is the pack's. Weights are a preference, so a pack between its arbitrage floor and its cut-out can still take part of a sell; fixing it needs a per-pack envelope depending on which claim won the tick.
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- **Shortfall.** `sum_h max(0, house_h - trust x sun_h)` over every expensive hour, `/ EFF`, from the floor. It replaces the use over the run with no sun counted, which on a day and night tariff bought for the whole day (G1, `reports/UX-69-design-report.md`). The sun before the run is unchanged. A run with its dear hours after sunset gives the old number.
|
||||
- **House use.** An hour with sun is read at no less than the night base, as the sun sum reads it (the profile is net of the sun). An hour without is read as measured. An hour with no forecast counts no sun.
|
||||
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by another battery (Cheap hours, Solar first) or the grid, as the screen says.
|
||||
- **Cheap hours** still fills up and is not counted in the share; **Solar first** still never charges from the grid. The mode help says so.
|
||||
- **Not done:** the run length (OPT-25); netting what a Cheap hours or Solar first battery will hold against the shortfall.
|
||||
- **Several batteries.** One total, shared by capacity with the ceiling passed on (`dayplan._spread()`). With the same floor and ceiling for all, they reach the ceiling together, so a shortfall that one battery cannot hold fills both to the same level, not one first. More than all hold: all go to the ceiling, `Plan.uncovered_kwh` is the rest, carried by the grid (OPT-36 counts the other batteries first), as the screen says.
|
||||
- **Cheap hours** still fills up; **Solar first** still never charges from the grid. The mode help says so.
|
||||
- **OPT-36 (0.20.1):** Cheap hours and Solar first batteries give first; Leftover solar shares only the rest of the shortfall (`dayplan._cheap_give_kwh()`, `_solar_first_give_kwh()`). A Cheap hours battery counts the level its charge reaches: the cheap run, then later slots still worth charging in and no dearer than any slot from the expensive hours on, under the peak aim, x EFF stored. A Solar first battery counts what is left after the house's use until the hours start, each hour at no less than the night base. Live, 2026-11-02: A at 60 % went to 95 % while B at 94 % covered the hours. Owner, 2026-11-02: Solar first counts too. Test: `test_opt36.py`.
|
||||
- **Not done:** the run length (OPT-25). On a dynamic tariff the Cheap hours count guesses where control will charge; a counted slot can still go unused, and that gap comes from the grid.
|
||||
|
||||
@@ -44,3 +44,5 @@ tonight." (`home_words.solar.kept`, EN and NL). The event log says
|
||||
**Not changed.** Control with modes off: it does not use the forecast. The
|
||||
screens still show the line, so it says "kept", not "used for the plan". A
|
||||
forecast that stays fresh. The hold does not look at the weather, only at the clock and the day.
|
||||
|
||||
**OPT-38 (2026-11-07).** A kept TOMORROW reading (at most `KEPT_MAX_AGE_S` old, same `day0`) now stands in at any hour, not only from `BELL_TO_H`: one sensor that stopped at 14:54 had dropped the whole sum (OPT-17) from 17:54 to 20:00. Today readings and the OPT-34 morning rules are unchanged; `test_opt38.py`.
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# SAFETY-128 - a reading that does not answer the last command is not current
|
||||
|
||||
**Status: FIXED on release/1.0 (after 0.20.0).** Code: `app/lateness.py`,
|
||||
`control.compute(hold=, hold_w=)`, `peakguard.cap_floor()`,
|
||||
`Controller._readings_late()`, `Controller.read_inputs()` (feeds the fits).
|
||||
Tests: `test_safety128.py` (`tools/safety128_rig.py`, TEST-23's plant).
|
||||
|
||||
**Cause** (SAFETY-127 report). Home Assistant can deliver readings late but
|
||||
still changing. Every gate judges arrival (SAFETY-01) or a value that stops
|
||||
changing (SAFETY-18, SAFETY-37), so a late reading passes them all. The loop
|
||||
then corrects, each cycle, an error its last command has already fixed: a
|
||||
loop with a dead time far longer than its cycle, which swings and grows. The
|
||||
peak guard projects the quarter from the same late figures and pushes to the
|
||||
inverter limit in its last seconds (OPT-34 rig F-2).
|
||||
|
||||
**Rule.**
|
||||
1. *Find late readings* (`Answer.watch()`, every 2 s, from the 1 s reads).
|
||||
- **Batteries** (`Delay`): fit one `d` so that each battery's reading minus
|
||||
its own request at `t - d` (averaged over 2 s for the battery's lag and
|
||||
the read) is flattest. The house does not move that reading, so a
|
||||
repeating load cannot line up with it (review round 1, B1). Needs 5 new
|
||||
readings in 60 s and the request and the reading each to move 300 W.
|
||||
Late from 9.5 s, after 2 late fits in a row. A late fit must explain 75 %
|
||||
and beat every fit up to 6.5 s by 2x; the shortest delay that fits about
|
||||
as well (within 2x, or within 2 500 W^2) is taken instead, since a swing's
|
||||
period fits again. A slow battery (3-10 s lag) answering at once gives no
|
||||
late fit; at a 15-20 s lag some fits read late (see "What it costs").
|
||||
No late battery fit counts for 60 s
|
||||
after a battery gave nothing under its ask: an emptied battery lined its
|
||||
old movement up as 85 s late.
|
||||
- **Meter** (`MeterFit`, round 1's fit): `meter(t) + request(t - d)` is the
|
||||
house alone at the right `d`. Quick, but confounded by the house. Needs
|
||||
16 new values in 60 s, so only a meter of about 3.5 s or faster: at 5 s
|
||||
it gave false delays. Late over 8 s. At a slower meter (a 5 s P1) the
|
||||
same fit runs over 120 s and calls only a delay over 16 s late, on 2 fits
|
||||
in a row, and never ends a verdict (SAFETY-129). Alone it reads 35-90 s on healthy
|
||||
runs, a few times in a row; only the slow-start and battery-fit check
|
||||
below keeps those from a verdict. 10 s late is not found: it cannot be
|
||||
told from a 10 s slow battery, and it does not swing at 5 s.
|
||||
- A meter fit says late only with evidence from the batteries' side too:
|
||||
a request more than 100 W from the battery reading took 3 s or more to
|
||||
move a reading towards it (`SLOW_START_S`; on time they start within
|
||||
0-1 s in the recorded house at 1 and 2 s ticks), and no battery fit in
|
||||
the last 60 s said on time (under 9.5 s). An ask during which a battery
|
||||
gave nothing under its ask is no slow evidence. A repeating load fools
|
||||
the meter fit but not these. No meter verdict in the first 150 s.
|
||||
- On time again on a battery fit under 6.5 s, or a meter fit within 8 s
|
||||
unless a battery fit said late (hysteresis). With no fit for 120 s the
|
||||
verdict is dropped.
|
||||
2. *Hold.* While late, a request more than 100 W from the battery reading is
|
||||
pending until that reading comes within 100 W of it, or half way. Meanwhile
|
||||
the loop may not lead the reading by more than 100 W that way, and the peak
|
||||
guard's floor is capped at the request that stands. The loop is not held
|
||||
while a battery gives nothing under its ask or refuses (SAFETY-52/94).
|
||||
3. *Late verdict.* Pending for `LATE_S` (23 s) with no reading moving 100 W
|
||||
towards it: control holds, then after `stale_input_s` goes to 0 W
|
||||
("readings late"), as for a missing input.
|
||||
4. *Seen.* While late an amber row says "Home Assistant readings show requests
|
||||
about N seconds late, so control holds the batteries." (NL too). N is the
|
||||
batteries' fitted delay: Home Assistant's lag plus the battery's own
|
||||
answer. While only the meter has shown it the row gives no figure. One
|
||||
WARNING (`READINGS_LATE`) per episode and one line when it ends.
|
||||
|
||||
**With readings on time** no verdict was reached in any reviewer scenario
|
||||
(owner-shaped house, ovens 20/40 to 60/60 s, clouds, a 300 W/s battery, 6-8 s
|
||||
dead times, HA jitter 0-4 s, an EV following the meter, capped batteries, HA
|
||||
restarts, meter pauses; 1, 2 and 5 s meters) nor in 2 days of the recorded
|
||||
house at 1, 2 and 5 s ticks, so the commands are the same as without the gate.
|
||||
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
|
||||
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.
|
||||
- A battery that answers slowly is read as late readings and held: a dead
|
||||
time of 10 s or more (8 s reads 7-8; 10 s reads 10 at 1-2 s meters, 9 at
|
||||
5 s), or a 15-20 s lag (review round 2). The GoodWe measured settles in 6 s
|
||||
(`freshness.PLANT_SETTLE_S`); the row then blames Home Assistant wrongly.
|
||||
- The row can stay up to 180 s after the readings are on time on a steady
|
||||
site. While only the meter has shown the delay it gives no figure.
|
||||
- A battery that cannot follow while the readings are late (at a limit, not
|
||||
reading 0 W) is held, then set to 0 W after 38 s.
|
||||
- The fits cost a few ms every 2 s (x86, measured after 3 idle days); the
|
||||
history they read is bounded (60 s of readings, 150 s of requests).
|
||||
|
||||
**Left out.** The setpoint -> Home Assistant round trip: the fitted delay gives
|
||||
the figure from readings the add-on already has. The hold's step-and-pull-back
|
||||
at extreme lag and the arbitrage charge's sizing on late readings
|
||||
(`charge_room_w()`) are follow-up cards.
|
||||
@@ -0,0 +1,21 @@
|
||||
# SAFETY-137 - the shutdown 0 W is the last write every unit hears
|
||||
|
||||
SMA-10 made a hand-back release wait for a write already on its way
|
||||
(`_release_last`, `Inverter.inflight`). A GoodWe unit skipped that, and rig 5b
|
||||
saw a cycle write (-2 823 W) land after the shutdown 0 W and stay until relaunch.
|
||||
|
||||
Now the 0 W of shutdown and of the Stop button is a *last* write
|
||||
(`_write_unit(last=True)`), for every driver:
|
||||
|
||||
- It first waits, at most `SETTLE_BUDGET_S` (2 s), for the writes already on
|
||||
its way to that unit (`_await_inflight`, shared with `_release_last`). A hung
|
||||
write is not waited out and not cancelled; the 0 W goes anyway.
|
||||
- Once shutdown has begun (`stopping`), every other write (cycle, heartbeat,
|
||||
safety stop) is dropped at the same place, right before the write is created.
|
||||
That is the gate that keeps the control cycle quiet; the cycle itself needs
|
||||
no cancelling.
|
||||
- Worst case for a GoodWe unit is 2 s + `WRITE_BUDGET_S` (4 s) = 6 s, the same
|
||||
as `HAND_BACK_WORST_S`; the units are handled together.
|
||||
|
||||
Not touched: the heartbeat's own timing, safety-stop paths, the maintenance
|
||||
abort (still a plain 0 W).
|
||||
@@ -709,7 +709,7 @@ rather than smuggled in.
|
||||
**Also still not in this card, and named by the shipped code already:** the
|
||||
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
|
||||
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
|
||||
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
|
||||
takes one number. The `Controller.pack_floor_soc` docstring in `app/main.py` says so, and it belongs to CFG-04/SPIKE-04's
|
||||
per-pack bound. Nothing here touched it.
|
||||
|
||||
## 6. What was changed, and one thing that was found on the way
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# SMA-10 - two idle states: hold 0 W, or hand the inverter back
|
||||
|
||||
**Status: for review.** Tests: `test_sma10_two_idle_states.py`. Source: SMA-9
|
||||
report sections 1.4, 3.2; owner decision 2026-10-20 (Q1, "as proposed").
|
||||
|
||||
A unit is idle in one of two ways, and the driver says which:
|
||||
`Driver.hands_back`. **False (GoodWe):** the add-on holds 0 W, `release()` is
|
||||
`write(0)` and the Controller never calls it, so Stop keeps today's 0 W write and
|
||||
today's 0 W heartbeat. **True:** `release()` gives the unit back to whatever
|
||||
steers it by itself (SMA energy management), once, and then nothing is written
|
||||
to it until control starts again.
|
||||
|
||||
**Why two.** When the add-on goes away (Stop, shutdown, SIGTERM, restart, task
|
||||
death, a config refusal) nobody is left to hold a number, so the unit goes back
|
||||
to its own controller. When the add-on itself decides to stop (sign trip, site
|
||||
sign, circulating power, a refused or non-finite write, missing readings,
|
||||
another writer, an inverter fault) it has just said it cannot trust that
|
||||
battery, and handing it to something else would let that something do what it
|
||||
likes with it. Those hold 0 W through `write(0)` for every unit, whatever its
|
||||
driver. Shadow mode on a unit that hands back uses `release()`: it must leave
|
||||
the unit alone, and 0 W would be a command.
|
||||
|
||||
**Where.** `Controller.command_site_release()` sits beside `command_site_zero()`
|
||||
(unchanged). Stop and `shutdown()` call it; the two maintenance-abort paths keep
|
||||
zero (the add-on runs on, the sentence says 0 W for a moment). With no
|
||||
hand-back unit it is `command_site_zero()`. `_write_unit()` also refuses to write
|
||||
a hand-back unit while the add-on is idle (stopping, stopped, or shadow mode): it
|
||||
releases once and returns, so the heartbeat and the cycle's own "stopped" 0 W
|
||||
cannot reach it. A safety stop runs while control runs, so it falls through to
|
||||
the write. Start re-arms: `released` drops and `last_write` clears.
|
||||
|
||||
**Bounds.** `RELEASE_BUDGET_S` equals `WRITE_BUDGET_S` (4 s): units are released
|
||||
together, so one slow unit costs the kill window no more than a hung GoodWe
|
||||
write does. A release at the bound is abandoned, not awaited. A release that
|
||||
raises, hangs or returns false leaves that unit un-handed (the next idle write
|
||||
tries again) and does not touch the others. Exit 78 before the Controller
|
||||
exists has no driver to release and keeps today's path.
|
||||
|
||||
**Words.** Every sentence that said "set to 0 W" for the add-on going away or
|
||||
for shadow mode is built from the roster in `app/handback.py` (EN and NL): a
|
||||
roster with no hand-back unit gets None and keeps its old literal, so a GoodWe
|
||||
site sees no changed byte. A hand-back unit's words (`Driver.words`: `back`,
|
||||
`back_nl`) come from its driver; one without them gets "its own control" /
|
||||
"de eigen sturing" (the article as in the generic clause; PR #239 review
|
||||
comment 2495, not the brief). MQTT `status` is unchanged; the Home battery
|
||||
row of a handed-back unit says "Back under SMA control" and carries
|
||||
`handed_back`.
|
||||
|
||||
**Not done here (SMA-4, SMA-5, SMA-11):** any SMA/FoxESS driver, the read-back
|
||||
after `release()`, per-driver timing, DOCS.md's safety model and the
|
||||
"control stalled" sentence, the site-level "Held at 0 W" tiles in shadow mode on
|
||||
a mixed roster, and the MQTT per-unit setpoint of a handed-back unit (it still
|
||||
publishes 0).
|
||||
|
||||
**After a failed release** (returns False, raises or hangs past the bound) the
|
||||
unit gets one `write(0)` through `Driver.release_fallback()` (default
|
||||
`write(0)`; a driver where 0 W is not always safe overrides it), then nothing:
|
||||
a repeated 0 W would keep resetting the inverter's own fallback timer, so after
|
||||
that the unit is left to its own fallback. The release is retried once per
|
||||
heartbeat; one error line and one action-log event per failure streak, one line
|
||||
on recovery. Worst case at shutdown is `RELEASE_BUDGET_S` + `FALLBACK_BUDGET_S`
|
||||
(6 s).
|
||||
@@ -0,0 +1,55 @@
|
||||
# SMA-2 - each inverter is reached through a driver
|
||||
|
||||
**Status: for review.** Tests: `test_sma2_driver.py`. Source: SMA-9 report
|
||||
section 1.1. A pure refactor: no behaviour change for GoodWe, no change to a
|
||||
command, a log line or a sentence the add-on shows.
|
||||
|
||||
Every place that read or steered an inverter used the GoodWe ESPHome entities
|
||||
directly, so a second make meant touching all of them. `app/drivers.py` now
|
||||
holds one `Driver` per roster entry (`Inverter.driver`): `configured()`,
|
||||
`actuator_id()`, `limits()`, `read()`, `write()`, `release()`, `probe()`, and
|
||||
`link_stale_s`, `batt_bound`, `settle_s`, `brand`, `words`. `GoodWeEsphomeDriver`
|
||||
makes the same calls in the same order as the code it replaced; its
|
||||
`release()` is `write(0)`.
|
||||
|
||||
**Moved to the driver:** the SoC and power read (`_read_unit`), the write
|
||||
(`_write_unit`, still under the SAFETY-28 gate, the clamp, shadow mode and the
|
||||
write budget), the device range (`run_control`, `amain`, the settings panel),
|
||||
liveness and its window (`has_writer`, `is_live`, `link_note`), the freshness
|
||||
bound, the status rows' "is a source set" test, rule 2's per-unit range check
|
||||
and SAFETY-28's census. **Not moved:** the clamp, the gate, the heartbeat and
|
||||
`command_site_zero()`; stop is still 0 W for every unit, and nothing calls
|
||||
`release()` yet (SMA-10). `settle_s` and the control constants are not read
|
||||
from the driver yet either: with only GoodWe they would change nothing.
|
||||
|
||||
**The seam is not TCP-shaped.** The census asks each entry's driver for its
|
||||
actuator id: the setpoint entity for GoodWe, `host:port:unit_id` for a driver
|
||||
reached over a socket, so two units behind one gateway are two actuators and
|
||||
one unit named twice is the SAFETY-28 refusal. The census still counts every
|
||||
`setpoint_entity` key as before and adds the driver's own actuator to it (parity
|
||||
with 23ff9d0 on 349 option shapes is a test). The driver binds its Home
|
||||
Assistant on first use by the Controller, so a unit added later is wired too.
|
||||
|
||||
**Schema, and why the new keys are not in it.** The brief asks for
|
||||
`driver`, `host`, `port`, `unit_id`, `maintenance` per entry and for
|
||||
`setpoint_entity` to become optional. Owner decision 2026-11-07: clients see
|
||||
nothing new until SMA-4/SMA-7 use the keys. The Supervisor form shows every
|
||||
schema key (and marks required ones), and a nested entry with a key the schema
|
||||
lacks has that key silently dropped (measured, shape probe section K), so the
|
||||
only way to hide them is to keep them out of config.yaml. So for now config.yaml, the
|
||||
translations and the labels are exactly 23ff9d0's, and `setpoint_entity` stays
|
||||
required there: until a driver without one is selectable, no entry can lack it.
|
||||
The code underneath is the brief's: the driver defaults to `goodwe_esphome`,
|
||||
rule 9(d) is per driver (a GoodWe entry without a setpoint entity is refused,
|
||||
same sentence), and rule 9(h) checks `driver`, `host`, `port`, `unit_id`,
|
||||
`maintenance` when a caller that skips the schema (the panel's save path,
|
||||
sim/inject, the checks) supplies them; those sentences name the key, having no
|
||||
label. When SMA-4 needs them: add them to the schema as optional with
|
||||
translations, make `setpoint_entity` `str?`, and add them to `uilayout` as card
|
||||
fields or a hidden list.
|
||||
|
||||
**Checked against 23ff9d0:** replay digests and a log of every Home Assistant
|
||||
call are byte-identical at N=1 and N=2; the TEL-07 announce diff is 0/0/0; a
|
||||
fake driver beside a GoodWe unit allocates, grades, drops and publishes per
|
||||
unit; a call site that goes round the seam is caught.
|
||||
|
||||
@@ -97,9 +97,14 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory.
|
||||
- Prices are planned about a day ahead (`priceruns.HORIZON`), so a block of
|
||||
tomorrow later than 24 h from now, or one that needs prices not yet
|
||||
published, appears as the day comes near. The tab says so.
|
||||
- Today's blocks are Home's, and are not extended or shortened: control may
|
||||
plan more once they end, and Home draws it then. The curve stops charging
|
||||
at its level (`charge_to()`) and giving at the floor.
|
||||
- Today's blocks are Home's. UX-77: Home draws today's grid block to where
|
||||
control will stop charging, as the tab does for tomorrow's: the cheap run
|
||||
rolls on at its end while a battery is short (`cheaproll.roll_end()`, the
|
||||
same loop as `dayview._levels()`), the level read at the charge power the
|
||||
peak limit leaves (peak aim less the house, else the rating) and shared by
|
||||
room left to full as control shares it (`packshare.weights()`, re-read as
|
||||
the levels move), and each battery's block ends at its own stop. The curve stops charging at its level
|
||||
(`charge_to()`) and giving at the floor.
|
||||
- Today, a give run in the plan on a battery that is empty now has no block on
|
||||
Home (no energy to give). The tab equals Home, and says so: "The add-on plans
|
||||
to give power. No block shows while a battery has no energy to give." The
|
||||
|
||||
@@ -549,7 +549,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
|
||||
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: for each expensive hour, what the house uses less the sun counted for that hour (never below 0), added up over all the expensive hours, early morning and evening. With a day and night tariff that is 07:00 to 22:00. Not up to the top level.
|
||||
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. In the expensive hours the rest comes from another battery (*Cheap hours only* or *Solar first*) or from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
|
||||
- All *Leftover solar* batteries hold that shortfall together, shared by their size. A battery that reaches the top level passes the rest to the others. If all of them together cannot hold it, they all fill to the top level. *Cheap hours only* and *Solar first* batteries give first: what they will hold when the expensive hours start (a *Cheap hours only* battery full, or as far as it can charge under the peak limit in the cheap hours left; a *Solar first* battery after the night's use) is taken off the shortfall. In the expensive hours the rest comes from the grid. The Batteries screen says so. The sun before the expensive hours still fills first, and is taken off what is bought.
|
||||
- A forecast that gives only one figure for the day (Forecast.Solar) is spread over the hours from now, so the hours add up to that figure.
|
||||
- *Cheap hours only* ignores the forecast and fills to the top level.
|
||||
- *Solar first* never charges from the grid.
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""UX-77: where the cheap run rolls on. Display only; control is unchanged.
|
||||
|
||||
A cheap run is sized for the batteries charging together, at their full
|
||||
rating, and starts at the first of the cheapest hours. The peak limit leaves
|
||||
less power than that, so a battery is still short when the run ends, and
|
||||
control plans the next run then (`Controller._price_runs()`). On a flat night
|
||||
that goes on until the batteries are at their level or the cheap hours end.
|
||||
|
||||
`roll_end()` asks what control asks at each run end, of the same functions
|
||||
(`modetrade.energy()`, `priceruns.plan()`), until nothing is short or the next
|
||||
run does not follow on. `for_today()` reads the batteries at the level they
|
||||
will have by then: the charge power is the peak limit's room (peak aim minus
|
||||
the house, as `dayplan` counts it), shared as control shares it: by room
|
||||
left to full (`packshare.weights()`), read again as the levels move, so a
|
||||
battery that fills drops out and its share goes to the others. Where that
|
||||
room is not known (no house profile or no aim), the rating is used.
|
||||
DECISIONS/UX-54-forecast-tab.md has the record.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, dayplan, modetrade, packshare, priceruns
|
||||
from .dayplan import EFF
|
||||
from .price import required_spread, uncertainty_of
|
||||
|
||||
SLOT_S = 900 # a price slot
|
||||
STEP = timedelta(minutes=5) # how finely the level is followed
|
||||
LIMIT = 24 # rolls at most (a day of one-hour runs)
|
||||
|
||||
|
||||
def roll_end(run, points, packs_at, *, required, floor_pct, ceiling_pct,
|
||||
targets=None, limit=LIMIT):
|
||||
"""Where the cheap run `run` ends once control has rolled it on.
|
||||
|
||||
`packs_at(end)` gives the `modetrade.Pack`s as they stand when a run
|
||||
ends at `end`. At each end the charge still short is asked of
|
||||
`modetrade.energy()` and the next run of `priceruns.plan()`, as control
|
||||
asks them. It stops when nothing is short or the next run does not follow
|
||||
on, and is `run.end` when control would not roll it."""
|
||||
end_t = run.end
|
||||
for _ in range(limit):
|
||||
e = modetrade.energy(packs_at(end_t), floor_pct, ceiling_pct,
|
||||
targets=targets)
|
||||
if e[0] <= 1e-6:
|
||||
break
|
||||
new = priceruns.plan(points, end_t, required_eur_kwh=required,
|
||||
charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0,
|
||||
sell_kw=0.0)
|
||||
nxt = new.cheap
|
||||
if nxt is None or not new.charge_useful() or nxt.start > end_t \
|
||||
or nxt.end <= end_t:
|
||||
break
|
||||
end_t = nxt.end
|
||||
return end_t
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Roll:
|
||||
"""`end`: where control stops charging, `stops`: name -> when that battery
|
||||
reaches its level (rounded up to a price slot), None while it still
|
||||
charges at `end`. A battery not in `stops` does not charge in the run."""
|
||||
end: datetime
|
||||
stops: dict
|
||||
|
||||
def end_for(self, name, base_end):
|
||||
"""The end of `name`'s block: its stop, no later than `end`."""
|
||||
if name not in self.stops:
|
||||
return base_end
|
||||
stop = self.stops[name]
|
||||
return self.end if stop is None else min(self.end, stop)
|
||||
|
||||
def last(self, base_end):
|
||||
"""The last end of any battery's block (the chart's cheap bar)."""
|
||||
return max([base_end] + [self.end_for(n, base_end)
|
||||
for n in self.stops])
|
||||
|
||||
|
||||
def _ceil_slot(t):
|
||||
return datetime.fromtimestamp(
|
||||
math.ceil(t.timestamp() / SLOT_S) * SLOT_S, tz=t.tzinfo
|
||||
or timezone.utc)
|
||||
|
||||
|
||||
class _Charge:
|
||||
"""The batteries' levels followed from `t0` while the chargers charge at
|
||||
the room the peak limit leaves: `min(sum of ratings, aim - house)`, shared
|
||||
by room left to full as control does (`_shares()`), each stopping at `modetrade.charge_to()`."""
|
||||
|
||||
def __init__(self, packs, t0, ceiling_pct, targets, aim_w, house):
|
||||
self.packs = {p.name: p for p in packs}
|
||||
self.t = t0
|
||||
self.ceil = ceiling_pct
|
||||
self.targets = targets
|
||||
self.aim = aim_w
|
||||
self.house = house
|
||||
self.stops = {}
|
||||
|
||||
def at(self, end):
|
||||
"""The packs when a run ends at `end`."""
|
||||
self._to(end)
|
||||
return list(self.packs.values())
|
||||
|
||||
def _room_w(self, hour, total):
|
||||
if self.aim is None or self.house is None:
|
||||
return total
|
||||
house_w, base_w = self.house
|
||||
return min(total, max(0.0, float(self.aim)
|
||||
- max(house_w(hour), base_w, 0.0)))
|
||||
|
||||
def _to(self, end):
|
||||
while self.t < end:
|
||||
nxt = min(end, self.t + STEP)
|
||||
for s, e, hour in dayplan._hour_slices(self.t, nxt):
|
||||
self._charge(s, e, hour)
|
||||
self.t = nxt
|
||||
|
||||
def _shares(self, ch, w):
|
||||
"""Watts per charger: `w` shared by `packshare.weights()` over all
|
||||
packs (room left to full, as control passes it), no charger above its
|
||||
rating, the surplus going to the others. By rating where the room left
|
||||
is not known (no capacity)."""
|
||||
wt = packshare.weights(list(self.packs.values()), -1.0)
|
||||
if wt is None:
|
||||
wt = {p.name: abs(p.rating_w) for p in ch}
|
||||
wt = {p.name: wt.get(p.name, 0.0) for p in ch}
|
||||
cap = {p.name: abs(p.rating_w) for p in ch}
|
||||
out, open_, left = {}, set(wt), float(w)
|
||||
while open_:
|
||||
tot = sum(wt[n] for n in open_)
|
||||
if tot <= 0.0:
|
||||
for n in open_:
|
||||
out[n] = 0.0
|
||||
break
|
||||
over = [n for n in open_ if left * wt[n] / tot > cap[n] + 1e-9]
|
||||
if not over:
|
||||
for n in open_:
|
||||
out[n] = left * wt[n] / tot
|
||||
break
|
||||
for n in over:
|
||||
out[n] = cap[n]
|
||||
left -= cap[n]
|
||||
open_.discard(n)
|
||||
return out
|
||||
|
||||
def _charge(self, s, e, hour):
|
||||
ch = modetrade.chargers(list(self.packs.values()), self.ceil,
|
||||
targets=self.targets)
|
||||
total = sum(abs(p.rating_w) for p in ch)
|
||||
if not ch or total <= 0.0:
|
||||
return
|
||||
w = self._room_w(hour, total)
|
||||
hrs = (e - s).total_seconds() / 3600.0
|
||||
share = self._shares(ch, w)
|
||||
for p in ch:
|
||||
self.stops.setdefault(p.name, None)
|
||||
if not p.capacity_kwh:
|
||||
continue
|
||||
to = modetrade.charge_to(p, self.ceil, self.targets)
|
||||
kwh = share[p.name] * hrs / 1000.0 * EFF
|
||||
room = (to - p.soc) / 100.0 * float(p.capacity_kwh)
|
||||
if kwh <= 0.0:
|
||||
continue
|
||||
if kwh >= room - 1e-9:
|
||||
self.stops[p.name] = _ceil_slot(
|
||||
s + (e - s) * (room / kwh))
|
||||
kwh = room
|
||||
self.packs[p.name] = dataclasses.replace(
|
||||
p, soc=p.soc + kwh / float(p.capacity_kwh) * 100.0)
|
||||
|
||||
|
||||
def for_today(c, rp, now):
|
||||
"""The `Roll` of the cheap run `rp.cheap` now under way or to come today,
|
||||
or None when control does not roll it on (no run left, charging not
|
||||
useful, the trade gate shut, nothing short at its end). Display only:
|
||||
nothing here is fed back to control."""
|
||||
if rp is None or not rp.on or rp.cheap is None or rp.cheap.end <= now \
|
||||
or not rp.charge_useful() \
|
||||
or getattr(c, "mode_gate", None) is not None:
|
||||
return None
|
||||
plan = c.fresh_tariff_plan(now)
|
||||
if plan is None:
|
||||
return None
|
||||
floor, ceil = arbitrage.soc_bounds(c.o)
|
||||
hp = getattr(c, "house_profile", None)
|
||||
sim = _Charge(c._mode_packs(), max(now, rp.cheap.start), ceil,
|
||||
c.plan_targets(), c.peak_aim_w(),
|
||||
None if hp is None else hp.estimator())
|
||||
required = required_spread(
|
||||
arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty_of(plan.read.product)
|
||||
| arbitrage.weather_uncertainty(True, c.solar_fc,
|
||||
bool(c.weather_held(now))))
|
||||
end = roll_end(rp.cheap, plan.read.points, sim.at, required=required,
|
||||
floor_pct=floor, ceiling_pct=ceil,
|
||||
targets=c.plan_targets())
|
||||
if end <= rp.cheap.end:
|
||||
return None
|
||||
return Roll(end, dict(sim.stops))
|
||||
@@ -298,6 +298,9 @@ PROBE_S = 34.0
|
||||
# command: every battery that may move this way is at its mode's limit.
|
||||
MODE_BOUND = "mode-bound"
|
||||
|
||||
# SAFETY-128. The loop's reason while it waits for the readings to answer.
|
||||
WAITING = "waiting for readings"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -486,6 +489,8 @@ def compute(
|
||||
bound: tuple | None = None,
|
||||
gave_nothing: bool | None = None,
|
||||
idle_w: float = 0.0,
|
||||
hold: int = 0,
|
||||
hold_w: float | None = None,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -506,6 +511,9 @@ def compute(
|
||||
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
|
||||
nothing (app/idle.py). The refusal's band and its end are
|
||||
centred on it. 0 is the law as it was.
|
||||
`hold` SAFETY-128: +1 / -1, the batteries have not answered the last
|
||||
command that way (app/lateness.py), so the loop may not go
|
||||
past `hold_w` (None: `prev_w`) that way. 0 is the law as it was.
|
||||
"""
|
||||
reason = "tracking"
|
||||
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
|
||||
@@ -622,6 +630,11 @@ def compute(
|
||||
i_w = min(moved, i_w)
|
||||
else:
|
||||
i_w = max(moved, i_w)
|
||||
# SAFETY-128: no further than `hold_w` until the readings answer the ask.
|
||||
held_at = float(prev_w if hold_w is None else hold_w)
|
||||
if hold and hold * (i_w - held_at) > 0:
|
||||
i_w = held_at
|
||||
reason = WAITING
|
||||
|
||||
# OPT-11: the integrator does not wind past what the modes let the
|
||||
# batteries place. Only a side tighter than the rail is left in `bound`
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
"""Controller methods that raise and clear the alarms.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import allocator
|
||||
from . import control as _ctl
|
||||
from .circulation import opposing
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, delivery_short, shortfall_note,
|
||||
write_clamp_note,
|
||||
)
|
||||
from . import messages
|
||||
from .mqtt import unit_slugs
|
||||
from . import peakguard
|
||||
from .price import brussels
|
||||
from .price_chain import fixed_tariff_beside_service
|
||||
from .ctl_writes import REFUSED_SPAN_S
|
||||
from .ctl_inputs import _mono
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
# SAFETY-34: a Core-only restart wipes persistent_notification without
|
||||
# restarting this add-on, so a latched alarm is re-asserted this often.
|
||||
REANNOUNCE_S = 300.0
|
||||
|
||||
|
||||
# SAFETY-85: a battery another owner set is graded against the least it was
|
||||
# told over the plant's settle time, so a battery still settling is not short.
|
||||
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
|
||||
|
||||
|
||||
# SAFETY-119/121: after a hand-back the loop's bound is hidden for
|
||||
# HANDBACK_CYCLES / SHORT_SPAN_S, and across hand-backs on at most
|
||||
# ROLLING_BOUND_CYCLES cycles per ROLLING_S
|
||||
# (DECISIONS/SAFETY-121-bound-every-quarter.md). Without a monotonic clock the
|
||||
# budget never refills.
|
||||
HANDBACK_CYCLES = 4
|
||||
|
||||
|
||||
ROLLING_S = 900.0
|
||||
|
||||
|
||||
ROLLING_BOUND_CYCLES = 11
|
||||
|
||||
|
||||
class AlarmsMixin:
|
||||
def _loop_alarm_input(self, resolution) -> None:
|
||||
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
|
||||
(a rating may cut it). A battery another owner set to discharge counts
|
||||
when it gave less than it was told for `saturation_cycles` cycles in a
|
||||
row. DECISIONS/SAFETY-85-no-false-not-keeping-up.md"""
|
||||
d = self._loop_decision
|
||||
if d is None:
|
||||
self._short_run = 0
|
||||
self._asked_hist = {}
|
||||
self._wire_lost, self._handback = False, None
|
||||
return
|
||||
unmet = self.allocation.unmet_w if self.allocation is not None else 0
|
||||
cut = (resolution.bound_by or "").split("(")[0]
|
||||
owns = (resolution.winner == "loop" and not d.held and not unmet
|
||||
and (resolution.target_w == d.target_w
|
||||
or cut in ("device", "supervised")))
|
||||
# Graded against the least that stood on the wire (0 W in shadow
|
||||
# mode) over SHORT_CYCLES cycles and SHORT_SPAN_S seconds.
|
||||
now, asked = self._now_mono, {}
|
||||
for inv in self.inverters:
|
||||
h = self._asked_hist.setdefault(inv.name, [])
|
||||
h.append((now, inv.standing_w()))
|
||||
while len(h) > SHORT_CYCLES and (
|
||||
now is None or h[0][0] is None
|
||||
or now - h[0][0] > SHORT_SPAN_S):
|
||||
h.pop(0)
|
||||
asked[inv.name] = min(w for _t, w in h)
|
||||
others = [inv for inv in self.inverters if not owns
|
||||
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
|
||||
is not None]
|
||||
short = [(inv, asked[inv.name], inv.batt) for inv in others
|
||||
if inv.batt is not None
|
||||
and delivery_short(asked[inv.name], inv.batt,
|
||||
self.tuning.saturation_w)]
|
||||
self._short_run = self._short_run + 1 if short else 0
|
||||
settling = self._settling(owns, now)
|
||||
if owns and self.loop_clamped[0] and not (
|
||||
settling and not self.loop_clamped[2]):
|
||||
return
|
||||
engaged = self._short_run >= self.tuning.saturation_cycles
|
||||
named = len(self.inverters) > 1
|
||||
self.loop_clamped = (engaged, shortfall_note(
|
||||
[(inv.name if named else None, a, g) for inv, a, g in short])
|
||||
if engaged else "", False)
|
||||
self.loop_clamp_facts = ()
|
||||
|
||||
def _settling(self, owns, now) -> bool:
|
||||
"""SAFETY-119: does the loop's bound on this cycle count? Not within a
|
||||
settle (HANDBACK_CYCLES cycles and SHORT_SPAN_S) after another owner
|
||||
handed the wire back; across all hand-backs the bound is hidden on at
|
||||
most ROLLING_BOUND_CYCLES cycles in any ROLLING_S.
|
||||
DECISIONS/SAFETY-121-bound-every-quarter.md"""
|
||||
def aged(t0, span):
|
||||
return now is None or t0 is None or now - t0 >= span
|
||||
|
||||
if not owns:
|
||||
self._wire_lost, self._handback = True, None
|
||||
return False
|
||||
if self._wire_lost:
|
||||
self._wire_lost, self._handback = False, (now, 0)
|
||||
if self._handback is None:
|
||||
return False
|
||||
tc, n = self._handback
|
||||
n += 1
|
||||
self._handback = (tc, n)
|
||||
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
|
||||
self._handback = None
|
||||
return False
|
||||
if self.loop_clamped[0] and not self.loop_clamped[2]:
|
||||
kept = tuple(t for t in self._hidden if now is None or (
|
||||
t is not None and now - t < ROLLING_S))
|
||||
if len(kept) >= ROLLING_BOUND_CYCLES:
|
||||
self._hidden = kept
|
||||
return False
|
||||
self._hidden = kept + (now,)
|
||||
return True
|
||||
|
||||
async def check_output_clamp(self) -> None:
|
||||
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle. Runs
|
||||
AFTER write_setpoint() (the clamp is arithmetic done inside the write).
|
||||
⚠️ PUBLISHING IS NOT IN THE WRITE PATH: a stalled supervisor must never
|
||||
delay or drop a frame. ⚠️ THIS IS NOT A TRIP: no claim, no latch, 0 W
|
||||
is not commanded (§11.2: clamp and alarm); turning it into one is a
|
||||
deliberate change. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
# ⚠️ THE EVIDENCE IS PER UNIT AND THE ALARM IS NOT: `clamp_last` is on
|
||||
# the Inverter, `ClampWatchdog` is one per controller (an anti-flood
|
||||
# counter), so any unit that clamped is an engagement and the reason
|
||||
# names it. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
clamped_units = [inv for inv in self.inverters
|
||||
if inv.clamp_last is not None and inv.clamp_last.engaged]
|
||||
engaged_write = bool(clamped_units)
|
||||
engaged_loop, loop_note, loop_bug = self.loop_clamped
|
||||
engaged = engaged_write or engaged_loop
|
||||
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
|
||||
bug = engaged_write or loop_bug
|
||||
if engaged_write:
|
||||
_named = len(self.inverters) > 1
|
||||
reason = write_clamp_note([(inv.name if _named else None,
|
||||
inv.clamp_last)
|
||||
for inv in clamped_units])
|
||||
facts = [messages.Clamp(inv.name if _named else None,
|
||||
inv.clamp_last.requested_w,
|
||||
inv.clamp_last.applied_w,
|
||||
inv.clamp_last.bound)
|
||||
for inv in clamped_units]
|
||||
else:
|
||||
reason = loop_note if engaged else ""
|
||||
facts = self.loop_clamp_facts if engaged else ()
|
||||
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
|
||||
bug=bug, facts=facts)
|
||||
|
||||
# THE ACTION LOG IS COALESCED PER EPISODE: consecutive engagements
|
||||
# update one row's count. ⚠️ NOT COSMETIC: the log keeps 40 rows and a
|
||||
# winding loop clamps every cycle, so a row each flushed SAFETY-18's
|
||||
# "METER FROZEN?" row. Here as well as in write_setpoint() because on a
|
||||
# default install the actuator-side clamp is unreachable.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if engaged and not engaged_write:
|
||||
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
||||
row = (f"{tag}: "
|
||||
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
|
||||
f" - {reason}")
|
||||
if self.events and tag in self.events[0]:
|
||||
stamp = self.events[0].split(" ", 1)[0]
|
||||
self.events[0] = f"{stamp} {row}"
|
||||
else:
|
||||
self.log_event(row, hint=messages.hint(
|
||||
"power_too_high" if bug else "not_keeping_up"))
|
||||
if not fired:
|
||||
return
|
||||
if not self.clamp_wd.bug:
|
||||
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
|
||||
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
||||
# it can never replace a BUG SIGNAL notice still on screen.
|
||||
_LOG.warning("The battery gives less than asked: %s. Held back %s "
|
||||
"so far.", messages.reason_code(self.clamp_wd.reason),
|
||||
messages.times(self.clamp_wd.engagements))
|
||||
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
||||
_LOG.debug("A short hold after a large load or solar step is "
|
||||
"normal. If it lasts, check that the battery follows "
|
||||
"its commands.")
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.loop_bound().data())
|
||||
return
|
||||
_LOG.error(
|
||||
"Power request too high, a software fault: %s. Cut back %s so "
|
||||
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
|
||||
messages.times(self.clamp_wd.engagements))
|
||||
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-03: silently clipping this is how a winding integrator "
|
||||
"reached 14 768 W on this project before. The clamp makes every "
|
||||
"dashboard read a legal number while the thing producing it goes "
|
||||
"on climbing. This is an alarm, not a trip.")
|
||||
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
||||
# and a DIFFERENT one from every other alarm this add-on raises,
|
||||
# because a clamp is not a frozen meter and neither is a saturation
|
||||
# freeze. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.output_clamp(self.clamp_wd.facts,
|
||||
shadow=self.shadow).data())
|
||||
|
||||
async def check_peak_saturated(self) -> None:
|
||||
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter. ⚠️ An
|
||||
engagement is not an alarm: a peak floor above the rating is a house
|
||||
load bigger than the inverter, not a defect (unlike SAFETY-03's winding
|
||||
integrator). SAFETY-81: `g.saturated` must agree with
|
||||
`peakguard.load_too_high()` for `ALARM_HOLD_S`. Edge-triggered: one
|
||||
notification per episode, at most once per quarter.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
g = self.peak_guard
|
||||
batt, prev = self.site_batt_w, self._peak_batt_prev
|
||||
if batt is None:
|
||||
# Unknown, not "fine": the hold below neither grows nor restarts.
|
||||
return
|
||||
self._peak_batt_prev = batt
|
||||
if prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
p = self._peak_proj
|
||||
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
|
||||
# projection read "no seconds left" (not high), which restarted the
|
||||
# hold; a rolled one (battery modes on) is not graded then, so a
|
||||
# persistent overload is still notified once per quarter.
|
||||
high = bool(
|
||||
g is not None and g.saturated and p is not None
|
||||
and p.block_start is not None and not self._peak_rolled
|
||||
and peakguard.load_too_high(
|
||||
p, batt_w=batt, ceiling_w=g.ceiling_w,
|
||||
achievable_w=self.site_rating_w))
|
||||
# SAFETY-81: and for ALARM_HOLD_S, on the projection's clock.
|
||||
saturated = False
|
||||
if high:
|
||||
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
|
||||
if self._peak_high_since is None:
|
||||
self._peak_high_since = t
|
||||
saturated = ((t - self._peak_high_since).total_seconds()
|
||||
>= peakguard.ALARM_HOLD_S)
|
||||
else:
|
||||
self._peak_high_since = None
|
||||
was, self._peak_saturated = self._peak_saturated, saturated
|
||||
if not saturated or was:
|
||||
return
|
||||
_LOG.error(
|
||||
"Switch off large loads, the battery alone cannot keep this "
|
||||
"15-minute period under the peak limit: %s.",
|
||||
messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# SAFETY-81: notified once per quarter; a later episode is logged only.
|
||||
if p.block_start == self._peak_alarm_block:
|
||||
return
|
||||
self._peak_alarm_block = p.block_start
|
||||
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
||||
hint=messages.hint("peak_saturated"))
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.peak_saturated(
|
||||
g.ceiling_w, shadow=self.shadow).data())
|
||||
|
||||
async def check_peak_floor_limited(self) -> None:
|
||||
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. ⚠️
|
||||
FAILING TO SHAVE MUST NOT BE SILENT (the capacity tariff bills the
|
||||
month's worst quarter). Not check_peak_saturated(): that is shed-load,
|
||||
this is nothing discharged; never both. ⚠️ SAFETY-25: the message says
|
||||
the floor binds this guard only; the loop has no SoC floor and goes on
|
||||
discharging. Edge-triggered. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
g = self.peak_guard
|
||||
limited = bool(g is not None and g.floor_limited)
|
||||
if limited and (g.floor_w or 0.0) > 0.0:
|
||||
limited = self._reserve_short(g)
|
||||
else:
|
||||
self._reserve_batt_prev = self._reserve_high_since = None
|
||||
# SAFETY-126: nothing can give, so it alarms at once, but only
|
||||
# when the quarter really misses the aim (`_peak_landing()`).
|
||||
limited = limited and not g.within_aim
|
||||
was, self._peak_floor_limited = self._peak_floor_limited, limited
|
||||
if not limited or was:
|
||||
return
|
||||
# No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
|
||||
# a second copy is the kind of stutter that makes a 40-line action log
|
||||
# read like boilerplate. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
|
||||
_LOG.error(
|
||||
"Switch off large loads, the peak guard may not draw the battery "
|
||||
"lower: %s.", messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. ⚠️ SAFETY-25's
|
||||
# sentence (the reserve binds the peak guard only) is in
|
||||
# messages.peak_reserve() on every branch, unreadable SoC included.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.peak_reserve(
|
||||
g.ceiling_w, soc_unread=g.soc is None,
|
||||
floor_unknown=(g.soc is not None
|
||||
and g.soc_floor is None),
|
||||
packs=(g.held if len(self.inverters) > 1
|
||||
else ()),
|
||||
others=(g.floor_w or 0.0) > 0.0).data())
|
||||
|
||||
def _reserve_short(self, g) -> bool:
|
||||
"""SAFETY-87: the batteries that can still give, at full power, miss
|
||||
the limit (`load_too_high()` on `g.able_w`), for ALARM_HOLD_S, as
|
||||
SAFETY-81 grades 'Load too high'."""
|
||||
p, batt = self._peak_proj, self.site_batt_w
|
||||
if batt is None:
|
||||
return self._peak_floor_limited
|
||||
prev, self._reserve_batt_prev = self._reserve_batt_prev, batt
|
||||
if prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
if not (p is not None and p.block_start is not None
|
||||
and peakguard.load_too_high(p, batt_w=batt,
|
||||
ceiling_w=g.ceiling_w,
|
||||
achievable_w=g.able_w)):
|
||||
self._reserve_high_since = None
|
||||
return False
|
||||
t = p.block_start + timedelta(seconds=float(p.elapsed_s))
|
||||
if self._reserve_high_since is None:
|
||||
self._reserve_high_since = t
|
||||
return ((t - self._reserve_high_since).total_seconds()
|
||||
>= peakguard.ALARM_HOLD_S)
|
||||
|
||||
async def check_circulating(self) -> None:
|
||||
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
|
||||
THE LATCH IS NOT THE ALARM: the notification is in memory, so
|
||||
`_circ_announced` (per-PROCESS, not persisted) makes a restart re-raise
|
||||
it, and REANNOUNCE_S re-asserts it after a Core-only restart. ⚠️ ONE
|
||||
CODE PATH for a fresh trip and a restart into one.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
if not self.circ_wd.tripped:
|
||||
return
|
||||
if self._circ_announced and not self._reannounce_due("goodwe_circulating_power"):
|
||||
return
|
||||
# Set BEFORE the await. `hass.call()` can raise or hang, and a retry
|
||||
# every cycle for a broker that is down would be an alarm storm on the
|
||||
# event bus on top of a site that is already stopped.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self._circ_announced = True
|
||||
self._announced_mono["goodwe_circulating_power"] = _mono()
|
||||
names = self.units
|
||||
pos, neg = opposing([inv.batt for inv in self.inverters],
|
||||
min_w=self.circ_wd.cfg.min_w / 2.0)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# this add-on raises - check_meter_frozen() gives the reasoning.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.circulating(
|
||||
[names[i] for i in pos] if pos and neg else (),
|
||||
[names[i] for i in neg] if pos and neg else (),
|
||||
).data())
|
||||
|
||||
def _reannounce_due(self, notification_id: str) -> bool:
|
||||
"""SAFETY-34: has this latched alarm gone REANNOUNCE_S without being re-asserted?"""
|
||||
t = self._announced_mono.get(notification_id)
|
||||
return t is None or _mono() - t >= REANNOUNCE_S
|
||||
|
||||
async def check_not_delivering(self) -> None:
|
||||
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
|
||||
THE LATCH IS NOT THE ALARM: the notification is in memory, so
|
||||
`_delivery_announced` (a per-PROCESS set of unit names, not persisted)
|
||||
makes a restart re-raise it, and REANNOUNCE_S re-asserts it. ⚠️ ONE
|
||||
CODE PATH for a fresh trip and a restart into one. ⚠️ ONE ID PER UNIT
|
||||
(`unit_slugs()`): two wedged inverters give two cards.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
names = self.units
|
||||
slugs = unit_slugs(names)
|
||||
for inv, slug in zip(self.inverters, slugs):
|
||||
nid = f"goodwe_not_delivering_{slug}"
|
||||
if not inv.delivery_wd.tripped:
|
||||
continue
|
||||
if inv.name in self._delivery_announced and not self._reannounce_due(nid):
|
||||
continue
|
||||
# Added BEFORE the await, for `check_circulating()`'s reason:
|
||||
# `hass.call()` can raise or hang, and a retry every cycle for a
|
||||
# broker that is down would be an alarm storm on the event bus.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self._delivery_announced.add(inv.name)
|
||||
self._announced_mono[nid] = _mono()
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.not_delivering(inv.name, slug).data())
|
||||
|
||||
async def check_price_gap(self) -> None:
|
||||
"""PRICE-01. One notification when no price covers now, gone when one
|
||||
does. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
if self.price_gap and not self._price_gap_announced:
|
||||
self._price_gap_announced = True
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.no_prices(*self._gap_args()).data())
|
||||
elif not self.price_gap and self._price_gap_announced:
|
||||
self._price_gap_announced = False
|
||||
await self.hass.call("persistent_notification", "dismiss",
|
||||
messages.prices_back().data())
|
||||
|
||||
async def check_fixed_note(self) -> None:
|
||||
"""PRICE-01 G1. First cycle only: one INFO line when `static_profile`
|
||||
sits beside `price_service` (the plan's last fallback, not a fault, so
|
||||
no notification). (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
if self._fixed_note_said:
|
||||
return
|
||||
self._fixed_note_said = True
|
||||
if fixed_tariff_beside_service(self.o):
|
||||
_LOG.info(messages.FIXED_TARIFF_LOG)
|
||||
try:
|
||||
await self.hass.call("persistent_notification", "dismiss",
|
||||
messages.fixed_tariff_card_gone().data())
|
||||
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
|
||||
_LOG.debug("Could not take down the old fixed-tariff card.")
|
||||
|
||||
def _gap_args(self) -> tuple:
|
||||
"""`(since, now)` in Brussels time for the gap's words. `since` is None
|
||||
when no price was ever published."""
|
||||
now = brussels(datetime.now(timezone.utc))
|
||||
if self.price_gap_since is None:
|
||||
return None, now
|
||||
return brussels(self.price_gap_since), now
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
Notification only: no claim, no latch, no 0 W (freeze.py carries the
|
||||
reasoning). getattr, not an attribute access: `unchanged_s` exists only
|
||||
on `homewizard_local`, and `p1_source` is None until amain() builds the
|
||||
transport. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
||||
# SITE, all three inputs: one meter, the SITE target the arbiter
|
||||
# resolved, and the rail the SITE could stand at - which is the summed
|
||||
# per-unit conjunction, so a small inverter B lowers the site rail rather
|
||||
# than being graded against inverter A's.
|
||||
fired = self.freeze_wd.evaluate(unchanged, self.target,
|
||||
self.site_max_w,
|
||||
datetime.now(timezone.utc),
|
||||
dev_min=self.site_dev_bound("dev_min"),
|
||||
dev_max=self.site_dev_bound("dev_max"))
|
||||
if not fired:
|
||||
return
|
||||
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
|
||||
hint=messages.hint("meter_frozen"))
|
||||
_LOG.error(
|
||||
"The meter shows the same value while the battery is asked for "
|
||||
"full power, and control still follows it, so check the meter: "
|
||||
"%s.", messages.reason_code(self.freeze_wd.reason))
|
||||
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
||||
"its age is fresh, the firmware watchdog is fed and the arbiter "
|
||||
"only bounds the magnitude. Kept up for a quarter of an hour, it "
|
||||
"sets the month's billed capacity peak.")
|
||||
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
|
||||
# rather than stacking. An operator who has to dismiss eleven identical
|
||||
# notifications learns to dismiss the twelfth without reading it.
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.meter_frozen(unchanged).data())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
"""Controller methods that read the inputs: meter, packs, PV, forecast.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .arbiter import actuator_for
|
||||
from . import solarfc
|
||||
from . import freshness
|
||||
from . import lateness
|
||||
from . import messages
|
||||
from . import pv as pv_read
|
||||
from .price import brussels, price_option, provider_configured
|
||||
from .price_chain import uses_chain
|
||||
from .static_tariff import static_status
|
||||
from .rows import tariff_plan_row
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
# UX-11: a quiet meter logs at most one WARNING this often (on `ha_signed` a
|
||||
# healthy meter can go 42-97 s between changes).
|
||||
METER_QUIET_REPEAT_S = 600.0
|
||||
|
||||
|
||||
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
|
||||
|
||||
|
||||
# OPT-04: the background tariff refresh. The tick NEVER awaits a fetch: it
|
||||
# reads `self.tariff_plan`, which the refresh task assigns.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
TARIFF_REFRESH_S = 1800.0
|
||||
|
||||
|
||||
# The age bound covers the refresh task dying: three missed refreshes.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
|
||||
|
||||
|
||||
# OPT-27: the forecast sensors' last fresh readings (`solarfc.Hold`), so an
|
||||
# evening restart keeps the night plan's figure.
|
||||
FC_HOLD_KEY = "solar_fc_hold"
|
||||
|
||||
|
||||
FORECAST_READ_S = 60.0
|
||||
|
||||
|
||||
class InputsMixin:
|
||||
def _readings_late(self, standing_w):
|
||||
"""SAFETY-128: update the answer gate; seconds the late verdict has
|
||||
stood, or None. DECISIONS/SAFETY-128-late-readings.md."""
|
||||
a = self.answer
|
||||
if self._now_mono is None:
|
||||
return None
|
||||
a.observe(self._now_mono, standing_w, self.site_batt_w,
|
||||
grid_w=self.grid, exempt=bool(self.batt_refused) or any(
|
||||
i.refused or (
|
||||
i.batt is not None
|
||||
and abs(i.standing_w()) >= lateness.ANSWER_W
|
||||
and abs(i.batt - i.idle_w) < lateness.ANSWER_W)
|
||||
for i in self.inverters),
|
||||
idle_w=self._site_idle_w())
|
||||
if a.late_seen != self._late_logged:
|
||||
self._late_logged = a.late_seen
|
||||
if a.late_seen:
|
||||
self.log_event("readings late - holding",
|
||||
hint=messages.hint("readings_late"))
|
||||
_LOG.warning("Home Assistant readings arrive late, so control "
|
||||
"holds the batteries: READINGS_LATE.")
|
||||
_LOG.debug("READINGS_LATE: the readings show a power request "
|
||||
"%.0f s after it is sent.", a.delay_s)
|
||||
else:
|
||||
self.log_event("readings on time", hint=messages.hint(
|
||||
"readings_back"))
|
||||
_LOG.info("Home Assistant readings are on time again.")
|
||||
return a.late_s(self._now_mono)
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings: every pack, not
|
||||
the live ones. A unit whose SoC did not answer cannot be graded for
|
||||
the maintenance FSM or the pack floor, and 11.1 rule 1 for a missing
|
||||
input is command zero and keep commanding."""
|
||||
if self.grid is None or not self.inverters:
|
||||
return False
|
||||
return all(inv.soc is not None and inv.batt is not None
|
||||
for inv in self.inverters)
|
||||
|
||||
def missing_reading(self) -> str:
|
||||
"""UX-11: the first reading `inputs_ok` finds missing, in words."""
|
||||
if self.grid is None or not self.inverters:
|
||||
return "meter"
|
||||
for inv in self.inverters:
|
||||
of = "" if len(self.inverters) == 1 else f" from {inv.name}"
|
||||
if inv.batt is None:
|
||||
return f"battery power{of}"
|
||||
if inv.soc is None:
|
||||
return f"battery level{of}"
|
||||
return "input"
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
@property
|
||||
def pv_roster(self) -> tuple:
|
||||
"""Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE
|
||||
reading of the options, `pv.entities()`, the same call
|
||||
`mqtt.optional_keys()` makes, so what is ANNOUNCED, READ and CHECKED
|
||||
cannot disagree; it replaced a `pv_entity` property."""
|
||||
return pv_read.entities(self.o)
|
||||
|
||||
async def _read_pv(self) -> None:
|
||||
"""This cycle's SITE PV production, or an ABSENCE with a reason
|
||||
(TEL-11/12). `pv.total()` makes "one of three absent" an absence of
|
||||
the SITE figure. It assigns on EVERY call including failing ones (a
|
||||
stale value must not survive a failure), and uses `hass.state()` not
|
||||
`hass.number()` because the UNIT is half of the answer."""
|
||||
# TEL-12: every entity in ONE gather. Read serially, three slow sensors
|
||||
# would add three 10 s timeouts to a cycle that feeds the ESP32
|
||||
# heartbeat; telemetry must have no foot in the control path.
|
||||
roster = self.pv_roster
|
||||
states = await asyncio.gather(
|
||||
*(self.hass.state(entity) for entity in roster))
|
||||
self.pv_w, self.pv_why, self.pv_parts = pv_read.total(
|
||||
list(zip(roster, states)), pv_read.inverted(self.o))
|
||||
# UX-54: kept for the Forecast tab only. It must never cost a cycle.
|
||||
try:
|
||||
self.solar_today.add(datetime.now(timezone.utc), self.pv_w)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOG.debug("measured solar not kept", exc_info=True)
|
||||
|
||||
def _peak_fc_unit_log(self, fault) -> None:
|
||||
"""SAFETY-118. One WARNING when the peak forecast's unit cannot be read
|
||||
in watts, and again only if the unit changes; the unit is on DEBUG."""
|
||||
if fault == self._peak_fc_fault:
|
||||
return
|
||||
self._peak_fc_fault = fault
|
||||
if fault is None:
|
||||
_LOG.info("The 15-minute demand forecast is used again.")
|
||||
elif fault == "":
|
||||
_LOG.warning("The 15-minute demand forecast has no unit, so it "
|
||||
"is not used.")
|
||||
else:
|
||||
_LOG.warning("The 15-minute demand forecast is not in a power "
|
||||
"unit, so it is not used.")
|
||||
_LOG.debug("The 15-minute demand forecast reads in %r.", fault)
|
||||
|
||||
async def _read_forecast(self) -> None:
|
||||
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
|
||||
failed or stale read is no forecast, never a zero."""
|
||||
# OPT-17: every member of both days in one gather.
|
||||
ents = self.fc_entities + self.fc_tomorrow_entities
|
||||
states = await asyncio.gather(*(self.hass.state(e) for e in ents))
|
||||
n = len(self.fc_entities)
|
||||
now = datetime.now(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now,
|
||||
hold=self.fc_hold)
|
||||
if self.fc_hold.changed:
|
||||
self.fc_hold.changed = False
|
||||
self.store.set(FC_HOLD_KEY, self.fc_hold.dump())
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
was_kept = bool(self.solar_fc is not None and self.solar_fc.kept)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps \
|
||||
or bool(fc is not None and fc.kept) != was_kept:
|
||||
self.log_event(
|
||||
"solar forecast: " + (
|
||||
"none (" + str(why) + ")" if fc is None else
|
||||
f"read, kept from {brussels(fc.at):%H:%M}" if fc.kept
|
||||
else "read")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
"forecast_none" if fc is None else
|
||||
"forecast_no_tomorrow" if gaps else "forecast_read"))
|
||||
self.solar_fc, self.solar_fc_why = fc, why
|
||||
self.solar_fc_gaps = gaps
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
`now_mono` is defaulted rather than read inside `_read_unit()` so
|
||||
ONE instant covers every unit in a gather."""
|
||||
o = self.o
|
||||
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
||||
self._now_mono = now_mono
|
||||
if self.p1_enabled:
|
||||
# P1 is the only authoritative measurement of what the utility sees
|
||||
# (5.1); when stale this is None -> "inputs missing -> command 0
|
||||
# W". NO fallback to an inverter-side figure: its AC power
|
||||
# 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
|
||||
else:
|
||||
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
||||
bool(o.get("meter_invert")))
|
||||
# DEV-02: each inverter's own two readings from its own entities,
|
||||
# CONCURRENTLY across units and sequentially within one (a slow entity
|
||||
# must not delay the heartbeat path). The PV reads ride in the same
|
||||
# gather and an empty PV roster appends nothing (no coroutine).
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
|
||||
if self.pv_roster:
|
||||
_reads.append(self._read_pv())
|
||||
# OPT-11 P4: the solar forecast, in the same gather (a slow sensor must
|
||||
# not delay control), at most once per FORECAST_READ_S.
|
||||
if self.fc_entity is not None and (
|
||||
self._fc_read_mono is None
|
||||
or now_mono - self._fc_read_mono >= FORECAST_READ_S
|
||||
or now_mono < self._fc_read_mono):
|
||||
self._fc_read_mono = now_mono
|
||||
_reads.append(self._read_forecast())
|
||||
await asyncio.gather(*_reads)
|
||||
if o.get("peak_forecast_entity"):
|
||||
# SAFETY-118: read in its own unit like the PV path, one state
|
||||
# read. No unit or a non-power unit: not used, as if the option
|
||||
# were empty.
|
||||
st = await self.hass.state(o["peak_forecast_entity"])
|
||||
self.peak_fc, _why = pv_read.watts(st)
|
||||
# A missing reading leaves the unit-fault state as it was.
|
||||
if pv_read.has_reading(st):
|
||||
self._peak_fc_unit_log(pv_read.unit_fault(st))
|
||||
else:
|
||||
self.peak_fc = None
|
||||
self._peak_fc_fault = None
|
||||
|
||||
now_e, avg_e = o.get("price_now_entity"), o.get("price_avg_entity")
|
||||
if now_e and avg_e:
|
||||
now_p = await self.hass.number(now_e)
|
||||
avg_p = await self.hass.number(avg_e)
|
||||
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
|
||||
self._price_gated = True
|
||||
self.tariff_words = "Prices from Home Assistant entities"
|
||||
elif uses_chain(o):
|
||||
# PRICE-01. A provider with fallbacks gives the maintenance gate
|
||||
# its own prices: the current 15-minute price against today's
|
||||
# average, as the Home Assistant pair did. No I/O here: the series
|
||||
# is the one the background refresh stored.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self._provider_gate(datetime.now(timezone.utc))
|
||||
else:
|
||||
# TARIFF-03: 9.2 makes a static day/night tariff a first-class
|
||||
# product and its EMS handling TIME-WINDOW SHIFTING; the one
|
||||
# shiftable load is the maintenance cycle's cheap-window grid
|
||||
# top-up. Synchronous and I/O-free on purpose (the provider-backed
|
||||
# plan is the background task's); validate.py refuses
|
||||
# `static_profile` with the price entities, so the two cannot both
|
||||
# be set.
|
||||
_now = datetime.now(timezone.utc)
|
||||
self.cheap, self.tariff_words = static_status(o, _now)
|
||||
self._price_gated = self._static_gated(o, _now)
|
||||
|
||||
# TARIFF-11: the provider plan reaches the *Tariff* row here, the only
|
||||
# place the two sources meet. `fresh_tariff_plan()` is an attribute
|
||||
# read and an age comparison against TARIFF_MAX_AGE_S: still no fetch
|
||||
# on this path.
|
||||
_tick = datetime.now(timezone.utc)
|
||||
_standing = self.fresh_tariff_plan(_tick)
|
||||
self.tariff_words = tariff_plan_row(
|
||||
_standing, self.tariff_words,
|
||||
age_s=(None if _standing is None
|
||||
else (_tick - self.tariff_at).total_seconds()),
|
||||
horizon_h=float(price_option(o, "price_horizon_h")),
|
||||
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
|
||||
|
||||
# UX-11: the red meter row says "the add-on log shows why", so a quiet
|
||||
# meter gets one WARNING per episode (at most one per
|
||||
# METER_QUIET_REPEAT_S; the rest DEBUG). Log only: no notification,
|
||||
# decides nothing.
|
||||
if self.p1_enabled:
|
||||
if (self.p1.stale and not self._meter_quiet_logged
|
||||
and self.p1.published_age_s > self.p1.max_age_s):
|
||||
self._meter_quiet_logged = True
|
||||
now = _mono()
|
||||
self._meter_quiet_loud = (
|
||||
self._meter_quiet_warned is None
|
||||
or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S)
|
||||
if self._meter_quiet_loud:
|
||||
self._meter_quiet_warned = now
|
||||
_LOG.warning("No meter reading for over %s, so control "
|
||||
"waits and then sets the battery to 0 W.",
|
||||
messages.seconds(self.p1.max_age_s))
|
||||
else:
|
||||
_LOG.debug("No meter reading for over %s again.",
|
||||
messages.seconds(self.p1.max_age_s))
|
||||
elif not self.p1.stale and self._meter_quiet_logged:
|
||||
self._meter_quiet_logged = False
|
||||
if self._meter_quiet_loud:
|
||||
_LOG.info("Meter readings are back.")
|
||||
else:
|
||||
_LOG.debug("Meter readings are back.")
|
||||
|
||||
self.answer.watch(now_mono, [(inv.name, inv.standing_w(), inv.batt)
|
||||
for inv in self.inverters], # SAFETY-128
|
||||
sum(inv.standing_w() for inv in self.inverters),
|
||||
self.grid)
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
self.log_event("inputs recovered",
|
||||
hint=messages.hint("inputs_back"))
|
||||
self.inputs_bad_since = None
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
def _driver(self, inv):
|
||||
"""`inv`'s driver, wired to this Controller's Home Assistant on first use
|
||||
(SMA-2: bound here, so a unit added later and a swapped `self.hass` are
|
||||
followed). DECISIONS/SMA-2-driver-seam.md."""
|
||||
drv = inv.driver
|
||||
if not drv.bound():
|
||||
drv.bind_getter(lambda: self.hass)
|
||||
return drv
|
||||
|
||||
async def _read_unit(self, inv, now_mono: float) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities.
|
||||
SAFETY-37: the POWER reading goes through the freshness gate and the
|
||||
SoC does not, deliberately: `batt` feeds SAFETY-30's per-unit sign
|
||||
check and the delivery check, which a frozen register would fool.
|
||||
The standing command is asked of the unit, not recomputed."""
|
||||
# SMA-2: the driver reads SoC, then power, as this method always did.
|
||||
# DECISIONS/SMA-2-driver-seam.md.
|
||||
inv.soc, raw, _extra = await self._driver(inv).read()
|
||||
was_stale = inv.batt_fresh.stale
|
||||
was_releases = inv.batt_fresh.releases
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
# `inv.batt` is the GATED value: a stale register is a MISSING input,
|
||||
# so the site holds for `stale_input_s` then commands 0 W and keeps
|
||||
# commanding (11.1 rule 1).
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
# SAFETY-97: learn this unit's idle reading, only while control runs
|
||||
# and nothing says the unit is refusing, stale or faulty.
|
||||
if inv.idle.observe(
|
||||
inv.batt, inv.standing_w(), now_mono,
|
||||
bool(self.auto) and inv.batt is not None
|
||||
and not inv.batt_fresh.stale and not inv.refused
|
||||
and not self.batt_refused and not inv.sign_wd.tripped):
|
||||
inv.store_set("idle_w", round(inv.idle.value, 1))
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
|
||||
hint=messages.hint("battery_stuck", unit=inv.name))
|
||||
self._refusal_from_stale(inv, now_mono)
|
||||
# SAFETY-56/57: the stale-reading line names two explanations once,
|
||||
# in `%s`. The escape clause "clears itself when the register
|
||||
# reports a different value" was false for the state this line
|
||||
# announces (the 0 W it announces is what stops the value
|
||||
# changing), so SAFETY-57 rewrote it and SAFETY-56 the diagnosis.
|
||||
# DECISIONS/SAFETY-57-the-exit-from-a-stale-verdict.md.
|
||||
_LOG.error("%s: battery power reading stopped changing, set to "
|
||||
"0 W until it changes, or %.0f s after the command "
|
||||
"drops below %.0f W: %s.",
|
||||
inv.name, inv.batt_fresh.bound.release_s,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
messages.reason_code(inv.batt_fresh.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
|
||||
_LOG.debug("%s: the limits are unchanged for %.0f s while asked "
|
||||
"to move: a command over %.0f W that moved over %.0f W.",
|
||||
inv.name,
|
||||
inv.batt_fresh.bound.unchanged_s,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
inv.batt_fresh.bound.move_w)
|
||||
elif inv.batt_fresh.releases > was_releases:
|
||||
# SAFETY-57: reported, never silent: a latch that lets go on a
|
||||
# timer and says nothing cannot be audited, and this one withdraws
|
||||
# a SITE STOP. `release_note()` says nothing has been learned about
|
||||
# the register.
|
||||
self.log_event(f"STALE WITHDRAWN {inv.name}",
|
||||
hint=messages.hint("battery_back", unit=inv.name))
|
||||
# Not "recovered": nothing is known about the reading yet, so the
|
||||
# line gives the value it holds and the window it is rechecked over
|
||||
# (SAFETY-57).
|
||||
_LOG.warning("%s: the command stayed below %.0f W for %.0f s, so "
|
||||
"battery power reading %+.0f W is used again and "
|
||||
"rechecked over %.0f s.", inv.name,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
inv.batt_fresh.bound.release_s, inv.batt_fresh.held,
|
||||
inv.batt_fresh.bound.unchanged_s)
|
||||
_LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name))
|
||||
|
||||
# -- the cycle -----------------------------------------------------------
|
||||
def actuator_of(self, source: str, inv=None) -> str:
|
||||
"""Which actuator a claim from `source` is filed on this cycle. `inv`
|
||||
is the roster entry the claim is ABOUT; left out it is the first
|
||||
inverter (what every per-inverter claim meant at N=1). The
|
||||
classification is not here: `arbiter.CLAIM_SCOPE` decides."""
|
||||
return actuator_for(source, (inv or self.inverters[0]).name, self.units)
|
||||
|
||||
def live_units(self) -> tuple:
|
||||
"""Which inverters may be commanded THIS TICK, for the allocator. The
|
||||
allocator DROPS what this does not list and the survivors carry the
|
||||
whole site target, so it is reported (`Allocation.dropped`).
|
||||
Answered per tick, never cached (a master goes stale). It answers
|
||||
ONE question: can this controller put a number on this unit's wire.
|
||||
Deliberately not "its sensors are missing". At a roster of ONE,
|
||||
False is a 0 W site."""
|
||||
now = datetime.now(timezone.utc)
|
||||
live = []
|
||||
for inv in self.inverters:
|
||||
ok, why = inv.is_live(now)
|
||||
if ok:
|
||||
live.append(inv.name)
|
||||
else:
|
||||
# Level-logged at DEBUG only: `Allocation.dropped` is the
|
||||
# operator-facing surface (edge-logged in cycle()); a line
|
||||
# every second would flush the 40-row action log.
|
||||
_LOG.debug("%s is not commandable this tick: %s", inv.name, why)
|
||||
return tuple(live)
|
||||
|
||||
def liveness_notes(self) -> list:
|
||||
"""Why each unit is or is not commandable, for the status page.
|
||||
SAFETY-37's stale-reading line rides here too: a different fault, a
|
||||
different remedy (a dropped unit is carried by the survivors; a
|
||||
stale power register is a missing input)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return [n for n in
|
||||
[x for inv in self.inverters
|
||||
for x in (inv.link_note(now), inv.stale_note(),
|
||||
inv.refusal_note())]
|
||||
if n]
|
||||
@@ -0,0 +1,927 @@
|
||||
"""Controller methods that build the status page, rows and MQTT output.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import arbitrage
|
||||
from . import packmode
|
||||
from .clamp import is_commandable
|
||||
from . import dayview, homeview
|
||||
from . import messages
|
||||
from .freeze import rail_unreachable, rail_w
|
||||
from .hass import OUTAGE
|
||||
from .maintenance import IDLE
|
||||
from .mqtt import (
|
||||
announced_units, optional_keys, parse_command, unit_slugs, unit_state_key,
|
||||
)
|
||||
from . import peakguard
|
||||
from . import pv as pv_read
|
||||
from . import shadow
|
||||
from .price import brussels
|
||||
from .validate import FIRMWARE_INGEST_VETO_S
|
||||
from .rows import arbitrage_row
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
class StatusMixin:
|
||||
async def mqtt_command(self, topic, payload, retained) -> None:
|
||||
"""OPT-11 P2: one command from a battery's HA switch or mode list,
|
||||
through handle_action(). A retained command is never applied (the store
|
||||
already holds the last choice). Never raises: MQTT must not reach
|
||||
control. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
try:
|
||||
slug, action, value, why = parse_command(
|
||||
topic, payload, retained, packmode.MODES)
|
||||
if why is None:
|
||||
names = [inv.name for inv in self.inverters]
|
||||
unit = dict(zip(unit_slugs(names), names)).get(slug)
|
||||
result = await self.handle_action(action, unit=unit,
|
||||
value=value)
|
||||
if not (isinstance(result, dict) and "error" in result):
|
||||
self.mqtt.publish(self.mode_values())
|
||||
return
|
||||
why = result["error"]
|
||||
else:
|
||||
why = messages.pack_refused(why)
|
||||
# Printable only: a topic is sender-chosen, so no log injection.
|
||||
topic_s = "".join(c if c.isprintable() else "?"
|
||||
for c in str(topic)[:128])
|
||||
_LOG.debug("Home Assistant command %s %s refused: %s", topic_s,
|
||||
repr(payload)[:48], why)
|
||||
if (topic_s, why) in self._mqtt_refused \
|
||||
or len(self._mqtt_refused) >= 32:
|
||||
# Not logged again, but counted: 1, 10, 100... stay visible.
|
||||
self._mqtt_refused_more += 1
|
||||
more = self._mqtt_refused_more
|
||||
if str(more).rstrip("0") == "1":
|
||||
_LOG.warning("Home Assistant commands still ignored: %d "
|
||||
"more so far.", more)
|
||||
return
|
||||
self._mqtt_refused.add((topic_s, why))
|
||||
_LOG.warning("Home Assistant command on %s ignored: %s", topic_s,
|
||||
why)
|
||||
except Exception: # noqa: BLE001
|
||||
_LOG.exception("Home Assistant command could not be handled")
|
||||
|
||||
@property
|
||||
def why_words(self) -> str:
|
||||
"""The page's "Why" line. While control waits for a missing reading
|
||||
the age is counted when the page asks (UX-10), not frozen at the age
|
||||
the last cycle saw."""
|
||||
if self._why_waiting_since is not None:
|
||||
return messages.why_waiting(max(0.0, (
|
||||
datetime.now(timezone.utc)
|
||||
- self._why_waiting_since).total_seconds()))
|
||||
return self._why_words
|
||||
|
||||
@why_words.setter
|
||||
def why_words(self, text: str) -> None:
|
||||
self._why_words = text
|
||||
self._why_waiting_since = None
|
||||
|
||||
def publish(self) -> None:
|
||||
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and
|
||||
# `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards);
|
||||
# per-inverter entities are TEL-07's.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
values = {
|
||||
"setpoint": self.target,
|
||||
"grid": self.grid,
|
||||
"battery": self.site_batt_w,
|
||||
"soc": self.pack_floor_soc,
|
||||
"phase": self.maint.phase,
|
||||
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
|
||||
# one: "failsafe" is additive to a sensor already on every
|
||||
# dashboard, and a P_SAFETY stop that did not reach it would be a
|
||||
# site at 0 W reporting "running".
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
"status": ("failsafe" if any(i.sign_wd.tripped
|
||||
for i in self.inverters)
|
||||
or (self.site_sign_wd.tripped
|
||||
and len(self.inverters) > 1)
|
||||
or self.circ_wd.tripped
|
||||
# SAFETY-10: "shadow" is its own value and not a flavour
|
||||
# of "stopped". ⚠️ BELOW `failsafe`: a latch that is up
|
||||
# is up, and shadow does not clear it.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
else "shadow" if self.shadow
|
||||
else "running" if self.auto else "stopped"),
|
||||
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
|
||||
# `site_circulating_w`. `publish()` drops None values, so a missing
|
||||
# per-unit reading leaves the last figure standing rather than
|
||||
# publishing a zero that would read as health.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
"circulation": (None if self.site_circulating_w is None
|
||||
else round(self.site_circulating_w)),
|
||||
# TEL-10. The loop's own three, written from the fields `cycle()`
|
||||
# recorded and not re-derived (the page and the publisher run on
|
||||
# their own tasks, and `self.target` has moved on).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
"loop_i": round(self.loop_i_w, 1),
|
||||
"loop_lead": round(self.loop_i_lead, 1),
|
||||
"loop_reason": self.loop_reason,
|
||||
}
|
||||
# TEL-11. ⚠️ THE KEY IS PRESENT WITH `None` WHEN THERE IS NO READING
|
||||
# AND ABSENT WHEN THERE IS NO SOURCE: two states
|
||||
# (`mqtt.ABSENT_AS_UNKNOWN` makes the first an `unknown`, a gap in the
|
||||
# recorder). TEL-12: still ONE number, the SITE total, None unless
|
||||
# EVERY entity answered. ⚠️ THE GATE IS `optional_keys()`, THE SAME
|
||||
# EXPRESSION THE ANNOUNCEMENT USES.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if "pv" in optional_keys(self.o):
|
||||
values["pv"] = (None if self.pv_w is None
|
||||
else round(self.pv_w, 1))
|
||||
# ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input
|
||||
# watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1
|
||||
# failsafe at max_age_s, so with meter_source off a published age would
|
||||
# climb unbounded and pin every existing install at 0 W within 30 s.
|
||||
# Publishing nothing leaves the entity non-existent (firmware
|
||||
# has_state()). (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if self.p1_enabled:
|
||||
# Recomputed here, once a second, on purpose - see P1Ingest.
|
||||
values["p1_age"] = round(self.p1.published_age_s, 1)
|
||||
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE: ⚠️ no `if
|
||||
# len(self.inverters) > 1` here, deliberately: `announced_units()`
|
||||
# answers [] at N=1, so what is announced and what is published come
|
||||
# from one expression. ⚠️ The site rows do not move: `battery` stays
|
||||
# the SUM and `soc` the EMPTIEST pack (live dashboards use those ids).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
names = announced_units([inv.name for inv in self.inverters])
|
||||
for inv, slug in zip(self.inverters, unit_slugs(names)):
|
||||
values[unit_state_key(slug, "setpoint")] = inv.target
|
||||
values[unit_state_key(slug, "battery")] = inv.batt
|
||||
values[unit_state_key(slug, "soc")] = inv.soc
|
||||
values[unit_state_key(slug, "sign_latch")] = (
|
||||
"tripped" if inv.sign_wd.tripped else "clear")
|
||||
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
|
||||
values.update(self.mode_values())
|
||||
self.mqtt.publish(values)
|
||||
|
||||
# -- UI ------------------------------------------------------------------
|
||||
def checks(self, when: datetime | None = None) -> list:
|
||||
"""The status rows. `when` is the reference time for anything billed by
|
||||
the calendar - today's month when it is None, which is what the live
|
||||
page means. ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17): a
|
||||
suite that banks an August quarter went red at the September boundary.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
o = self.o
|
||||
out = []
|
||||
# SAFETY-61. First, so a stalled loop is the headline: the heartbeat is
|
||||
# sent from this loop, so past layer 1's veto the inverter is at 0 W.
|
||||
if self.loop_pass_mono is None:
|
||||
out.append({"ok": True, "warn": False, "id": "controller",
|
||||
"text": messages.row_controller_starting()})
|
||||
else:
|
||||
_pass_age = time.monotonic() - self.loop_pass_mono
|
||||
if _pass_age > FIRMWARE_INGEST_VETO_S:
|
||||
out.append({"ok": False, "warn": False, "id": "controller",
|
||||
"text": messages.row_controller_stalled(
|
||||
_pass_age, FIRMWARE_INGEST_VETO_S)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "controller",
|
||||
"text": messages.row_controller_ran(_pass_age)})
|
||||
# SAFETY-112. The headline unless control has stalled: while the
|
||||
# Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or
|
||||
# write gets through, and the rows below that need it follow from it.
|
||||
if OUTAGE.waiting():
|
||||
out.insert(1 if out[0]["ok"] is False else 0,
|
||||
{"ok": False, "warn": False, "id": "home_assistant",
|
||||
"banner": messages.banner_ha_waiting(),
|
||||
"text": messages.row_ha_waiting()})
|
||||
if self.p1_enabled:
|
||||
age = self.p1.published_age_s
|
||||
if (self.p1.stale and self.p1.age_s() is None
|
||||
and age <= self.p1.max_age_s):
|
||||
# UX-10: no reading yet since the start, still inside the
|
||||
# limit. Amber, not red: it is not a fault yet, but control
|
||||
# waits for it. Red at 0 s contradicted the limit the row
|
||||
# prints. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": False, "warn": True, "id": "meter",
|
||||
"text": messages.row_meter_waiting(
|
||||
self.p1.max_age_s)})
|
||||
elif self.p1.stale:
|
||||
# The last error is on the add-on log's WARNING line (p1.py).
|
||||
out.append({"ok": False, "warn": False, "id": "meter",
|
||||
"text": messages.row_meter_stale(
|
||||
age, self.p1.max_age_s)})
|
||||
else:
|
||||
# ⚠️ unchanged_s is REPORTED, never thresholded and never
|
||||
# folded into the age - see HomeWizardLocalSource.unchanged_s
|
||||
# for why (at the converged -10 W this controller aims for, a 1
|
||||
# Wh register needs ~6 minutes to move, so any limit
|
||||
# false-trips at the exact operating point we target).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
unchanged = getattr(self.p1_source, "unchanged_s", None)
|
||||
out.append({"ok": True, "warn": False, "id": "meter",
|
||||
"text": messages.row_meter(
|
||||
self.p1.net_w, age, self.p1.parse_errors,
|
||||
unchanged)})
|
||||
# SAFETY-18. Reported in both states (a detector nobody can see is
|
||||
# armed is not trusted when it fires). ok=False/warn=False so the
|
||||
# banner goes red; it changes NOTHING about control (see
|
||||
# check_meter_frozen()). (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if self.freeze_wd.active:
|
||||
# The figures are in `freeze_wd.reason`, on the log and the
|
||||
# event list (check_meter_frozen()).
|
||||
out.append({"ok": False, "warn": False, "id": "meter_frozen",
|
||||
"text": messages.row_meter_frozen()})
|
||||
elif getattr(self.p1_source, "unchanged_s", None) is None:
|
||||
# Not "armed". Every transport but homewizard_local is invisible
|
||||
# to this check, and saying "armed" there would be a lie the
|
||||
# status page tells on behalf of a guard that cannot run.
|
||||
out.append({"ok": False, "warn": True, "id": "freeze_check",
|
||||
"text": messages.row_freeze_off("source")})
|
||||
elif rail_unreachable(self.site_max_w,
|
||||
dev_min=self.site_dev_bound("dev_min"),
|
||||
dev_max=self.site_dev_bound("dev_max")):
|
||||
# SAFETY-20: the same row as the transport case above (the
|
||||
# check cannot be evaluated here); only the missing term
|
||||
# differs. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_no_rating = (not self.site_max_w
|
||||
or not is_commandable(self.site_max_w))
|
||||
out.append({"ok": False, "warn": True, "id": "freeze_check",
|
||||
"text": messages.row_freeze_off(
|
||||
"rating" if _no_rating else "range")})
|
||||
else:
|
||||
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
|
||||
# USES, device range included.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_rail = rail_w(self.site_max_w,
|
||||
dev_min=self.site_dev_bound("dev_min"),
|
||||
dev_max=self.site_dev_bound("dev_max"))
|
||||
# And when the device set that number, SAY SO: the operator
|
||||
# configured `max_w`. ⚠️ Either bound may be None on its own
|
||||
# (`hass.limits()` reads min and max independently), so render
|
||||
# what is there. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": True, "warn": False, "id": "freeze_check",
|
||||
"text": messages.row_freeze_on(
|
||||
self.freeze_wd.cfg.hold_s, _rail,
|
||||
own_limit=_rail < rail_w(self.site_max_w))})
|
||||
|
||||
# TARIFF-01. The month's billed peak, and a DIFFERENT row when the
|
||||
# store could not answer: "no peak yet" is normal on the 1st,
|
||||
# "unreadable" is a broken instrument.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_pk = self.peak.peak(when)
|
||||
if _pk.is_unreadable:
|
||||
out.append({"ok": False, "warn": True, "id": "month_peak",
|
||||
"text": messages.row_month_peak_unreadable()})
|
||||
else:
|
||||
# §9.1's "warning entity at 80 %", as a status row rather than
|
||||
# a second entity - see the constructor on not doubling up.
|
||||
_frac = self.peak.warning_frac(when) or 0.0
|
||||
_ok = _frac < 0.8
|
||||
out.append({"ok": _ok, "warn": not _ok, "id": "month_peak",
|
||||
"text": (messages.row_month_peak_none()
|
||||
if not _pk.is_observed else
|
||||
messages.row_month_peak(
|
||||
_pk.peak_w,
|
||||
brussels(_pk.quarter_start),
|
||||
_frac))})
|
||||
out.append({"ok": True, "warn": False, "id": "periods_measured",
|
||||
"text": messages.row_periods_measured(
|
||||
self.peak.recorded)})
|
||||
|
||||
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
|
||||
# are opposite states and a page that spelled them the same would
|
||||
# show a green line over a guard that is switched off. `None` is a
|
||||
# fourth state on purpose: before the first cycle nothing has been
|
||||
# concluded. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_pg = self.peak_guard
|
||||
if _pg is None:
|
||||
out.append({"ok": True, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_waiting()})
|
||||
elif _pg.saturated and not _pg.within_aim:
|
||||
out.append({"ok": False, "warn": False, "id": "peak_guard",
|
||||
"runs": True,
|
||||
"text": messages.row_peak_saturated(_pg.ceiling_w)})
|
||||
elif _pg.floor_limited and (
|
||||
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
|
||||
or self._peak_floor_limited):
|
||||
# SAFETY-24. NOT ok, like saturation (the money is being spent
|
||||
# and a human is the only actuator left); a separate row
|
||||
# because the remedy is opposite. ⚠️ SAFETY-25: the scope
|
||||
# sentence (the floor binds the guard, not the loop) is part of
|
||||
# the row, which carries its own short `banner`; shortening the
|
||||
# ROW would weaken AC 3.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": False, "warn": False, "id": "peak_guard",
|
||||
"runs": True,
|
||||
"banner": messages.banner_peak_reserve(),
|
||||
"text": messages.row_peak_reserve(
|
||||
_pg.ceiling_w, soc_unread=_pg.soc is None,
|
||||
floor_unknown=(_pg.soc is not None
|
||||
and _pg.soc_floor is None),
|
||||
packs=(_pg.held if len(self.inverters) > 1
|
||||
else ()),
|
||||
others=(_pg.floor_w or 0.0) > 0.0)})
|
||||
elif _pg.blind:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_blind(
|
||||
peakguard.blind_kind(_pg.blind))})
|
||||
elif self.peak_unmeasured_s > 0.0:
|
||||
# SAFETY-90: the start of this quarter came before the first
|
||||
# reading, and the guard counts it as high use.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_unmeasured()})
|
||||
elif _pg.engaged:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_on(
|
||||
_pg.floor_w or 0.0, _pg.ceiling_w,
|
||||
shadow=self.shadow)})
|
||||
elif _pg.projected_w is not None:
|
||||
# SAFETY-125: "under" only when it is; over the limit at this
|
||||
# rate, a grid charge is cut back to the quarter's room.
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_idle(
|
||||
_pg.projected_w, _pg.ceiling_w,
|
||||
month_peak=(_pg.ceiling_from
|
||||
== peakguard.CEILING_MONTH_PEAK),
|
||||
cut=self.room_charge and not self.shadow)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||
"text": _pg.explain()})
|
||||
else:
|
||||
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the
|
||||
# point: SAFETY-07 is NOT COVERED on the legacy single-entity path
|
||||
# (no telegrams), and a row that rendered only where the guard runs
|
||||
# would leave the unprotected install showing nothing.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_off()})
|
||||
|
||||
# ⚠️ ONE PAIR OF ROWS PER INVERTER, NAMED. A single "battery SoC" row
|
||||
# on a site with two packs is a page that shows one pack and calls it
|
||||
# the site. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
rows = []
|
||||
for inv in self.inverters:
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
rows.append((messages.LABEL_SOC, unit, inv.soc,
|
||||
inv.driver.soc_source()))
|
||||
rows.append((messages.LABEL_BATTERY, unit, inv.batt,
|
||||
inv.driver.batt_source()))
|
||||
if not self.p1_enabled:
|
||||
# In P1 mode the check above replaces this one; leaving both in
|
||||
# would report "no entity configured" for a meter_entity that is
|
||||
# correctly unused, i.e. a permanent false NOT READY.
|
||||
rows.insert(0, (messages.LABEL_GRID, None, self.grid,
|
||||
o.get("meter_entity")))
|
||||
# UX-08: the rows name the setting, never the entity id (STYLE.md).
|
||||
for label, unit, value, entity in rows:
|
||||
if not entity:
|
||||
out.append({"ok": False, "warn": False, "id": "sensor",
|
||||
"text": messages.row_sensor_unset(label, unit)})
|
||||
elif value is None:
|
||||
# ⚠️ SAFETY-56. THIS ROW ONCE ASSERTED A FALSE FACT ABOUT A
|
||||
# NAMED ENTITY: `Freshness.reading()` returns None BY DESIGN
|
||||
# while the gate withholds a numeric reading. ⚠️ FIXED BY
|
||||
# SAYING LESS: the gate has its own surface (`stale_note()` in
|
||||
# `liveness_notes()`).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": False, "warn": False, "id": "sensor",
|
||||
"text": messages.row_sensor_unusable(label, unit)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "sensor",
|
||||
"text": messages.row_sensor(label, value, unit)})
|
||||
|
||||
for inv in self.inverters:
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
if not inv.driver.configured():
|
||||
out.append({"ok": False, "warn": False, "id": "control",
|
||||
"text": messages.row_control_unset(unit)})
|
||||
elif inv.dev_max is None:
|
||||
out.append({"ok": False, "warn": False, "id": "control",
|
||||
"text": messages.row_control_missing(unit)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "control",
|
||||
"text": messages.row_control(inv.dev_min,
|
||||
inv.dev_max, unit)})
|
||||
|
||||
# DEV-02. Whether this controller can still put a number on each
|
||||
# inverter: a dropped link that shows nothing on the page reads as a
|
||||
# healthy site. Red only for a unit no longer live; the rest amber. The
|
||||
# same notes, in the same order, as `liveness_notes()`.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_now = datetime.now(timezone.utc)
|
||||
for inv in self.inverters:
|
||||
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
|
||||
(inv.stale_note(), False),
|
||||
(inv.refusal_note(), False)):
|
||||
if note:
|
||||
out.append({"ok": False, "warn": not red, "text": note})
|
||||
if self.answer.late_seen: # SAFETY-128
|
||||
out.append({"ok": False, "warn": True, "id": "late",
|
||||
"text": messages.row_readings_late(
|
||||
self.answer.shown_s, self._late_zero)})
|
||||
|
||||
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
|
||||
# integrator bound, SAFETY-40), and the engagement COUNT stays shown
|
||||
# after it clears. Each count is shown under ITS alarm's name, never
|
||||
# the two summed (UX-07/UX-08).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_cw = self.clamp_wd
|
||||
if _cw.active and _cw.bug:
|
||||
out.append({"ok": False, "warn": False, "id": "power_too_high",
|
||||
"banner": messages.banner_power_too_high(),
|
||||
"text": messages.row_power_too_high(
|
||||
_cw.facts, _cw.over_rating, shadow=self.shadow)})
|
||||
elif _cw.active:
|
||||
# SAFETY-40: the integrator bound alone is amber, not red.
|
||||
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
|
||||
"text": messages.row_not_keeping_up(
|
||||
_cw.bound_episodes)})
|
||||
# No over-rating in THIS episode (`bug` would be set), so any count
|
||||
# is from an earlier one: keep that software-fault evidence shown.
|
||||
if _cw.over_rating:
|
||||
out.append({"ok": False, "warn": True, "id": "power_too_high",
|
||||
"text": messages.row_before(
|
||||
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
|
||||
else:
|
||||
if _cw.over_rating:
|
||||
out.append({"ok": False, "warn": True, "id": "power_too_high",
|
||||
"text": messages.row_before(
|
||||
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
|
||||
if _cw.bound_episodes:
|
||||
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
|
||||
"text": messages.row_before(
|
||||
messages.T_LOOP_BOUND, _cw.bound_episodes)})
|
||||
|
||||
for inv in self.inverters:
|
||||
if inv.last_write_ok is False:
|
||||
out.append({"ok": False, "warn": False, "id": "write",
|
||||
"text": messages.row_write_refused(
|
||||
None if len(self.inverters) == 1
|
||||
else inv.name)})
|
||||
|
||||
# SAFETY-02. Reported in both states. ⚠️ ONE ROW PER INVERTER: a trip
|
||||
# on B leaves A commanding. Since SAFETY-30 the row says what the unit
|
||||
# was compared against (`sign_wd.against`, the string the trip sentence
|
||||
# uses); at N=1 the wording is unchanged.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
for inv in self.inverters:
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE: at N=1 the
|
||||
# two sentences below are the ones that have always been on the
|
||||
# page, word for word (AC 8); at N>=2 the noun is load-bearing, so
|
||||
# the row does not send an operator to P1 for a fault visible only
|
||||
# on one inverter's own power.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if inv.sign_wd.tripped:
|
||||
out.append({"ok": False, "warn": False, "id": "direction",
|
||||
"text": messages.row_direction_trip(unit)})
|
||||
else:
|
||||
held = inv.sign_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "direction",
|
||||
"text": messages.row_direction_check(
|
||||
held, inv.sign_wd.cfg.hold_s, unit)})
|
||||
|
||||
# SAFETY-30: the SITE row. ⚠️ ONLY AT N>=2, and the absence at N=1 is
|
||||
# the honest report: with one inverter the site comparison and the row
|
||||
# above are the same sentence about the same actuator.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if len(self.inverters) > 1:
|
||||
if self.site_sign_wd.tripped:
|
||||
out.append({"ok": False, "warn": False, "id": "site_direction",
|
||||
"text": messages.row_site_direction_trip()})
|
||||
else:
|
||||
_sheld = self.site_sign_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "site_direction",
|
||||
"text": messages.row_site_direction_check(
|
||||
_sheld, self.site_sign_wd.cfg.hold_s)})
|
||||
|
||||
# SAFETY-31. ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and
|
||||
# the row says so: there is one circulating figure for the site, and
|
||||
# naming an inverter would invite clearing "the other one".
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_circ = self.site_circulating_w
|
||||
if self.circ_wd.tripped:
|
||||
out.append({"ok": False, "warn": False, "id": "circulating",
|
||||
"text": messages.row_circulating_trip()})
|
||||
elif len(self.inverters) <= 1:
|
||||
# AC 5. At the shipped roster the quantity is identically zero for
|
||||
# every value of every reading, so "armed" would be a claim to be
|
||||
# watching something that cannot happen. Say what is true instead.
|
||||
out.append({"ok": True, "warn": False, "id": "circulating",
|
||||
"text": messages.row_one_inverter(
|
||||
messages.CHECK_CIRCULATING)})
|
||||
else:
|
||||
held = self.circ_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "circulating",
|
||||
"text": messages.row_circulating_check(
|
||||
_circ, held, self.circ_wd.cfg.hold_s)})
|
||||
|
||||
# SAFETY-42. ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE
|
||||
# ONE PLACE THE "reports, does not act" DECISION IS VISIBLE: every
|
||||
# other latched row means the site is at 0 W, this one means it is
|
||||
# STILL RUNNING and quietly short by one unit's share; `ok: False` with
|
||||
# `warn: True` is this list's caution.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if len(self.inverters) > 1:
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
out.append({"ok": False, "warn": True, "id": "delivery",
|
||||
"text": messages.row_not_delivering(inv.name)})
|
||||
else:
|
||||
_dheld = inv.delivery_wd.held_s(datetime.now(timezone.utc))
|
||||
out.append({"ok": True, "warn": False, "id": "delivery",
|
||||
"text": messages.row_delivery_check(
|
||||
inv.name, _dheld,
|
||||
inv.delivery_wd.cfg.hold_s)})
|
||||
else:
|
||||
# AC 5: at one inverter this check has nothing to find, so it is
|
||||
# not called "armed" (as `circulating power: n/a` above).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
out.append({"ok": True, "warn": False, "id": "delivery",
|
||||
"text": messages.row_one_inverter("Delivery check")})
|
||||
|
||||
if not o.get("estop_fitted", False):
|
||||
out.append({"ok": False, "warn": True, "id": "estop",
|
||||
"text": messages.row_no_estop()})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "estop",
|
||||
"text": messages.row_estop_fitted()})
|
||||
|
||||
if not o.get("peak_forecast_entity"):
|
||||
# ok=False + warn=True renders as a caution, not a pass. A check that
|
||||
# is both is a check nobody reads.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_no_peak_forecast()})
|
||||
elif getattr(self, "_peak_fc_fault", None) is not None:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_peak_forecast_unit(
|
||||
self._peak_fc_fault)})
|
||||
# OPT-17: a forecast summed from several entities names, by position,
|
||||
# each member with no usable reading. A caution: control goes on.
|
||||
gaps = getattr(self, "solar_fc_gaps", ())
|
||||
for day, ents in (("today", getattr(self, "fc_entities", ())),
|
||||
("tomorrow",
|
||||
getattr(self, "fc_tomorrow_entities", ()))):
|
||||
gone = [i for d, i, _e, _w in gaps if d == day]
|
||||
if gone:
|
||||
out.append({"ok": False, "warn": True, "id": "solar_forecast",
|
||||
"text": messages.row_forecast_gap(
|
||||
day, gone, len(ents))})
|
||||
# TEL-11. ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY (pure
|
||||
# instrumentation: no warning spam). ⚠️ WHEN IT IS SET, A FAILED READ
|
||||
# IS `ok=False, warn=True`, a CAUTION never `bad`: telemetry must not
|
||||
# put a battery site into NOT READY. UX-08 replaced 0.11.0's sentences
|
||||
# on purpose. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
roster = self.pv_roster
|
||||
if roster:
|
||||
# UX-08: sources are numbered in the order of the settings list,
|
||||
# never named by entity id (STYLE.md). The per-source figures and
|
||||
# units are what `pv.total()` returns; they stay in `pv_parts`.
|
||||
flip = pv_read.inverted(self.o)
|
||||
n_src = len(roster)
|
||||
if self.pv_w is None and n_src == 1:
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_none(self.pv_why)})
|
||||
elif self.pv_w is None:
|
||||
# ⚠️ THE ROW THAT SAYS THERE IS NO TOTAL, AND IT SAYS IT IN
|
||||
# THOSE WORDS - and which sources are missing, by position.
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_partial(
|
||||
[i + 1 for i, (_e, watt, _y)
|
||||
in enumerate(self.pv_parts) if watt is None],
|
||||
n_src)})
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "pv",
|
||||
"text": messages.row_pv(
|
||||
self.pv_w, n_src,
|
||||
n_src == 1 and roster[0] in flip)})
|
||||
# ⚠️ AND WHEN BOTH KEYS ARE SET, WHICH ONE WON IS STATED. `pv.py`'s
|
||||
# `entities()` makes `pv_entities` REPLACE `pv_entity`, which is
|
||||
# the rule an operator upgrading from 0.11.0 is most likely to trip
|
||||
# over. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if pv_read.entity_is_ignored(self.o):
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_single_ignored()})
|
||||
# TEL-14: a sign that looks reversed, per entity rather than on the
|
||||
# total - see `pv.NEGATIVE_W` and DECISIONS/TEL-14-pv-sign.md §3.
|
||||
for i, (ent, watt, _y) in enumerate(self.pv_parts):
|
||||
if watt is not None and watt < pv_read.NEGATIVE_W:
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_negative(
|
||||
i + 1, n_src, watt, ent in flip)})
|
||||
_inv_list = [str(x or "").strip()
|
||||
for x in (self.o.get("pv_invert_entities") or [])]
|
||||
for ent in pv_read.stray_inverts(self.o):
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_stray(
|
||||
_inv_list.index(ent) + 1)})
|
||||
return out
|
||||
|
||||
def status_payload(self, lang: str = "en") -> dict:
|
||||
checks = self.checks()
|
||||
bad = [c for c in checks if not c["ok"] and not c["warn"]]
|
||||
# SAFETY-87: a red row with `runs` (the peak alarms) is an alarm while
|
||||
# control goes on, so it neither reads "Not ready" nor holds the page.
|
||||
runs = [c for c in bad if c.get("runs")]
|
||||
bad = [c for c in bad if not c.get("runs")]
|
||||
running_alarm = waiting = False
|
||||
# SAFETY-10, AND IT IS FIRST IN THE CASCADE: "The battery is NOT being
|
||||
# controlled" may never be displaced by another condition. ⚠️ AND IT
|
||||
# CARRIES A STANDING LATCH RATHER THAN HIDING ONE: `shadow.banner()`
|
||||
# names the latch and goes red for it, so nothing the cascade would
|
||||
# have said is lost. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_shadow = shadow.banner(self.o, control_started=self.auto,
|
||||
latched=self.shadow_latches())
|
||||
if _shadow is not None:
|
||||
level, banner = _shadow
|
||||
elif self.circ_wd.tripped:
|
||||
# ⚠️ ABOVE THE SIGN FAILSAFE, and the order is a decision: both are
|
||||
# latched P_SAFETY stops and the banner carries one sentence; this
|
||||
# one goes first because it is the fault the operator has NO OTHER
|
||||
# WAY of learning about (a sign disagreement is visible at the
|
||||
# meter, circulating power nowhere).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "bad", messages.banner_circulating()
|
||||
elif self.site_sign_wd.tripped and len(self.inverters) > 1:
|
||||
# SAFETY-30's site half, ABOVE the per-unit rows below and below
|
||||
# circulating power above: the banner carries one sentence, so the
|
||||
# one that says the widest true thing. A site sign trip parks EVERY
|
||||
# inverter and the fault is in what the site was asking for; naming
|
||||
# a unit would send an operator to swap the wrong box.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "bad", messages.banner_site_direction()
|
||||
elif any(i.sign_wd.tripped for i in self.inverters):
|
||||
# Outranks "Not ready": nothing is missing, something went wrong.
|
||||
# At N>=2 the unit was graded against its own power, so the banner
|
||||
# says so (see checks()). (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "bad", messages.banner_direction(
|
||||
len(self.inverters) > 1)
|
||||
elif bad:
|
||||
# ⚠️ `banner` IF THE ROW OFFERS ONE, `text` OTHERWISE: a row may
|
||||
# say more than fits the headline (SAFETY-25's floor row).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "bad", messages.banner_not_ready(bad[0])
|
||||
# SAFETY-112: nothing to do but wait, so Home says that instead
|
||||
# of "Needs you now". Only when it IS the headline: a safety stop
|
||||
# above outranks it and still needs a person.
|
||||
waiting = bad[0].get("id") == "home_assistant"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", messages.banner_stopped()
|
||||
elif runs:
|
||||
level, running_alarm = "bad", True
|
||||
banner = runs[0].get("banner") or messages.headline_bi(
|
||||
runs[0]["text"])
|
||||
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
||||
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE
|
||||
# (the site is STILL RUNNING, one unit short). ⚠️ BUT IT MUST
|
||||
# DISPLACE "Running - holding grid at zero": that sentence is a
|
||||
# claim the site cannot make while a unit is absent. It sits BELOW
|
||||
# "Stopped". (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "warn", messages.banner_not_delivering(
|
||||
[i.name for i in self.inverters if i.delivery_wd.tripped])
|
||||
elif self.maint.phase != IDLE:
|
||||
# ⚠️ THE PACK IS NAMED ONLY WHEN THERE IS MORE THAN ONE: at N=1 the
|
||||
# string is what it has always been, character for character.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
level, banner = "warn", messages.banner_maintenance(
|
||||
self.maint.phase,
|
||||
self.maint_unit.name if len(self.inverters) > 1 else "")
|
||||
else:
|
||||
level, banner = "ok", messages.banner_running()
|
||||
|
||||
def w(v):
|
||||
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
|
||||
|
||||
# ⚠️ THE EARLIEST ACROSS THE PACKS, NOT THE FIRST PACK'S. `None` means
|
||||
# "never run, due now" and has to win over any date, so it is tested
|
||||
# for rather than sorted with. At N=1 this is that pack's own date.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_now = datetime.now(timezone.utc)
|
||||
_dues = [m.next_due(_now) for m in self.maints]
|
||||
_waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u]
|
||||
next_due = None if any(d is None for d in _dues) else min(_dues)
|
||||
rows = [
|
||||
("Grid", w(self.grid)),
|
||||
("Battery", w(self.site_batt_w)),
|
||||
# SAFETY-31, and it belongs DIRECTLY UNDER the two figures it is
|
||||
# not derivable from ("Grid 0 W, Battery 0 W, Circulating 6000 W"
|
||||
# is the whole card in three rows). Only shown at N>=2: at one
|
||||
# inverter it is identically zero.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
*(() if len(self.inverters) <= 1
|
||||
else ((("Circulating", w(self.site_circulating_w)),))),
|
||||
# TEL-11, and ONLY when a source is configured - see `checks()`.
|
||||
# `w(None)` renders "-", which is how this row says "no reading"
|
||||
# rather than "0 W". (DECISIONS/MAIN-orchestration-archive.md)
|
||||
*(() if not self.pv_roster else ((("Solar production",
|
||||
w(self.pv_w)),))),
|
||||
("Battery level", "—" if self.pack_floor_soc is None
|
||||
else f"{self.pack_floor_soc:g} %"),
|
||||
("Power request", w(self.target)),
|
||||
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
|
||||
# "Commanded 0 W" above and a "Why" below that explains a trade is
|
||||
# the exact pair an operator would otherwise have to guess at; this
|
||||
# row is what makes the two of them consistent, and it is rendered
|
||||
# in EVERY state - including `off`, where it says the battery IS
|
||||
# being controlled - because a surface whose ABSENCE is how you
|
||||
# learn something is a surface you have to already know about.
|
||||
("Shadow mode", shadow.words(
|
||||
self.o, control_started=self.auto,
|
||||
would_w=self.shadow_would_w,
|
||||
# SAFETY-47. THE FLAG THE CYCLE RECORDED, NOT ONE DERIVED HERE
|
||||
# - the page runs on its own task and `self.peak_guard` has
|
||||
# moved on. See `Controller.__init__`.
|
||||
sized_from_meter=self.shadow_sized_from_meter,
|
||||
reason=self.why_words)),
|
||||
("Why", self.why_words),
|
||||
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
|
||||
else f"{self.maint.phase} ({self.maint_unit.name})"),
|
||||
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
|
||||
# page - where `_maint_deferred` is empty on every tick - carries
|
||||
# exactly the rows it carried before.
|
||||
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
|
||||
for n, why in self._maint_deferred)),)
|
||||
if self._maint_deferred else ()),
|
||||
# MAINT-13: only while a failed cycle waits, so a site with nothing
|
||||
# failed carries exactly the rows it carried before.
|
||||
*((("Maintenance paused", messages.row_maint_waits(
|
||||
min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()),
|
||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||
("Cheap window", "yes" if self.cheap else "no"),
|
||||
("Tariff", self.tariff_words),
|
||||
# PRICE-01: only while no price covers now.
|
||||
*((("Prices", messages.row_no_prices(*self._gap_args())),)
|
||||
if self.price_gap else ()),
|
||||
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, the difference between a
|
||||
# 48-hour plan and a 12-hour one on a weekday morning. Its own row:
|
||||
# "Tariff" says what the plan IS, this says what it could still be
|
||||
# filled from if the feed is late again.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
("Price history", self.price_history.words),
|
||||
# OPT-04. The strategy's own sentence, not a re-derivation: every
|
||||
# refusal it can make carries a reason a human can act on. ⚠️
|
||||
# TARIFF-11: `self.arb is None` is NOT "trading is off" (cycle()
|
||||
# returns at the input hold first); `arbitrage_row()` reads the
|
||||
# option, and `cycle_at`/`arb_at` make "no decision this cycle" an
|
||||
# observation. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
("Arbitrage", arbitrage_row(
|
||||
on=arbitrage.enabled(self.o) or self.modes_on,
|
||||
decision=self.arb,
|
||||
decided_at=self.arb_at, cycle_at=self.cycle_at,
|
||||
now=datetime.now(timezone.utc), why=self.why_words)),
|
||||
]
|
||||
held = level == "bad" and not running_alarm
|
||||
# UX-46. The page's language is chosen once, here, for the Home
|
||||
# sentences, checks, rows that carry one and the banner; a plain str
|
||||
# (no Dutch twin) stays as it is.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
|
||||
rows = [(k, messages.pick(v, lang)) for k, v in rows]
|
||||
banner = messages.pick(banner, lang)
|
||||
out = {
|
||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||
"hint": self.hint,
|
||||
# The action log itself, newest first: not shown on the page (UX-08
|
||||
# 2d) but readable in the `status` JSON, since several events are
|
||||
# written nowhere else. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
"events": list(self.events),
|
||||
}
|
||||
# UX-13 P1: the Home screen's structured half. Read-only, and it may
|
||||
# never cost the page its rows: a fault here is logged and the old
|
||||
# fields are served alone (the page then shows the installer details).
|
||||
try:
|
||||
out.update(homeview.build(self, level=level, checks=checks,
|
||||
lang=lang, held=held, waiting=waiting))
|
||||
except Exception: # noqa: BLE001
|
||||
# Once per process at ERROR: the page polls every 3 s.
|
||||
if not getattr(self, "_home_failed", False):
|
||||
self._home_failed = True
|
||||
_LOG.exception("Home view could not be built, so the page "
|
||||
"shows the plain status rows only")
|
||||
else:
|
||||
_LOG.debug("Home view failed again", exc_info=True)
|
||||
out["home_error"] = True
|
||||
return out
|
||||
|
||||
def forecast_payload(self, lang: str = "en") -> dict:
|
||||
"""UX-54: the Forecast tab's data. Built on request, from what the
|
||||
control cycle already holds; no Home Assistant call. Today's blocks are
|
||||
Home's own, so `status_payload()` is built first and read."""
|
||||
try:
|
||||
return dayview.build(self, self.status_payload(lang=lang),
|
||||
datetime.now(timezone.utc), lang)
|
||||
except Exception: # noqa: BLE001
|
||||
if not getattr(self, "_forecast_failed", False):
|
||||
self._forecast_failed = True
|
||||
_LOG.exception("The Forecast tab could not be built")
|
||||
else:
|
||||
_LOG.debug("The Forecast tab failed again", exc_info=True)
|
||||
return {"ok": False}
|
||||
|
||||
async def handle_action(self, what: str, *, unit=None, value=None):
|
||||
now = datetime.now(timezone.utc)
|
||||
if what in ("pack_use", "pack_mode", "pack_sell"):
|
||||
return self._pack_action(what, unit, value, now)
|
||||
if what == "auto_toggle":
|
||||
self.auto = not self.auto
|
||||
self.store.set("auto", self.auto)
|
||||
self.log_event("control started" if self.auto else "control stopped",
|
||||
hint=messages.hint("control_started" if self.auto
|
||||
else "control_stopped"))
|
||||
if self.auto:
|
||||
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose:
|
||||
# it clears on a deliberate human start, not on the condition
|
||||
# going away (the condition ALWAYS goes away, because the trip
|
||||
# commands 0 W). ⚠️ IT CLEARS EVERY INVERTER'S LATCH, AND SAYS
|
||||
# SO PER INVERTER, each under its OWN key.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
for inv in self.inverters:
|
||||
if not inv.sign_wd.tripped:
|
||||
continue
|
||||
inv.sign_wd.reset()
|
||||
inv.store_set("sign_tripped", False)
|
||||
inv.store_set("sign_reason", "")
|
||||
self.log_event(f"sign-disagreement failsafe cleared by "
|
||||
f"operator on {inv.name}",
|
||||
hint=messages.hint("stop_cleared",
|
||||
unit=inv.name))
|
||||
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM,
|
||||
# UNCONDITIONALLY: this is what makes `flat True + namespaced
|
||||
# False` MEAN something, and why `_grade_store_migration()` can
|
||||
# run on every start. Left behind, a flat key is a fossil that
|
||||
# re-arms the failsafe on every restart.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
self.store.set("sign_tripped", False)
|
||||
self.store.set("sign_reason", "")
|
||||
# SAFETY-30's SITE latch, cleared by the SAME press for the
|
||||
# same reason (the trip commands 0 W, so the condition goes
|
||||
# away on its own). ⚠️ UNCONDITIONAL, NOT GUARDED ON THE
|
||||
# ROSTER: an install reconfigured back to one inverter would
|
||||
# otherwise keep `site_sign_tripped: true` for ever.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if self.site_sign_wd.tripped:
|
||||
self.log_event("site sign-disagreement failsafe cleared "
|
||||
"by operator",
|
||||
hint=messages.hint("site_stop_cleared"))
|
||||
self.site_sign_wd.reset()
|
||||
self.store.set("site_sign_tripped", False)
|
||||
self.store.set("site_sign_reason", "")
|
||||
# SAFETY-31, cleared by the SAME press and for the same reason.
|
||||
# ⚠️ AND `_circ_announced` GOES WITH IT: left True, the NEXT
|
||||
# trip in this process would latch, stop the site and raise no
|
||||
# notification. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if self.circ_wd.tripped:
|
||||
self.circ_wd.reset()
|
||||
self.store.set("circ_tripped", False)
|
||||
self.store.set("circ_reason", "")
|
||||
self.log_event("circulating-power trip cleared by operator",
|
||||
hint=messages.hint("site_stop_cleared"))
|
||||
self._circ_announced = False
|
||||
# SAFETY-42, cleared by the SAME press, for a reason that is
|
||||
# NOT the one above: this latch commands nothing, so its
|
||||
# evidence still stands and it could clear itself. It does not:
|
||||
# a unit that stopped delivering and resumed is an INTERMITTENT
|
||||
# fault. ⚠️ AND `_delivery_announced` GOES WITH IT, like
|
||||
# `_circ_announced`. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
self.log_event(f"not-delivering report on {inv.name} "
|
||||
f"cleared by operator",
|
||||
hint=messages.hint(
|
||||
"not_delivering_cleared",
|
||||
unit=inv.name))
|
||||
inv.delivery_wd.reset()
|
||||
inv.store_set("delivery_tripped", False)
|
||||
inv.store_set("delivery_reason", "")
|
||||
self._delivery_announced = set()
|
||||
if not self.auto:
|
||||
# ⚠️ THE WHOLE SITE, TARGETS INCLUDED - see
|
||||
# command_site_zero(). SMA-10: Stop gives hand-back units back.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.command_site_release()
|
||||
return {"auto": self.auto}
|
||||
if what in ("maint_start", "maint_abort") and unit is not None:
|
||||
# MAINT-11: the operator named the battery. A request with no unit
|
||||
# falls through to the branches below, unchanged.
|
||||
return await self._maint_unit_action(what, unit, now)
|
||||
if what == "maint_start":
|
||||
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
|
||||
# ONE LABEL: `_maint_button()` picks the pack that holds the
|
||||
# maintenance slot, else the one longest without a cycle; at N=1
|
||||
# both are `maints[0]`. This is the bare request; a named battery
|
||||
# is `_maint_unit_action()` (MAINT-11).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.force_start(now):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=_m.phase))
|
||||
return {"phase": self.maint.phase}
|
||||
if what == "maint_abort":
|
||||
# Aborts the pack that is actually cycling - `_maint_button()` again
|
||||
# - and then zeroes the SITE, which is what it always did.
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.abort(now, "operator"):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=_m.phase))
|
||||
await self.command_site_zero()
|
||||
return {"phase": self.maint.phase}
|
||||
return None
|
||||
@@ -0,0 +1,243 @@
|
||||
"""Controller methods that run as background tasks.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from .inverter import UNIT_A
|
||||
from .price import brussels, price_option
|
||||
from .static_tariff import resolve_tariff
|
||||
from .validate import refuse, validate_startup_config, validate_unit_ranges
|
||||
from .ctl_inputs import TARIFF_MAX_AGE_S, TARIFF_REFRESH_S
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
class TasksMixin:
|
||||
def _grade_store_migration(self, store) -> None:
|
||||
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
|
||||
Graded, not performed: a latch that silently fails to move is a
|
||||
DISARMED SAFETY GUARD on an install that believes it is protected. The
|
||||
pre-DEV-02 flat value is compared with what the roster came up with; a
|
||||
disagreement re-arms the latch by hand (the fail-safe direction is ON).
|
||||
It runs on EVERY start, not only the migrating one: gating on `rep.ran`
|
||||
swallowed store.py's rule-2 round trip (upgrade, downgrade with a trip
|
||||
in the window, upgrade; performed on this site 2026-09-02).
|
||||
DECISIONS/MAIN-orchestration.md."""
|
||||
rep = self.store_migration
|
||||
if rep.ran and not rep.ok:
|
||||
_LOG.error("The saved safety stops did not copy over cleanly, so "
|
||||
"they are read again from the older saved state: %s.",
|
||||
rep.explain())
|
||||
data = getattr(store, "data", {})
|
||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||
if not legacy_tripped or not self.inverters:
|
||||
return
|
||||
# The question is "is any namespaced latch set THAT A MACHINE READS"
|
||||
# (SAFETY-44), asked of the STORE's keys, not the roster's objects:
|
||||
# re-arming A off a flat key that B's trip mirrored would latch a
|
||||
# healthy inverter on every restart. Three cases: on the roster, stop;
|
||||
# off-roster named `UNIT_A` (the broken migration's fingerprint),
|
||||
# repair onto `inverters[0]`; off-roster, other name: stop unless a
|
||||
# stranded `UNIT_A` latch is present. This guard is the ONLY repair for
|
||||
# sites upgraded before the fix. Residual: a first unit really named
|
||||
# `inverter-a`, latched and removed, is re-armed on `inverters[0]`
|
||||
# (conservative). DECISIONS/MAIN-orchestration.md.
|
||||
on_roster = {inv.name for inv in self.inverters}
|
||||
stranded, on_roster_latched, other_off_roster = [], False, False
|
||||
for k, v in data.items():
|
||||
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
|
||||
continue
|
||||
if not bool(v):
|
||||
continue
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1:
|
||||
# split through the writer's own spelling.
|
||||
unit = k.split("/")[1]
|
||||
if unit in on_roster:
|
||||
on_roster_latched = True
|
||||
elif unit == UNIT_A:
|
||||
stranded.append(k)
|
||||
else:
|
||||
other_off_roster = True
|
||||
if on_roster_latched or any(inv.sign_wd.tripped
|
||||
for inv in self.inverters):
|
||||
return
|
||||
if other_off_roster and not stranded:
|
||||
# A removed unit's own latch and no misdirected one: the flat key
|
||||
# is that unit's mirror, so re-arming `inverters[0]` from it would
|
||||
# latch a healthy pack.
|
||||
return
|
||||
if stranded:
|
||||
_LOG.error(
|
||||
"A saved safety stop belongs to %s, which is not in the "
|
||||
"inverter list %s, so it is set again on the first inverter.",
|
||||
", ".join(sorted(stranded)), sorted(on_roster))
|
||||
first = self.inverters[0]
|
||||
# The one state this method exists for.
|
||||
reason = str(data.get("sign_reason", "") or "")
|
||||
first.sign_wd.tripped = True
|
||||
first.sign_wd.reason = reason or (
|
||||
"SIGN_DISAGREEMENT: latched before this version; the reason was "
|
||||
"not carried across the per-inverter store migration")
|
||||
first.store_set("sign_reason", first.sign_wd.reason)
|
||||
first.store_set("sign_tripped", True)
|
||||
_LOG.error(
|
||||
"%s is set to 0 W again because an older saved safety stop was "
|
||||
"found, so find out why before you start control: %s.",
|
||||
first.name, messages.reason_code(first.sign_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
||||
"sign-disagreement failsafe is latched and no inverter came up "
|
||||
"latched. Either the per-inverter migration lost it, or an older "
|
||||
"add-on wrote it during a rollback. Both would be a disarmed "
|
||||
"guard, so the latch is re-armed on %s.", first.name)
|
||||
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
|
||||
+ first.sign_wd.reason,
|
||||
hint=messages.hint("stop_restored", unit=first.name))
|
||||
|
||||
# -- tasks ---------------------------------------------------------------
|
||||
# -- OPT-04: the background tariff refresh -------------------------------
|
||||
def fresh_tariff_plan(self, now):
|
||||
"""The stored plan if it is still current, else None. NEVER a stale
|
||||
one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
|
||||
`loop_is_following()`'s reason: a check has to be able to assert on the
|
||||
shipped expression rather than on a transcription that drifts.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
if self.tariff_plan is None or self.tariff_at is None:
|
||||
return None
|
||||
if (now - self.tariff_at).total_seconds() > TARIFF_MAX_AGE_S:
|
||||
return None
|
||||
return self.tariff_plan
|
||||
|
||||
async def run_tariff_refresh(self) -> None:
|
||||
"""Resolve the tariff off the control path, forever. NEVER on the tick.
|
||||
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would
|
||||
freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age
|
||||
bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN
|
||||
(TARIFF-12): a degraded plan replaces a standing observation only once
|
||||
it has aged out; a §11.4 fallback never replaces a fresh provider plan.
|
||||
⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN.
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
horizon = float(price_option(self.o, "price_horizon_h"))
|
||||
while not self.stopping:
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
plan = await resolve_tariff(
|
||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||
history=self.price_history, watch=self.price_watch)
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
# Three terms, and each of them is the only false one in some
|
||||
# check: the feed answered (replace), nothing current stands
|
||||
# (take the degradation - this is what makes the age bound
|
||||
# govern), or what stands is itself a degradation (replace).
|
||||
keep = (not plan.from_feed
|
||||
and standing is not None and standing.from_feed)
|
||||
if keep:
|
||||
_LOG.warning(
|
||||
"The price update was incomplete, so the plan from %s "
|
||||
"is kept until it is %s old. The update held %s.",
|
||||
messages.when(brussels(self.tariff_at)),
|
||||
messages.duration(TARIFF_MAX_AGE_S), plan.words)
|
||||
else:
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
except Exception as err: # noqa: BLE001
|
||||
# ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()`
|
||||
# returns on every failure a site can have, so what reaches
|
||||
# here is unexpected, and the plan is kept on the same terms.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
_LOG.warning(
|
||||
"The price update failed (%s), so the plan from %s is kept "
|
||||
"until it is %s old, and trading stops then.",
|
||||
err, messages.when(brussels(self.tariff_at)),
|
||||
messages.duration(TARIFF_MAX_AGE_S))
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.stop_event.wait(),
|
||||
timeout=TARIFF_REFRESH_S)
|
||||
|
||||
async def run_control(self) -> None:
|
||||
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one
|
||||
# read of one `setpoint_entity` left a second inverter's clamp, freeze
|
||||
# rail and `achievable_w` on the first one's numbers). Concurrently: a
|
||||
# slow ESPHome on one unit must not delay the start on the others.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
async def _limits(inv):
|
||||
if inv.driver.configured():
|
||||
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
|
||||
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
# ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a
|
||||
# max_w above the device's rating was silently clamped. amain() ran
|
||||
# this gate already; it runs AGAIN because ESPHome is often not up at
|
||||
# boot (limits None) and this is the first moment the real rating is
|
||||
# known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE
|
||||
# `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's
|
||||
# hazard): a per-inverter config leaves the flat `setpoint_entity`
|
||||
# blank, so the re-check was skipped; the per-unit rule now runs
|
||||
# unconditionally. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
problems = []
|
||||
if entity:
|
||||
_first = self.inverters[0]
|
||||
problems += validate_startup_config(self.o, _first.dev_min,
|
||||
_first.dev_max)
|
||||
problems += validate_unit_ranges(self.inverters, skip_entity=entity)
|
||||
if problems:
|
||||
refuse(problems)
|
||||
self.refused = problems
|
||||
self.log_event("REFUSED: " + problems[0],
|
||||
hint=messages.hint("refused"))
|
||||
# Signal and return WITHOUT entering the loop: amain() runs the
|
||||
# wind-down (0 W, or a hand-back: SMA-10, see
|
||||
# DECISIONS/SMA-10-two-idle-states.md) and exits with code 78.
|
||||
self.stop_event.set()
|
||||
return
|
||||
|
||||
last_grid = object()
|
||||
heartbeat = float(self.o.get("heartbeat_s", 10))
|
||||
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not
|
||||
# a second scheduler: every cycle resets it, so on a healthy meter it
|
||||
# never elapses and the per-cycle gain is unchanged. It exists because
|
||||
# a meter that repeats a value would run NO cycles, and the `inputs
|
||||
# missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️
|
||||
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under
|
||||
# validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and
|
||||
# moves with the firmware's veto; the schema minimum of 5 s is the
|
||||
# reference meter's interval and deserves a second look.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
floor = float(self.o.get("cycle_floor_s", 15))
|
||||
last_cycle = None
|
||||
while not self.stopping:
|
||||
await self.read_inputs()
|
||||
now_mono = asyncio.get_running_loop().time()
|
||||
# A cycle is one meter update, exactly as on the reference install -
|
||||
# or the floor elapsing, whichever comes first.
|
||||
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
|
||||
if self.grid != last_grid or overdue:
|
||||
last_grid = self.grid
|
||||
last_cycle = now_mono
|
||||
# ⚠️ The FULL cycle, not a degraded one: in P1 mode past
|
||||
# `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing").
|
||||
# ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`,
|
||||
# read_inputs() has NO age gate, so a quiet entity reads as
|
||||
# current; the floor does not make it fresh. The integrator
|
||||
# advances per CYCLE, so bounding the gap bounds the
|
||||
# accumulation. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.cycle()
|
||||
# ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the
|
||||
# inverter after ~30 s of silence, and refreshing the SAME value
|
||||
# proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed
|
||||
# concurrently, each bounded by WRITE_BUDGET_S (budget plus the
|
||||
# largest `heartbeat_s` stays under layer 1's veto: graded); each
|
||||
# unit refreshes its own `inv.target`, never the site target.
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await self.send_heartbeats(asyncio.get_running_loop().time(),
|
||||
heartbeat)
|
||||
self.publish()
|
||||
self.loop_pass_mono = time.monotonic()
|
||||
await asyncio.sleep(1)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
"""Controller methods that write to, refuse and hand back the inverters.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import control as _ctl
|
||||
from .clamp import clamp_to_rating, is_commandable
|
||||
from . import messages
|
||||
from . import shadow
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
|
||||
|
||||
# DEV-02: the per-write budget, DERIVED: `heartbeat_s` max 25 s + 4 <
|
||||
# `validate.FIRMWARE_INGEST_VETO_S` (30 s), graded by test_inverter.py.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
WRITE_BUDGET_S = 4.0
|
||||
|
||||
|
||||
# SMA-10: the bound for handing a unit back (`driver.release()`); units are
|
||||
# released concurrently and one still running at the bound is abandoned.
|
||||
# DECISIONS/SMA-10-two-idle-states.md.
|
||||
RELEASE_BUDGET_S = WRITE_BUDGET_S
|
||||
|
||||
|
||||
# SMA-10: the one fallback write after a failed release (default 0 W). Worst
|
||||
# case RELEASE_BUDGET_S + FALLBACK_BUDGET_S = 6 s, inside the Supervisor's 10 s.
|
||||
# DECISIONS/SMA-10-two-idle-states.md.
|
||||
FALLBACK_BUDGET_S = 2.0
|
||||
|
||||
|
||||
# SAFETY-137: how long a LAST write waits for a write already on its way to the
|
||||
# same unit; half the release budget, so a GoodWe unit's worst case is
|
||||
# HAND_BACK_WORST_S. DECISIONS/SAFETY-137-last-write-is-zero.md.
|
||||
SETTLE_BUDGET_S = RELEASE_BUDGET_S / 2
|
||||
|
||||
|
||||
# SAFETY-73: the evidence must also span this long, not only SETTLE_CYCLES
|
||||
# cycles: a 1 s meter makes 12 cycles only 11 s, shorter than a healthy battery
|
||||
# reading's lag, and both inverters were refused after ordinary load steps.
|
||||
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
|
||||
|
||||
|
||||
# SAFETY-94: evidence restarts when the reading has moved this far toward the
|
||||
# ask. A refusing battery reads flat; live readings wander under 25 W at rest;
|
||||
# a first-order lag gains at most 0.4 tau x 100 W across the band, so the
|
||||
# evidence cannot span REFUSED_SPAN_S unless the lag is over 54 s.
|
||||
REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
|
||||
|
||||
|
||||
async def _bounded(awaitable, budget_s: float):
|
||||
"""SMA-10. `await awaitable`, abandoning it after `budget_s` without waiting
|
||||
for it to wind down (`wait_for` would). Raises `asyncio.TimeoutError`."""
|
||||
task = asyncio.ensure_future(awaitable)
|
||||
done, _ = await asyncio.wait({task}, timeout=budget_s)
|
||||
if not done:
|
||||
task.cancel()
|
||||
# a result or error that arrives later has nobody to read it
|
||||
task.add_done_callback(lambda t: t.cancelled() or t.exception())
|
||||
raise asyncio.TimeoutError
|
||||
return task.result()
|
||||
|
||||
|
||||
class WritesMixin:
|
||||
# helpers; SAFETY-52: a battery that refuses is asked for 0 W
|
||||
def _refusal_probe(self) -> bool:
|
||||
"""True while a refused direction may try `PROBE_W` (every 15 min)."""
|
||||
if (not self.batt_refused or self.batt_refused_at is None
|
||||
or self._now_mono is None):
|
||||
return False
|
||||
held = self._now_mono - self.batt_refused_at
|
||||
if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S:
|
||||
self.batt_refused_at = self._now_mono
|
||||
return False
|
||||
return held >= _ctl.PROBE_EVERY_S
|
||||
|
||||
def _refusal_start(self, sign: int, evidence: str) -> None:
|
||||
self.batt_refused = sign
|
||||
self.batt_refused_at = self._now_mono
|
||||
self.log_event(f"BATTERY REFUSES {evidence}",
|
||||
hint=messages.hint("battery_refuses"))
|
||||
_LOG.info("The battery refuses to give power, so the add-on asks 0 W "
|
||||
"and tries %.0f W every %.0f minutes: BATTERY_REFUSES.",
|
||||
_ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60)
|
||||
_LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence)
|
||||
|
||||
def _refusal_end(self, why: str) -> None:
|
||||
if not self.batt_refused:
|
||||
return
|
||||
self.batt_refused, self.batt_refused_at = 0, None
|
||||
self.log_event(f"BATTERY REFUSES ENDED: {why}",
|
||||
hint=messages.hint("battery_gives"))
|
||||
_LOG.info("The battery refusal ends, control asks for power again: %s.",
|
||||
why)
|
||||
|
||||
def _refusal_update(self, decision, asked_w, batt_w, probe,
|
||||
rejudged: int = 0) -> None:
|
||||
"""Follow `compute()`'s carried refused direction, and say so.
|
||||
`rejudged`: the refusal held while another writer had the wire, so
|
||||
`compute()` judged it afresh against that writer's ask (SAFETY-107)."""
|
||||
now = decision.refused or 0
|
||||
if rejudged:
|
||||
if now == rejudged:
|
||||
# Still refused against the ask on the wire: the same episode.
|
||||
# The probe clock restarts: a probe is the loop's ask and is
|
||||
# not on the wire while another writer is.
|
||||
self.batt_refused_at = self._now_mono
|
||||
return
|
||||
gave = (rejudged * (batt_w - self._site_idle_w())
|
||||
>= _ctl.PROBE_ANSWER_W)
|
||||
self._refusal_end("the battery gives power" if gave
|
||||
else "another writer")
|
||||
if now and not self.batt_refused:
|
||||
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
|
||||
f"gave {batt_w:+.0f} W for "
|
||||
f"{decision.sat_count} cycles")
|
||||
elif self.batt_refused and not now:
|
||||
gave = (self.batt_refused * (batt_w - self._site_idle_w())
|
||||
>= _ctl.PROBE_ANSWER_W)
|
||||
self._refusal_end("the probe was answered" if gave and probe
|
||||
else "the battery gives power" if gave
|
||||
else "the grid now asks the other direction")
|
||||
|
||||
def _refusal_from_stale(self, inv, now_mono) -> None:
|
||||
"""A stale-reading stop while the loop is frozen on ~0 W is a refusal.
|
||||
Only at one inverter: with several, one empty battery must not stop
|
||||
the site (OPT-09 moves its share)."""
|
||||
t = self.tuning
|
||||
standing = inv.standing_w()
|
||||
if len(self.inverters) > 1:
|
||||
# SAFETY-73: at N>1 the same trip refuses THIS unit only.
|
||||
if ((self.auto or self.shadow) and not inv.refused
|
||||
and inv.ref_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > _ctl.PROBE_W):
|
||||
self._now_mono = now_mono
|
||||
self._unit_refusal_start(
|
||||
inv, 1 if standing > 0 else -1,
|
||||
f"stale reading {inv.batt_fresh.held:+.0f} W while asked "
|
||||
f"{standing:+.0f} W")
|
||||
return
|
||||
if (len(self.inverters) == 1 and (self.auto or self.shadow)
|
||||
and not self.batt_refused
|
||||
and self.sat_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > t.saturation_w):
|
||||
self._now_mono = now_mono
|
||||
self._refusal_start(1 if standing > 0 else -1,
|
||||
f"stale reading {inv.batt_fresh.held:+.0f} W "
|
||||
f"while asked {standing:+.0f} W")
|
||||
|
||||
# SAFETY-73: SAFETY-52's decisions per inverter at N>1: a unit giving <
|
||||
# REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in that
|
||||
# direction by a P_SAFETY unit limit, so the allocator hands its share on;
|
||||
# probed at PROBE_W every PROBE_EVERY_S, one unit at a time. The ask is
|
||||
# PROBE_W, not `saturation_w`: a unit's share is often small and SAFETY-43
|
||||
# would name the empty battery as not delivering.
|
||||
# DECISIONS/SAFETY-73-refusal-per-inverter.md.
|
||||
def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None:
|
||||
inv.refused, inv.refused_at = sign, self._now_mono
|
||||
inv.probe_from, inv.ref_count = None, 0
|
||||
self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}",
|
||||
hint=messages.hint("unit_refuses", unit=inv.name))
|
||||
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
|
||||
"other inverters take over: BATTERY_REFUSES.", inv.name)
|
||||
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
|
||||
# Only a refusal to GIVE: a full battery that stops taking charge is
|
||||
# above its cut-out level by definition.
|
||||
if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None
|
||||
and float(inv.soc) > float(inv.bms_soc_floor)):
|
||||
# Owner decision 3: a refusal above the configured cut-out level
|
||||
# may mean that level is set wrong. One line, no safety stop.
|
||||
_LOG.warning("%s refuses power above its battery cut-out level, "
|
||||
"check its cut-out level: REFUSES_ABOVE_CUT_OUT.",
|
||||
inv.name)
|
||||
_LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level "
|
||||
"%.0f %%, cut-out level %.0f %%.", inv.name,
|
||||
float(inv.soc), float(inv.bms_soc_floor))
|
||||
|
||||
def _unit_refusal_end(self, inv, why: str) -> None:
|
||||
if not inv.refused:
|
||||
return
|
||||
inv.refused, inv.refused_at, inv.probe_from = 0, None, None
|
||||
inv.ref_count = 0
|
||||
self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}",
|
||||
hint=messages.hint("unit_gives", unit=inv.name))
|
||||
_LOG.info("%s: the battery refusal ends, it is asked for power again: "
|
||||
"%s.", inv.name, why)
|
||||
# The site target may stand above what was placed (the refused share
|
||||
# was unmet). Restart it from the watts on the wire, or the reopened
|
||||
# unit gets the parked remainder at once (phase A: -1059 W).
|
||||
placed = float(sum(i.standing_w() for i in self.inverters))
|
||||
self.target = self.i_w = placed
|
||||
self.sat_count = 0
|
||||
|
||||
def _unit_probe(self, inv) -> bool:
|
||||
"""True while `inv` may try PROBE_W. One unit probes at a time."""
|
||||
now = self._now_mono
|
||||
if not inv.refused or inv.refused_at is None or now is None:
|
||||
return False
|
||||
if inv.probe_from is not None:
|
||||
if now - inv.probe_from < _ctl.PROBE_S:
|
||||
return True
|
||||
inv.probe_from, inv.refused_at = None, now
|
||||
return False
|
||||
if now - inv.refused_at < _ctl.PROBE_EVERY_S or any(
|
||||
i.probe_from is not None for i in self.inverters):
|
||||
return False
|
||||
inv.probe_from = now
|
||||
return True
|
||||
|
||||
def _unit_refusals(self) -> None:
|
||||
"""Engage and end each unit's refusal from its own reading."""
|
||||
for inv in self.inverters:
|
||||
b = inv.batt
|
||||
if inv.refused:
|
||||
# SAFETY-97: given and taken are counted from the idle reading.
|
||||
if (b is not None and inv.refused * (b - inv.idle_w)
|
||||
>= _ctl.PROBE_ANSWER_W):
|
||||
self._unit_refusal_end(
|
||||
inv, "the probe was answered"
|
||||
if inv.probe_from is not None
|
||||
else "the battery gives power")
|
||||
continue
|
||||
# The other direction: the battery takes PROBE_ANSWER_W or more
|
||||
# the other way for SETTLE_CYCLES, counted on its own reading
|
||||
# so one overshoot through zero does not hand its share back.
|
||||
inv.ref_count = (inv.ref_count + 1 if b is not None and
|
||||
-inv.refused * (b - inv.idle_w)
|
||||
>= _ctl.PROBE_ANSWER_W else 0)
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES:
|
||||
self._unit_refusal_end(
|
||||
inv, "the battery takes power the other way")
|
||||
continue
|
||||
if inv.rejected_since is not None:
|
||||
# SAFETY-96: its writes are refused, so its board may have gone
|
||||
# to 0 W on its own; that is no refusal by the battery. The
|
||||
# count is kept, as across a gap.
|
||||
continue
|
||||
sign = self._gave_nothing(inv)
|
||||
if sign:
|
||||
self._unit_refusal_start(
|
||||
inv, sign,
|
||||
f"asked {inv.standing_w():+.0f} W, the battery gave "
|
||||
f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles")
|
||||
|
||||
def _gave_nothing(self, inv) -> int:
|
||||
"""The refusal evidence, one cycle of it: the sign `inv` has given
|
||||
under REFUSED_BELOW_W while asked over PROBE_W, one way, for
|
||||
SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1;
|
||||
SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the
|
||||
band is centred on the unit's idle reading."""
|
||||
b = inv.batt
|
||||
if b is None:
|
||||
return 0 # a gap keeps the count: the gate's trip seeds
|
||||
standing = inv.standing_w()
|
||||
sign = 1 if standing > 0 else -1
|
||||
# One direction throughout: a command that swaps sign every cycle reads
|
||||
# ~0 through the plant's lag, which is a battery following, not
|
||||
# refusing (TEST-19).
|
||||
if (abs(standing) > _ctl.PROBE_W
|
||||
and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W):
|
||||
# SAFETY-94: a reading still moving towards the ask is a battery
|
||||
# catching up through its lag: start again.
|
||||
if (sign != inv.ref_sign or not inv.ref_count
|
||||
or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W):
|
||||
inv.ref_count, inv.ref_since, inv.ref_from = (
|
||||
0, self._now_mono, b)
|
||||
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
|
||||
else:
|
||||
inv.ref_count = 0
|
||||
inv.ref_sign = sign
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
|
||||
inv.ref_since is None or self._now_mono is None
|
||||
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
|
||||
return sign
|
||||
return 0
|
||||
|
||||
def _site_idle_w(self) -> float:
|
||||
"""SAFETY-97: the site reading at idle, the sum of the units'."""
|
||||
return float(sum(inv.idle_w for inv in self.inverters))
|
||||
|
||||
def _site_refused(self):
|
||||
"""Owner decision 4: every unit refuses the same way, so the site law
|
||||
holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law."""
|
||||
signs = {inv.refused for inv in self.inverters}
|
||||
return signs.pop() if len(signs) == 1 and 0 not in signs else None
|
||||
|
||||
@property
|
||||
def shadow(self) -> bool:
|
||||
"""SAFETY-10. Is the battery being left alone right now? A property,
|
||||
not a field (as `self.units`): the cycle, the write and the status
|
||||
page ask at three moments, and the write gate asks THIS, not a
|
||||
parameter passed down."""
|
||||
return shadow.engaged(self.o, control_started=self.auto)
|
||||
|
||||
def shadow_latches(self) -> tuple:
|
||||
"""The failsafe latches that are UP, named, for the shadow banner: a
|
||||
standing latch is standing when the controller stops being inert, so
|
||||
an operator who turns shadow off is not handed a battery that will
|
||||
not move with no reason shown."""
|
||||
out = []
|
||||
if self.circ_wd.tripped:
|
||||
out.append("circulating power")
|
||||
if self.site_sign_wd.tripped and len(self.inverters) > 1:
|
||||
out.append("site sign watchdog")
|
||||
for inv in self.inverters:
|
||||
if inv.sign_wd.tripped:
|
||||
out.append(f"{inv.name} sign watchdog")
|
||||
return tuple(out)
|
||||
|
||||
def write_refusal(self, value: float, inv=None) -> str | None:
|
||||
"""Why `value` may NOT be written to `inv` right now, or None if it
|
||||
may. SAFETY-28: a number reaches the wire only if THE SINGLE ALLOCATOR
|
||||
placed it, on this tick, for THIS unit (`Allocation.authorises()`); a
|
||||
second writer must file a Claim. 0 W is ALWAYS authorised and tested
|
||||
first, reading nothing: the failsafe writes run while the allocator has
|
||||
REFUSED, so a gate asking permission to command zero would deadlock the
|
||||
failsafe. It tests `value == 0.0`, not a flag the caller sets. DEV-02:
|
||||
the unit is an ARGUMENT (the record holds one share per unit); the
|
||||
default is for N=1 callers and REFUSES at N>=2.
|
||||
DECISIONS/MAIN-orchestration.md."""
|
||||
if value == 0.0:
|
||||
return None
|
||||
inv = self._only() if inv is None else inv
|
||||
rec = self.allocation
|
||||
if rec is None:
|
||||
return ("no allocation record exists yet - nothing has authorised "
|
||||
f"{value:+.0f} W on {inv.name}")
|
||||
return rec.authorises(inv.name, value, self.alloc_tick)
|
||||
|
||||
async def write_setpoint(self, value: float, *, force: bool = False,
|
||||
unit=None, last: bool = False) -> None:
|
||||
"""Put `value` on ONE inverter, or with `unit=None` on all. `unit=None`
|
||||
is for a number that is not a share (stop button, `shutdown()`,
|
||||
`maint_abort()`: 0 W means the same to every inverter). Writes are
|
||||
concurrent, each under its own budget (DEV-02). `last`:
|
||||
DECISIONS/SAFETY-137-last-write-is-zero.md."""
|
||||
if unit is None:
|
||||
targets = [i for i in self.inverters]
|
||||
if len(targets) > 1 and is_commandable(value) and float(value) != 0.0:
|
||||
# Loud, and it commands zero rather than the value asked for:
|
||||
# fail toward inaction, as every other refusal here.
|
||||
self.log_event(
|
||||
f"refusing to broadcast {float(value):+.0f} W to "
|
||||
f"{len(targets)} inverters - that is the ARCH-01 fault; "
|
||||
f"commanding 0 W", hint=messages.hint("all_blocked"))
|
||||
_LOG.error("Refused to send %+.0f W to all %s at once, so "
|
||||
"every inverter is set to 0 W. This is a software "
|
||||
"fault.", float(value),
|
||||
messages.count(len(targets), "inverter"))
|
||||
_LOG.debug("ARCH-01: a site target handed to each unit is "
|
||||
"supplied by each of them, so the gain at the meter "
|
||||
"becomes N times the per-unit gain. Only 0 W may be "
|
||||
"written site-wide, and shares come from the "
|
||||
"allocator.")
|
||||
value = 0.0
|
||||
else:
|
||||
targets = [unit]
|
||||
if not targets:
|
||||
return
|
||||
await asyncio.gather(*(self._write_unit(inv, value, force=force,
|
||||
last=last)
|
||||
for inv in targets))
|
||||
|
||||
async def _await_inflight(self, inv, budget_s: float) -> None:
|
||||
"""Wait, at most `budget_s`, for writes already on their way to `inv`; one
|
||||
still running is left to its own WRITE_BUDGET_S.
|
||||
DECISIONS/SAFETY-137-last-write-is-zero.md."""
|
||||
pending = [t for t in inv.inflight if not t.done()]
|
||||
if pending:
|
||||
await asyncio.wait(pending, timeout=budget_s)
|
||||
|
||||
async def _release_last(self, inv) -> bool:
|
||||
"""`release()` once any write in flight to `inv` has finished (at most
|
||||
SETTLE_BUDGET_S): the release is the LAST thing the unit hears.
|
||||
DECISIONS/SMA-10-two-idle-states.md."""
|
||||
await self._await_inflight(inv, SETTLE_BUDGET_S)
|
||||
return await self._driver(inv).release()
|
||||
|
||||
async def _hand_back(self, inv, *, force: bool = False) -> bool:
|
||||
"""SMA-10. Give ONE unit back: exactly one `release()`, then nothing.
|
||||
True when handed back. Never raises; a failure is retried on the next
|
||||
idle write. ⚠️ `released` is set before the first await (one release per
|
||||
unit, not two). ⚠️ It is not a write: no `note_write`, no `last_write`.
|
||||
DECISIONS/SMA-10-two-idle-states.md."""
|
||||
if inv.released:
|
||||
return True
|
||||
if not inv.driver.configured():
|
||||
return False
|
||||
# After a failure the next try waits one heartbeat; Stop and shutdown
|
||||
# (`force`) always try. DECISIONS/SMA-10-two-idle-states.md.
|
||||
now_m = time.monotonic()
|
||||
if not force and now_m < inv.release_retry_at:
|
||||
return False
|
||||
inv.released = True
|
||||
why = ""
|
||||
try:
|
||||
ok = bool(await _bounded(self._release_last(inv),
|
||||
RELEASE_BUDGET_S))
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok, why = False, f"took over {RELEASE_BUDGET_S:.0f} s"
|
||||
except Exception as err: # noqa: BLE001 - one unit never stops the rest
|
||||
ok, why = False, f"{type(err).__name__}: {err}"
|
||||
if ok:
|
||||
inv.last_write = None
|
||||
if inv.release_fails:
|
||||
_LOG.info("Handing back %s worked again after %d failed "
|
||||
"tries.", inv.name, inv.release_fails)
|
||||
inv.release_fails = 0
|
||||
inv.release_retry_at = 0.0
|
||||
inv.release_zero_sent = False
|
||||
self.log_event(f"{inv.name}: handed back",
|
||||
hint=messages.hint("handed_back", unit=inv.name))
|
||||
return True
|
||||
# SMA-10: a failed hand-back is tried again, and sent 0 W once below.
|
||||
inv.released = False
|
||||
inv.release_fails += 1
|
||||
inv.release_retry_at = now_m + float(self.o.get("heartbeat_s", 10))
|
||||
_LOG.debug("Handing back %s failed (try %d): %s", inv.name,
|
||||
inv.release_fails, why)
|
||||
if inv.release_fails == 1: # one line per failure streak
|
||||
_LOG.error("Handing back %s failed, so it is sent 0 W once and "
|
||||
"handing back is tried again.", inv.name)
|
||||
self.log_event(f"{inv.name}: hand-back failed",
|
||||
hint=messages.hint("hand_back_failed", unit=inv.name))
|
||||
if not inv.release_zero_sent:
|
||||
# ⚠️ ONCE: a 0 W re-sent on every try would reset the inverter's own
|
||||
# fallback timer for ever. DECISIONS/SMA-10-two-idle-states.md.
|
||||
inv.release_zero_sent = True
|
||||
try:
|
||||
if await _bounded(self._driver(inv).release_fallback(),
|
||||
FALLBACK_BUDGET_S):
|
||||
inv.last_write = 0.0
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOG.debug("The fallback write to %s failed: %r", inv.name, err)
|
||||
return False
|
||||
|
||||
async def _write_unit(self, inv, value: float, *, force: bool = False,
|
||||
last: bool = False) -> None:
|
||||
if not inv.driver.configured():
|
||||
return
|
||||
# SMA-10: a unit that hands back is released once and not written while
|
||||
# the add-on is idle (shutdown, stopped, shadow). ⚠️ A safety stop is
|
||||
# NOT idle: it falls through to the 0 W write. Control starting again
|
||||
# re-arms the unit; `stopping` never does.
|
||||
# DECISIONS/SMA-10-two-idle-states.md.
|
||||
if inv.driver.hands_back:
|
||||
if self.stopping or self.shadow or not self.auto:
|
||||
await self._hand_back(inv)
|
||||
return
|
||||
if inv.released or inv.release_fails or inv.release_zero_sent:
|
||||
# Control has the unit again: forget the hand-back and any
|
||||
# failed streak.
|
||||
inv.released = False
|
||||
inv.last_write = None
|
||||
inv.release_fails = 0
|
||||
inv.release_retry_at = 0.0
|
||||
inv.release_zero_sent = False
|
||||
entity = inv.driver.actuator_id() # for the log line below
|
||||
|
||||
# SAFETY-03: the clamp is on the ONLY path to the wire (cycle,
|
||||
# heartbeat, stop button, shutdown(); no optimizer, maintenance or
|
||||
# manual path routes around it). A non-finite value is loss of input,
|
||||
# not an out-of-range one (11.1): coerced to 0 W.
|
||||
if not is_commandable(value):
|
||||
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W",
|
||||
hint=messages.hint("bad_value"))
|
||||
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
|
||||
"0 W and continuing to command. This is a bug upstream, "
|
||||
"not a limit.", value)
|
||||
value = 0.0
|
||||
|
||||
# SAFETY-28: the refusal is HERE, above the clamp and below the
|
||||
# non-finite coercion (a nan is loss of input, not an unauthorised
|
||||
# number), and asked about `inv`, not the site.
|
||||
# DECISIONS/MAIN-orchestration.md.
|
||||
refusal = self.write_refusal(value, inv)
|
||||
if refusal is not None:
|
||||
self.log_event(
|
||||
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
|
||||
hint=messages.hint("write_blocked", unit=inv.name))
|
||||
_LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W "
|
||||
"instead, which is a software fault. The reason: %s.",
|
||||
value, inv.name, refusal)
|
||||
value = 0.0
|
||||
|
||||
# This unit's own three numbers: `inv.max_w` (supervised cap on THIS
|
||||
# setpoint entity) and `inv.dev_min/dev_max` (what THIS device
|
||||
# reported). Read off `self.o` they were one inverter's rating applied
|
||||
# to every inverter.
|
||||
result = clamp_to_rating(value, inv.max_w, inv.dev_min, inv.dev_max)
|
||||
inv.clamp_last = result
|
||||
if result.engaged:
|
||||
# EVERY engagement is logged, never deduplicated: the action log
|
||||
# (15) is a different surface from the deduplicated notification.
|
||||
# The count says how hard the loop pushed.
|
||||
self.log_event(f"{inv.name}: {result.describe()}",
|
||||
hint=messages.hint("write_cut", unit=inv.name))
|
||||
value = result.applied_w
|
||||
|
||||
# SAFETY-10: shadow mode runs the whole decision path and commands
|
||||
# nothing, and the suppression is HERE, the last thing before the wire,
|
||||
# in the one method every write reaches (so no call site routes round
|
||||
# it). A substitution, not a return, like the two refusals above; below
|
||||
# SAFETY-28's gate and SAFETY-03's clamp; not a second writer (no
|
||||
# claim, no transport call).
|
||||
if self.shadow and value != shadow.WIRE_W:
|
||||
# UX-10: DEBUG, because it repeats every cycle in shadow mode; the
|
||||
# INFO line when shadow comes on says it once.
|
||||
_LOG.debug("Shadow mode: would send %+.0f W to %s, and holds "
|
||||
"%+.0f W. Nothing was sent to the inverter.",
|
||||
value, inv.name, shadow.WIRE_W)
|
||||
value = shadow.WIRE_W
|
||||
|
||||
# SAFETY-137: `last` (the Stop and shutdown 0 W) waits, bounded, for a
|
||||
# write already on its way; once `stopping`, every other write is dropped
|
||||
# here with no await before the write is made.
|
||||
# DECISIONS/SAFETY-137-last-write-is-zero.md.
|
||||
if last:
|
||||
await self._await_inflight(inv, SETTLE_BUDGET_S)
|
||||
elif self.stopping:
|
||||
_LOG.debug("Shutting down, so %+.0f W to %s is not sent.",
|
||||
value, inv.name)
|
||||
return
|
||||
changed = (inv.last_write is None or value != inv.last_write)
|
||||
if not (changed or force):
|
||||
return
|
||||
# BOUNDED, and the budget is AC 6: one hung transport would hold the
|
||||
# coroutine, the whole loop and every OTHER inverter's heartbeat. An
|
||||
# abandoned write is re-issued next tick (`last_write` moves only on an
|
||||
# ACCEPTED write).
|
||||
try:
|
||||
_w = self._driver(inv).write(value)
|
||||
# SMA-10 / SAFETY-137: kept so a release or last 0 W can wait for
|
||||
# it. DECISIONS/SAFETY-137-last-write-is-zero.md.
|
||||
_w = asyncio.ensure_future(_w)
|
||||
inv.inflight.add(_w)
|
||||
_w.add_done_callback(inv.inflight.discard)
|
||||
ok = await asyncio.wait_for(_w, timeout=WRITE_BUDGET_S)
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok = False
|
||||
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
|
||||
"dropped, so it is sent again next cycle.",
|
||||
value, inv.name, entity, WRITE_BUDGET_S)
|
||||
self.log_event(f"{inv.name}: write timed out after "
|
||||
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick",
|
||||
hint=messages.hint("write_slow", unit=inv.name))
|
||||
now = datetime.now(timezone.utc)
|
||||
inv.note_write(bool(ok), now)
|
||||
if ok:
|
||||
inv.last_write = value
|
||||
inv.last_write_at = now
|
||||
|
||||
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
|
||||
"""Refresh every inverter whose own deadline has come. AC 6 lives here.
|
||||
⚠️ A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` so a check
|
||||
can inject a hang: "a hung write to A cannot consume B's layer-1
|
||||
budget"; inline, the mutation to a serial `for` loop passed the whole
|
||||
suite. (DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
due = [inv for inv in self.inverters
|
||||
if loop_now - inv.last_beat >= heartbeat_s]
|
||||
if not due:
|
||||
return
|
||||
# ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT: after, a
|
||||
# hanging write drifts into being re-sent every tick and a raising one
|
||||
# never advances. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
for inv in due:
|
||||
inv.last_beat = loop_now
|
||||
# ⚠️ CONCURRENTLY: in series, every inverter behind a hung one waits
|
||||
# out its whole budget, pushing a healthy sibling towards layer 1's ~30
|
||||
# s veto. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
await asyncio.gather(*(
|
||||
self.write_setpoint(inv.target, force=True, unit=inv)
|
||||
for inv in due))
|
||||
|
||||
async def command_site_release(self) -> None:
|
||||
"""SMA-10. The add-on is going away (Stop, shutdown, restart, task death,
|
||||
config refusal): units that hand back are given back, the rest held at
|
||||
0 W as `command_site_zero()` does. ⚠️ Safety stops and refusals do NOT
|
||||
come here: they hold 0 W for EVERY unit. A roster with no hand-back unit
|
||||
is `command_site_zero()` line for line.
|
||||
DECISIONS/SMA-10-two-idle-states.md."""
|
||||
if not any(inv.driver.hands_back for inv in self.inverters):
|
||||
await self.command_site_zero(last=True)
|
||||
return
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await asyncio.gather(*(
|
||||
self._hand_back(inv, force=True) if inv.driver.hands_back
|
||||
else self._write_unit(inv, 0.0, force=True, last=True)
|
||||
for inv in self.inverters))
|
||||
|
||||
async def command_site_zero(self, *, last: bool = False) -> None:
|
||||
"""Every inverter to 0 W, and every inverter's TARGET with it. ⚠️ NOT
|
||||
BOOKKEEPING (DEV-02): the heartbeat re-emits `inv.target`, so zeroing
|
||||
only `self.target` lets the next heartbeat put each unit's last share
|
||||
back on the wire. ⚠️ 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE
|
||||
(`write_setpoint()` refuses others: ARCH-01).
|
||||
(DECISIONS/MAIN-orchestration-archive.md)"""
|
||||
self.target = 0.0
|
||||
for inv in self.inverters:
|
||||
inv.target = 0.0
|
||||
await self.write_setpoint(0.0, force=True, last=last)
|
||||
@@ -22,7 +22,10 @@ OPT-28 for the second):
|
||||
all of them cannot hold (`Plan.uncovered_kwh`) is not bought: another
|
||||
battery (Cheap hours, Solar first) or the grid carries it.
|
||||
* Cheap hours only ignores the plan and fills to the ceiling, as in P3 and
|
||||
the design (section 1). Skipping the sun's share cost money in the replay
|
||||
the design (section 1). OPT-36: what it, and a Solar first battery after
|
||||
the night's use, will give in the expensive hours is taken off `short`
|
||||
before the Leftover solar batteries share it (`_cheap_give_kwh()`,
|
||||
`_solar_first_give_kwh()`). Skipping the sun's share cost money in the replay
|
||||
(PR #156 review: 29.72 against 29.41 EUR on real 2026 prices).
|
||||
* A Leftover solar battery charges from the grid up to the level it needs
|
||||
for its part of `short`, less its share of the sun before the run:
|
||||
@@ -45,7 +48,7 @@ import statistics
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .packmode import LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .packmode import CHEAP_HOURS, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .tariff import BILLING_TZ
|
||||
|
||||
EFF = 0.95 # charge (and discharge) efficiency
|
||||
@@ -170,6 +173,14 @@ class Plan:
|
||||
short_kwh: float = 0.0
|
||||
cover_kwh: dict = field(default_factory=dict)
|
||||
uncovered_kwh: float = 0.0
|
||||
# OPT-36: what Cheap hours batteries will give in those hours (kWh on the
|
||||
# house's side), and, per Leftover solar battery, the grid kWh it buys
|
||||
# less because of it.
|
||||
cheap_kwh: float = 0.0
|
||||
saved_kwh: dict = field(default_factory=dict)
|
||||
# ...and the same for Solar first batteries (owner, 2026-11-02): their
|
||||
# level when the expensive hours start, after the night's use.
|
||||
solar_first_kwh: float = 0.0
|
||||
|
||||
|
||||
def _hour_slices(t0, t1):
|
||||
@@ -219,12 +230,100 @@ def _room(p, to_pct):
|
||||
p.capacity_kwh)
|
||||
|
||||
|
||||
def _cheap_give_kwh(runs, now, house, packs, *, floor_pct, ceiling_pct,
|
||||
aim_w, until=None, slots=None, spread=None):
|
||||
"""OPT-36: what the Cheap hours batteries in `packs` give in the
|
||||
expensive hours, in kWh on the house's side: (the level at the start of
|
||||
those hours - the floor) x capacity x `EFF`.
|
||||
|
||||
That level is the one its cheap charging reaches: the ceiling, or less
|
||||
when the time left cannot get there. That time is the cheap run from now
|
||||
(or the whole run ahead of it) and, given the firm price `slots`, each
|
||||
later slot before the expensive hours (`until`, a give run) still worth
|
||||
charging in (its price plus the spread control asks, `spread`, at most
|
||||
the give run's dearest slot, `priceruns.plan()`'s test) and no dearer
|
||||
than every slot from the start of those hours on: control plans the
|
||||
charge left in the cheapest window ahead, so a slot with a cheaper one
|
||||
after it may never be used (round 4, a three-price night). The run is
|
||||
sized at the batteries' ratings (`priceruns.need_hours()`), not the peak
|
||||
limit, so a run too short to fill them is planned again in that time;
|
||||
counting the run alone let A's level fall in the night after A had
|
||||
bought. In that time the batteries together charge at their ratings,
|
||||
held to what the peak aim leaves above the house's use that hour
|
||||
(`aim_w`, None: no limit), shared by rating; x `EFF` of it is stored.
|
||||
No cheap run worth charging in: the level now.
|
||||
A battery that refuses a direction counts 0."""
|
||||
ps = [p for p in packs if p.mode == CHEAP_HOURS and p.refused == 0]
|
||||
if not ps:
|
||||
return 0.0
|
||||
house_w, base_w = house
|
||||
gain = {p.name: 0.0 for p in ps}
|
||||
rate = {p.name: abs(float(p.rating_w)) for p in ps}
|
||||
total = sum(rate.values())
|
||||
run = runs.cheap
|
||||
spans = [(max(now, run.start), run.end)]
|
||||
top = max((eur for s, _e, eur in slots or ()
|
||||
if until is not None and until.start <= s < until.end),
|
||||
default=None)
|
||||
low = min((eur for s, _e, eur in slots or ()
|
||||
if until is not None and s >= until.start), default=None)
|
||||
if top is not None and spread is not None:
|
||||
spans += [(max(s, now, run.end), min(e, until.start))
|
||||
for s, e, eur in slots
|
||||
if eur + float(spread) <= top + 1e-9
|
||||
and (low is None or eur <= low + 1e-9)
|
||||
and e > run.end and s < until.start]
|
||||
if runs.charge_useful() and total > 0.0:
|
||||
for t0, t1 in spans:
|
||||
for s, e, h in _hour_slices(t0, t1):
|
||||
w = total
|
||||
if aim_w is not None:
|
||||
w = min(w, max(0.0, float(aim_w)
|
||||
- max(house_w(h), base_w, 0.0)))
|
||||
hrs = (e - s).total_seconds() / 3600.0
|
||||
for p in ps:
|
||||
gain[p.name] += w * rate[p.name] / total * hrs \
|
||||
/ 1000.0 * EFF
|
||||
out = 0.0
|
||||
for p in ps:
|
||||
cap = float(p.capacity_kwh)
|
||||
lvl = max(float(p.soc), min(float(ceiling_pct),
|
||||
float(p.soc) + gain[p.name] / cap * 100.0))
|
||||
out += max(0.0, lvl - float(floor_pct)) / 100.0 * cap * EFF
|
||||
return out
|
||||
|
||||
|
||||
def _solar_first_give_kwh(house, now, start, packs, *, floor_pct):
|
||||
"""OPT-36 (owner, 2026-11-02): what the Solar first batteries in `packs`
|
||||
will still give when the expensive hours start at `start`, in kWh on the
|
||||
house's side. They give at any hour, so the house's use from now to
|
||||
`start` is taken off first, shared by the energy each holds above the
|
||||
floor, and what is left x `EFF` counts. Each hour is read at no less than
|
||||
the night base: the profile is net of the sun the house used, and read
|
||||
net it let a dark day's morning drain a battery that the plan still
|
||||
counted (round 4, real-price replays). A battery
|
||||
that runs down to the floor before then counts 0; one that refuses a
|
||||
direction counts 0."""
|
||||
ps = [p for p in packs if p.mode == SOLAR_FIRST and p.refused == 0]
|
||||
if not ps:
|
||||
return 0.0
|
||||
house_w, base_w = house
|
||||
use = sum(max(0.0, house_w(h), base_w) * (e - s).total_seconds() / 3.6e6
|
||||
for s, e, h in _hour_slices(now, start))
|
||||
have = {p.name: max(0.0, float(p.soc) - float(floor_pct)) / 100.0
|
||||
* float(p.capacity_kwh) for p in ps}
|
||||
took, _ = _spread(use / EFF, have, have)
|
||||
return sum(have[n] - took[n] for n in have) * EFF
|
||||
|
||||
|
||||
def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
ceiling_pct):
|
||||
ceiling_pct, aim_w=None, slots=None, spread=None):
|
||||
"""The day plan, or None when there is none to make: no forecast, no
|
||||
house profile yet, no price plan, or no expensive run after the cheap
|
||||
run. `house` is `HouseProfile.estimator()`'s answer. `packs` are
|
||||
`modetrade.Pack`s."""
|
||||
`modetrade.Pack`s. `aim_w`, `slots` (`priceruns.firm_slots()`) and
|
||||
`spread` (the price gap control asks, EUR/kWh) size a Cheap hours
|
||||
battery's charge (`_cheap_give_kwh()`)."""
|
||||
if forecast is None or house is None or runs is None or not runs.on \
|
||||
or runs.cheap is None:
|
||||
return None
|
||||
@@ -273,11 +372,23 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
- trust * pv)
|
||||
caps = {p.name: float(p.capacity_kwh) for p in price}
|
||||
span = max(0.0, float(ceiling_pct) - float(floor_pct))
|
||||
held, left = _spread(short / EFF,
|
||||
{n: span / 100.0 * c for n, c in caps.items()},
|
||||
caps)
|
||||
# OPT-36: the Cheap hours and Solar first batteries give first;
|
||||
# Leftover solar holds the rest. A Leftover solar battery's own stored energy counts through its
|
||||
# level: it buys from where it is to the level it needs.
|
||||
cheap = _cheap_give_kwh(runs, now, house, usable, floor_pct=floor_pct,
|
||||
ceiling_pct=ceiling_pct, aim_w=aim_w,
|
||||
until=until, slots=slots, spread=spread) \
|
||||
if price else 0.0
|
||||
cheap = min(cheap, short)
|
||||
sfirst = _solar_first_give_kwh(house, now, until.start, usable,
|
||||
floor_pct=floor_pct) if price else 0.0
|
||||
sfirst = min(sfirst, short - cheap)
|
||||
others = cheap + sfirst
|
||||
room_of = {n: span / 100.0 * c for n, c in caps.items()}
|
||||
held, left = _spread((short - others) / EFF, room_of, caps)
|
||||
held0 = _spread(short / EFF, room_of, caps)[0] if others > 0.0 else held
|
||||
|
||||
targets, need, share, cover = {}, {}, {}, {}
|
||||
targets, need, share, cover, saved = {}, {}, {}, {}, {}
|
||||
for p in price:
|
||||
sh = excess * rooms[p.name] / total_room if total_room > 0 else 0.0
|
||||
sh_pct = sh / caps[p.name] * 100.0
|
||||
@@ -285,6 +396,9 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
level = max(0.0, top - sh_pct)
|
||||
targets[p.name] = round(level, 2)
|
||||
need[p.name] = round(_room(p, level), 3)
|
||||
top0 = float(floor_pct) + held0[p.name] / caps[p.name] * 100.0
|
||||
saved[p.name] = round(
|
||||
_room(p, max(0.0, top0 - sh_pct)) - need[p.name], 3)
|
||||
share[p.name] = round(sh, 3)
|
||||
cover[p.name] = round(held[p.name] * EFF, 3)
|
||||
if sun < sf_room:
|
||||
@@ -295,7 +409,8 @@ def plan(forecast, house, runs, now, packs, *, trust_pct, floor_pct,
|
||||
verdict = SUNNY
|
||||
return Plan(verdict, round(sun, 3), round(fc_sum, 3), until.start,
|
||||
targets, need, share, round(short, 3), cover,
|
||||
round(left * EFF if price else 0.0, 3))
|
||||
round(left * EFF if price else 0.0, 3), round(cheap, 3),
|
||||
saved, round(sfirst, 3))
|
||||
|
||||
|
||||
def hold(old: dict | None, new: dict, capacity: dict) -> dict:
|
||||
|
||||
@@ -19,7 +19,8 @@ empty chart. DECISIONS/UX-54-forecast-tab.md has the record.
|
||||
import dataclasses
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns
|
||||
from . import arbitrage, cheaproll, dayplan, homeview as hv, modetrade
|
||||
from . import priceruns
|
||||
from . import solarfc
|
||||
from .daycurve import Batt, curve
|
||||
from .packmode import SOLAR_FIRST
|
||||
@@ -210,25 +211,18 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
|
||||
arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty_of(plan.read.product)
|
||||
| arbitrage.weather_uncertainty(True, c.solar_fc))
|
||||
end_t = cheap.end
|
||||
for _ in range(24):
|
||||
ext = dataclasses.replace(rp, cheap=priceruns.Run(
|
||||
|
||||
def packs_at(end_t):
|
||||
"""The packs at the curve's level when a run ends at `end_t`."""
|
||||
at = dataclasses.replace(rp, cheap=priceruns.Run(
|
||||
cheap.start, end_t, cheap.mean_eur_kwh))
|
||||
rt, lv = settle(ext, lv0)
|
||||
at_end = [dataclasses.replace(packs[n], soc=v)
|
||||
for n, v in ((n, _level_at(lv.get(n, ()), end_t))
|
||||
for n in lv) if v is not None]
|
||||
e = modetrade.energy(at_end, floor, ceil, targets=targets)
|
||||
if e[0] <= 1e-6:
|
||||
break
|
||||
new = priceruns.plan(
|
||||
plan.read.points, end_t, required_eur_kwh=required,
|
||||
charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0, sell_kw=0.0)
|
||||
nxt = new.cheap
|
||||
if nxt is None or not new.charge_useful() or nxt.start > end_t \
|
||||
or nxt.end <= end_t:
|
||||
break
|
||||
end_t = nxt.end
|
||||
_rt, lv = settle(at, lv0)
|
||||
return [dataclasses.replace(packs[n], soc=v)
|
||||
for n, v in ((n, _level_at(lv.get(n, ()), end_t))
|
||||
for n in lv) if v is not None]
|
||||
end_t = cheaproll.roll_end(
|
||||
cheap, plan.read.points, packs_at, required=required,
|
||||
floor_pct=floor, ceiling_pct=ceil, targets=targets)
|
||||
ext = dataclasses.replace(rp, cheap=priceruns.Run(
|
||||
cheap.start, end_t, cheap.mean_eur_kwh))
|
||||
rt, lv = settle(ext, lv0)
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""The inverter driver seam (SMA-2): how this add-on reads and steers ONE inverter.
|
||||
|
||||
Before this, every place that read or steered an inverter talked to the GoodWe
|
||||
ESPHome entities itself (`hass.limits`, `hass.number`, `hass.set_number`, the
|
||||
SAFETY-28 census, selfconfig, validate), so a second brand meant touching all of
|
||||
them. Now each `Inverter` holds a `Driver`, and those places ask it. This card
|
||||
is a pure refactor: `GoodWeEsphomeDriver` does exactly what the code it replaced
|
||||
did, in the same order, with the same arguments. DECISIONS/SMA-2-driver-seam.md.
|
||||
|
||||
THE SEAM IS NOT TCP-SHAPED. Nothing here assumes a connection, or one connection
|
||||
per host: a later FoxESS unit is RTU framing over a TCP socket, and its
|
||||
`actuator_id()` is host:port:unit_id, so two units behind one gateway are two
|
||||
actuators and one unit named twice is the SAFETY-28 refusal.
|
||||
|
||||
WHAT STAYS OUTSIDE THE DRIVER, on purpose. The gate (SAFETY-28), the clamp
|
||||
(SAFETY-03), shadow mode and the write budget all sit above `write()` in
|
||||
`Controller._write_unit`, and the freshness gate sits above `read()`; a driver
|
||||
never sees a value those did not pass. A driver moves one number to or from one
|
||||
inverter and says whether it worked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .control import PLANT_SETTLE_S
|
||||
from .freshness import BOUNDS
|
||||
|
||||
# How long every write to one GoodWe inverter must have been REJECTED before
|
||||
# this controller stops claiming it can command it. Layer 1's own window: the
|
||||
# ESP32 commands 0 W after ~30 s without a refresh (`validate.
|
||||
# FIRMWARE_INGEST_VETO_S`). `inverter.LINK_STALE_S` is this number, re-exported.
|
||||
GOODWE_LINK_STALE_S = 30.0
|
||||
|
||||
# `driver` when an entry does not say: every install that exists today.
|
||||
DEFAULT_DRIVER = "goodwe_esphome"
|
||||
|
||||
# The `driver` values the options accept. Only the GoodWe one for now; the
|
||||
# registry below may hold more (a test registers a fake), but this tuple is
|
||||
# what validate.py rule 9 lets through and what config.yaml's schema lists.
|
||||
SELECTABLE = (DEFAULT_DRIVER,)
|
||||
|
||||
|
||||
class DriverNotBound(RuntimeError):
|
||||
"""A driver was asked to talk to the world before it was given a Home
|
||||
Assistant. A wiring bug, so it is loud rather than a quiet "no answer"."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Probe:
|
||||
"""What a read-only look at one inverter found. None means "did not answer"."""
|
||||
|
||||
dev_min: float | None = None
|
||||
dev_max: float | None = None
|
||||
soc: float | None = None
|
||||
batt_w: float | None = None
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def census_key(actuator_id: str) -> str:
|
||||
"""The form SAFETY-28 compares: stripped and lowercase, so a config cannot
|
||||
name one actuator twice by changing its case (`Number.X` vs `number.x`)."""
|
||||
return str(actuator_id or "").strip().lower()
|
||||
|
||||
|
||||
class Driver:
|
||||
"""One inverter's I/O. Subclasses set the class attributes and the methods.
|
||||
|
||||
Constructed once per roster entry, with no Home Assistant: `bind()` gives
|
||||
it one (the Controller does, and so does the settings panel, which has no
|
||||
Controller). Everything that talks to the world is a coroutine.
|
||||
"""
|
||||
|
||||
#: the `driver` option value that selects this class
|
||||
name = ""
|
||||
#: the option key that names the actuator in an entry (the census reads it)
|
||||
writer_key = ""
|
||||
#: the brand word the per-driver sentences and device names use
|
||||
brand = ""
|
||||
#: SMA-10. Does `release()` give the inverter BACK to something that steers
|
||||
#: it by itself (SMA energy management)? False: `release()` is `write(0)`,
|
||||
#: the add-on goes on holding 0 W, and the Controller never calls it.
|
||||
hands_back = False
|
||||
#: per-driver sentences. A driver that hands back says whose control the
|
||||
#: unit returns to: `{"back": "SMA control", "back_nl": "de sturing van SMA"}`
|
||||
#: ("goes back to SMA control"). Missing words fall back to a plain phrase.
|
||||
words: dict = {}
|
||||
#: how long all-rejected writes last before the unit stops being live
|
||||
link_stale_s = GOODWE_LINK_STALE_S
|
||||
#: this driver's `freshness.Bound` for the battery power reading
|
||||
batt_bound = BOUNDS["batt"]
|
||||
#: the plant's settling time after a step, in s (control.PLANT_SETTLE_S)
|
||||
settle_s = PLANT_SETTLE_S
|
||||
|
||||
# -- from the options ----------------------------------------------------
|
||||
@classmethod
|
||||
def from_entry(cls, entry: dict) -> "Driver":
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, entry: dict) -> str:
|
||||
"""The actuator an options entry names, or "" for none. Pure: the
|
||||
SAFETY-28 census runs on options, before any driver exists."""
|
||||
raise NotImplementedError
|
||||
|
||||
# -- wiring --------------------------------------------------------------
|
||||
def bind(self, hass) -> "Driver":
|
||||
"""Give this driver its Home Assistant (or whatever it talks through)."""
|
||||
return self
|
||||
|
||||
def bind_getter(self, getter) -> "Driver":
|
||||
"""Like `bind()`, but looked up at each use: the Controller passes
|
||||
`lambda: self.hass`, so replacing `controller.hass` is seen at once."""
|
||||
return self
|
||||
|
||||
def bound(self) -> bool:
|
||||
"""Has it been given what it talks through? A driver that talks
|
||||
through nothing the Controller owns is always bound."""
|
||||
return True
|
||||
|
||||
# -- what it is ----------------------------------------------------------
|
||||
def configured(self) -> bool:
|
||||
"""Is there any way to reach this inverter at all?"""
|
||||
raise NotImplementedError
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
"""What this driver writes to. Two units with one id are one actuator."""
|
||||
raise NotImplementedError
|
||||
|
||||
def soc_source(self) -> str:
|
||||
"""Where the pack level is read from; "" when nowhere. Status rows only."""
|
||||
return ""
|
||||
|
||||
def batt_source(self) -> str:
|
||||
"""Where the battery power is read from; "" when nowhere."""
|
||||
return ""
|
||||
|
||||
# -- what it does --------------------------------------------------------
|
||||
async def limits(self) -> tuple:
|
||||
"""`(dev_min, dev_max)` in W the device accepts; (None, None) unread."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def read(self) -> tuple:
|
||||
"""`(soc, batt_w, extra)`. A value that did not arrive is None. `batt_w`
|
||||
is the RAW power, in the sign the controller uses; the freshness gate
|
||||
is the caller's."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def write(self, w: float) -> bool:
|
||||
"""Put `w` on the inverter. True only when it took it."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def release(self) -> bool:
|
||||
"""Stop steering this inverter. For a driver with nothing better, 0 W.
|
||||
|
||||
SMA-10. Only a driver with `hands_back = True` has this called by the
|
||||
Controller, and only when the add-on goes away (Stop, shutdown, restart,
|
||||
task death) or sits in shadow mode. After it the Controller writes
|
||||
nothing more to the unit until control starts again. A safety stop is
|
||||
never a release: that is `write(0)`."""
|
||||
return await self.write(0.0)
|
||||
|
||||
async def release_fallback(self) -> bool:
|
||||
"""SMA-10 round 2. What to send, ONCE, when `release()` has failed
|
||||
(False, raised, or hung past the bound), so the unit does not carry its
|
||||
last command while nothing else steers it. Default: `write(0)`. It is
|
||||
not repeated: a repeat would keep resetting the inverter's own fallback
|
||||
timer. A driver for which 0 W is not always a safe hold overrides this."""
|
||||
return await self.write(0.0)
|
||||
|
||||
async def probe(self) -> Probe:
|
||||
"""A read-only look, usable without a Controller (panel, wizard)."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class GoodWeEsphomeDriver(Driver):
|
||||
"""The GoodWe behind the ESP32: three Home Assistant entities.
|
||||
|
||||
A transcription of what `main.py` and `selfconfig.py` did inline: `limits()`
|
||||
is `hass.limits(setpoint)`, `read()` is `hass.number(soc)` then
|
||||
`hass.number(batt, invert)`, `write()` is `hass.set_number(setpoint, w)`.
|
||||
`release()` is `write(0)` and `hands_back` is False: the board's own watchdog
|
||||
is what hands back, so after Stop the unit keeps its 0 W write and heartbeat.
|
||||
"""
|
||||
|
||||
name = DEFAULT_DRIVER
|
||||
writer_key = "setpoint_entity"
|
||||
brand = "GoodWe"
|
||||
|
||||
def __init__(self, setpoint_entity="", soc_entity="", batt_entity="",
|
||||
batt_invert=False):
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
self.batt_entity = str(batt_entity or "")
|
||||
self.batt_invert = bool(batt_invert)
|
||||
self._hass = None
|
||||
self._getter = None
|
||||
|
||||
@classmethod
|
||||
def from_entry(cls, entry: dict) -> "GoodWeEsphomeDriver":
|
||||
return cls(setpoint_entity=entry.get("setpoint_entity", ""),
|
||||
soc_entity=entry.get("soc_entity", ""),
|
||||
batt_entity=entry.get("batt_entity", ""),
|
||||
batt_invert=bool(entry.get("batt_invert")))
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, entry: dict) -> str:
|
||||
return str(entry.get("setpoint_entity") or "").strip()
|
||||
|
||||
def bind(self, hass) -> "GoodWeEsphomeDriver":
|
||||
self._hass, self._getter = hass, None
|
||||
return self
|
||||
|
||||
def bind_getter(self, getter) -> "GoodWeEsphomeDriver":
|
||||
self._hass, self._getter = None, getter
|
||||
return self
|
||||
|
||||
def bound(self) -> bool:
|
||||
return self._hass is not None or self._getter is not None
|
||||
|
||||
def _h(self):
|
||||
hass = self._getter() if self._getter is not None else self._hass
|
||||
if hass is None:
|
||||
raise DriverNotBound(f"{self.brand} driver for "
|
||||
f"{self.setpoint_entity!r} has no Home Assistant")
|
||||
return hass
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.setpoint_entity)
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
return self.setpoint_entity
|
||||
|
||||
def soc_source(self) -> str:
|
||||
return self.soc_entity
|
||||
|
||||
def batt_source(self) -> str:
|
||||
return self.batt_entity
|
||||
|
||||
async def limits(self) -> tuple:
|
||||
dev_min, dev_max, _step = await self._h().limits(self.setpoint_entity)
|
||||
return dev_min, dev_max
|
||||
|
||||
async def read(self) -> tuple:
|
||||
hass = self._h()
|
||||
soc = await hass.number(self.soc_entity)
|
||||
raw = await hass.number(self.batt_entity, self.batt_invert)
|
||||
return soc, raw, {}
|
||||
|
||||
async def write(self, w: float) -> bool:
|
||||
return await self._h().set_number(self.setpoint_entity, w)
|
||||
|
||||
async def probe(self) -> Probe:
|
||||
dev_min, dev_max = await self.limits()
|
||||
soc, raw, extra = await self.read()
|
||||
return Probe(dev_min, dev_max, soc, raw, extra)
|
||||
|
||||
|
||||
class UnavailableDriver(Driver):
|
||||
"""What an entry naming a driver this build does not have gets. Validation
|
||||
refuses such an entry before a Controller exists; this is only what
|
||||
`build_roster()` (which refuses nothing) builds meanwhile: a unit that is
|
||||
not configured, so the refusal branch names it and commands 0 W."""
|
||||
|
||||
name = ""
|
||||
# The census still counts a `setpoint_entity` on such an entry: refusing
|
||||
# too much is the safe direction for SAFETY-28.
|
||||
writer_key = "setpoint_entity"
|
||||
brand = ""
|
||||
|
||||
def __init__(self, wanted=""):
|
||||
self.wanted = str(wanted or "")
|
||||
|
||||
@classmethod
|
||||
def from_entry(cls, entry: dict) -> "UnavailableDriver":
|
||||
return cls(entry.get("driver"))
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, entry: dict) -> str:
|
||||
return str(entry.get("setpoint_entity") or "").strip()
|
||||
|
||||
def configured(self) -> bool:
|
||||
return False
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
return ""
|
||||
|
||||
async def limits(self) -> tuple:
|
||||
return None, None
|
||||
|
||||
async def read(self) -> tuple:
|
||||
return None, None, {}
|
||||
|
||||
async def write(self, w: float) -> bool:
|
||||
return False
|
||||
|
||||
async def probe(self) -> Probe:
|
||||
return Probe()
|
||||
|
||||
|
||||
# name -> class. The options' `driver` value picks one. A test may add a fake;
|
||||
# `SELECTABLE` (not this) is what the options accept.
|
||||
REGISTRY = {GoodWeEsphomeDriver.name: GoodWeEsphomeDriver}
|
||||
|
||||
|
||||
def driver_name(entry) -> str:
|
||||
"""The `driver` an options entry selects; blank or absent is the default."""
|
||||
raw = entry.get("driver") if isinstance(entry, dict) else None
|
||||
return str(raw).strip() if raw not in (None, "") else DEFAULT_DRIVER
|
||||
|
||||
|
||||
def driver_class(entry):
|
||||
"""The class an options entry selects, `UnavailableDriver` if none."""
|
||||
return REGISTRY.get(driver_name(entry), UnavailableDriver)
|
||||
|
||||
|
||||
def build_driver(entry) -> Driver:
|
||||
"""The driver for ONE options entry (or the flat options, for one inverter)."""
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
return driver_class(entry).from_entry(entry)
|
||||
@@ -0,0 +1,386 @@
|
||||
"""SMA-10: the two idle states, in words.
|
||||
|
||||
A unit is either HELD at 0 W (GoodWe: the add-on goes on writing 0 W) or
|
||||
HANDED BACK (its driver has `hands_back = True`: `release()` gives it back to
|
||||
whatever steers it by itself, and the add-on then writes nothing to it). So every
|
||||
sentence that said "every inverter is set to 0 W" for the add-on going away or
|
||||
for shadow mode has to be built from the roster. DECISIONS/SMA-10-two-idle-
|
||||
states.md.
|
||||
|
||||
THE RULE FOR EVERY BUILDER HERE: a roster with no hand-back unit gets None, and
|
||||
the caller then uses the sentence it always used, untouched. So a GoodWe-only
|
||||
site cannot see a changed byte; test_sma10_two_idle_states.py renders each one
|
||||
and compares it with the literal from before this card.
|
||||
|
||||
The words for a hand-back unit come from its driver (`Driver.words`), not from
|
||||
here: `back` is "SMA control" in "goes back to SMA control", `back_nl` is the
|
||||
Dutch noun phrase after "terug naar". A driver without them gets a plain,
|
||||
brand-free phrase.
|
||||
|
||||
Safety stops and refusals are not in this file on purpose: they hold 0 W for
|
||||
every unit, and their sentences say so.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
_BACK_EN = "its own control"
|
||||
_BACK_NL = "de eigen sturing"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Unit:
|
||||
name: str
|
||||
hands_back: bool = False
|
||||
back_en: str = _BACK_EN
|
||||
back_nl: str = _BACK_NL
|
||||
|
||||
|
||||
def unit_of(inv) -> Unit:
|
||||
"""One roster entry's view: its name, and (for a hand-back driver) the
|
||||
words of whose control it returns to."""
|
||||
drv = inv.driver
|
||||
back = bool(getattr(drv, "hands_back", False))
|
||||
words = getattr(drv, "words", None) or {}
|
||||
return Unit(str(inv.name), back,
|
||||
str(words.get("back") or _BACK_EN),
|
||||
str(words.get("back_nl") or _BACK_NL))
|
||||
|
||||
|
||||
def units_of(inverters) -> tuple:
|
||||
return tuple(unit_of(i) for i in inverters)
|
||||
|
||||
|
||||
# The roster of the Controller that is running, for the callers that have no
|
||||
# Controller in hand (the page, the banner). One process runs one Controller.
|
||||
_CURRENT: list = [()]
|
||||
|
||||
|
||||
def set_roster(units) -> None:
|
||||
_CURRENT[0] = tuple(units)
|
||||
|
||||
|
||||
def current() -> tuple:
|
||||
return _CURRENT[0]
|
||||
|
||||
|
||||
def active(units) -> bool:
|
||||
"""Is any unit handed back? False is every GoodWe-only roster."""
|
||||
return any(u.hands_back for u in units)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The grammar. English and Dutch, a few verb forms each.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _join(names, lang) -> str:
|
||||
names = list(names)
|
||||
word = " en " if lang == "nl" else " and "
|
||||
if len(names) <= 1:
|
||||
return "".join(names)
|
||||
return ", ".join(names[:-1]) + word + names[-1]
|
||||
|
||||
|
||||
def _forms(lang):
|
||||
"""kind -> (zero template, back template); `{n}` the names, `{b}` the words.
|
||||
`one`/`many` pick the verb by how many units the clause is about."""
|
||||
if lang == "nl":
|
||||
return {
|
||||
"set": (("{n} gaat naar 0 W", "{n} gaan naar 0 W"),
|
||||
("{n} gaat terug naar {b}", "{n} gaan terug naar {b}")),
|
||||
"at": (("{n} op 0 W",) * 2, ("{n} onder {b}",) * 2),
|
||||
"is": (("{n} staat op 0 W", "{n} staan op 0 W"),
|
||||
("{n} staat onder {b}", "{n} staan onder {b}")),
|
||||
"stays": (("{n} blijft op 0 W", "{n} blijven op 0 W"),
|
||||
("{n} blijft onder {b}", "{n} blijven onder {b}")),
|
||||
}
|
||||
return {
|
||||
"set": (("{n} is set to 0 W", "{n} are set to 0 W"),
|
||||
("{n} goes back to {b}", "{n} go back to {b}")),
|
||||
"at": (("{n} at 0 W",) * 2, ("{n} under {b}",) * 2),
|
||||
"is": (("{n} is at 0 W", "{n} are at 0 W"),
|
||||
("{n} is under {b}", "{n} are under {b}")),
|
||||
"stays": (("{n} stays at 0 W", "{n} stay at 0 W"),
|
||||
("{n} stays under {b}", "{n} stay under {b}")),
|
||||
}
|
||||
|
||||
|
||||
def _groups(units, lang):
|
||||
"""`(zero names, [(names, back words)])`: the held units, and the handed-back
|
||||
ones grouped by whose control they return to, in roster order."""
|
||||
zeros = [u.name for u in units if not u.hands_back]
|
||||
groups: dict = {}
|
||||
for u in units:
|
||||
if u.hands_back:
|
||||
groups.setdefault(u.back_nl if lang == "nl" else u.back_en,
|
||||
[]).append(u.name)
|
||||
return zeros, [(names, back) for back, names in groups.items()]
|
||||
|
||||
|
||||
# A clause may not eat the sentence it sits in: STYLE.md allows 25 words, and
|
||||
# the longest tail after a clause is about 11.
|
||||
CLAUSE_MAX_WORDS = 14
|
||||
|
||||
|
||||
def _count_txt(n, lang) -> str:
|
||||
if lang == "nl":
|
||||
return f"{n} omvormer" if n == 1 else f"{n} omvormers"
|
||||
return f"{n} inverter" if n == 1 else f"{n} inverters"
|
||||
|
||||
|
||||
def _clause(units, lang, kind, sep, generic, counted) -> str:
|
||||
forms = _forms(lang)[kind]
|
||||
zeros, groups = _groups(units, lang)
|
||||
if generic: # one group, and no make named
|
||||
names = [n for g, _b in groups for n in g]
|
||||
own = ("de eigen sturing" if lang == "nl" else
|
||||
"their own control" if len(names) > 1 else "its own control")
|
||||
groups = [(names, own)] if names else []
|
||||
subject = ("de omvormers", "de omvormer") if lang == "nl" else (
|
||||
"the inverters", "the inverter")
|
||||
parts = []
|
||||
if zeros:
|
||||
parts.append(forms[0][1 if len(zeros) > 1 else 0].format(
|
||||
n=(str(len(zeros)) if groups else _count_txt(len(zeros), lang))
|
||||
if counted else _join(zeros, lang)))
|
||||
for names, back in groups:
|
||||
if not zeros and len(groups) == 1:
|
||||
names_txt = subject[0 if len(names) > 1 else 1]
|
||||
elif counted:
|
||||
# the bare number after a counted first clause: "1 inverter is set
|
||||
# to 0 W and 2 go back to their own control"
|
||||
names_txt = (str(len(names)) if zeros
|
||||
else _count_txt(len(names), lang))
|
||||
else:
|
||||
names_txt = _join(names, lang)
|
||||
parts.append(forms[1][1 if len(names) > 1 else 0].format(
|
||||
n=names_txt, b=back))
|
||||
if sep:
|
||||
return sep.join(parts)
|
||||
word = " en " if lang == "nl" else " and "
|
||||
return (", ".join(parts[:-1]) + word + parts[-1]) if len(parts) > 1 \
|
||||
else "".join(parts)
|
||||
|
||||
|
||||
def clause(units, lang, kind, sep=None) -> str:
|
||||
"""One clause about the whole roster, `kind` one of set/at/is/stays.
|
||||
|
||||
Held units first, then the handed-back ones, joined by "and" (or `sep`).
|
||||
When EVERY unit is handed back to the same control the subject is "the
|
||||
inverters", not their names, as the card's table has it.
|
||||
|
||||
A long roster would break the 25-word rule, so a clause over
|
||||
CLAUSE_MAX_WORDS is said again without naming the make (one "own control"
|
||||
for all), and then by counting units instead of naming them."""
|
||||
text = _clause(units, lang, kind, sep, False, False)
|
||||
if sep or _words(text) <= CLAUSE_MAX_WORDS:
|
||||
return text
|
||||
text = _clause(units, lang, kind, sep, True, False)
|
||||
if _words(text) <= CLAUSE_MAX_WORDS:
|
||||
return text
|
||||
return _clause(units, lang, kind, sep, True, True)
|
||||
|
||||
|
||||
def _cap(text) -> str:
|
||||
return text[:1].upper() + text[1:]
|
||||
|
||||
|
||||
def _bi(en, nl):
|
||||
from .messages import Bi
|
||||
return Bi(en, nl)
|
||||
|
||||
|
||||
def _words(text) -> int:
|
||||
return len([w for w in re.findall(r"\S+", text)
|
||||
if re.search(r"[A-Za-z0-9]", w)])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The sentences. Each returns None for a roster with no hand-back unit.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def stop_dialog(units, lang):
|
||||
"""Stop control? (was "Every inverter is set to 0 W until you start
|
||||
control again.")"""
|
||||
if not active(units):
|
||||
return None
|
||||
c = clause(units, lang, "set")
|
||||
return (f"{_cap(c)} tot u de regeling opnieuw start." if lang == "nl"
|
||||
else f"{_cap(c)} until you start control again.")
|
||||
|
||||
|
||||
def restart_dialog(units, lang):
|
||||
"""Restart now? / Save and restart? (live)"""
|
||||
if not active(units):
|
||||
return None
|
||||
c = clause(units, lang, "set")
|
||||
return (f"{_cap(c)} terwijl de add-on herstart, daarna gelden de nieuwe "
|
||||
f"instellingen." if lang == "nl" else
|
||||
f"{_cap(c)} while the add-on restarts, and then the new settings "
|
||||
f"apply.")
|
||||
|
||||
|
||||
def restart_dialog_shadow(units, lang):
|
||||
"""Save and restart? when shadow mode keeps the units idle afterwards."""
|
||||
if not active(units):
|
||||
return None
|
||||
c = clause(units, lang, "set")
|
||||
return (f"{_cap(c)} terwijl de add-on herstart, en de schaduwmodus houdt "
|
||||
f"het zo." if lang == "nl" else
|
||||
f"{_cap(c)} while the add-on restarts, and shadow mode keeps it "
|
||||
f"that way.")
|
||||
|
||||
|
||||
def setup_dialog_shadow(units, lang):
|
||||
"""Save and restart? in setup, shadow mode on."""
|
||||
if not active(units):
|
||||
return None
|
||||
c = clause(units, lang, "at")
|
||||
return (f"De add-on herstart met deze instellingen, en de schaduwmodus "
|
||||
f"houdt {c}." if lang == "nl" else
|
||||
f"The add-on restarts with these settings, and shadow mode keeps "
|
||||
f"{c}.")
|
||||
|
||||
|
||||
def control_stopped(units, lang):
|
||||
"""The page's control line when stopped."""
|
||||
if not active(units):
|
||||
return None
|
||||
c = clause(units, lang, "is")
|
||||
return (f"De regeling is gestopt. {_cap(c)}." if lang == "nl"
|
||||
else f"Control is stopped. {_cap(c)}.")
|
||||
|
||||
|
||||
def control_shadow(units, lang):
|
||||
"""The page's control line in shadow mode."""
|
||||
if not active(units):
|
||||
return None
|
||||
c = clause(units, lang, "stays")
|
||||
return (f"Schaduwmodus: {c}." if lang == "nl" else f"Shadow mode: {c}.")
|
||||
|
||||
|
||||
def attn_shadow_body(units, lang):
|
||||
"""The attention card's body in shadow mode."""
|
||||
if not active(units):
|
||||
return None
|
||||
c = clause(units, lang, "stays")
|
||||
return (f"De add-on beslist alles, maar {c}." if lang == "nl" else
|
||||
f"The add-on decides everything, but {c}.")
|
||||
|
||||
|
||||
def shadow_state(units, lang, many=True):
|
||||
"""The tail of a notification: "Shadow mode is on, so <this>." For the
|
||||
three output-clamp sentences that said every inverter stays at 0 W."""
|
||||
if not active(units):
|
||||
return None
|
||||
return clause(units, lang, "stays")
|
||||
|
||||
|
||||
def banner_stopped(units):
|
||||
"""Stopped. 12 words at most (STYLE.md)."""
|
||||
if not active(units):
|
||||
return None
|
||||
out = {}
|
||||
for lang, stopped in (("en", "Stopped"), ("nl", "Gestopt")):
|
||||
text = f"{stopped} — {clause(units, lang, 'at', sep=', ')}"
|
||||
if _words(text) > 12:
|
||||
text = _banner_counts(units, lang, stopped)
|
||||
out[lang] = text
|
||||
return _bi(out["en"], out["nl"])
|
||||
|
||||
|
||||
def _banner_counts(units, lang, stopped) -> str:
|
||||
"""The long-roster form: how many, not which."""
|
||||
zeros, groups = _groups(units, lang)
|
||||
if len(groups) == 1:
|
||||
back = groups[0][1]
|
||||
n_back = len(groups[0][0])
|
||||
if lang == "nl":
|
||||
held = f"{len(zeros)} op 0 W, " if zeros else ""
|
||||
return f"{stopped} — {held}{n_back} onder {back}"
|
||||
held = f"{len(zeros)} at 0 W, " if zeros else ""
|
||||
return f"{stopped} — {held}{n_back} under {back}"
|
||||
return (f"{stopped} — omvormers op 0 W of teruggegeven" if lang == "nl"
|
||||
else f"{stopped} — inverters at 0 W or handed back")
|
||||
|
||||
|
||||
def banner_shadow(units):
|
||||
"""Shadow mode, nothing standing. The GoodWe sentence adds "Inverters stay
|
||||
at 0 W." which is false for a unit that hands back, and a banner is 12
|
||||
words, so the unit-by-unit state is left to the control line."""
|
||||
if not active(units):
|
||||
return None
|
||||
return _bi("Shadow mode: the battery is not controlled.",
|
||||
"Schaduwmodus: de batterij wordt niet geregeld.")
|
||||
|
||||
|
||||
def hint_shadow_on(units):
|
||||
""""Shadow mode on, inverters stay at 0 W" for the Last event line."""
|
||||
if not active(units):
|
||||
return None
|
||||
return "Shadow mode on, " + clause(units, "en", "at", sep=", ")
|
||||
|
||||
|
||||
def unit_state(unit, lang):
|
||||
"""One handed-back unit's own state, in place of "Held at 0 W"."""
|
||||
if lang == "nl":
|
||||
return f"Terug onder {unit.back_nl}"
|
||||
return f"Back under {unit.back_en}"
|
||||
|
||||
|
||||
def log_shutdown(units):
|
||||
"""The INFO line at shutdown (was "shutting down - commanding 0 W on every
|
||||
inverter")."""
|
||||
if not active(units):
|
||||
return None
|
||||
zeros, groups = _groups(units, "en")
|
||||
held = f"commanding 0 W on {_join(zeros, 'en')}" if zeros else ""
|
||||
back = " and ".join(f"handing back {_join(n, 'en')} to {b}"
|
||||
for n, b in groups)
|
||||
return "shutting down - " + " and ".join(x for x in (held, back) if x)
|
||||
|
||||
|
||||
def task_died_state(units) -> str:
|
||||
"""The part of the CRITICAL line after "so" when the control task dies.
|
||||
For a roster with no hand-back unit it is the sentence it always was."""
|
||||
if not active(units):
|
||||
return "every inverter is set to 0 W"
|
||||
zeros, groups = _groups(units, "en")
|
||||
backs = [n for names, _b in groups for n in names]
|
||||
parts = []
|
||||
if zeros:
|
||||
parts.append(f"{_join(zeros, 'en')} "
|
||||
f"{'are' if len(zeros) > 1 else 'is'} set to 0 W")
|
||||
parts.append(f"{_join(backs, 'en')} "
|
||||
f"{'are' if len(backs) > 1 else 'is'} handed back")
|
||||
return " and ".join(parts)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The page words: the ui_text sentences that move with the roster, by language.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def overlay(units) -> dict:
|
||||
"""`{lang: nested dict}` of ui_text paths to replace; {} when no unit is
|
||||
handed back, so the page is the file as it always was."""
|
||||
if not active(units):
|
||||
return {}
|
||||
out = {}
|
||||
for lang in ("en", "nl"):
|
||||
out[lang] = {
|
||||
"home": {"dialog": {"stop": {
|
||||
"description": stop_dialog(units, lang)}}},
|
||||
"settings": {
|
||||
"dialog": {"description": restart_dialog(units, lang)},
|
||||
"setup": {
|
||||
"dialog": {"description":
|
||||
restart_dialog_shadow(units, lang)},
|
||||
"dialog_live": {"description":
|
||||
restart_dialog(units, lang)},
|
||||
"dialog_setup": {"description":
|
||||
setup_dialog_shadow(units, lang)}}},
|
||||
"home_words": {
|
||||
"attn_shadow_body": attn_shadow_body(units, lang),
|
||||
"control": {"shadow": control_shadow(units, lang),
|
||||
"stopped": control_stopped(units, lang)}},
|
||||
}
|
||||
return out
|
||||
@@ -19,7 +19,7 @@ section at the bottom and is held to STYLE.md by test_messages.py.
|
||||
import contextlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, messages, peakguard, uitext
|
||||
from . import arbitrage, cheaproll, handback, messages, peakguard, priceruns, uitext
|
||||
from .dayplan import EFF
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE, next_slot_start
|
||||
@@ -218,14 +218,20 @@ def price_windows(c, plan, verdict, now=None):
|
||||
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
|
||||
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
|
||||
(`c.mode_runs`): the cheap run when charging in it pays and the trade gate
|
||||
allows it (UX-53), and every give run. With them off it is the arbitrage
|
||||
verdict's legs the add-on will take, the ones `trade_runs()` draws
|
||||
(`trade_legs()`, given `now`). Nothing is classified here."""
|
||||
allows it (UX-53), and every give run. UX-77: the cheap run is drawn to
|
||||
where control stops charging as it rolls on (`cheaproll`). With them off
|
||||
it is the arbitrage verdict's legs the add-on will take, the ones
|
||||
`trade_runs()` draws (`trade_legs()`, given `now`). Nothing is classified
|
||||
here."""
|
||||
if getattr(c, "modes_on", False):
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
if rp is None or not rp.on or not _price_mode_in_use(c):
|
||||
return []
|
||||
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
|
||||
end = rp.cheap.end
|
||||
roll = None if now is None else cheaproll.for_today(c, rp, now)
|
||||
if roll is not None:
|
||||
end = roll.last(end) # UX-77: to where control stops
|
||||
out = [("cheap", rp.cheap.start, end)] \
|
||||
if rp.charge_useful() and getattr(c, "mode_gate", None) is None \
|
||||
else []
|
||||
return out + [("dear", g.start, g.end) for g in rp.give]
|
||||
@@ -422,6 +428,7 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
`ahead=True`) a charge from the floor and a give from the ceiling. The
|
||||
same filter, the same `MIN_KWH`. `rp`: a run plan to read instead of
|
||||
`c.mode_runs`. Home never passes either."""
|
||||
today = rp is None and not ahead
|
||||
rp = rp if rp is not None else getattr(c, "mode_runs", None)
|
||||
if getattr(c, "mode_tick", None) is None:
|
||||
return [], None # no cycle yet: nothing is known
|
||||
@@ -431,6 +438,9 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
return [], "not_worth"
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
gate = getattr(c, "mode_gate", None)
|
||||
# UX-77: today's grid block runs to where control stops charging (the run
|
||||
# rolls on when it ends), each battery's to its own stop.
|
||||
roll = cheaproll.for_today(c, rp, now) if today else None
|
||||
out = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
@@ -444,9 +454,16 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
return got if got is not None else (
|
||||
floor if kind == "grid" else ceiling)
|
||||
|
||||
grids = [] # (end, kWh) of the grid blocks drawn
|
||||
|
||||
def room(kind, hrs, start):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
lvl = start_pct(kind, start) if ahead else soc
|
||||
if kind != "grid" and not ahead and cap and lvl is not None:
|
||||
# OPT-36: a give block reads the level after the grid block
|
||||
# before it, not the level now.
|
||||
lvl = min(float(ceiling), lvl + sum(
|
||||
k for e, k in grids if e <= start) / cap * 100.0)
|
||||
if cap and lvl is not None:
|
||||
kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid"
|
||||
else (lvl - floor)) / 100.0 * cap)
|
||||
@@ -458,7 +475,10 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
if gate is None and rp.charge_useful() and (
|
||||
need > 0.0 if need is not None
|
||||
else pm.mode in GRID_CHARGE_MODES):
|
||||
blocks.append(("grid", rp.cheap))
|
||||
cheap = rp.cheap if roll is None else priceruns.Run(
|
||||
rp.cheap.start, roll.end_for(inv.name, rp.cheap.end),
|
||||
rp.cheap.mean_eur_kwh)
|
||||
blocks.append(("grid", cheap))
|
||||
sell = rp.sell if pm.sell and pm.can_sell else None
|
||||
for g in rp.give:
|
||||
if sell is not None and g.start <= sell.start < g.end:
|
||||
@@ -474,6 +494,8 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
|
||||
kwh = min(kwh, need)
|
||||
if kwh < MIN_KWH:
|
||||
continue
|
||||
if kind == "grid":
|
||||
grids.append((run.end, kwh))
|
||||
price = run.mean_eur_kwh
|
||||
if kind == "grid":
|
||||
cov = plan_cover(c, inv.name)
|
||||
@@ -522,17 +544,16 @@ def plan_cover(c, name):
|
||||
grid: the level it charges to, and the expensive-hour use of the house
|
||||
that charge covers and the sun will not. Read from the level control
|
||||
holds (`plan_targets()`), so Home says what control does. None when the
|
||||
plan buys nothing for it."""
|
||||
plan buys nothing for it. OPT-36: the kWh is the plan's own cover figure
|
||||
(`Plan.cover_kwh`), not one rebuilt from the level."""
|
||||
dp = getattr(c, "day_plan", None)
|
||||
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
|
||||
inv = next((i for i in c.inverters if i.name == name), None)
|
||||
if dp is None or not targets or name not in targets or inv is None \
|
||||
or not inv.capacity_kwh or targets[name] <= 0.0:
|
||||
or not inv.capacity_kwh or targets[name] <= 0.0 \
|
||||
or name not in dp.cover_kwh:
|
||||
return None
|
||||
floor, _ceil = arbitrage.soc_bounds(c.o)
|
||||
cap = float(inv.capacity_kwh)
|
||||
top = targets[name] + dp.share_kwh.get(name, 0.0) / cap * 100.0
|
||||
return targets[name], max(0.0, top - floor) / 100.0 * cap * EFF
|
||||
return targets[name], dp.cover_kwh[name]
|
||||
|
||||
|
||||
def _latched_below(c) -> bool:
|
||||
@@ -915,6 +936,13 @@ def battery_rows(c, shadow, running, held=False):
|
||||
else other, at=at,
|
||||
**plan_words(c, inv.name, why)),
|
||||
})
|
||||
if getattr(inv, "released", False):
|
||||
# SMA-10. A unit handed back says so, in its driver's words, not
|
||||
# "Held at 0 W": nothing is written to it. The key exists only for
|
||||
# such a unit, so a GoodWe-only page is unchanged.
|
||||
rows[-1]["now"] = handback.unit_state(handback.unit_of(inv),
|
||||
_LANG[0])
|
||||
rows[-1]["handed_back"] = True
|
||||
if modes_on:
|
||||
# OPT-11 P1: the battery's switch and mode, for the Batteries
|
||||
# screen. Only with battery modes on, so the page is unchanged
|
||||
@@ -931,7 +959,8 @@ def battery_rows(c, shadow, running, held=False):
|
||||
|
||||
def day_key(c, name):
|
||||
"""P4: the day plan's word for one Leftover solar or Cheap hours
|
||||
battery: `sun_fills`, `tops_up`, `no_forecast`, or None."""
|
||||
battery: `sun_fills`, `others_cover`, `tops_up`, `no_forecast`, or
|
||||
None."""
|
||||
st = plan_state(c)
|
||||
if st == "no_forecast":
|
||||
mode = c.pack_mode(next(i for i in c.inverters if i.name == name))
|
||||
@@ -941,7 +970,14 @@ def day_key(c, name):
|
||||
need = plan_need(c, name)
|
||||
if need is None:
|
||||
return None
|
||||
return "tops_up" if need >= MIN_KWH else "sun_fills"
|
||||
if need >= MIN_KWH:
|
||||
return "tops_up"
|
||||
# OPT-36: no grid charge because other batteries (Cheap hours only,
|
||||
# Solar first) cover the hours, not the sun.
|
||||
dp = getattr(c, "day_plan", None)
|
||||
if dp is not None and dp.saved_kwh.get(name, 0.0) >= MIN_KWH:
|
||||
return "others_cover"
|
||||
return "sun_fills"
|
||||
|
||||
|
||||
def battery_why(*, modes_on, mode, off, reading, phase, shadow, running,
|
||||
@@ -1182,6 +1218,9 @@ _WORDS: dict = {}
|
||||
|
||||
def _hw() -> dict:
|
||||
lang = _LANG[0]
|
||||
if _WORDS.get("") != uitext.generation(): # SMA-10: the roster's words moved
|
||||
_WORDS.clear()
|
||||
_WORDS[""] = uitext.generation()
|
||||
if lang not in _WORDS:
|
||||
_WORDS[lang] = uitext.words(lang)
|
||||
return _WORDS[lang]
|
||||
@@ -1473,7 +1512,8 @@ def line_solar(fc_kwh, count_kwh, at, kept=False) -> str:
|
||||
|
||||
def line_uncovered(kwh) -> str:
|
||||
"""OPT-28: the Leftover solar batteries cannot hold the expensive hours'
|
||||
shortfall. The rest comes from another battery or the grid."""
|
||||
shortfall. The rest comes from the grid: OPT-36 counts the other
|
||||
batteries first."""
|
||||
return _t("solar.uncovered", kwh=_kwh(kwh))
|
||||
|
||||
|
||||
|
||||
@@ -124,9 +124,12 @@ from datetime import datetime
|
||||
from .clamp import ClampResult
|
||||
from .delivery import DeliveryConfig, DeliveryWatchdog
|
||||
from . import messages
|
||||
# SMA-2. The driver seam: how this unit's inverter is read and steered.
|
||||
from .drivers import (GOODWE_LINK_STALE_S, GoodWeEsphomeDriver,
|
||||
build_driver)
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
from .freshness import Freshness
|
||||
from .idle import IdleReading
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
@@ -149,7 +152,11 @@ from .watchdog import SignWatchdog, SignWatchdogConfig
|
||||
# decided it will not. No config key: CFG-03 tracks three places a default has
|
||||
# to be kept in step and this does not earn a fourth - it is derived from a
|
||||
# constant the firmware and validate.py already share.
|
||||
LINK_STALE_S = 30.0
|
||||
#
|
||||
# SMA-2. This is the GoodWe driver's `link_stale_s` (drivers.py), kept under its
|
||||
# old name for the checks and for main.py. `is_live()` and `link_note()` now ask
|
||||
# each unit's DRIVER for its window; a driver for another make states its own.
|
||||
LINK_STALE_S = GOODWE_LINK_STALE_S
|
||||
|
||||
# The name of the inverter a SINGLE-INVERTER config describes. ⚠️ A NAME, NOT A
|
||||
# CONFIGURATION - it exists so that a per-inverter claim has an actuator to be
|
||||
@@ -195,12 +202,14 @@ class Inverter:
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, capacity_kwh=None, store=None,
|
||||
sign_cfg=None, delivery_cfg=None):
|
||||
sign_cfg=None, delivery_cfg=None, driver=None):
|
||||
self.name = str(name)
|
||||
self.setpoint_entity = str(setpoint_entity or "")
|
||||
self.soc_entity = str(soc_entity or "")
|
||||
self.batt_entity = str(batt_entity or "")
|
||||
self.batt_invert = bool(batt_invert)
|
||||
# SMA-2. HOW THIS UNIT IS READ AND STEERED. The entity keywords above
|
||||
# are the GoodWe driver's, kept so every existing caller builds the
|
||||
# same unit; a roster entry that selects another driver passes
|
||||
# `driver` and the entity keywords stay empty.
|
||||
self.driver = driver if driver is not None else GoodWeEsphomeDriver(
|
||||
setpoint_entity, soc_entity, batt_entity, batt_invert)
|
||||
# The supervised cap on what may be written to THIS setpoint entity.
|
||||
# See the module docstring on why this is a number and not a `Tuning`.
|
||||
self.max_w = float(max_w)
|
||||
@@ -256,7 +265,7 @@ class Inverter:
|
||||
# constructed here: SoC's obligation term is energy through the pack, not
|
||||
# command travel, and the constant it needs has not been measured. That
|
||||
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
|
||||
self.batt_fresh = Freshness(BOUNDS["batt"])
|
||||
self.batt_fresh = Freshness(self.driver.batt_bound)
|
||||
|
||||
# --- what this unit was last told, and what it last accepted ---------
|
||||
# `target` is THIS unit's share of the site target from the last
|
||||
@@ -266,6 +275,21 @@ class Inverter:
|
||||
self.target = 0.0
|
||||
self.last_write = None
|
||||
self.last_write_at = None
|
||||
# SMA-10. True from the moment this unit is handed back (`release()`)
|
||||
# until control starts again. While it is True nothing is written to the
|
||||
# unit - not the heartbeat, not the cycle - so the unit's own
|
||||
# controller keeps what it was given. Only a driver with
|
||||
# `hands_back = True` ever sets it.
|
||||
self.released = False
|
||||
# SMA-10. Writes to a hand-back driver that are on their way (see
|
||||
# Controller._write_unit), so the release can wait for them and be LAST.
|
||||
self.inflight: set = set()
|
||||
# SMA-10 round 2. A failed hand-back: how many tries in a row failed
|
||||
# (the log is written at the first and on recovery only), when the next
|
||||
# try is due (monotonic s), and whether the one fallback 0 W was sent.
|
||||
self.release_fails = 0
|
||||
self.release_retry_at = 0.0
|
||||
self.release_zero_sent = False
|
||||
self.last_write_ok = None
|
||||
# ⚠️ TWO TIMESTAMPS, NOT ONE FLAG. `last_write_ok` is the last write's
|
||||
# verdict and it flips on a single transient 400; liveness needs a RUN
|
||||
@@ -338,6 +362,50 @@ class Inverter:
|
||||
def idle_w(self) -> float:
|
||||
return self.idle.w
|
||||
|
||||
# -- the GoodWe driver's entities, read through for the callers that name
|
||||
# them (the panel's range check, the status rows, the checks). A driver
|
||||
# without such an entity answers "" - the same as "not set".
|
||||
@property
|
||||
def setpoint_entity(self) -> str:
|
||||
return getattr(self.driver, "setpoint_entity", "")
|
||||
|
||||
@property
|
||||
def soc_entity(self) -> str:
|
||||
return getattr(self.driver, "soc_entity", "")
|
||||
|
||||
@property
|
||||
def batt_entity(self) -> str:
|
||||
return getattr(self.driver, "batt_entity", "")
|
||||
|
||||
@property
|
||||
def batt_invert(self) -> bool:
|
||||
return bool(getattr(self.driver, "batt_invert", False))
|
||||
|
||||
# Assigning one writes through to the driver, as the attributes did before
|
||||
# SMA-2 (nothing in the repo assigns them today; this keeps it working).
|
||||
# A driver that has no such field refuses, rather than losing the value.
|
||||
def _set_driver_field(self, name, value):
|
||||
if not hasattr(self.driver, name):
|
||||
raise AttributeError(
|
||||
f"{type(self.driver).__name__} has no {name}")
|
||||
setattr(self.driver, name, value)
|
||||
|
||||
@setpoint_entity.setter
|
||||
def setpoint_entity(self, v):
|
||||
self._set_driver_field("setpoint_entity", str(v or ""))
|
||||
|
||||
@soc_entity.setter
|
||||
def soc_entity(self, v):
|
||||
self._set_driver_field("soc_entity", str(v or ""))
|
||||
|
||||
@batt_entity.setter
|
||||
def batt_entity(self, v):
|
||||
self._set_driver_field("batt_entity", str(v or ""))
|
||||
|
||||
@batt_invert.setter
|
||||
def batt_invert(self, v):
|
||||
self._set_driver_field("batt_invert", bool(v))
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
@@ -387,7 +455,7 @@ class Inverter:
|
||||
return out
|
||||
|
||||
def has_writer(self) -> bool:
|
||||
return bool(self.setpoint_entity)
|
||||
return self.driver.configured()
|
||||
|
||||
# -- liveness ------------------------------------------------------------
|
||||
def note_write(self, ok: bool, now: datetime) -> None:
|
||||
@@ -398,7 +466,7 @@ class Inverter:
|
||||
elif self.rejected_since is None:
|
||||
self.rejected_since = now
|
||||
|
||||
def is_live(self, now: datetime, stale_s: float = LINK_STALE_S):
|
||||
def is_live(self, now: datetime, stale_s: float | None = None):
|
||||
"""`(live, why_not)` - may this controller command this unit THIS TICK?
|
||||
|
||||
Read the module docstring before widening this - in particular on why a
|
||||
@@ -411,7 +479,11 @@ class Inverter:
|
||||
|
||||
⚠️ ANSWERED PER TICK, NEVER CACHED, for `allocator.plan()`'s reason: a
|
||||
link goes stale between one cycle and the next.
|
||||
|
||||
`stale_s` is the driver's `link_stale_s` unless a caller passes one.
|
||||
"""
|
||||
if stale_s is None:
|
||||
stale_s = self.driver.link_stale_s
|
||||
if self.rejected_since is not None:
|
||||
held = (now - self.rejected_since).total_seconds()
|
||||
if held >= float(stale_s):
|
||||
@@ -451,9 +523,11 @@ class Inverter:
|
||||
held = (0.0 if self.rejected_since is None
|
||||
else (now - self.rejected_since).total_seconds())
|
||||
if not live:
|
||||
return messages.row_not_controllable(self.name, held, LINK_STALE_S)
|
||||
return messages.row_not_controllable(self.name, held,
|
||||
self.driver.link_stale_s)
|
||||
if self.rejected_since is not None:
|
||||
return messages.row_commands_refused(self.name, held, LINK_STALE_S)
|
||||
return messages.row_commands_refused(self.name, held,
|
||||
self.driver.link_stale_s)
|
||||
return ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -556,10 +630,8 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
# identically, which is AC 3.
|
||||
return [Inverter(
|
||||
UNIT_A,
|
||||
setpoint_entity=opts.get("setpoint_entity", ""),
|
||||
soc_entity=opts.get("soc_entity", ""),
|
||||
batt_entity=opts.get("batt_entity", ""),
|
||||
batt_invert=bool(opts.get("batt_invert")),
|
||||
# SMA-2. The flat keys are the GoodWe shape and only that.
|
||||
driver=GoodWeEsphomeDriver.from_entry(opts),
|
||||
max_w=_flat_max_w(opts),
|
||||
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
||||
# - the one pack the flat options describe is the one pack whose
|
||||
@@ -588,10 +660,9 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
roster.append(Inverter(
|
||||
name,
|
||||
setpoint_entity=entry.get("setpoint_entity", ""),
|
||||
soc_entity=entry.get("soc_entity", ""),
|
||||
batt_entity=entry.get("batt_entity", ""),
|
||||
batt_invert=bool(entry.get("batt_invert")),
|
||||
# SMA-2. The entry's `driver` (default goodwe_esphome) picks the
|
||||
# class; the entry itself says where its entities are.
|
||||
driver=build_driver(entry),
|
||||
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
|
||||
# not state is not the flat one, it is a rating rule 9 refuses.
|
||||
max_w=_entry_max_w(entry),
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
"""SAFETY-128: a reading that does not answer the last command is not current.
|
||||
|
||||
When Home Assistant delivers readings late but still changing, every gate that
|
||||
judges arrival (SAFETY-01) or an unchanged value (SAFETY-18, SAFETY-37) passes
|
||||
them, and the loop corrects again, each cycle, an error its last command has
|
||||
already fixed. The readings show it: they answer each power request late.
|
||||
|
||||
`Answer.watch()` (each 1 s read) fits two delays every FIT_EVERY_S:
|
||||
- `Delay`: how long after its request each battery's reading shows it. The
|
||||
house does not move that reading, so a repeating load cannot fool it. It
|
||||
needs the request to move FIT_MOVE_W; one reading per 10 s makes it slow.
|
||||
- `MeterFit`: how long after the site request the meter shows it. Quick at a
|
||||
1-3 s meter, slower and for long lags only at a 5 s one (SAFETY-129), and a
|
||||
house load that repeats can fool it.
|
||||
The meter counts only when an ask was slow to start and no battery fit said
|
||||
"on time"; with hysteresis (ON_S / OFF_S) the result is `late_seen`. Then (`observe()`, each cycle):
|
||||
- a new command more than ANSWER_W from the battery reading is PENDING until
|
||||
that reading comes within ANSWER_W of it, or half way;
|
||||
- while pending the loop may not lead the battery reading by more than
|
||||
ANSWER_W that way (`hold`, `hold_w`; not while `exempt`: a battery that
|
||||
gives nothing under its ask or refuses is SAFETY-52/94's case) and the peak
|
||||
guard may not push past what stands (`peak_cap`). Pending for LATE_S with
|
||||
no reading moving towards it is LATE: control holds, then 0 W after
|
||||
`stale_input_s`, as for a missing input.
|
||||
|
||||
Not `late_seen`, nothing here acts. DECISIONS/SAFETY-128-late-readings.md.
|
||||
"""
|
||||
|
||||
import bisect
|
||||
from collections import deque
|
||||
|
||||
from . import freshness
|
||||
from .control import PROBE_ANSWER_W
|
||||
|
||||
# Within this of the command the reading has answered it (a probe's answer).
|
||||
ANSWER_W = PROBE_ANSWER_W # 100
|
||||
# The slowest healthy battery answer: SAFETY-75's 6 s dead time, the 6 s plant
|
||||
# settle, one 10 s publish and one 1 s read.
|
||||
LATE_S = (2 * freshness.PLANT_SETTLE_S + freshness.PUBLISH_PERIOD_S
|
||||
+ freshness.READ_PERIOD_S) # 23.0
|
||||
|
||||
|
||||
# Each battery reading's delay is fitted over this much history, up to this.
|
||||
FIT_S = 60.0
|
||||
MAX_DELAY_S = 90
|
||||
FIT_EVERY_S = 2.0
|
||||
# A fit needs this many new readings (all batteries) and this much movement
|
||||
# of the reading and the request.
|
||||
FIT_ROWS = 5
|
||||
FIT_MOVE_W = 3 * ANSWER_W # 300
|
||||
# Late from ON_S, on time again under OFF_S (hysteresis).
|
||||
ON_S = 9.5
|
||||
OFF_S = 6.5
|
||||
# ...after this many late fits in a row.
|
||||
ON_FITS = 2
|
||||
# With no fit for this long the evidence is dropped (a steady site shows none).
|
||||
FIT_GONE_S = 120.0
|
||||
# A fit counts only if the commands explain this share of the reading's swing.
|
||||
FIT_SHARE = 0.5
|
||||
# A late delay must explain this share.
|
||||
FIT_SHARE_LATE = 0.75
|
||||
# ...and a late delay only if it fits this much better than any fresh one.
|
||||
FIT_BETTER = 0.5
|
||||
# A shorter delay within this factor of the best fit is taken instead.
|
||||
FIT_NEAR = 2.0
|
||||
# ...or within this of it (W^2): two fits this good are both exact.
|
||||
FIT_FLOOR_W2 = (ANSWER_W / 2) ** 2 # 2 500
|
||||
|
||||
|
||||
def per_second(cmds, first, last):
|
||||
"""The request standing at each whole second from `first` to `last`."""
|
||||
ts = [t for t, _c in cmds]
|
||||
return [cmds[max(0, bisect.bisect_right(ts, k) - 1)][1]
|
||||
for k in range(first, last + 1)]
|
||||
|
||||
|
||||
def pooled(sets, d):
|
||||
"""Pooled variance over [(rows, c, first, sign)] of value + sign *
|
||||
c[t - d], each set about its own mean."""
|
||||
tot, n = 0.0, 0
|
||||
for rows, c, first, sign in sets:
|
||||
e = [v + sign * c[int(t) - d - first] for t, v in rows]
|
||||
m = sum(e) / len(e)
|
||||
tot += sum((x - m) ** 2 for x in e)
|
||||
n += len(e)
|
||||
return tot / n
|
||||
|
||||
|
||||
class UnitFit:
|
||||
"""One battery's power requests and readings, as they were seen."""
|
||||
|
||||
def __init__(self):
|
||||
self.cmds = deque() # (t, request) when it changed
|
||||
self.reads = deque() # (t, reading) when it changed
|
||||
|
||||
def add(self, now, cmd, reading):
|
||||
if cmd is not None and (not self.cmds or self.cmds[-1][1] != cmd):
|
||||
self.cmds.append((now, float(cmd)))
|
||||
if reading is not None and (not self.reads
|
||||
or self.reads[-1][1] != reading):
|
||||
self.reads.append((now, float(reading)))
|
||||
while self.reads and self.reads[0][0] < now - FIT_S:
|
||||
self.reads.popleft()
|
||||
while (len(self.cmds) > 1
|
||||
and self.cmds[1][0] <= now - FIT_S - MAX_DELAY_S):
|
||||
self.cmds.popleft()
|
||||
|
||||
def series(self):
|
||||
"""(rows, request each second, first second) for the fit, or None:
|
||||
too few new readings, or a reading or a request that has not moved
|
||||
FIT_MOVE_W. The request is averaged over the 2 s before each second:
|
||||
the battery's own lag and the read."""
|
||||
rows = [r for r in self.reads
|
||||
if self.cmds and r[0] >= self.cmds[0][0] + MAX_DELAY_S]
|
||||
vals = [v for _t, v in rows]
|
||||
if len(rows) < 3 or max(vals) - min(vals) < FIT_MOVE_W:
|
||||
return None
|
||||
cs = [c for _t, c in self.cmds]
|
||||
if max(cs) - min(cs) < FIT_MOVE_W:
|
||||
return None
|
||||
first, last = int(rows[0][0]) - MAX_DELAY_S - 2, int(rows[-1][0])
|
||||
c = per_second(self.cmds, first, last)
|
||||
avg = [c[0], c[0]] + [(c[k - 2] + c[k - 1] + c[k]) / 3
|
||||
for k in range(2, len(c))]
|
||||
return rows, avg, first
|
||||
|
||||
|
||||
class Delay:
|
||||
"""How long after a power request the batteries' readings show it.
|
||||
|
||||
A reading follows its battery's own request and the house does not move
|
||||
it, so `reading(t) - request(t - d)` is flattest at the right `d`. Each new
|
||||
reading counts once, at the second it was seen, so the 10 s publish adds no
|
||||
delay. One `d` for every battery (Home Assistant delays them all). None
|
||||
with too few readings, or when the requests explain too little of them (a
|
||||
battery that does not follow); not late unless it fits clearly better than
|
||||
any fresh delay, and then the shortest delay that fits about as well."""
|
||||
|
||||
def __init__(self):
|
||||
self.units = {}
|
||||
|
||||
def add(self, now, unit, cmd, reading):
|
||||
self.units.setdefault(unit, UnitFit()).add(now, cmd, reading)
|
||||
|
||||
def estimate(self):
|
||||
ser = [x for x in (u.series() for u in self.units.values()) if x]
|
||||
if sum(len(rows) for rows, _c, _f in ser) < FIT_ROWS:
|
||||
return None
|
||||
sets = [(rows, c, first, -1.0) for rows, c, first in ser]
|
||||
r_var = pooled([(rows, [0.0] * len(c), first, -1.0)
|
||||
for rows, c, first in ser], 0)
|
||||
pure = [pooled(sets, d) for d in range(0, MAX_DELAY_S + 1)]
|
||||
# The shortest that fits about as well: a swing's period fits again.
|
||||
best = next(d for d, v in enumerate(pure)
|
||||
if v <= FIT_NEAR * min(pure) + FIT_FLOOR_W2)
|
||||
if r_var <= 0 or pure[best] > (1 - FIT_SHARE) * r_var:
|
||||
return None
|
||||
if best < ON_S:
|
||||
return float(best)
|
||||
if (pure[best] > (1 - FIT_SHARE_LATE) * r_var
|
||||
or pure[best] > FIT_BETTER * min(pure[:int(OFF_S) + 1])):
|
||||
return None
|
||||
return float(best)
|
||||
|
||||
|
||||
# The meter's delay: fitted over this much history, from this many new
|
||||
# values (a meter of about 3.5 s or faster; a slower one see M_SLOW_FIT_S);
|
||||
# late over M_LATE_S (the plant's 6 s settle and one 2 s meter tick), but only
|
||||
# when the batteries' own readings have not just shown them on time.
|
||||
M_FIT_S = 60.0
|
||||
M_ROWS = 16
|
||||
M_LATE_S = freshness.PLANT_SETTLE_S + 2.0 # 8.0
|
||||
M_SHARE, M_BETTER = 0.4, 0.7
|
||||
# SAFETY-129: a meter slower than that (a 5 s P1) has fewer than M_ROWS values
|
||||
# in M_FIT_S, so it is fitted over M_SLOW_FIT_S instead, from the same M_ROWS
|
||||
# values. It calls a delay late only over M_SLOW_LATE_S and on ON_FITS fits in
|
||||
# a row. Alone it reads 35-90 s on healthy runs, a few times in a row; what
|
||||
# keeps those from a verdict is the slow-start and battery-fit check in
|
||||
# watch(), not the limit. A 10 s lag is not found: it cannot be told from a
|
||||
# slow battery (R2-1), nor does it swing at 5 s.
|
||||
M_SLOW_FIT_S = 120.0
|
||||
M_SLOW_LATE_S = 16.0
|
||||
# A meter fit counts only after an ask that took this long to start moving a
|
||||
# reading: on time they start within 1-2 s (the recorded house, at 1-5 s).
|
||||
SLOW_START_S = 3.0
|
||||
|
||||
|
||||
class MeterFit:
|
||||
"""How long after a site request the meter shows it (round 1's fit).
|
||||
|
||||
`meter(t) + request(t - d)` is the house alone at the right `d`. Quick,
|
||||
as the meter reads every 1-5 s, but a house load that repeats can line
|
||||
up with an earlier request, so it only counts while the batteries' own
|
||||
fit is not on time (`Answer.watch`)."""
|
||||
|
||||
def __init__(self, fit_s=M_FIT_S, late_s=M_LATE_S):
|
||||
self.fit_s, self.late_s = fit_s, late_s
|
||||
self.cmds = deque() # (t, request) when it changed
|
||||
self.grid = deque() # (t, meter) new values only
|
||||
|
||||
def slow(self):
|
||||
"""The meter is too slow for M_ROWS values in `fit_s`: the usual gap
|
||||
between its new values (median, so a pause does not count) is over
|
||||
fit_s / M_ROWS."""
|
||||
ts = [t for t, _g in self.grid]
|
||||
if len(ts) < 5:
|
||||
return False
|
||||
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
|
||||
return gaps[len(gaps) // 2] * M_ROWS > self.fit_s
|
||||
|
||||
def add(self, now, cmd, grid):
|
||||
if cmd is not None and (not self.cmds or self.cmds[-1][1] != cmd):
|
||||
self.cmds.append((now, float(cmd)))
|
||||
if grid is not None and (not self.grid or self.grid[-1][1] != grid):
|
||||
self.grid.append((now, float(grid)))
|
||||
while self.grid and self.grid[0][0] < now - self.fit_s:
|
||||
self.grid.popleft()
|
||||
while (len(self.cmds) > 1
|
||||
and self.cmds[1][0] <= now - self.fit_s - MAX_DELAY_S):
|
||||
self.cmds.popleft()
|
||||
|
||||
def estimate(self):
|
||||
if len(self.cmds) < 2:
|
||||
return None
|
||||
rows = [(t, g) for t, g in self.grid
|
||||
if t >= self.cmds[0][0] + MAX_DELAY_S]
|
||||
cs = [c for _t, c in self.cmds]
|
||||
if len(rows) < M_ROWS or max(cs) - min(cs) < ANSWER_W:
|
||||
return None
|
||||
first, last = int(rows[0][0]) - MAX_DELAY_S, int(rows[-1][0])
|
||||
c = per_second(self.cmds, first, last)
|
||||
g_var = pooled([(rows, [0.0] * len(c), first, 1.0)], 0)
|
||||
pure = [pooled([(rows, c, first, 1.0)], d)
|
||||
for d in range(0, MAX_DELAY_S + 1)]
|
||||
best = min(range(len(pure)), key=lambda d: (pure[d], d))
|
||||
if g_var <= 0 or pure[best] > (1 - M_SHARE) * g_var:
|
||||
return None
|
||||
if best <= self.late_s:
|
||||
return float(best)
|
||||
if pure[best] > M_BETTER * min(pure[:int(self.late_s) + 1]):
|
||||
return None
|
||||
return float(best)
|
||||
|
||||
|
||||
class Answer:
|
||||
"""The answer gate. `observe()` once per cycle, before the loop."""
|
||||
|
||||
def __init__(self):
|
||||
self.ask = None # the pending command, W (site, + = give)
|
||||
self.from_w = None # the battery reading when it was asked
|
||||
self.from_grid = None # the meter when it was asked
|
||||
self.since = None # when it was asked
|
||||
self.started = False # a reading has moved towards it
|
||||
self.late_seen = False # the batteries' readings run late
|
||||
self.late_at = None # when the late verdict started
|
||||
self.exempt = False # a battery that cannot follow explains it
|
||||
self._was = None # (command, reading, meter) at the last call
|
||||
self.delay = Delay()
|
||||
self.meter = MeterFit()
|
||||
self.meter_slow = MeterFit(M_SLOW_FIT_S, M_SLOW_LATE_S) # SAFETY-129
|
||||
self._ms_run = 0 # late slow-meter fits in a row
|
||||
self._batt_fit = None # (when, d): the batteries' last fit
|
||||
self._b_run = 0 # late battery fits in a row
|
||||
self._start_age = None # (when, how long the last ask took to start)
|
||||
self._exempt_at = None # when a battery last gave nothing under its ask
|
||||
self._ask_exempt = False
|
||||
self._start = None
|
||||
self.delay_s = None # the last fitted delay (either fit), s
|
||||
self.shown_s = None # the batteries' last late delay, for the page
|
||||
self.fits = 0 # fits made (instrumentation)
|
||||
self._fit_at = self._fit_ok = None
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self.ask is not None and self.late_seen
|
||||
|
||||
@property
|
||||
def hold(self) -> int:
|
||||
"""+1 / -1: the loop may not push further that way. 0: no hold."""
|
||||
if not self.active or self.exempt:
|
||||
return 0
|
||||
return 1 if self.ask > self.from_w else -1
|
||||
|
||||
@property
|
||||
def hold_w(self):
|
||||
"""How far the loop may go while held: ANSWER_W past the reading."""
|
||||
if not self.hold:
|
||||
return None
|
||||
return self._was[1] + self.hold * ANSWER_W
|
||||
|
||||
def peak_cap(self, standing_w):
|
||||
"""The most the peak guard may ask, or None."""
|
||||
return max(float(standing_w), 0.0) if self.active else None
|
||||
|
||||
def late_s(self, now):
|
||||
"""Seconds the late verdict has stood, or None."""
|
||||
return None if self.late_at is None else max(0.0, now - self.late_at)
|
||||
|
||||
def watch(self, now, units, standing_w=None, grid_w=None):
|
||||
"""Each read (1 s): `units` = [(name, request on its wire, its battery
|
||||
reading)], the site request and the meter. Every FIT_EVERY_S, fit both
|
||||
delays. Late on ON_FITS battery fits from ON_S, or on a meter fit
|
||||
over M_LATE_S when an ask was slow to start (SLOW_START_S) and no
|
||||
battery fit within FIT_S said on time: a house load that repeats can
|
||||
fool the meter fit, not these. A meter too slow for M_ROWS values in
|
||||
M_FIT_S (a 5 s P1) is fitted over M_SLOW_FIT_S and counts only over
|
||||
M_SLOW_LATE_S, on ON_FITS fits in a row. On time again on a battery fit
|
||||
under OFF_S, or a meter fit within M_LATE_S unless a battery fit said
|
||||
late.
|
||||
"""
|
||||
if self._start is None:
|
||||
self._start = now
|
||||
for name, cmd, reading in units:
|
||||
self.delay.add(now, name, cmd, reading)
|
||||
self.meter.add(now, standing_w, grid_w)
|
||||
self.meter_slow.add(now, standing_w, grid_w)
|
||||
if self._fit_at is not None and now - self._fit_at < FIT_EVERY_S:
|
||||
return
|
||||
self._fit_at = now
|
||||
b = self.delay.estimate()
|
||||
if (b or 0.0) >= ON_S and self._exempt_at is not None \
|
||||
and now - self._exempt_at <= FIT_S:
|
||||
b = None # a battery that stopped following: no late fit
|
||||
m = self.meter.estimate()
|
||||
ms = None
|
||||
if self.meter.slow():
|
||||
ms = self.meter_slow.estimate()
|
||||
if b is not None:
|
||||
self._batt_fit = (now, b)
|
||||
self.shown_s = b if b >= ON_S else None
|
||||
recent = (self._batt_fit is not None
|
||||
and now - self._batt_fit[0] <= FIT_S)
|
||||
b_fresh = recent and self._batt_fit[1] < ON_S
|
||||
b_late = recent and self._batt_fit[1] >= ON_S
|
||||
|
||||
self._b_run = self._b_run + 1 if (b or 0.0) >= ON_S else 0
|
||||
self._ms_run = self._ms_run + 1 if (ms or 0.0) > M_SLOW_LATE_S else 0
|
||||
# A slow-meter fit only ever adds a late verdict: it counts as a fit
|
||||
# when it is late on ON_FITS in a row, and never ends one, so a slow
|
||||
# battery (R2-1) runs exactly as before.
|
||||
if self._ms_run < ON_FITS:
|
||||
ms = None
|
||||
d = b if b is not None else m if m is not None else ms
|
||||
if d is None:
|
||||
if self._fit_ok is None or now - self._fit_ok > FIT_GONE_S:
|
||||
self.late_seen = False # nothing to tell it by
|
||||
return
|
||||
self.fits += 1
|
||||
self.delay_s, self._fit_ok = d, now
|
||||
warm = now - self._start >= MAX_DELAY_S + FIT_S
|
||||
m_late = ((m or 0.0) > M_LATE_S and warm) or (
|
||||
self._ms_run >= ON_FITS
|
||||
and now - self._start >= MAX_DELAY_S + M_SLOW_FIT_S)
|
||||
slow = (self._start_age is not None
|
||||
and now - self._start_age[0] <= M_FIT_S
|
||||
and self._start_age[1] >= SLOW_START_S)
|
||||
if self._b_run >= ON_FITS or (m_late and slow and not b_fresh):
|
||||
self.late_seen = True
|
||||
elif (b is not None and b < OFF_S) or (
|
||||
m is not None and m <= M_LATE_S and not b_late):
|
||||
self.late_seen = False
|
||||
|
||||
def observe(self, now, standing_w, batt_w, *, grid_w=None, exempt=False,
|
||||
idle_w=0.0):
|
||||
"""`standing_w` the site command on the wire, `batt_w` the site
|
||||
battery reading (None: unknown, nothing changes), `grid_w` the meter
|
||||
(+ = import), `idle_w` what the batteries read while giving nothing.
|
||||
`exempt`: a battery gives nothing under its ask, or refuses."""
|
||||
if batt_w is None or standing_w is None:
|
||||
return
|
||||
cmd, r = float(standing_w), float(batt_w) - float(idle_w)
|
||||
g = None if grid_w is None else float(grid_w)
|
||||
self.exempt = bool(exempt)
|
||||
if self.exempt:
|
||||
self._exempt_at = now
|
||||
was, self._was = self._was, (cmd, r, g)
|
||||
if self.ask is None:
|
||||
if was is None or cmd == was[0] or abs(cmd - was[1]) < ANSWER_W:
|
||||
return
|
||||
self.ask, self.from_w, self.from_grid = cmd, was[1], was[2]
|
||||
self.since, self.started = now, False
|
||||
self._ask_exempt = False
|
||||
self.ask = cmd
|
||||
# A battery that gives nothing under its ask explains a slow start.
|
||||
self._ask_exempt = self._ask_exempt or self.exempt
|
||||
age = now - self.since
|
||||
gap = self.ask - self.from_w
|
||||
way = 1.0 if gap > 0 else -1.0
|
||||
batt_to = (r - self.from_w) * way
|
||||
grid_to = (-1e9 if g is None or self.from_grid is None
|
||||
else (self.from_grid - g) * way)
|
||||
if not self.started and max(batt_to, grid_to) >= ANSWER_W:
|
||||
self.started, self.late_at = True, None
|
||||
self._start_age = (now, 0.0 if self._ask_exempt else age)
|
||||
elif (not self.started and age >= SLOW_START_S
|
||||
and not self._ask_exempt):
|
||||
self._start_age = (now, age)
|
||||
if (self.ask - r) * way < ANSWER_W or batt_to >= abs(gap) / 2:
|
||||
self._clear() # answered
|
||||
elif abs(gap) < ANSWER_W:
|
||||
self._clear() # withdrawn
|
||||
elif exempt:
|
||||
self.late_at = None
|
||||
elif (self.late_at is None and self.late_seen and not self.started
|
||||
and age >= LATE_S):
|
||||
self.late_at = now
|
||||
|
||||
def _clear(self):
|
||||
self.ask = self.from_w = self.from_grid = self.since = None
|
||||
self.started, self.late_at = False, None
|
||||
+402
-7379
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from . import handback
|
||||
|
||||
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
|
||||
# that no action a person must take is lost; everything else gets one.
|
||||
ALARM, WARNING, INFO, RESOLVED = "alarm", "warning", "info", "resolved"
|
||||
@@ -228,6 +230,19 @@ class Clamp:
|
||||
limit: str # a key of LIMIT_WORDS
|
||||
|
||||
|
||||
def _shadow_so(held: str) -> str:
|
||||
""""Shadow mode is on, so <held>." SMA-10: with a unit handed back, the
|
||||
roster's own words say where each unit stays; otherwise `held`, as ever."""
|
||||
moved = handback.shadow_state(handback.current(), "en")
|
||||
return f"Shadow mode is on, so {moved or held}."
|
||||
|
||||
|
||||
def _shadow_keeps(held: str) -> str:
|
||||
moved = handback.clause(handback.current(), "en", "at") \
|
||||
if handback.active(handback.current()) else None
|
||||
return moved or held
|
||||
|
||||
|
||||
def output_clamp(clamps, *, shadow: bool = False) -> Notice:
|
||||
"""SAFETY-03: a computed command above the limit. A software fault.
|
||||
|
||||
@@ -238,7 +253,7 @@ def output_clamp(clamps, *, shadow: bool = False) -> Notice:
|
||||
fault = "This is a software fault, so please report it to your installer."
|
||||
if not clamps:
|
||||
cut = ("would have been cut back" if shadow else "was cut back")
|
||||
state = ("Shadow mode is on, so every inverter stays at 0 W." if shadow
|
||||
state = ((_shadow_so("every inverter stays at 0 W")) if shadow
|
||||
else "Control is still running.")
|
||||
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
|
||||
f"The add-on asked for more power than {RATING} allows "
|
||||
@@ -250,11 +265,11 @@ def output_clamp(clamps, *, shadow: bool = False) -> Notice:
|
||||
if others:
|
||||
verb = "was" if len(others) == 1 else "were"
|
||||
state = (f"{names(others)} would have been cut too, and shadow mode "
|
||||
f"keeps every inverter at 0 W." if shadow else
|
||||
f"keeps {_shadow_keeps('every inverter at 0 W')}." if shadow else
|
||||
f"{names(others)} {verb} cut too, and control is still "
|
||||
f"running.")
|
||||
else:
|
||||
state = ("Shadow mode is on, so the inverter stays at 0 W." if shadow
|
||||
state = (_shadow_so("the inverter stays at 0 W") if shadow
|
||||
else "Control is still running.")
|
||||
return Notice(ID_OUTPUT_CLAMP, ALARM, T_OUTPUT_CLAMP,
|
||||
f"{who} add-on asked for {watts(c.asked_w)}, above "
|
||||
@@ -576,6 +591,9 @@ def banner_running() -> str:
|
||||
|
||||
|
||||
def banner_stopped() -> str:
|
||||
moved = handback.banner_stopped(handback.current())
|
||||
if moved is not None: # SMA-10: a unit is handed back, not held
|
||||
return moved
|
||||
return Bi("Stopped — the inverters are set to 0 W",
|
||||
"Gestopt — de omvormers staan op 0 W")
|
||||
|
||||
@@ -601,6 +619,9 @@ def banner_shadow(latched: bool) -> str:
|
||||
mode. Each stop has its own red row below, which names it and says how it
|
||||
clears.
|
||||
"""
|
||||
moved = None if latched else handback.banner_shadow(handback.current())
|
||||
if moved is not None: # SMA-10: a unit is handed back, not held
|
||||
return moved
|
||||
if latched:
|
||||
return Bi("Shadow mode: battery not controlled. A safety stop stays "
|
||||
"on, see below.",
|
||||
@@ -644,6 +665,31 @@ def row_controller_stalled(age_s, veto_s) -> str:
|
||||
f"stuurmodule zet de omvormer na {seconden(veto_s)} op 0 W.")
|
||||
|
||||
|
||||
def row_readings_late(delay_s, zero=False) -> str:
|
||||
"""SAFETY-128: readings show the power request late; `delay_s` the
|
||||
batteries' fitted delay (their own answer included), or None when only
|
||||
the meter has shown it; past the hold, 0 W."""
|
||||
if delay_s is None:
|
||||
if zero:
|
||||
return Bi("Home Assistant readings show requests late, so every "
|
||||
"inverter is at 0 W.",
|
||||
"Metingen uit Home Assistant tonen vermogensvraag te "
|
||||
"laat, omvormers op 0 W.")
|
||||
return Bi("Home Assistant readings show requests late, so control "
|
||||
"holds the batteries.",
|
||||
"Metingen uit Home Assistant tonen vermogensvraag te laat, "
|
||||
"dus de batterijen wachten.")
|
||||
if zero:
|
||||
return Bi(f"Home Assistant readings show requests {seconds(delay_s)} "
|
||||
f"late, so every inverter is at 0 W.",
|
||||
f"Metingen uit Home Assistant tonen vermogensvraag "
|
||||
f"{seconden(delay_s)} te laat, omvormers op 0 W.")
|
||||
return Bi(f"Home Assistant readings show requests about {seconds(delay_s)} "
|
||||
f"late, so control holds the batteries.",
|
||||
f"Metingen uit Home Assistant tonen vermogensvraag zo'n "
|
||||
f"{seconden(delay_s)} te laat, dus de batterijen wachten.")
|
||||
|
||||
|
||||
def row_meter_waiting(limit_s) -> str:
|
||||
"""UX-10: no reading yet since the start, and still inside the limit."""
|
||||
return Bi(f"Meter: waiting for the first reading, limit "
|
||||
@@ -1270,6 +1316,7 @@ WHY_WINNER = {
|
||||
"circulation": "Safety stop, inverters worked against each other",
|
||||
"stopped": "Control is stopped",
|
||||
"inputs missing": "A reading is missing",
|
||||
"readings late": "Home Assistant readings arrive late",
|
||||
"no claim was made": "Nothing asked for power",
|
||||
# UX-10: the claim reasons main.why_key() can return for a safety or
|
||||
# failsafe winner. test_messages.py part 7 finds every one in the code.
|
||||
@@ -1320,6 +1367,11 @@ def why_missing(what, limit_s) -> str:
|
||||
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
|
||||
|
||||
|
||||
def why_late() -> str:
|
||||
"""SAFETY-128: readings arrive late, so control holds the batteries."""
|
||||
return "Home Assistant readings arrive late, control holds"
|
||||
|
||||
|
||||
def why_waiting(missing_s) -> str:
|
||||
"""A reading went missing a moment ago: control holds and waits."""
|
||||
return f"A reading is missing for {seconds(missing_s)}, control waits"
|
||||
@@ -1336,7 +1388,11 @@ HINTS = {
|
||||
"setup_only": "In setup, nothing is sent to any inverter",
|
||||
"roster": "Inverter list read: {n} inverters",
|
||||
"maint_history": "Maintenance history did not move over cleanly",
|
||||
"handed_back": "{unit}: handed back, the add-on writes nothing to it",
|
||||
"hand_back_failed": "{unit}: could not be handed back, set to 0 W once, trying again",
|
||||
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
|
||||
"readings_late": "Home Assistant readings arrive late, control holds",
|
||||
"readings_back": "Home Assistant readings are on time again",
|
||||
"battery_stuck": "{unit}: battery power reading stopped changing",
|
||||
"battery_back": "{unit}: battery power reading is used again",
|
||||
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
|
||||
@@ -1437,6 +1493,10 @@ def hint(key: str, **kw) -> str:
|
||||
kw["w"] = watts(kw["w"])
|
||||
if "sw" in kw:
|
||||
kw["sw"] = signed_watts(kw["sw"])
|
||||
if key == "shadow_on": # SMA-10: a unit handed back is not at 0 W
|
||||
moved = handback.hint_shadow_on(handback.current())
|
||||
if moved is not None:
|
||||
return moved
|
||||
return HINTS[key].format(**kw)
|
||||
|
||||
|
||||
@@ -1488,6 +1548,10 @@ def status_samples() -> list:
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
("row_meter_stale", RED, row_meter_stale(40, 10)),
|
||||
("row_meter_waiting", AMBER, row_meter_waiting(30)),
|
||||
("row_readings_late", AMBER, row_readings_late(30)),
|
||||
("row_readings_late", AMBER, row_readings_late(30, True)),
|
||||
("row_readings_late", AMBER, row_readings_late(None)),
|
||||
("row_readings_late", AMBER, row_readings_late(None, True)),
|
||||
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
|
||||
("row_meter_frozen", RED, row_meter_frozen()),
|
||||
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
|
||||
|
||||
@@ -446,6 +446,19 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
return decision is not None and decision.engaged
|
||||
|
||||
|
||||
def cap_floor(decision, cap_w):
|
||||
"""SAFETY-128: the floor no higher than `cap_w` (the command standing while
|
||||
the readings have not answered it), or `decision` unchanged when None.
|
||||
DECISIONS/SAFETY-128-late-readings.md."""
|
||||
if (cap_w is None or decision is None or decision.floor_w is None
|
||||
or decision.floor_w <= cap_w):
|
||||
return decision
|
||||
return replace(
|
||||
decision, floor_w=float(cap_w), saturated=False,
|
||||
reason=decision.reason + f", held at {float(cap_w):+.0f} W until the "
|
||||
"readings answer the last power request")
|
||||
|
||||
|
||||
def claim(decision: PeakDecision) -> Claim | None:
|
||||
"""The decision as one arbiter claim (`lo` only, `hi` stays `+inf`), or None."""
|
||||
if not decision.engaged or decision.floor_w is None:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Module functions for the status rows and the Why line.
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
from .arbiter import ACTUATOR_SITE, SET
|
||||
from . import control as _ctl
|
||||
from . import messages
|
||||
|
||||
|
||||
def why_raised(resolution, claims) -> bool:
|
||||
"""F2 (OPT-11 P1 test): did the limit that bound the site target push it
|
||||
ABOVE what the winner asked (the peak floor asking for more power)? Then
|
||||
the Why line must not say "held back". False when nothing bound it."""
|
||||
if not resolution.bound_by or resolution.contradiction:
|
||||
return False
|
||||
asked = next((c.value for c in sorted(
|
||||
(c for c in claims if c.kind == SET
|
||||
and c.actuator == ACTUATOR_SITE and c.source == resolution.winner),
|
||||
key=lambda c: c.priority, reverse=True)), None)
|
||||
return asked is not None and resolution.target_w > float(asked)
|
||||
|
||||
|
||||
def why_key(winner: str, claims) -> str:
|
||||
"""The key of `messages.WHY_WINNER` for a resolution's winner. "safety" and
|
||||
"failsafe" stand for several causes, so their own claim reason is the
|
||||
key ("stopped", "inputs missing", "sign disagreement", or "unsplit" when
|
||||
this module refuses to split the power)."""
|
||||
if winner == "loop":
|
||||
# SAFETY-52: a loop holding a refused direction says so.
|
||||
return next((c.reason for c in claims if c.source == "loop"
|
||||
and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner)
|
||||
if winner in ("safety", "failsafe"):
|
||||
return next((c.reason for c in claims
|
||||
if c.source == winner and c.kind == "set"),
|
||||
"no claim was made" if not any(
|
||||
c.kind == "set" for c in claims) else "unsplit")
|
||||
return winner
|
||||
|
||||
|
||||
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
|
||||
provider: bool, max_age_s: float) -> str:
|
||||
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan
|
||||
(TARIFF-11): two sources, two clocks, and the row says so. The plan is
|
||||
stamped with its own age from `tariff_at` (not moved on a kept plan,
|
||||
TARIFF-12), up to TARIFF_MAX_AGE_S (5400 s); `age_s` is the caller's
|
||||
measurement. "No plan" is said aloud on a site that configured a provider.
|
||||
DECISIONS/MAIN-orchestration.md."""
|
||||
# UX-08: plain words, every figure kept. The tick's sentence is "now"; the
|
||||
# plan carries its own age.
|
||||
if plan is None:
|
||||
if not provider:
|
||||
return tick_words
|
||||
return (f"{tick_words}. No price plan: none yet, or the last one is "
|
||||
f"over {max_age_s:.0f} s old")
|
||||
return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for "
|
||||
f"{horizon_h:.0f} hours ahead: {plan.words}")
|
||||
|
||||
|
||||
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
||||
why: str) -> str:
|
||||
"""The *Arbitrage* status row: three states (TARIFF-11): off (not armed),
|
||||
armed with no decision this cycle (`why` is the loop's reason), or a
|
||||
decision with its age.
|
||||
`on` is read from the options, never inferred from `decision is None`:
|
||||
`cycle()` returns at the input hold before `self.arb = None`, so the old
|
||||
row named an option that was ON. The age is always printed, even 0.
|
||||
DECISIONS/MAIN-orchestration.md."""
|
||||
# UX-08: plain words; the setting is named by its label.
|
||||
if not on:
|
||||
return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not "
|
||||
f"switched on")
|
||||
if decided_at is None:
|
||||
return (f"On, but no decision yet, because control has not got that "
|
||||
f"far. Control says: {why or 'no reason recorded'}")
|
||||
age = (now - decided_at).total_seconds()
|
||||
stale = cycle_at is not None and decided_at < cycle_at
|
||||
head = (f"On, no decision this cycle ({why or 'no reason recorded'}), "
|
||||
f"the last one {age:.0f} s ago: " if stale else
|
||||
f"On, decided {age:.0f} s ago: ")
|
||||
if decision is None:
|
||||
return (f"On, no decision this cycle ({why or 'no reason recorded'}) "
|
||||
f"and none before")
|
||||
return head + decision.explain()
|
||||
@@ -343,10 +343,12 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
f"disagreement between the two and should be reported."]
|
||||
return found, []
|
||||
if hass is not None:
|
||||
# SMA-2. Each unit's driver, bound to the panel's own (read-only)
|
||||
# Home Assistant: there is no Controller here.
|
||||
async def _limits(unit):
|
||||
if unit.setpoint_entity:
|
||||
unit.dev_min, unit.dev_max, _ = await hass.limits(
|
||||
unit.setpoint_entity)
|
||||
if unit.driver.configured():
|
||||
unit.dev_min, unit.dev_max = await unit.driver.bind(
|
||||
hass).limits()
|
||||
await asyncio.gather(*(_limits(unit) for unit in units))
|
||||
|
||||
dev_min = dev_max = None
|
||||
@@ -357,8 +359,8 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max,
|
||||
notes=notes))
|
||||
problems += validate_unit_ranges(units, skip_entity=flat)
|
||||
unread = [unit.setpoint_entity for unit in units
|
||||
if unit.setpoint_entity and unit.dev_min is None
|
||||
unread = [unit.driver.actuator_id() for unit in units
|
||||
if unit.driver.configured() and unit.dev_min is None
|
||||
and unit.dev_max is None]
|
||||
return problems, unread
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@ evening and api.forecast.solar is sometimes out for a poll. A kept reading
|
||||
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
|
||||
or unavailable, as a sensor that merely stopped updating is already
|
||||
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
|
||||
midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
|
||||
midnight, if it is at most `KEPT_MAX_AGE_S` old when used. A TOMORROW
|
||||
reading needs no evening for (b), at any hour (OPT-38: one Forecast.Solar
|
||||
sensor stopped at 14:54 and tomorrow vanished from 17:54 to 20:00).
|
||||
Never beyond that, and never for another day: a today figure is only today's,
|
||||
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
|
||||
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
|
||||
@@ -330,8 +332,9 @@ class Hold:
|
||||
|
||||
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
|
||||
old, or (b) used at night: from the end of the bell to local midnight,
|
||||
at most `KEPT_MAX_AGE_S` old. `kept` is true when (b) was needed,
|
||||
or `as_today` (OPT-34: a tomorrow figure standing in for today, after
|
||||
at most `KEPT_MAX_AGE_S` old; a "tomorrow" reading at any hour
|
||||
(OPT-38; `read_sum()` flags it kept only from the end of the bell).
|
||||
`kept` is true when (b) was needed, or `as_today` (OPT-34: a tomorrow figure standing in for today, after
|
||||
midnight and until the bell starts, `KEPT_MORNING_MAX_AGE_S`)."""
|
||||
rec = self._r.get((kind, entity, day0))
|
||||
if rec is None:
|
||||
@@ -347,8 +350,8 @@ class Hold:
|
||||
return (day, at, True) if ok else None
|
||||
if age > KEPT_MAX_AGE_S:
|
||||
return None
|
||||
# A tomorrow sensor is asked with day0 = the next midnight, which is
|
||||
# in the future all day, so the evening ends at that day's end.
|
||||
if kind == "tomorrow":
|
||||
return day, at, True # OPT-38: no evening needed
|
||||
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
|
||||
return (day, at, True) if evening else None
|
||||
|
||||
@@ -556,6 +559,9 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
kept = kept or (tday is not None and tkept)
|
||||
# OPT-38: before the bell ends a kept tomorrow reading is no "kept for
|
||||
# tonight": it must not replace today's line or the log.
|
||||
kept = kept or (tday is not None and tkept
|
||||
and _local_hour(now) >= BELL_TO_H)
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
|
||||
|
||||
@@ -357,10 +357,11 @@
|
||||
"no_prices": "No prices, so it follows the house.",
|
||||
"no_spread": "Prices differ too little, so it follows the house.",
|
||||
"sun_fills": "Enough sun is forecast, so it does not charge from the grid.",
|
||||
"others_cover": "Other batteries cover the expensive hours. It does not charge from the grid.",
|
||||
"tops_up": "It charges from the grid to {pct}. That covers {kwh} of expensive hours the sun will not.",
|
||||
"no_forecast": "No solar forecast, so it charges from solar only."
|
||||
},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from another battery or the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"solar": {"line": "Solar still to come today: {kwh}, we count on {count}. Updated {at}.", "uncovered": "The expensive hours need more than Leftover solar can hold. The rest, {kwh}, comes from the grid.", "kept": "Solar forecast read {at}, kept for tonight.", "no_forecast": "No solar forecast now, so Leftover solar charges from solar only.", "latched": "No solar forecast now. The charge under way goes on to its planned level.", "learning": "The add-on is learning your house's use. The plan starts after one full day."},
|
||||
"today": {
|
||||
"not_planned": "Not planned yet.",
|
||||
"no_runs": "No cheap or expensive hours left today.",
|
||||
@@ -476,6 +477,7 @@
|
||||
"stopped": "Control is stopped. Every inverter is at 0 W."
|
||||
},
|
||||
"attn": {
|
||||
"late": "Readings arrive late",
|
||||
"home_assistant": "Home Assistant is not answering",
|
||||
"controller": "Control is not running",
|
||||
"meter": "Meter reading",
|
||||
|
||||
@@ -297,10 +297,11 @@
|
||||
"no_prices": "Geen prijzen, dus ze volgt het huis.",
|
||||
"no_spread": "De prijzen verschillen te weinig, dus ze volgt het huis.",
|
||||
"sun_fills": "Er is genoeg zon voorspeld, dus ze laadt niet van het net.",
|
||||
"others_cover": "Andere batterijen dekken de dure uren. Ze laadt niet van het net.",
|
||||
"tops_up": "Ze laadt van het net tot {pct}. Dat dekt {kwh} dat de zon in dure uren niet levert.",
|
||||
"no_forecast": "Geen zonneprognose, dus ze laadt alleen met zonne-energie."
|
||||
},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van een andere batterij of het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"solar": {"line": "Nog verwachte zon vandaag: {kwh}, we rekenen op {count}. Bijgewerkt om {at}.", "uncovered": "De dure uren vragen meer dan Overschot zonne-energie kan opslaan. De rest, {kwh}, komt van het net.", "kept": "Zonneprognose van {at}, bewaard voor vannacht.", "no_forecast": "Nu geen zonneprognose, dus Overschot zonne-energie laadt alleen met zon.", "latched": "Nu geen zonneprognose. De lopende lading gaat door tot het geplande niveau.", "learning": "De add-on leert eerst het verbruik van uw huis. Het plan start na één volle dag."},
|
||||
"today": {
|
||||
"not_planned": "Nog niet gepland.",
|
||||
"no_runs": "Vandaag geen goedkope of dure uren meer.",
|
||||
@@ -397,6 +398,7 @@
|
||||
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
|
||||
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
|
||||
"attn": {
|
||||
"late": "Metingen komen te laat",
|
||||
"home_assistant": "Home Assistant antwoordt niet",
|
||||
"controller": "Regeling is niet actief",
|
||||
"meter": "Meting van de meter",
|
||||
|
||||
@@ -13,6 +13,11 @@ import os
|
||||
DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text")
|
||||
LANGS = ("en", "nl")
|
||||
_CACHE: dict = {}
|
||||
# SMA-10. The sentences that move with the roster (handback.overlay): empty for
|
||||
# every GoodWe-only roster, and then `raw()` is the file as it always was.
|
||||
_IDLE: dict = {}
|
||||
_GEN = [0]
|
||||
_MERGED: dict = {}
|
||||
|
||||
|
||||
def lang_of(value) -> str:
|
||||
@@ -21,14 +26,36 @@ def lang_of(value) -> str:
|
||||
return head if head in LANGS else "en"
|
||||
|
||||
|
||||
def raw(lang: str) -> dict:
|
||||
"""One language file as written, without the English fallback."""
|
||||
def _file(lang: str) -> dict:
|
||||
if lang not in _CACHE:
|
||||
with io.open(os.path.join(DIR, f"{lang}.json"), encoding="utf-8") as f:
|
||||
_CACHE[lang] = json.load(f)
|
||||
return _CACHE[lang]
|
||||
|
||||
|
||||
def set_idle(overlay: dict) -> None:
|
||||
"""SMA-10: the roster-built sentences, `{lang: nested dict}` (or {})."""
|
||||
_IDLE.clear()
|
||||
_IDLE.update(overlay or {})
|
||||
_MERGED.clear()
|
||||
_GEN[0] += 1
|
||||
|
||||
|
||||
def generation() -> int:
|
||||
"""Changes whenever `set_idle()` does, so a cache of words can tell."""
|
||||
return _GEN[0]
|
||||
|
||||
|
||||
def raw(lang: str) -> dict:
|
||||
"""One language file as written, without the English fallback (and with the
|
||||
roster's own sentences laid over it when a unit is handed back)."""
|
||||
if lang not in _IDLE:
|
||||
return _file(lang)
|
||||
if lang not in _MERGED:
|
||||
_MERGED[lang] = _merge(_file(lang), _IDLE[lang])
|
||||
return _MERGED[lang]
|
||||
|
||||
|
||||
def _merge(base, over):
|
||||
if not (isinstance(base, dict) and isinstance(over, dict)):
|
||||
return copy.deepcopy(over)
|
||||
|
||||
@@ -328,12 +328,14 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
|
||||
from . import dayplan, messages, pv, solarfc
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from . import drivers
|
||||
from .custom_formula import (
|
||||
EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in,
|
||||
parse_export_formula, parse_price_formula, stale_card)
|
||||
@@ -457,13 +459,28 @@ def _collect_writers(node, path: str, out: list) -> None:
|
||||
however deeply nested and under whatever container.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
# SMA-2. THE DRIVER NAMES THE ACTUATOR. An entry's `driver` (default
|
||||
# goodwe_esphome) says which key declares its writer - `setpoint_entity`
|
||||
# for GoodWe, `host` for a driver reached over a socket - and what that
|
||||
# entry's `actuator_id()` is. The walk is as structural as before: it
|
||||
# still finds an entry wherever the options keep it.
|
||||
cls = drivers.driver_class(node)
|
||||
for k, v in node.items():
|
||||
here = f"{path}.{k}" if path else str(k)
|
||||
# Every key in ACTUATOR_WRITE_KEYS declares a writer, as before
|
||||
# SMA-2 and whatever the entry's driver; PLUS the entry driver's
|
||||
# own writer key. The second is added to the first, never in place
|
||||
# of it (census parity with 23ff9d0 is graded in test_sma2_driver).
|
||||
if k in ACTUATOR_WRITE_KEYS:
|
||||
entity = str(v or "").strip()
|
||||
if entity:
|
||||
out.append((here, entity))
|
||||
continue
|
||||
if k == cls.writer_key:
|
||||
actuator = cls.actuator_id_of(node)
|
||||
if actuator:
|
||||
out.append((here, actuator))
|
||||
continue
|
||||
_collect_writers(v, here, out)
|
||||
elif isinstance(node, (list, tuple)):
|
||||
for i, v in enumerate(node):
|
||||
@@ -498,7 +515,7 @@ def actuator_writers(opts: dict) -> dict:
|
||||
_collect_writers(opts, "", found)
|
||||
census: dict = {}
|
||||
for where, entity in found:
|
||||
census.setdefault(entity.lower(), []).append(where)
|
||||
census.setdefault(drivers.census_key(entity), []).append(where)
|
||||
return census
|
||||
|
||||
|
||||
@@ -729,11 +746,16 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
else:
|
||||
seen[name] = where
|
||||
who = f"{where} ({name})" if name else where
|
||||
if not str(entry.get("setpoint_entity") or "").strip():
|
||||
# SMA-2. WHETHER THE ENTRY REACHES ITS INVERTER IS THE DRIVER'S TO SAY.
|
||||
# For goodwe_esphome that is a setpoint entity, and the sentence is the
|
||||
# one it always was. An unknown driver is refused by (h) below.
|
||||
_cls = drivers.driver_class(entry)
|
||||
if _cls is not drivers.UnavailableDriver and not _cls.actuator_id_of(entry):
|
||||
problems.append(
|
||||
f"{who} has no {S('setpoint_entity')}, so the add-on cannot "
|
||||
"control it. Set its ESPHome number entity, or remove the "
|
||||
"entry.")
|
||||
problems += _driver_problems(entry, who)
|
||||
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
|
||||
problems.append(
|
||||
f"{who} has no usable {S('max_w')}: it is "
|
||||
@@ -775,6 +797,56 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def _whole(raw, lo: int, hi: int):
|
||||
"""`raw` as an int in [lo, hi], or None. A bool or a fraction is not one."""
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
val = float(raw)
|
||||
# NaN and infinity: int() raises on them, so refuse them first.
|
||||
if not math.isfinite(val) or val != int(val):
|
||||
return None
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return int(val) if lo <= val <= hi else None
|
||||
|
||||
|
||||
def _driver_problems(entry: dict, who: str) -> list[str]:
|
||||
"""Rule 9(h) (SMA-2): the keys that choose and address an inverter driver.
|
||||
|
||||
NOT IN config.yaml's SCHEMA, so the Supervisor form and its validator never
|
||||
see them (owner decision 2026-11-07: clients see nothing new until SMA-4).
|
||||
They can still reach this rule from callers that skip the schema (the
|
||||
panel's save path, sim/inject, the checks), so they are graded here, and
|
||||
named by key because they have no label.
|
||||
|
||||
Only `goodwe_esphome` is selectable for now. `host`, `port`, `unit_id` and
|
||||
`maintenance` are accepted and checked for shape, and nothing reads them
|
||||
yet: a later driver's entry must not be refused for carrying them.
|
||||
"""
|
||||
out: list[str] = []
|
||||
raw = entry.get("driver")
|
||||
if raw not in (None, "") and str(raw).strip() not in drivers.SELECTABLE:
|
||||
out.append(
|
||||
f"{who} has `inverters.driver` {messages.shown(raw)}. Use "
|
||||
f"{', '.join(drivers.SELECTABLE)}, or leave it empty.")
|
||||
host = entry.get("host")
|
||||
if host not in (None, "") and (not isinstance(host, str)
|
||||
or re.search(r"\s", host.strip())):
|
||||
out.append(f"{who} has `inverters.host` {messages.shown(host)}, which is "
|
||||
"not an address. Use a name or an IP address.")
|
||||
for key, lo, hi in (("port", 1, 65535), ("unit_id", 0, 247)):
|
||||
val = entry.get(key)
|
||||
if val not in (None, "") and _whole(val, lo, hi) is None:
|
||||
out.append(f"{who} has `inverters.{key}` {messages.shown(val)}. Use a "
|
||||
f"whole number from {lo} to {hi}.")
|
||||
maint = entry.get("maintenance")
|
||||
if maint is not None and not isinstance(maint, bool):
|
||||
out.append(f"{who} has `inverters.maintenance` {messages.shown(maint)}. "
|
||||
"Use on or off.")
|
||||
return out
|
||||
|
||||
|
||||
def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
||||
"""Rule 2, per inverter, against each unit's OWN reported device range.
|
||||
|
||||
@@ -787,8 +859,8 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
||||
2's own failure mode restored by a config shape: the operator's stated
|
||||
ceiling is not the ceiling and nothing says which won.
|
||||
|
||||
`units` is the roster (anything with `.name`, `.setpoint_entity`, `.max_w`,
|
||||
`.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not
|
||||
`units` is the roster (anything with `.name`, `.driver` or `.setpoint_entity`,
|
||||
`.max_w`, `.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not
|
||||
guessed at, for the reason in this module's docstring: ESPHome is often not
|
||||
up when the add-on boots and (None, None) means "not read", not "zero".
|
||||
|
||||
@@ -800,7 +872,12 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
||||
problems: list[str] = []
|
||||
skip = str(skip_entity or "").strip().lower()
|
||||
for unit in units:
|
||||
entity = str(getattr(unit, "setpoint_entity", "") or "").strip()
|
||||
# SMA-2. THE DRIVER'S ACTUATOR, not a GoodWe key: a unit reached some
|
||||
# other way is graded too. A bare object with only `.setpoint_entity`
|
||||
# (the checks pass some) is read as it always was.
|
||||
drv = getattr(unit, "driver", None)
|
||||
entity = str((drv.actuator_id() if drv is not None
|
||||
else getattr(unit, "setpoint_entity", "")) or "").strip()
|
||||
if not entity or entity.lower() == skip:
|
||||
continue
|
||||
cap = float(getattr(unit, "max_w", 0.0) or 0.0)
|
||||
|
||||
@@ -442,7 +442,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"""Restart the add-on, at the operator's explicit request.
|
||||
|
||||
⚠️ THIS IS THE ONE ROUTE ON THIS SURFACE THAT COSTS ANYTHING. The
|
||||
wind-down commands 0 W on every inverter (`Controller.shutdown()`), so
|
||||
wind-down commands 0 W on every inverter, or hands a unit back when its
|
||||
driver does (`Controller.shutdown()`, SMA-10), so
|
||||
the request must carry the intent token; a reload, a retry or a stray
|
||||
POST cannot spend a control interruption. It restarts THIS ADD-ON and
|
||||
nothing else: no setpoint is written from here, and a latched failsafe
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.19.2"
|
||||
version: "0.21.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
|
||||
@@ -109,6 +109,8 @@ from app.validate import validate_startup_config
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
@@ -779,9 +781,7 @@ check("...and cycle() calls it rather than inlining the conjunction, so a "
|
||||
"mutation back to a bare `engaged_now` is red rather than silent",
|
||||
"arbitrage.guard_state(self.peak_guard)" in
|
||||
ast.get_source_segment(
|
||||
open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read(),
|
||||
_fn(open(os.path.join(HERE, "app", "main.py"),
|
||||
encoding="utf-8").read(), "cycle")))
|
||||
main_files.source(), _fn(main_files.source(), "cycle")))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -907,8 +907,8 @@ check("a ZERO headroom does not cap a DISCHARGE. §9.1: 'discharge and export "
|
||||
# above calls decide() itself - and a required keyword-only parameter that no
|
||||
# caller fills is a TypeError only on the one path a suite does not run. Same
|
||||
# pin, same reason, as `weights=` in test_packshare.py part 10.
|
||||
_S45_MAIN = open(os.path.join(HERE, "app", "main.py"), encoding="utf-8").read()
|
||||
_S45_MAIN_TREE = ast.parse(_S45_MAIN)
|
||||
_S45_MAIN = main_files.source()
|
||||
_S45_MAIN_TREE = main_files.tree(_S45_MAIN)
|
||||
_s45_calls = [n for n in ast.walk(_S45_MAIN_TREE)
|
||||
if isinstance(n, ast.Call)
|
||||
and ast.unparse(n.func).endswith("arbitrage.decide")]
|
||||
@@ -2215,8 +2215,7 @@ print()
|
||||
print("8. the background refresh - the tick must never await a fetch")
|
||||
|
||||
|
||||
_MAIN_SRC = derived(lambda: open(os.path.join(HERE, "app", "main.py"),
|
||||
encoding="utf-8").read())
|
||||
_MAIN_SRC = derived(main_files.source)
|
||||
_cycle = derived(lambda: _fn(_MAIN_SRC, "cycle"))
|
||||
_refresh = derived(lambda: _fn(_MAIN_SRC, "run_tariff_refresh"))
|
||||
_read_inputs = derived(lambda: _fn(_MAIN_SRC, "read_inputs"))
|
||||
|
||||
@@ -323,6 +323,9 @@ _cases = [
|
||||
day="sun_fills"), "sun_fills"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="tops_up"),
|
||||
"tops_up"),
|
||||
# OPT-36: a other batteries cover the hours, not the sun.
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="others_cover"),
|
||||
"others_cover"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="hold", day="no_forecast"),
|
||||
"no_forecast"),
|
||||
(dict(mode=packmode.LEFTOVER_SOLAR, role="charge", day="tops_up"),
|
||||
|
||||
@@ -1969,9 +1969,11 @@ print("9. SAFETY-40: the integrator bound is not a BUG SIGNAL, and the numbers "
|
||||
# The fixtures are chosen so the numbers the old text used DIFFER from the true
|
||||
# ones (MUTATION.md habit 4); the PRECONDITION checks pin that.
|
||||
import logging
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as _main_mod
|
||||
|
||||
_real_compute = _main_mod.compute
|
||||
_real_compute = main_files.live.compute
|
||||
|
||||
|
||||
class _LogCap(logging.Handler):
|
||||
@@ -1995,7 +1997,7 @@ def s40_run(grid, cycles, track, **over):
|
||||
cap = _LogCap()
|
||||
lg = logging.getLogger("goodwe")
|
||||
lg.addHandler(cap)
|
||||
_main_mod.compute = spy
|
||||
main_files.live.compute = spy
|
||||
|
||||
async def go():
|
||||
hass = _Hass(grid, track=track)
|
||||
@@ -2024,7 +2026,7 @@ def s40_run(grid, cycles, track, **over):
|
||||
try:
|
||||
hass, c, red = asyncio.run(go())
|
||||
finally:
|
||||
_main_mod.compute = _real_compute
|
||||
main_files.live.compute = _real_compute
|
||||
lg.removeHandler(cap)
|
||||
return ticks, hass, c, red, cap.records
|
||||
|
||||
@@ -2104,8 +2106,7 @@ check("...and its ERROR says it is a software fault and that control keeps "
|
||||
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
|
||||
and "control runs on" in x.getMessage() for x in log_o)
|
||||
and "reached 14 768 W on this project" in
|
||||
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||
"main.py"), encoding="utf-8").read())
|
||||
main_files.source())
|
||||
check("...under the over-rating title, quoting a tick's own asked and sent "
|
||||
"numbers in that order, with a red status row",
|
||||
clamp_notes(hass_o)
|
||||
|
||||
@@ -149,9 +149,15 @@ print("2. the mirrors, DISCOVERED from the tree rather than listed here")
|
||||
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
|
||||
# grades whatever it finds, so the seventh is covered on the commit that
|
||||
# introduces it. That is the property the six hand-written pins cannot have.
|
||||
MODULES = sorted(p.name for p in APP.glob("*.py"))
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
# MAINT-21: app/main.py stands for itself and the files split from it.
|
||||
MODULES = sorted(p.name for p in APP.glob("*.py")
|
||||
if p.name == "main.py" or p.name not in main_files.files())
|
||||
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
|
||||
for name in MODULES}
|
||||
TREES["main.py"] = main_files.tree()
|
||||
|
||||
MIRROR = {} # schema key -> (module, table name, value)
|
||||
TABLES = []
|
||||
|
||||
@@ -1272,6 +1272,9 @@ check("the operator's START press is the only way out, and it clears the "
|
||||
|
||||
print()
|
||||
print("8b. SAFETY-34: a Core-only restart - same add-on process, HA's memory wiped")
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as _main_mod # noqa: E402
|
||||
|
||||
|
||||
@@ -1283,8 +1286,8 @@ async def _core_restart():
|
||||
|
||||
|
||||
_c = asyncio.run(_core_restart())
|
||||
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||
_main_mod._mono = lambda: _clock[0]
|
||||
_real_mono, _clock = main_files.live._mono, [main_files.live._mono()]
|
||||
main_files.live._mono = lambda: _clock[0]
|
||||
try:
|
||||
_c.hass = _DeliveryHass(b_soc=50.0)
|
||||
asyncio.run(_c.check_not_delivering())
|
||||
@@ -1295,7 +1298,7 @@ try:
|
||||
_late = [x for x in _c.hass.calls
|
||||
if "not_delivering" in str(x[2].get("notification_id", ""))]
|
||||
finally:
|
||||
_main_mod._mono = _real_mono
|
||||
main_files.live._mono = _real_mono
|
||||
check("SAFETY-34: the latch tripped in this process",
|
||||
_c.inverters[1].delivery_wd.tripped)
|
||||
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise",
|
||||
|
||||
@@ -790,7 +790,7 @@ print(f" BOUNDS['soc'] subscripted in CODE by: {_uses_soc or 'nothing'}")
|
||||
check("⚠️ `wired=True` MEANS SOMETHING CONSUMES IT. A policy table nobody "
|
||||
"checks is a comment; this is the row `batt` claims and the module that "
|
||||
"claims it",
|
||||
BOUNDS["batt"].wired is True and _uses_batt == ["inverter.py"])
|
||||
BOUNDS["batt"].wired is True and _uses_batt == ["drivers.py"])
|
||||
check("⚠️ AND `wired=False` MEANS NOTHING DOES. SoC is TEL-08's and it is "
|
||||
"STATED here rather than wired, because its obligation term is energy "
|
||||
"through the pack and the constant that needs has never been measured on "
|
||||
|
||||
@@ -75,6 +75,9 @@ from pathlib import Path
|
||||
|
||||
from named_absence import first, given, nth, present
|
||||
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as app_main
|
||||
from app.inverter import LINK_STALE_S, UNIT_A, Inverter, build_roster
|
||||
from app.main import WRITE_BUDGET_S, Controller
|
||||
@@ -88,8 +91,10 @@ from app.arbiter import P_LOOP, P_SAFETY, Claim, resolve
|
||||
|
||||
fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
MAIN_SRC = io.open(HERE / "app" / "main.py", encoding="utf-8").read()
|
||||
MAIN_AST = ast.parse(MAIN_SRC)
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
MAIN_SRC = main_files.source()
|
||||
MAIN_AST = main_files.tree(MAIN_SRC)
|
||||
CTRL = [n for n in MAIN_AST.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "Controller"][0]
|
||||
|
||||
@@ -1097,8 +1102,8 @@ check("...and it is below hass.py's own 10 s HTTP timeout, so the budget is "
|
||||
# is healthy. The budget is shortened for the run - the shipped value is graded
|
||||
# above - because a 4 s wait per case is 8 s of suite time to measure something
|
||||
# that is already a ratio.
|
||||
_REAL_BUDGET = app_main.WRITE_BUDGET_S
|
||||
app_main.WRITE_BUDGET_S = 0.30
|
||||
_REAL_BUDGET = main_files.live.WRITE_BUDGET_S
|
||||
main_files.live.WRITE_BUDGET_S = 0.30
|
||||
|
||||
|
||||
async def _hung_heartbeat():
|
||||
@@ -1124,18 +1129,18 @@ async def _hung_heartbeat():
|
||||
_c6, _h6, _elapsed, _t0 = run(_hung_heartbeat())
|
||||
_b_at = [t - _t0 for e, _v, t in _h6.write_at if e == "number.sp_b"]
|
||||
print(f" A hangs forever, B is healthy: the pair took {_elapsed:.2f} s "
|
||||
f"(budget {app_main.WRITE_BUDGET_S:.2f} s); B's write reached the wire "
|
||||
f"(budget {main_files.live.WRITE_BUDGET_S:.2f} s); B's write reached the wire "
|
||||
f"{(_b_at[0] if _b_at else float('nan')):.3f} s in; wire saw "
|
||||
f"{_h6.writes}")
|
||||
check("⚠️ AC 6. B'S WRITE REACHED THE WIRE WHILE A WAS STILL HANGING. That is "
|
||||
"the whole criterion: a hung write to A cannot consume B's layer-1 "
|
||||
"budget, because B's write was never behind A's in a queue",
|
||||
_h6.writes == [("number.sp_b", 700.0)]
|
||||
and bool(_b_at) and _b_at[0] < app_main.WRITE_BUDGET_S / 2.0)
|
||||
and bool(_b_at) and _b_at[0] < main_files.live.WRITE_BUDGET_S / 2.0)
|
||||
check("...and the pair returned in about ONE budget rather than in two, which "
|
||||
"is what says the writes were concurrent and not merely bounded. Two "
|
||||
"bounded writes in series is still a sibling waiting for a corpse",
|
||||
_elapsed < app_main.WRITE_BUDGET_S * 1.9)
|
||||
_elapsed < main_files.live.WRITE_BUDGET_S * 1.9)
|
||||
check("...and the hung inverter is recorded as having FAILED its write, so "
|
||||
"`last_write` is not advanced and the next tick re-issues it by itself",
|
||||
_c6.inverters[0].last_write is None
|
||||
@@ -1176,7 +1181,7 @@ check("⚠️ AND THE DEFECT IS REPRODUCED BESIDE THE FIX. Written in series, B'
|
||||
"every inverter behind the hung one, and with the unbounded write this "
|
||||
"card replaces it was the HTTP stack's timeout - which is what put a "
|
||||
"healthy sibling within reach of layer 1's veto",
|
||||
_ser_b_at >= app_main.WRITE_BUDGET_S)
|
||||
_ser_b_at >= main_files.live.WRITE_BUDGET_S)
|
||||
|
||||
# AND THE DEADLINE ITSELF IS PER UNIT - graded through the shipped method, for
|
||||
# the reason the experiment above is: a `due` list computed in this file is a
|
||||
@@ -1219,7 +1224,7 @@ check("...and the heartbeat re-emits each unit's OWN share rather than the "
|
||||
# heartbeat is not the only place a hung inverter can hold the loop: every cycle
|
||||
# writes every unit's share, and in series that is the same defect at the cycle
|
||||
# rate rather than at the heartbeat rate.
|
||||
app_main.WRITE_BUDGET_S = 0.30
|
||||
main_files.live.WRITE_BUDGET_S = 0.30
|
||||
|
||||
|
||||
async def _hung_cycle():
|
||||
@@ -1248,17 +1253,17 @@ check("⚠️ AND A REAL CYCLE DELIVERS B'S SHARE WHILE A HANGS. This is the sam
|
||||
"`cycle()` go out together, so a hung inverter costs the loop one budget "
|
||||
"and costs its siblings nothing",
|
||||
_h6c.writes and _h6c.writes[0][0] == "number.sp_b"
|
||||
and bool(_cyc_b) and _cyc_b[0] < app_main.WRITE_BUDGET_S / 2.0
|
||||
and _cyc_elapsed < app_main.WRITE_BUDGET_S * 1.9)
|
||||
and bool(_cyc_b) and _cyc_b[0] < main_files.live.WRITE_BUDGET_S / 2.0
|
||||
and _cyc_elapsed < main_files.live.WRITE_BUDGET_S * 1.9)
|
||||
check("...and the share B was told is the allocator's, not the site target - "
|
||||
"the hung sibling does not turn the cycle into a broadcast",
|
||||
_h6c.writes[0][1] == float(_c6c.allocation.shares["inverter-b"])
|
||||
and _h6c.writes[0][1] != _c6c.target)
|
||||
|
||||
app_main.WRITE_BUDGET_S = _REAL_BUDGET
|
||||
main_files.live.WRITE_BUDGET_S = _REAL_BUDGET
|
||||
check("...and the shipped budget is restored after the experiments, so nothing "
|
||||
"below runs against a 0.3 s one",
|
||||
app_main.WRITE_BUDGET_S == WRITE_BUDGET_S)
|
||||
main_files.live.WRITE_BUDGET_S == WRITE_BUDGET_S)
|
||||
|
||||
|
||||
# ⚠️ AND THE OPERATOR'S STOP HAS TO MOVE THE SAME TWO THINGS. This is a hazard
|
||||
|
||||
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
from app import maintenance as MT # noqa: E402
|
||||
from app.maintenance import ( # noqa: E402
|
||||
@@ -367,7 +368,7 @@ def amain_zone(zone_body, env_tz):
|
||||
|
||||
M.load_options = lambda: {"setpoint_entity": "number.sp"}
|
||||
M.HomeAssistant = lambda session, **kw: _Hass()
|
||||
M.validate_startup_config = lambda *a, **k: []
|
||||
main_files.live.validate_startup_config = lambda *a, **k: []
|
||||
M.mode_announcement = lambda opts, store: ((), ())
|
||||
M.Controller = _controller
|
||||
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
|
||||
|
||||
@@ -520,9 +520,11 @@ check("a fixed tariff set up counts as a gate: outside its cheap hours a pack "
|
||||
"that is not charging is closed, inside them it is open",
|
||||
c._price_gated is True
|
||||
and c._charge_gate_open(c.inverters[0]) is c.cheap)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as main_mod # noqa: E402
|
||||
_real_gated, _calls = main_mod.static_gated, []
|
||||
main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
|
||||
_real_gated, _calls = main_files.live.static_gated, []
|
||||
main_files.live.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
|
||||
try:
|
||||
c._gated_key = None
|
||||
read(c)
|
||||
@@ -530,7 +532,7 @@ try:
|
||||
read(c)
|
||||
c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1))
|
||||
finally:
|
||||
main_mod.static_gated = _real_gated
|
||||
main_files.live.static_gated = _real_gated
|
||||
check("...worked out once an hour, not on every tick (3 reads in one hour = 1 "
|
||||
f"build; a new hour = 1 more): {len(_calls)} builds",
|
||||
len(_calls) == 2 and c._price_gated is True)
|
||||
|
||||
@@ -241,6 +241,27 @@ check("the output-clamp alarm quotes asked before sent",
|
||||
# ============================================================================== #
|
||||
print("\n2. no notification is worded outside app/messages.py")
|
||||
APP = HERE / "app"
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
|
||||
class _AppFile:
|
||||
"""An app/*.py file. `main.py` stands for itself and the files split from
|
||||
it (MAINT-21), so every scan below still sees the Controller whole."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.name, self.stem, self._path = path.name, path.stem, path
|
||||
|
||||
def read_text(self, encoding="utf-8"):
|
||||
if self.name == "main.py":
|
||||
return main_files.source()
|
||||
return self._path.read_text(encoding=encoding)
|
||||
|
||||
|
||||
def _app_py():
|
||||
split = set(main_files.files()) - {"main.py"}
|
||||
return [_AppFile(f) for f in sorted(APP.glob("*.py"))
|
||||
if f.name not in split]
|
||||
|
||||
|
||||
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
|
||||
@@ -318,7 +339,7 @@ def notification_calls(tree):
|
||||
|
||||
|
||||
bad_calls, n_calls = [], 0
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for f in _app_py():
|
||||
for call in notification_calls(ast.parse(f.read_text(encoding="utf-8"))):
|
||||
n_calls += 1
|
||||
payload = call.args[2] if len(call.args) > 2 else None
|
||||
@@ -368,13 +389,13 @@ def _module_constants(tree):
|
||||
|
||||
|
||||
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
|
||||
for f in sorted(APP.glob("*.py"))}
|
||||
for f in _app_py()}
|
||||
|
||||
|
||||
def collect():
|
||||
"""(kind, where, text) for every user-facing literal of a ratcheted kind."""
|
||||
out = []
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for f in _app_py():
|
||||
if f.name == "messages.py":
|
||||
continue
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
@@ -543,7 +564,7 @@ for key, text in messages.hint_samples():
|
||||
p = row_problems(messages.GREEN, text)
|
||||
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
|
||||
_calls, _bare = 0, []
|
||||
for f in sorted(APP.glob("*.py")):
|
||||
for f in _app_py():
|
||||
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
|
||||
if (isinstance(node, ast.Call)
|
||||
and ast.unparse(node.func).endswith("log_event")):
|
||||
@@ -793,6 +814,7 @@ from datetime import datetime as _dt, timedelta as _td, timezone as _tz # noqa:
|
||||
|
||||
from app import clamp as _clamp, freshness as _fresh, peakguard as _pg # noqa: E402
|
||||
from app import store as _store # noqa: E402
|
||||
from app import handback # noqa: E402
|
||||
from app.circulation import CirculationConfig, CirculationWatchdog # noqa: E402
|
||||
from app.control import Tuning, compute # noqa: E402
|
||||
from app.delivery import (DeliveryConfig, DeliveryWatchdog, UnitDelivery, # noqa: E402
|
||||
@@ -1011,7 +1033,7 @@ def _static(spec):
|
||||
the named file (placeholders count as one word, as in part 3)."""
|
||||
fname, kind, name = spec
|
||||
out = []
|
||||
for f in ([APP / fname] if fname != "*" else sorted(APP.glob("*.py"))):
|
||||
for f in ([_AppFile(APP / fname)] if fname != "*" else _app_py()):
|
||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if kind == "call" and isinstance(node, ast.Call) and node.args \
|
||||
@@ -1058,6 +1080,12 @@ TEXT = {
|
||||
"main.py:messages.reason_code(inv.batt_fresh.reason)": _coded(_stale_texts),
|
||||
"main.py:rep.explain()": _migration_texts,
|
||||
"store.py:rep.explain()": _migration_texts,
|
||||
# SMA-10: the control-task CRITICAL line's state, for a GoodWe-only roster
|
||||
# (the sentence it always was) and a mixed one.
|
||||
"main.py:handback.task_died_state(handback.current())": lambda: [
|
||||
handback.task_died_state(()),
|
||||
handback.task_died_state((handback.Unit("inverter-a"),
|
||||
handback.Unit("inverter-b", True)))],
|
||||
"main.py:plan.words": _tariff_texts,
|
||||
# OPT-11 P2: why an MQTT command was ignored.
|
||||
"main.py:why": lambda: list(messages.PACK_REFUSED.values()),
|
||||
@@ -1194,7 +1222,7 @@ def scan_log_lines(files, text=None, data=None, long_ok=None, long_seen=None):
|
||||
|
||||
|
||||
_FILES = [(f.name, ast.parse(f.read_text(encoding="utf-8")))
|
||||
for f in sorted(APP.glob("*.py"))]
|
||||
for f in _app_py()]
|
||||
_long_seen = set()
|
||||
_unknown, _stale, _bad = scan_log_lines(_FILES, long_seen=_long_seen)
|
||||
for k, sample, p in _bad:
|
||||
@@ -1408,7 +1436,7 @@ def _captured(logger_name, fn):
|
||||
|
||||
def _level_of(fname, words_in_line):
|
||||
"""The level of the log call in `fname` whose literal holds the words."""
|
||||
tree = ast.parse((APP / fname).read_text(encoding="utf-8"))
|
||||
tree = ast.parse(_AppFile(APP / fname).read_text(encoding="utf-8"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Call) and node.args \
|
||||
and re.search(r"_LOG\.\w+$", ast.unparse(node.func)):
|
||||
@@ -1554,7 +1582,7 @@ check(f"a socket Home Assistant closed is a plain ConnectionResetError, which "
|
||||
# words. The keys are found in the code: each `Claim.set(source, ..., reason)`
|
||||
# through main.why_key(), plus main.py's own refusal to split ("unsplit").
|
||||
_SOURCES = {}
|
||||
for _f in sorted(APP.glob("*.py")):
|
||||
for _f in _app_py():
|
||||
_tree = ast.parse(_f.read_text(encoding="utf-8"))
|
||||
_consts = _module_constants(_tree)
|
||||
for _n in ast.walk(_tree):
|
||||
@@ -1774,7 +1802,7 @@ check(f"...and in the action log, which an installer reads at the default "
|
||||
and any(e.endswith(messages.NO_REASON_SAVED) for e in _rs_ev))
|
||||
|
||||
# UX-11 item 4: the action log's clamp count and the shadow-off entry.
|
||||
_main_src = (APP / "main.py").read_text(encoding="utf-8")
|
||||
_main_src = _AppFile(APP / "main.py").read_text(encoding="utf-8")
|
||||
check("the action log counts 'engagements' with a real plural and the "
|
||||
"shadow-off entry is plain, not 'engagement(s)' or 'SHADOW MODE OFF -'",
|
||||
"engagement(s)" not in _main_src and "SHADOW MODE OFF" not in _main_src
|
||||
|
||||
@@ -32,6 +32,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import homeview, messages, packmode # noqa: E402
|
||||
from app.control import MODE_BOUND # noqa: E402
|
||||
@@ -62,24 +63,24 @@ def store_with(**modes):
|
||||
|
||||
def spy_claims():
|
||||
"""Record every claim list main.py's own `resolve` is handed."""
|
||||
seen, real = [], app_main.resolve
|
||||
seen, real = [], main_files.live.resolve
|
||||
|
||||
def _spy(claims, **kw):
|
||||
seen.append(list(claims))
|
||||
return real(claims, **kw)
|
||||
app_main.resolve = _spy
|
||||
return seen, lambda: setattr(app_main, "resolve", real)
|
||||
main_files.live.resolve = _spy
|
||||
return seen, lambda: setattr(main_files.live, "resolve", real)
|
||||
|
||||
|
||||
def spy_bounds():
|
||||
"""Record the `bound` main.py hands `compute()` on every cycle."""
|
||||
seen, real = [], app_main.compute
|
||||
seen, real = [], main_files.live.compute
|
||||
|
||||
def _spy(*a, **kw):
|
||||
seen.append(kw.get("bound", "absent"))
|
||||
return real(*a, **kw)
|
||||
app_main.compute = _spy
|
||||
return seen, lambda: setattr(app_main, "compute", real)
|
||||
main_files.live.compute = _spy
|
||||
return seen, lambda: setattr(main_files.live, "compute", real)
|
||||
|
||||
|
||||
def flat(t):
|
||||
|
||||
@@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, dayplan, homeview, modetrade, packmode # noqa
|
||||
from app import priceruns, solarfc, validate # noqa: E402
|
||||
@@ -777,7 +778,7 @@ check("battery modes off: the forecast sensor is never read", READS[0] == 0)
|
||||
print("7. the Batteries screen and the Simple view")
|
||||
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0},
|
||||
hours=0.6)
|
||||
rp.m.datetime = datetime
|
||||
main_files.live.datetime = datetime
|
||||
sb = homeview.solar_block(c, c.solar_fc.at)
|
||||
check("the forecast line names the forecast, what is counted and when",
|
||||
sb["line"].startswith("Solar still to come today:")
|
||||
@@ -812,14 +813,14 @@ check("a lost forecast says so: no forecast, Leftover solar solar only",
|
||||
c._charge_latch = latch
|
||||
# the latch ends at its end time; this replay ran in September, so ask at a
|
||||
# moment inside it
|
||||
_real_dt = rp.m.datetime
|
||||
rp.m.datetime = type("_DT", (datetime,), {"now": classmethod(
|
||||
_real_dt = main_files.live.datetime
|
||||
main_files.live.datetime = type("_DT", (datetime,), {"now": classmethod(
|
||||
lambda cls, tz=None: latch[0] - timedelta(hours=1))})
|
||||
check("OPT-34: with a charge under way (B charged in this run), the level "
|
||||
"stays: plan_targets() gives it (the line: test_opt34.py)",
|
||||
latch is not None and c.plan_targets() is not None
|
||||
and B in c.plan_targets() and c.plan_targets()[B] == c._plan_targets[B])
|
||||
rp.m.datetime = _real_dt
|
||||
main_files.live.datetime = _real_dt
|
||||
check("the Why line wins over 'saves its power' only while holding",
|
||||
homeview.battery_why(modes_on=True, mode="leftover_solar", off=False,
|
||||
reading=True, phase="idle", shadow=False,
|
||||
|
||||
@@ -254,9 +254,16 @@ check("B on Solar first never gets a level (never charges from the grid)",
|
||||
more = owner_plan(6.0, [pk(A, packmode.LEFTOVER_SOLAR),
|
||||
pk(B, packmode.CHEAP_HOURS, cap=CAP_B,
|
||||
rating=4600.0)])
|
||||
check("B on Cheap hours fills up and is no part of A's share: A's level is "
|
||||
"the same, B is not in the plan",
|
||||
more.targets == res[6.0].targets and B not in more.targets
|
||||
# OPT-36 (on purpose, was: "B on Cheap hours ... A's level is the same"): a
|
||||
# Cheap hours battery still fills up and is still not in the plan, but what
|
||||
# its own cheap run will give in the expensive hours is taken off the
|
||||
# shortfall before Leftover solar shares it. Here B (10 %, 4.6 kW, the run
|
||||
# 22:00-07:00) reaches the ceiling and covers all of the 6 kWh case.
|
||||
check("B on Cheap hours fills up and gives first (OPT-36): A buys nothing, "
|
||||
"B is not in the plan",
|
||||
more.need_kwh[A] == 0.0 and more.targets[A] == FLOOR
|
||||
and more.cheap_kwh > more.short_kwh - 1e-6
|
||||
and more.uncovered_kwh == 0.0 and B not in more.targets
|
||||
and modetrade.charge_to(pk(B, packmode.CHEAP_HOURS, cap=CAP_B), CEIL,
|
||||
more.targets) == CEIL)
|
||||
# The profile is net of the sun the house used, so an hour with sun is read
|
||||
@@ -341,8 +348,8 @@ print(f" 4 kW house, 2 kWh: shortfall {sh4:.1f} kWh; both hold "
|
||||
f"levels {p4.targets}")
|
||||
check("more than all of them hold: both go to the ceiling, never above",
|
||||
p4.targets[A] == CEIL and p4.targets[B] == CEIL)
|
||||
check("...and the rest is said, not bought: another battery or the grid "
|
||||
"carries it (`uncovered_kwh` = shortfall less what they cover)",
|
||||
check("...and the rest is said, not bought: the grid carries it (OPT-36: "
|
||||
"the other batteries are counted before) (`uncovered_kwh` = shortfall less what they cover)",
|
||||
abs(p4.uncovered_kwh - (sh4 - rooms * EFF)) < 0.01
|
||||
and abs(sum(p4.cover_kwh.values()) - rooms * EFF) < 0.01)
|
||||
# The pass-through itself: a battery that reaches its ceiling passes the rest.
|
||||
@@ -439,6 +446,10 @@ print("5. the screens say what control does (EN and NL)")
|
||||
now = DAY0 + timedelta(minutes=18, seconds=5)
|
||||
c = S.make("dynamic", now, soc=(20.0, 20.0))
|
||||
c.solar_fc = S.forecast(now, 3.0, 3.0)
|
||||
# OPT-36: B (Cheap hours) now gives first, so A buys only what B's cheap run
|
||||
# cannot reach. A 2.5 kW house leaves B 0.95 kW of peak room in the 3.5 h run:
|
||||
# B gets part of the way, A buys the rest.
|
||||
c.house_profile = profile_of({h: 2500.0 for h in range(24)})
|
||||
c._runs_key = None
|
||||
c._mode_roles(now, None)
|
||||
tg = c.plan_targets()
|
||||
@@ -494,8 +505,9 @@ for lang in ("en", "nl"):
|
||||
check(f"{lang}: '{key}' is at most 2 sentences of at most 12 words",
|
||||
len(sentences) <= 2 and all(
|
||||
len(s.split()) <= 12 for s in sentences))
|
||||
# More than all hold: the Batteries screen says another battery or the grid
|
||||
# carries the rest (here B is on Cheap hours: "the grid" alone is not true).
|
||||
# More than all hold: the Batteries screen says the grid carries the rest.
|
||||
# OPT-36 (on purpose, was "another battery or the grid"): B on Cheap hours is
|
||||
# counted before, so what is left comes from the grid.
|
||||
c2 = S.make("dynamic", now, soc=(20.0, 20.0))
|
||||
c2.solar_fc = S.forecast(now, 1.0, 1.0)
|
||||
c2.house_profile = profile_of({h: 3500.0 for h in range(24)})
|
||||
@@ -508,14 +520,16 @@ print(f" a 3.5 kW house: shortfall {dp2.short_kwh:.1f} kWh, unheld "
|
||||
check("the plan finds more than the battery holds", dp2.uncovered_kwh > 1.0
|
||||
and c2.plan_targets()[A] == 95.0)
|
||||
check("EN: the line under the Batteries cards says Leftover solar cannot "
|
||||
"hold it all and the rest comes from another battery or the grid",
|
||||
"hold it all and the rest comes from the grid",
|
||||
"more than Leftover solar can hold" in sol["line"]
|
||||
and "another battery or the grid" in sol["line"]
|
||||
and "comes from the grid." in sol["line"]
|
||||
and "another battery" not in sol["line"]
|
||||
and homeview._kwh(dp2.uncovered_kwh) in sol["line"])
|
||||
with homeview.in_lang("nl"):
|
||||
sol_nl = homeview.solar_block(c2, now)
|
||||
check("NL: and in Dutch", "meer dan Overschot zonne-energie kan opslaan"
|
||||
in sol_nl["line"] and "andere batterij of het net" in sol_nl["line"]
|
||||
in sol_nl["line"] and "komt van het net." in sol_nl["line"]
|
||||
and "andere batterij" not in sol_nl["line"]
|
||||
and "{" not in sol_nl["line"])
|
||||
check("a covered plan has no such sentence",
|
||||
"can hold" not in homeview.solar_block(c, now)["line"])
|
||||
|
||||
@@ -46,6 +46,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -144,8 +145,9 @@ rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
|
||||
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
|
||||
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
|
||||
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
|
||||
"no tomorrow, nothing kept",
|
||||
fd is not None and fd.tomorrow is None and not fd.kept)
|
||||
"the 09:05 figure covers it, kept (OPT-38: at any hour)",
|
||||
fd is not None and fd.tomorrow is not None
|
||||
and fd.tomorrow.kwh == 12.0 and not fd.kept)
|
||||
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
|
||||
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
|
||||
fn is not None and fn.tomorrow is not None
|
||||
@@ -403,14 +405,14 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
||||
|
||||
async def go():
|
||||
c, hass = build(store)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
task = None
|
||||
try:
|
||||
for t_pre in pre:
|
||||
@@ -476,7 +478,7 @@ def drive27(o, sensors, *, pre, start, minutes=20, restart=None,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows, pre_rows
|
||||
|
||||
@@ -26,7 +26,9 @@ DECISIONS note and the dev report.
|
||||
solar + B Cheap hours, both 10 % at the start of the night, the night rate
|
||||
22:00-07:00, tomorrow 6 kWh, a 2.5 kW peak limit so the charge takes hours),
|
||||
sensors silent from 17:05, whole nights at 5 s per cycle (run in parallel
|
||||
processes, about 2.5 minutes each):
|
||||
processes, about 2.5 minutes each). OPT-36: B is Off in the nights that
|
||||
test A's level (a Cheap hours B now counts before A buys and would cover
|
||||
these hours); the `_b` nights keep it on for B's checks:
|
||||
a. silent from 17:05: both batteries end the night where a fresh figure
|
||||
ends them (the base left A at 57 % against 87 %), and the screens agree;
|
||||
b. restarts at 01:00 and 04:00 change nothing;
|
||||
@@ -59,6 +61,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -184,8 +187,9 @@ check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
|
||||
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
|
||||
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
|
||||
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
|
||||
"no tomorrow (the 3rd's figure is not the 5th's)",
|
||||
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
|
||||
"tomorrow is the 03:50 figure for the 5th (9.0, not flagged kept before 20:00: OPT-38), never "
|
||||
"the 3rd's 12.0", fy2 is not None and fy2.today.kwh == 5.0
|
||||
and fy2.tomorrow is not None and fy2.tomorrow.kwh == 9.0 and not fy2.kept)
|
||||
|
||||
# Restart: the saved hold keeps the figure.
|
||||
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
|
||||
@@ -267,6 +271,10 @@ PROFILE_LATE = ('{"periods": ['
|
||||
' "to": "22:00", "eur_per_kwh": 0.34}]}')
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
# OPT-36: a Cheap hours B now gives first, and with these house shapes it would
|
||||
# cover the dear hours alone, so A would not charge. The nights that test A's
|
||||
# level have B Off (`b_off`, the default; the house is as written); `_b`
|
||||
# nights keep B on Cheap hours for B's own checks.
|
||||
LAST = at(3, 17, 5)
|
||||
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
|
||||
+ [at(3, 21, 55)]
|
||||
@@ -301,7 +309,10 @@ def run_night(spec):
|
||||
soc0 = spec.get("soc", {"a": 10.0, "b": 10.0})
|
||||
house_w = spec.get("house_w", 700.0)
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
choice = dict(CHOICE)
|
||||
if spec.get("b_off", True):
|
||||
choice["b"] = (False, "cheap_hours", False)
|
||||
for u, (use, mode, sell) in choice.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
@@ -338,16 +349,19 @@ def run_night(spec):
|
||||
|
||||
async def go():
|
||||
cur = store
|
||||
c = build(cur)
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
try:
|
||||
# TEST-37: built under the scenario's clock, not the real one:
|
||||
# Controller.__init__ checks a stored latch's end against now().
|
||||
clock[0] = start
|
||||
c = build(cur)
|
||||
for t_pre in EVENING:
|
||||
clock[0] = t_pre
|
||||
await c._read_forecast()
|
||||
@@ -407,7 +421,7 @@ def run_night(spec):
|
||||
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
|
||||
out["latch"] = getattr(c, "_charge_latch", None) is not None
|
||||
finally:
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
return out
|
||||
|
||||
return asyncio.run(go())
|
||||
@@ -419,15 +433,24 @@ SCEN = {
|
||||
marks=[at(4, 4, 30)]),
|
||||
"silent": dict(start=NIGHT, hours=9,
|
||||
marks=[at(4, 1, 0), at(4, 4, 30), at(4, 6, 30)]),
|
||||
# OPT-36: B on Cheap hours, for B's checks
|
||||
"fresh_b": dict(start=NIGHT, hours=9, silent=False, b_off=False),
|
||||
"silent_b": dict(start=NIGHT, hours=9, b_off=False),
|
||||
# OPT-36: alone, A is full by 02:00 from 22:00; from midnight its charge
|
||||
# runs through 03:00
|
||||
"midnight": dict(start=at(4, 0, 0), hours=7),
|
||||
"restart": dict(start=NIGHT, hours=9,
|
||||
restarts=[at(4, 1, 0), at(4, 4, 0)],
|
||||
marks=[at(4, 1, 30), at(4, 4, 30)]),
|
||||
# c. the charge cannot start before 03:00 (the price dips at 04:00)
|
||||
"late": dict(start=at(4, 2, 30), hours=4.5, late=True, house_w=500.0,
|
||||
soc={"a": 40.0, "b": 70.0}, marks=[at(4, 3, 30)]),
|
||||
# OPT-36: the fresh sensors read the kept figure's 6.0 kWh for the 4th.
|
||||
# Alone, A reaches its level, so the runs must plan on the same sun (with
|
||||
# B charging too, A reached neither level by 07:00 and they matched).
|
||||
"late_fresh": dict(start=at(4, 2, 30), hours=4.5, late=True,
|
||||
house_w=500.0, soc={"a": 40.0, "b": 70.0},
|
||||
silent=False),
|
||||
fresh_from=at(4, 2, 30), fresh=(6.0, 6.0)),
|
||||
# d. a fresh, darker figure at 02:00 during the charge (a lighter house
|
||||
# use, so the level has room to rise)
|
||||
"dark": dict(start=at(4, 1, 30), hours=2.0, soc={"a": 45.0, "b": 45.0},
|
||||
@@ -446,8 +469,10 @@ SCEN = {
|
||||
house_w=500.0, fresh_from=at(4, 2, 40),
|
||||
fresh=(30.0, 30.0), marks=[at(4, 2, 44)]),
|
||||
# d. the last hour, after the kept figure ended at 06:00
|
||||
# (OPT-36: A at 60 %, not 70 %: alone it charges twice as fast and from
|
||||
# 70 % is full at 06:38, before the 06:40 mark)
|
||||
"tail": dict(start=at(4, 5, 40), hours=80 / 60.0,
|
||||
soc={"a": 70.0, "b": 70.0}, restarts=[at(4, 6, 30)],
|
||||
soc={"a": 60.0, "b": 70.0}, restarts=[at(4, 6, 30)],
|
||||
marks=[at(4, 5, 50), at(4, 6, 10), at(4, 6, 40)]),
|
||||
# a fresh, brighter figure at 03:10 lowers A's level below its SoC (A
|
||||
# stops, B goes on), then the sensors go quiet: the forecast ends at 06:10
|
||||
@@ -461,12 +486,12 @@ SCEN = {
|
||||
latch={"until": at(3, 7, 15).isoformat(),
|
||||
"born": at(2, 23, 0).isoformat(),
|
||||
"levels": {A: 80.0, B: 80.0}},
|
||||
marks=[at(3, 22, 20)]),
|
||||
marks=[at(3, 22, 20)], b_off=False),
|
||||
"stale_live": dict(start=at(3, 22, 0), hours=0.5, gone=True,
|
||||
latch={"until": at(4, 7, 15).isoformat(),
|
||||
"born": at(3, 22, 0).isoformat(),
|
||||
"levels": {A: 80.0, B: 80.0}},
|
||||
marks=[at(3, 22, 20)]),
|
||||
marks=[at(3, 22, 20)], b_off=False),
|
||||
# the same, B already full and a 1.5 kW limit: the run is sized at the
|
||||
# battery's 2.5 kW rating and ends at 06:33, before A is there, so the
|
||||
# next slice is planned after 06:00 and is only A's
|
||||
@@ -506,17 +531,20 @@ for k in R:
|
||||
fa, si, rr = R["fresh"], R["silent"], R["restart"]
|
||||
|
||||
# a. silent from 17:05
|
||||
fb_, sb_ = R["fresh_b"], R["silent_b"]
|
||||
check("a fresh figure all night: A ends at its planned level (87 %), B "
|
||||
"full", abs(fa["soc"][0] - 86.9) < 0.5 and fa["soc"][1] > 94.5)
|
||||
"(Cheap hours, on in the `_b` night) full",
|
||||
abs(fa["soc"][0] - 86.9) < 0.5 and fb_["soc"][1] > 94.5)
|
||||
check("sensors silent from 17:05: A ends where the fresh run ends it "
|
||||
f"({si['soc'][0]:.1f} against {fa['soc'][0]:.1f} %)",
|
||||
abs(si["soc"][0] - fa["soc"][0]) < 0.3)
|
||||
check("... and so does B (Cheap hours; the wide no-forecast gap does not "
|
||||
f"stop its slices): {si['soc'][1]:.1f} against {fa['soc'][1]:.1f} %",
|
||||
abs(si["soc"][1] - fa["soc"][1]) < 0.3)
|
||||
f"stop its slices): {sb_['soc'][1]:.1f} against {fb_['soc'][1]:.1f} %",
|
||||
abs(sb_["soc"][1] - fb_["soc"][1]) < 0.3)
|
||||
mn = R["midnight"]
|
||||
check("A charges through 03:00 without a break (no step at 03:00)",
|
||||
si["soc_at"]["03:30"][0] > si["soc_at"]["03:00"][0] + 4.0
|
||||
and si["soc_at"]["03:00"][0] > si["soc_at"]["02:55"][0] + 0.3)
|
||||
mn["soc_at"]["03:30"][0] > mn["soc_at"]["03:00"][0] + 4.0
|
||||
and mn["soc_at"]["03:00"][0] > mn["soc_at"]["02:55"][0] + 0.3)
|
||||
m1, m4 = si["marks"]["01:00"], si["marks"]["04:30"]
|
||||
check("01:00 and 04:30: the kept figure is in use, with a day plan, and the "
|
||||
"line says so (EN)",
|
||||
@@ -601,12 +629,12 @@ check("a fresh, brighter figure lowers A's level below its SoC during the "
|
||||
and lo["soc_at"]["03:30"][0] < lo["soc_at"]["03:20"][0] + 0.5)
|
||||
check("the sensors go quiet, the forecast ends (06:30): the latch holds the "
|
||||
"LOWERED level, so A does not charge back up to the old one "
|
||||
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %), "
|
||||
"while B goes on",
|
||||
f"({lo['soc_at']['03:30'][0]:.1f} -> {lo['soc_at']['06:55'][0]:.1f} %)"
|
||||
" (OPT-36: B is Off here; B's slices with no forecast are the `_b` "
|
||||
"night's check above)",
|
||||
l2["fc_none"] and l2["target_a"] is not None
|
||||
and l2["target_a"] < l2["soc_a"] and l2["p_a"] > -200.0
|
||||
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0
|
||||
and lo["soc"][1] > lo["soc_at"]["03:30"][1] + 15.0)
|
||||
and lo["soc_at"]["06:55"][0] < lo["soc_at"]["03:30"][0] + 1.0)
|
||||
st_, sl_ = R["stale"], R["stale_live"]
|
||||
check("F1: a latch from the night before (ended 07:15) loaded at a restart "
|
||||
"inside a cheap run, with no forecast: nothing grid-charges "
|
||||
@@ -695,7 +723,7 @@ from types import SimpleNamespace # noqa: E402
|
||||
from app import modetrade, priceruns # noqa: E402
|
||||
|
||||
CLOCK = [at(4, 1, 0)]
|
||||
_dt0 = m.datetime
|
||||
_dt0 = main_files.live.datetime
|
||||
|
||||
|
||||
class _FixedDT(_dt0):
|
||||
@@ -704,7 +732,7 @@ class _FixedDT(_dt0):
|
||||
return CLOCK[0] if tz is None else CLOCK[0].astimezone(tz)
|
||||
|
||||
|
||||
m.datetime = homeview.datetime = _FixedDT
|
||||
main_files.live.datetime = homeview.datetime = _FixedDT
|
||||
_tmp = tempfile.mkdtemp(prefix="o34-")
|
||||
lc = Controller(dict(opts()), rp.Hass(("a", "b"), lambda t: 300.0,
|
||||
{"a": 40.0, "b": 40.0}),
|
||||
@@ -831,7 +859,7 @@ lc._charge_latch = (at(4, 6, 10), {A: 80.0}, at(4, 1, 0))
|
||||
lt, _s = latch_step(None, resting, None, now=at(4, 6, 5), runs=r3)
|
||||
check("F4: a run that starts AT the bell (06:00-09:00) does carry it on, "
|
||||
"to its end + one slot", lt is not None and lt[0] == at(4, 9, 15))
|
||||
m.datetime = homeview.datetime = _dt0
|
||||
main_files.live.datetime = homeview.datetime = _dt0
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -0,0 +1,920 @@
|
||||
"""Runnable check for OPT-36. `python3 test_opt36.py`
|
||||
|
||||
Leftover solar counts what Cheap hours only batteries will give in the
|
||||
expensive hours before it buys from the grid at night (hotfix 0.20.1).
|
||||
|
||||
Live 0.20.0, 2026-11-02 01:01: A (Leftover solar, 60 %) went to 95 %
|
||||
(5.38 kWh) while B (Cheap hours only, 94 %) already covered the expensive
|
||||
hours. DECISIONS/OPT-23-28 had left "netting what a Cheap hours battery will
|
||||
hold" out; this adds it.
|
||||
|
||||
1. The live figures through the real Controller: A buys 0, nothing is
|
||||
"uncovered", and Home says the same in EN and NL.
|
||||
2. `dayplan.plan()` alone: B low with a short cheap run under the peak limit,
|
||||
A buys only what B cannot reach; the numbers written out. Round 3: B's
|
||||
charge counts the cheap slots left before the expensive hours, not only
|
||||
the run (sized at the ratings), so the level does not fall in the night.
|
||||
3. A Cheap hours battery that is off, refusing, in maintenance, not live or
|
||||
without capacity counts 0; Solar first counts after the night's use
|
||||
(owner, 2026-11-02); N=1 and Leftover solar only are unchanged.
|
||||
4. The screens: the give line reads the level after the planned charge,
|
||||
`plan_cover()` is the plan's own figure, the Why line says who covers.
|
||||
5. The real Controller through a whole night (parallel processes, 5 s per
|
||||
cycle): A's commands follow the new level, which holds all night while
|
||||
the figure does; OPT-34 F-1 with B counted (a raised level is reached and
|
||||
held); the OPT-34 latch still holds a started run (a lowered level stays
|
||||
lowered). Round 4: a three-price night holds the shortfall at 07:00, and
|
||||
two real-price replays with a Solar first battery cost no more than
|
||||
0.20.0.
|
||||
|
||||
Mutations (MUTATION.md, purged copies): listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from multiprocessing import get_context
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from named_absence import nth, present # noqa: E402
|
||||
import ux54_scenarios as S # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import dayplan, homeview, modetrade, packmode # noqa: E402
|
||||
from app import priceruns, solarfc, uitext # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.static_tariff import static_plan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import BILLING_TZ # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
EFF = dayplan.EFF
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
FLOOR, CEIL = 20.0, 95.0
|
||||
CAP_A, CAP_B = 15.36, 20.48
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. the live figures through the real Controller")
|
||||
# ============================================================================ #
|
||||
NOW = datetime(2026, 11, 2, 1, 1, 26, tzinfo=BILLING_TZ).astimezone(UTC)
|
||||
|
||||
|
||||
def shape_of(scale=1.0):
|
||||
"""The brief's owner example (0.6 kW at night, 1.2 kW 06-10, 0.9 kW by
|
||||
day, 1.8 kW 17-22), scaled."""
|
||||
return {h: scale * (600.0 if h < 6 or h >= 22 else 1200.0 if h < 10
|
||||
else 900.0 if h < 17 else 1800.0) for h in range(24)}
|
||||
|
||||
|
||||
def profile_of(shape, now=NOW):
|
||||
d = now.astimezone(BILLING_TZ).date()
|
||||
return dayplan.HouseProfile(
|
||||
{(d - timedelta(days=k)).isoformat():
|
||||
{str(h): w for h, w in shape.items()} for k in (1, 2, 3)})
|
||||
|
||||
|
||||
def make(shape, soc=(60.0, 94.0), modes=("leftover_solar", "cheap_hours"),
|
||||
today=10.2, tomorrow=10.0, now=NOW, peak_cap=None):
|
||||
"""A Controller at `now`: day/night tariff, floor 20 %, ceiling 95 %,
|
||||
three state-only sensors (10.2 kWh today), its mode roles run once."""
|
||||
opts = dict(S.BASE)
|
||||
opts.update(battery_modes_enabled=True,
|
||||
pv_forecast_entities=[f"{S.FC1}_{i}" for i in range(3)],
|
||||
pv_forecast_tomorrow_entities=[f"{S.FCT1}_{i}"
|
||||
for i in range(3)],
|
||||
static_profile=json.dumps(S.DN), arbitrage_enabled=False,
|
||||
arbitrage_soc_floor=20, arbitrage_soc_ceiling=95,
|
||||
cycle_cost_threshold_eur=0.04)
|
||||
if peak_cap is not None:
|
||||
opts.update(peak_cap_w=peak_cap)
|
||||
plan = static_plan(opts, S.day0_of(now), S.day0_of(now) + timedelta(
|
||||
hours=48), now=now)
|
||||
c = Controller(opts, S.Hass(), S.Store(), S.Pub(), stop=S.asyncio.Event())
|
||||
c.auto = True
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i]
|
||||
c.grid, c.pv_w = 3000.0, 0.0
|
||||
c.tariff_plan, c.tariff_at = plan, now
|
||||
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0,
|
||||
ceiling_from="cap", projected_w=800.0)
|
||||
c.arb_headroom_w = 3450.0 - 3000.0 + 2000.0
|
||||
c.solar_fc = S.forecast(now, today, tomorrow, members=3)
|
||||
c.solar_fc_why = None
|
||||
c.house_profile = profile_of(shape, now)
|
||||
c.tariff_at = now
|
||||
c._mode_roles(now, None)
|
||||
return c
|
||||
|
||||
|
||||
def screen(c, lang="en", now=NOW):
|
||||
st = homeview.build(c, level="ok", checks=[], now=now, lang=lang)
|
||||
rows = {r["name"]: r for r in st["batteries"]}
|
||||
return st, rows
|
||||
|
||||
|
||||
LIVE = shape_of(0.973) # the scale whose plan matches live (11.3 kWh)
|
||||
c = make(LIVE)
|
||||
dp = c.day_plan
|
||||
tg = c.plan_targets()
|
||||
print(f" short {dp.short_kwh:.2f} kWh, Cheap hours give {dp.cheap_kwh:.2f}, "
|
||||
f"A to {tg[A]} %, A buys {dp.need_kwh[A]:.2f} kWh, uncovered "
|
||||
f"{dp.uncovered_kwh:.2f}")
|
||||
check("live: A (Leftover solar 60 %) buys nothing while B (Cheap hours 94 %) "
|
||||
"covers the expensive hours (was: to 95 %, 5.38 kWh)",
|
||||
dp.need_kwh[A] == 0.0 and tg[A] <= 60.0
|
||||
and homeview.plan_need(c, A) == 0.0)
|
||||
check("live: nothing is left uncovered (was 0.4 kWh), and B's give is "
|
||||
"counted up to the shortfall and no more",
|
||||
dp.uncovered_kwh == 0.0 and abs(dp.cheap_kwh - dp.short_kwh) < 1e-6)
|
||||
check("live: control gives A no grid charge at all (the run charges B only)",
|
||||
[p.name for p in modetrade.chargers(
|
||||
c._mode_packs(None), CEIL, targets=c.plan_targets())] == [B])
|
||||
for lang in ("en", "nl"):
|
||||
st, rows = screen(c, lang)
|
||||
mine = [r for r in st["plan"]["runs"]
|
||||
if r["battery"] == 0 and r["kind"] != "house"]
|
||||
theirs = [r for r in st["plan"]["runs"]
|
||||
if r["battery"] == 1 and r["kind"] != "house"]
|
||||
line = st["solar"]["line"]
|
||||
check(f"{lang}: A has no grid block, only its give block "
|
||||
f"({[r['kind'] for r in mine]})",
|
||||
[r["kind"] for r in mine] == ["give"])
|
||||
check(f"{lang}: A's give block is what A holds (60 % -> 6.1 kWh)",
|
||||
abs(nth(mine, 0)["kwh"] - (60.0 - FLOOR) / 100.0 * CAP_A) < 0.01)
|
||||
check(f"{lang}: B's give block reads B after its charge (to 95 %: "
|
||||
"15.4 kWh, not 15.2 from 94 %)",
|
||||
abs(nth(theirs, -1)["kwh"] - (CEIL - FLOOR) / 100.0 * CAP_B) < 0.01)
|
||||
check(f"{lang}: the solar line has no 'the rest' sentence",
|
||||
"0.4" not in line and "0,4" not in line
|
||||
and "rest, " not in line and "rest van" not in line
|
||||
and "rest," not in line)
|
||||
check(f"{lang}: no line anywhere says Leftover solar charges from the "
|
||||
"grid to 95",
|
||||
all("95" not in r["why"] for r in rows.values())
|
||||
and all("95 %" not in t["what"] for r in rows.values()
|
||||
for t in r["today"] if "grid" in t["kind"]
|
||||
and r is rows[A]))
|
||||
check("live: while B charges, A's Why says another battery charges (it "
|
||||
"rests)", screen(c, "en")[1][A]["why_key"] == "rests_charge")
|
||||
|
||||
# B at 100 % of its run done: the cheap run is over, B only gives. A's Why
|
||||
# says who covers the hours, not the sun.
|
||||
c2 = make(LIVE, soc=(60.0, 95.0))
|
||||
c2._mode_roles(NOW, None)
|
||||
role_a = c2.mode_tick.roles[A]
|
||||
key_a = homeview.day_key(c2, A)
|
||||
print(f" B full: A's role {role_a}, day key {key_a}")
|
||||
check("B already at the ceiling: A has no charge, and the day key says the "
|
||||
"Cheap hours battery covers, not the sun",
|
||||
key_a == "others_cover" and c2.day_plan.need_kwh[A] == 0.0
|
||||
and c2.day_plan.saved_kwh[A] > 5.0)
|
||||
for lang in ("en", "nl"):
|
||||
with homeview.in_lang(lang):
|
||||
txt = homeview.why_words("others_cover")
|
||||
sentences = [x for x in txt.split(".") if x.strip()]
|
||||
check(f"{lang}: the 'others_cover' line is at most 2 sentences of at "
|
||||
f"most 12 words and has no raw placeholder: {txt!r}",
|
||||
len(sentences) <= 2 and all(len(x.split()) <= 12
|
||||
for x in sentences) and "{" not in txt)
|
||||
check("EN and NL say that other batteries cover, in plain words",
|
||||
"Other batteries" in uitext.words("en")["home_words"]["why"][
|
||||
"others_cover"]
|
||||
and "Andere batterijen" in uitext.words("nl")["home_words"]["why"][
|
||||
"others_cover"])
|
||||
|
||||
# Owner, 2026-11-02: a Solar first battery counts too, at its level when the
|
||||
# expensive hours start: level now less the house's night use to 07:00
|
||||
# (01:01 to 07:00: 0.58 kW to 06:00, then 1.17 kW), shared among Solar first
|
||||
# batteries, x 0.95. A Leftover solar at its floor buys only what is left.
|
||||
c3 = make(LIVE, soc=(20.0, 94.0), modes=("leftover_solar", "solar_first"))
|
||||
# Round 4: each hour at no less than the night base (the profile is net of
|
||||
# the sun the house used).
|
||||
_hw, base_n = c3.house_profile.estimator()
|
||||
use_n = sum(max(LIVE[h_], base_n) / 1000.0 * (e - s_).total_seconds() / 3600.0
|
||||
for s_, e, h_ in dayplan._hour_slices(
|
||||
NOW, datetime(2026, 11, 2, 7, 0, tzinfo=BILLING_TZ)))
|
||||
have_b = (94.0 - FLOOR) / 100.0 * CAP_B
|
||||
give_sf = (have_b - use_n / EFF) * EFF
|
||||
d3 = c3.day_plan
|
||||
want_hold = (d3.short_kwh - give_sf) / EFF
|
||||
print(f" B Solar first 94 %: night use {use_n:.2f}, B gives {give_sf:.2f} "
|
||||
f"of {d3.short_kwh:.2f}; A (20 %) buys {d3.need_kwh[A]:.3f}")
|
||||
check("B on Solar first at 94 % counts: its level at 07:00 is 94 % less the "
|
||||
"night's use, (level - floor) x cap x 0.95",
|
||||
abs(d3.solar_first_kwh - give_sf) < 0.01 and d3.cheap_kwh == 0.0)
|
||||
check("...and A (at its floor) buys only the shortfall after that "
|
||||
"(was 5.38 kWh and to 95 %)",
|
||||
abs(d3.need_kwh[A] - want_hold) < 0.02 and d3.need_kwh[A] < 1.5
|
||||
and d3.uncovered_kwh == 0.0)
|
||||
c3b = make(LIVE, soc=(60.0, 94.0), modes=("leftover_solar", "solar_first"))
|
||||
check("...and A at 60 % with B Solar first 94 % buys nothing",
|
||||
c3b.day_plan.need_kwh[A] == 0.0 and c3b.day_plan.uncovered_kwh == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. dayplan.plan() alone: B low, a short cheap run under the peak limit")
|
||||
# ============================================================================ #
|
||||
|
||||
|
||||
def loc(day, hour, minute=0):
|
||||
return datetime(2026, 10, day, hour, minute,
|
||||
tzinfo=BILLING_TZ).astimezone(UTC)
|
||||
|
||||
|
||||
SHAPE = shape_of(1.0)
|
||||
T = loc(30, 4, 0) # 04:00, three cheap hours left
|
||||
RUNS = priceruns.RunPlan(
|
||||
priceruns.Run(loc(29, 22), loc(30, 7), 0.2333),
|
||||
(priceruns.Run(loc(30, 7), loc(30, 22), 0.2801),
|
||||
priceruns.Run(loc(31, 7), loc(31, 22), 0.2801)),
|
||||
None, 0.2801, None, True)
|
||||
|
||||
|
||||
def pk(name, mode, soc, cap, rating=4600.0, **kw):
|
||||
return modetrade.Pack(name, True, mode, False, rating, soc, cap, **kw)
|
||||
|
||||
|
||||
def fc_of(kwh, now=T):
|
||||
d0, _d1 = solarfc._bounds(now)
|
||||
return solarfc.Forecast(solarfc.Day(d0, kwh),
|
||||
solarfc.Day(d0 + timedelta(days=1), kwh), now)
|
||||
|
||||
|
||||
def mk_plan(packs, *, aim=3450.0, runs=RUNS, now=T, kwh=6.0, shape=SHAPE):
|
||||
return dayplan.plan(fc_of(kwh, now), profile_of(shape, now).estimator(),
|
||||
runs, now, packs, trust_pct=70, floor_pct=FLOOR,
|
||||
ceiling_pct=CEIL, aim_w=aim)
|
||||
|
||||
|
||||
def expect_short(kwh, shape=SHAPE, trust=0.7):
|
||||
"""The shortfall written out: per hour 07..21 max(0, house - 0.7 x sun),
|
||||
an hour with sun read at no less than the night base."""
|
||||
base = sorted(shape[h] for h in (0, 1, 2, 3, 4, 5, 22, 23))
|
||||
base = (base[3] + base[4]) / 2.0
|
||||
tot = 0.0
|
||||
for h in range(7, 22):
|
||||
pv = kwh * (solarfc._bell(h + 1.0) - solarfc._bell(float(h)))
|
||||
use = (max(shape[h], base) if pv > 0 else shape[h]) / 1000.0
|
||||
tot += max(0.0, use - trust * pv)
|
||||
return tot
|
||||
|
||||
|
||||
def expect_b_level(soc, cap, gain_kwh):
|
||||
"""`gain_kwh` drawn from the grid; x 0.95 of it is stored (round 4)."""
|
||||
return max(soc, min(CEIL, soc + gain_kwh * EFF / cap * 100.0))
|
||||
|
||||
|
||||
SH = expect_short(6.0)
|
||||
# 04:00-07:00 at the night 0.6 kW (04, 05) and 1.2 kW (06): the peak aim 3450
|
||||
# leaves 2850, 2850 and 2250 W of the battery's 4600 W.
|
||||
GAIN = 2.85 + 2.85 + 2.25
|
||||
LB = expect_b_level(30.0, CAP_B, GAIN)
|
||||
GIVE_B = (LB - FLOOR) / 100.0 * CAP_B * EFF
|
||||
A_LVL = FLOOR + (SH - GIVE_B) / EFF / CAP_A * 100.0
|
||||
p_low = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)])
|
||||
print(f" short {SH:.2f}; B 30 % + {GAIN:.2f} kWh -> {LB:.1f} % gives "
|
||||
f"{GIVE_B:.2f}; A to {A_LVL:.1f} % (plan: {p_low.targets[A]} %, B "
|
||||
f"gives {p_low.cheap_kwh})")
|
||||
check("the shortfall is the written-out sum", abs(p_low.short_kwh - SH) < 0.005)
|
||||
check("B's give is (its level at the start of the dear hours - floor) x "
|
||||
"capacity x 0.95, the level being what 3 h under the peak aim reach",
|
||||
abs(p_low.cheap_kwh - GIVE_B) < 0.005 and LB < CEIL - 20.0)
|
||||
check("A buys only what B cannot reach: its level is floor + (shortfall - "
|
||||
"B's give) / 0.95",
|
||||
abs(p_low.targets[A] - A_LVL) < 0.05
|
||||
and abs(p_low.need_kwh[A] - (A_LVL - FLOOR) / 100.0 * CAP_A) < 0.01
|
||||
and p_low.uncovered_kwh == 0.0)
|
||||
alone = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A)])
|
||||
check("...which is less than A would buy with no Cheap hours battery",
|
||||
alone.need_kwh[A] > p_low.need_kwh[A] + 4.0 and alone.cheap_kwh == 0.0)
|
||||
check("the grid kWh A saves is reported (`saved_kwh`)",
|
||||
abs(p_low.saved_kwh[A] - (alone.need_kwh[A] - p_low.need_kwh[A])) < 0.01)
|
||||
|
||||
# No peak limit given: the batteries' ratings alone (4.6 kW x 3 h reaches 95).
|
||||
p_free = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], aim=None)
|
||||
GIVE_FREE = (expect_b_level(30.0, CAP_B, 4.6 * 3.0) - FLOOR) / 100.0 \
|
||||
* CAP_B * EFF
|
||||
check("with no peak aim the run is held by the rating only: 4.6 kW x 3 h "
|
||||
"x 0.95 stored takes B to 94 %; A buys only what is left",
|
||||
abs(p_free.cheap_kwh - GIVE_FREE) < 0.005
|
||||
and abs(p_free.need_kwh[A] - (SH - GIVE_FREE) / EFF) < 0.01
|
||||
and p_free.uncovered_kwh == 0.0)
|
||||
# A house at the aim leaves no room: B cannot charge, counts what it holds.
|
||||
p_full = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], aim=500.0)
|
||||
check("a house at the peak aim leaves no room: B counts its level now "
|
||||
"((30 - 20) % x 20.48 x 0.95)",
|
||||
abs(p_full.cheap_kwh - 10.0 / 100.0 * CAP_B * EFF) < 0.005)
|
||||
# The run is not worth charging in: B stays where it is.
|
||||
NOCH = priceruns.RunPlan(RUNS.cheap, RUNS.give, None, RUNS.level_eur_kwh,
|
||||
None, False)
|
||||
p_noch = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)], runs=NOCH,
|
||||
aim=None)
|
||||
check("a cheap run not worth charging in: B counts its level now",
|
||||
abs(p_noch.cheap_kwh - 10.0 / 100.0 * CAP_B * EFF) < 0.005)
|
||||
# A cheap run that is already over (now past its end): the level now.
|
||||
p_over = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.CHEAP_HOURS, 80.0, CAP_B)],
|
||||
now=loc(30, 7, 0) - timedelta(seconds=1), aim=None)
|
||||
check("the last second of the run: B counts its level (80 %), no more",
|
||||
abs(p_over.cheap_kwh - (80.0 - FLOOR) / 100.0 * CAP_B * EFF) < 0.01)
|
||||
# Two Cheap hours batteries share the peak room by rating.
|
||||
p_two = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B, rating=3000.0),
|
||||
pk("inverter-c", packmode.CHEAP_HOURS, 30.0, CAP_B,
|
||||
rating=1500.0)])
|
||||
g_b = GAIN * 3000.0 / 4500.0
|
||||
g_c = GAIN * 1500.0 / 4500.0
|
||||
give_two = sum((expect_b_level(30.0, CAP_B, g) - FLOOR) / 100.0 * CAP_B * EFF
|
||||
for g in (g_b, g_c))
|
||||
# 2.85 kW is under the 4.5 kW of the two together: shared 2:1. Hours 04-05
|
||||
# take 2.85 of 4.5, hour 06 takes 2.25 of 4.5.
|
||||
check("two Cheap hours batteries share the peak room by rating",
|
||||
abs(p_two.cheap_kwh - min(give_two, p_two.short_kwh)) < 0.01)
|
||||
|
||||
# Round 3: the run is sized at the ratings, not the peak limit, so a run too
|
||||
# short to fill B is planned again later in the night. B's charge counts the
|
||||
# firm price slots after the run, up to the expensive hours, that a run would
|
||||
# still be worth charging in: price + the spread control asks (`spread`, 0.04
|
||||
# here) at most the give run's dearest slot (0.2801). Without them A's level
|
||||
# fell through the night after A had bought.
|
||||
RUN1 = priceruns.RunPlan(
|
||||
priceruns.Run(loc(30, 4), loc(30, 5), 0.2333), RUNS.give, None,
|
||||
RUNS.level_eur_kwh, None, True)
|
||||
|
||||
|
||||
def quarters(t0, t1, eur):
|
||||
out, t = [], t0
|
||||
while t < t1:
|
||||
out.append((t, t + timedelta(minutes=15), eur))
|
||||
t += timedelta(minutes=15)
|
||||
return out
|
||||
|
||||
|
||||
NIGHT_Q = quarters(loc(30, 4), loc(30, 7), 0.2333)
|
||||
DAY_Q = quarters(loc(30, 7), loc(30, 22), 0.2801)
|
||||
LATE_Q = quarters(loc(30, 22), loc(31, 7), 0.2333)
|
||||
PK2 = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)]
|
||||
|
||||
|
||||
def mk_slots(slots, runs=RUN1, spread=0.04):
|
||||
return dayplan.plan(fc_of(6.0, T), profile_of(SHAPE, T).estimator(),
|
||||
runs, T, PK2, trust_pct=70, floor_pct=FLOOR,
|
||||
ceiling_pct=CEIL, aim_w=3450.0, slots=slots,
|
||||
spread=spread)
|
||||
|
||||
|
||||
p_run1 = mk_slots(None)
|
||||
p_q = mk_slots(NIGHT_Q + DAY_Q + LATE_Q)
|
||||
GIVE_1H = (expect_b_level(30.0, CAP_B, 2.85) - FLOOR) / 100.0 * CAP_B * EFF
|
||||
print(f" a 1 h run (04:00-05:00): B gives {p_run1.cheap_kwh:.3f} without "
|
||||
f"the slots, {p_q.cheap_kwh:.3f} with the night's slots to 07:00")
|
||||
check("a 1 h run with no slots given: B counts what 1 h reaches (as before)",
|
||||
abs(p_run1.cheap_kwh - GIVE_1H) < 0.005)
|
||||
check("...and with the slots but no spread given, the same",
|
||||
abs(mk_slots(NIGHT_Q + DAY_Q, spread=None).cheap_kwh - GIVE_1H)
|
||||
< 0.005)
|
||||
check("the same run with the night's quarters (0.2333) to 07:00: B "
|
||||
"counts what 04:00-07:00 reach, as the 3 h run does (and A buys the "
|
||||
"same); the day and the next night are not counted",
|
||||
abs(p_q.cheap_kwh - GIVE_B) < 0.005
|
||||
and abs(p_q.targets[A] - p_low.targets[A]) < 0.01)
|
||||
p_dear = mk_slots(quarters(loc(30, 5), loc(30, 7), 0.2500) + DAY_Q)
|
||||
check("slots after the run not worth charging in (0.25 + 0.04 over the "
|
||||
"dearest 0.2801) are not counted: B counts the 1 h run only",
|
||||
abs(p_dear.cheap_kwh - GIVE_1H) < 0.005)
|
||||
p_part = mk_slots(quarters(loc(30, 5), loc(30, 6), 0.2400)
|
||||
+ quarters(loc(30, 6), loc(30, 7), 0.2500) + DAY_Q)
|
||||
GIVE_2H = (expect_b_level(30.0, CAP_B, 2.85 + 2.85) - FLOOR) / 100.0 \
|
||||
* CAP_B * EFF
|
||||
check("...and only those are: 05:00-06:00 at 0.24 (dearer than the run, "
|
||||
"still worth it) adds one hour (04:00-06:00 reach)",
|
||||
abs(p_part.cheap_kwh - GIVE_2H) < 0.005)
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. who counts, and who does not")
|
||||
# ============================================================================ #
|
||||
base_pk = [pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A)]
|
||||
want = mk_plan(base_pk)
|
||||
for label, b in (
|
||||
("off", modetrade.Pack(B, False, packmode.CHEAP_HOURS, False, 4600.0,
|
||||
94.0, CAP_B)),
|
||||
("refusing to give", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
|
||||
refused=1)),
|
||||
("refusing to charge", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
|
||||
refused=-1)),
|
||||
("in maintenance", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B,
|
||||
idle=False)),
|
||||
("not live", pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B, live=False)),
|
||||
("without capacity", modetrade.Pack(B, True, packmode.CHEAP_HOURS,
|
||||
False, 4600.0, 94.0, None)),
|
||||
("without a level", modetrade.Pack(B, True, packmode.CHEAP_HOURS,
|
||||
False, 4600.0, None, CAP_B)),
|
||||
("Solar first refusing to give",
|
||||
pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B, refused=1)),
|
||||
("Solar first in maintenance",
|
||||
pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B, idle=False)),
|
||||
("Solar first with the night draining it to the floor",
|
||||
pk(B, packmode.SOLAR_FIRST, 24.0, CAP_B))):
|
||||
pl = mk_plan(base_pk + [b])
|
||||
check(f"B {label}: counts 0, A's plan is as with no B",
|
||||
pl.cheap_kwh == 0.0 and pl.targets == want.targets
|
||||
and pl.need_kwh == want.need_kwh
|
||||
and pl.uncovered_kwh == want.uncovered_kwh
|
||||
and pl.cover_kwh == want.cover_kwh)
|
||||
on = mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)])
|
||||
check("(control: the same B, on and idle, counts)",
|
||||
on.cheap_kwh > 5.0 and on.need_kwh[A] < want.need_kwh[A] - 1.0)
|
||||
# Solar first at 94 % at 04:00: three night hours (0.6, 0.6, 1.2 kW) are used
|
||||
# before 07:00, shared with nobody else; the rest counts.
|
||||
p_sf = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 94.0, CAP_B)])
|
||||
use_t = 0.6 + 0.6 + 1.2
|
||||
give_t = ((94.0 - FLOOR) / 100.0 * CAP_B - use_t / EFF) * EFF
|
||||
check("Solar first at 94 % at 04:00: counts its level at 07:00 "
|
||||
f"({give_t:.2f} kWh), A buys only the rest",
|
||||
abs(p_sf.solar_first_kwh - give_t) < 0.005 and p_sf.cheap_kwh == 0.0
|
||||
and p_sf.need_kwh[A] < want.need_kwh[A] - 4.0)
|
||||
# Two Solar first batteries share the night's use by the energy they hold.
|
||||
p_sf2 = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 60.0, CAP_B),
|
||||
pk("inverter-c", packmode.SOLAR_FIRST, 40.0,
|
||||
CAP_B)])
|
||||
tot_h = (60.0 - FLOOR) / 100.0 * CAP_B + (40.0 - FLOOR) / 100.0 * CAP_B
|
||||
check("two Solar first batteries: the night's use is shared by the energy "
|
||||
"held; the total is the same sum",
|
||||
abs(p_sf2.solar_first_kwh - min(
|
||||
p_sf2.short_kwh, (tot_h - use_t / EFF) * EFF)) < 0.005)
|
||||
# Both kinds: Cheap hours and Solar first add up (capped at the shortfall).
|
||||
p_both = mk_plan(base_pk + [pk(B, packmode.SOLAR_FIRST, 60.0, CAP_B),
|
||||
pk("inverter-c", packmode.CHEAP_HOURS, 30.0,
|
||||
CAP_B)])
|
||||
check("Cheap hours and Solar first batteries both count",
|
||||
p_both.cheap_kwh > 5.0 and p_both.solar_first_kwh > 3.0
|
||||
and abs(p_both.cheap_kwh + p_both.solar_first_kwh
|
||||
- min(p_both.short_kwh, p_both.cheap_kwh
|
||||
+ p_both.solar_first_kwh)) < 1e-6)
|
||||
# A Cheap hours battery with Leftover solar A: when A is not Leftover solar
|
||||
# (N=1 on Cheap hours alone) the plan has no Leftover solar to size.
|
||||
solo = mk_plan([pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)])
|
||||
check("Cheap hours only sites: no Leftover solar battery, nothing is sized",
|
||||
solo.targets == {} and solo.need_kwh == {} and solo.cheap_kwh == 0.0
|
||||
and solo.uncovered_kwh == 0.0)
|
||||
# Everything the plan says about a Leftover solar only site is the same as
|
||||
# before OPT-36: the new fields are at rest.
|
||||
two_ls = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.LEFTOVER_SOLAR, 94.0, CAP_B)])
|
||||
check("Leftover solar only (two batteries): cheap_kwh 0, nothing saved",
|
||||
two_ls.cheap_kwh == 0.0
|
||||
and all(v == 0.0 for v in two_ls.saved_kwh.values())
|
||||
and abs(sum(two_ls.cover_kwh.values()) - two_ls.short_kwh) < 0.01)
|
||||
# More than the Cheap hours battery holds: A buys the rest, "the rest" is said
|
||||
# only for what nothing holds.
|
||||
big = shape_of(2.0)
|
||||
p_big = mk_plan([pk(A, packmode.LEFTOVER_SOLAR, FLOOR, CAP_A),
|
||||
pk(B, packmode.CHEAP_HOURS, 94.0, CAP_B)], shape=big,
|
||||
aim=None)
|
||||
want_big = expect_short(6.0, big)
|
||||
cheap_big = (CEIL - FLOOR) / 100.0 * CAP_B * EFF
|
||||
check("a shortfall B cannot cover: A holds the rest, to the ceiling; what "
|
||||
"neither holds is 'uncovered'",
|
||||
abs(p_big.cheap_kwh - cheap_big) < 0.005
|
||||
and p_big.targets[A] == CEIL
|
||||
and abs(p_big.uncovered_kwh - max(
|
||||
0.0, want_big - cheap_big - (CEIL - FLOOR) / 100.0 * CAP_A * EFF))
|
||||
< 0.01)
|
||||
check("the plan is pure: the same call twice gives the same plan",
|
||||
mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)])
|
||||
== mk_plan(base_pk + [pk(B, packmode.CHEAP_HOURS, 30.0, CAP_B)]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. the screens agree with control")
|
||||
# ============================================================================ #
|
||||
# A tops up (B low, the house large): the give block reads A after its charge.
|
||||
cs = make(shape_of(1.5), soc=(20.0, 30.0), now=NOW)
|
||||
dps = cs.day_plan
|
||||
tgs = cs.plan_targets()
|
||||
print(f" A 20 %, B 30 %: B gives {dps.cheap_kwh:.2f}, A to {tgs[A]} %, "
|
||||
f"buys {dps.need_kwh[A]:.2f}, uncovered {dps.uncovered_kwh:.2f}")
|
||||
check("(A does buy here: B cannot cover the larger house)",
|
||||
dps.need_kwh[A] > 1.0 and dps.cheap_kwh > 5.0)
|
||||
for lang in ("en", "nl"):
|
||||
st, rows = screen(cs, lang)
|
||||
runs_a = [r for r in st["plan"]["runs"] if r["battery"] == 0]
|
||||
grid = next((r for r in runs_a if r["kind"] == "grid"), None)
|
||||
give = next((r for r in runs_a if r["kind"] == "give"), None)
|
||||
cov = homeview.plan_cover(cs, A)
|
||||
with homeview.in_lang(lang):
|
||||
pct, kwh = homeview._pct(tgs[A]), homeview._kwh(dps.cover_kwh[A])
|
||||
check(f"{lang}: A tops up: the grid block names the plan's level and "
|
||||
"the plan's own cover figure",
|
||||
grid is not None and pct in grid["what"] and kwh in grid["why"]
|
||||
and cov == (tgs[A], dps.cover_kwh[A]))
|
||||
check(f"{lang}: the give block is A at the level it charges to "
|
||||
f"({(tgs[A] - FLOOR) / 100.0 * CAP_A:.2f} kWh), not at 20 % (0)",
|
||||
give is not None and abs(
|
||||
give["kwh"] - (tgs[A] - FLOOR) / 100.0 * CAP_A) < 0.02)
|
||||
# The Batteries line for a battery that holds (`tops_up`) is made from the
|
||||
# same figures: the plan's level and its own cover figure.
|
||||
check("(A's day key is tops_up)", homeview.day_key(cs, A) == "tops_up")
|
||||
for lang in ("en", "nl"):
|
||||
with homeview.in_lang(lang):
|
||||
words = homeview.plan_words(cs, A, "tops_up")
|
||||
txt = homeview.why_words("tops_up", **words)
|
||||
pct, kwh = homeview._pct(tgs[A]), homeview._kwh(dps.cover_kwh[A])
|
||||
check(f"{lang}: A's tops_up line names the plan's level ({pct}) and "
|
||||
f"cover figure ({kwh}): {txt!r}", pct in txt and kwh in txt)
|
||||
# The controller hands the plan its peak aim (peak_cap_w - peak_margin_w): a
|
||||
# lower peak limit leaves B less room in its run, so it covers less.
|
||||
lo_cap = make(shape_of(1.5), soc=(20.0, 30.0), peak_cap=2000)
|
||||
hi_cap = make(shape_of(1.5), soc=(20.0, 30.0), peak_cap=20000)
|
||||
print(f" peak limit 2000 W: B gives {lo_cap.day_plan.cheap_kwh:.2f} kWh; "
|
||||
f"20000 W: {hi_cap.day_plan.cheap_kwh:.2f} kWh")
|
||||
check("the Controller passes its peak aim: a 2 kW limit leaves B less room "
|
||||
"than a 20 kW one, and A buys the difference",
|
||||
lo_cap.day_plan.cheap_kwh < hi_cap.day_plan.cheap_kwh - 2.0
|
||||
and lo_cap.day_plan.need_kwh[A] > hi_cap.day_plan.need_kwh[A] + 1.0)
|
||||
# A hysteresis-held level: the cover figure is still the plan's own.
|
||||
real = cs.plan_targets
|
||||
cs.plan_targets = lambda: {A: tgs[A] + 0.7}
|
||||
cv = homeview.plan_cover(cs, A)
|
||||
check("plan_cover() is the plan's cover figure whatever level control holds "
|
||||
"(hysteresis), the level being the held one",
|
||||
cv == (tgs[A] + 0.7, dps.cover_kwh[A]))
|
||||
cs.plan_targets = real
|
||||
# A give run before the cheap run (10:00: tomorrow night's charge is after
|
||||
# today's give): the give block reads the level now.
|
||||
c10 = make(shape_of(2.0), soc=(40.0, 25.0), modes=("leftover_solar", "solar_first"),
|
||||
now=datetime(2026, 11, 2, 10, 0, tzinfo=BILLING_TZ).astimezone(UTC))
|
||||
st10 = homeview.build(c10, level="ok", checks=[],
|
||||
now=datetime(2026, 11, 2, 10, 0,
|
||||
tzinfo=BILLING_TZ).astimezone(UTC),
|
||||
lang="en")
|
||||
g10 = [r for r in st10["plan"]["runs"]
|
||||
if r["battery"] == 0 and r["kind"] == "give"]
|
||||
check("a give run that comes BEFORE the next cheap run is read at the level "
|
||||
"now (40 % -> 3.1 kWh), not at tonight's charge",
|
||||
g10 and abs(g10[0]["kwh"] - (40.0 - FLOOR) / 100.0 * CAP_A) < 0.02
|
||||
and any(r["kind"] == "grid" and r["battery"] == 0
|
||||
for r in st10["plan"]["runs"]))
|
||||
|
||||
# ============================================================================ #
|
||||
print("5. the real Controller through a whole night")
|
||||
# ============================================================================ #
|
||||
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(UTC)
|
||||
|
||||
|
||||
def st_of(kwh, when, *, unit="kWh"):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": unit},
|
||||
"last_updated": when.isoformat(),
|
||||
"last_reported": when.isoformat()}
|
||||
|
||||
|
||||
GONE = {"state": "unavailable", "attributes": {}}
|
||||
T_ENT, M_ENT = "sensor.fc_today", "sensor.fc_tomorrow"
|
||||
PROFILE = ('{"periods": ['
|
||||
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.26},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
CHOICE = {"a": (True, "leftover_solar", False),
|
||||
"b": (True, "cheap_hours", False)}
|
||||
EVENING = [at(3, 16, 0) + timedelta(minutes=30 * i) for i in range(12)] \
|
||||
+ [at(3, 21, 55)]
|
||||
|
||||
|
||||
def night_opts(cap, profile=PROFILE):
|
||||
o = dict(rp.options(True, False, 2))
|
||||
o.update(cycle_cost_threshold_eur=0.07, static_profile=profile,
|
||||
pv_forecast_entity=T_ENT, pv_forecast_tomorrow_entity=M_ENT,
|
||||
peak_cap_w=cap)
|
||||
return o
|
||||
|
||||
|
||||
def run_night(spec):
|
||||
"""One whole night in this process -> a plain dict. `spec`: start (UTC),
|
||||
hours, soc, house_w, cap, fresh (today, tomorrow) before `change`,
|
||||
change (UTC) and the figures after it, silent_from (UTC): the sensors
|
||||
show their last reading from then on."""
|
||||
start = spec["start"]
|
||||
store = rp._Store()
|
||||
for u, (use, mode, sell) in CHOICE.items():
|
||||
for k, val in (("pack_use", use), ("pack_mode", mode),
|
||||
("pack_sell", sell)):
|
||||
store.data[unit_key(f"inverter-{u}", k)] = val
|
||||
clock = [EVENING[0]]
|
||||
figs = spec.get("fresh", (1.0, 5.0))
|
||||
change = spec.get("change")
|
||||
after = spec.get("after", figs)
|
||||
silent = spec.get("silent_from")
|
||||
|
||||
def sens(now, ent):
|
||||
f = after if change is not None and now >= change else figs
|
||||
when = now if silent is None else min(now, silent)
|
||||
return st_of(f[0] if ent == T_ENT else f[1], when)
|
||||
|
||||
class H(rp.Hass):
|
||||
async def state(self, e):
|
||||
return sens(clock[0], e)
|
||||
|
||||
hass = H(("a", "b"), lambda t: 300.0, spec["soc"])
|
||||
out = dict(marks={}, soc_at={}, p_a=[])
|
||||
tmp = tempfile.mkdtemp(prefix="o36-")
|
||||
c = Controller(dict(night_opts(spec.get("cap", 2500),
|
||||
spec.get("profile", PROFILE))), hass, store,
|
||||
rp._Pub(), stop=asyncio.Event(),
|
||||
peak_db_path=os.path.join(tmp, "tariff.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
hw = spec.get("house_w", 700.0)
|
||||
c.house_profile.load({d: {str(hh): hw for hh in range(24)}
|
||||
for d in ("2026-10-31", "2026-11-01", "2026-11-02")})
|
||||
|
||||
async def go():
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
try:
|
||||
for t_pre in EVENING:
|
||||
clock[0] = t_pre
|
||||
await c._read_forecast()
|
||||
marks = sorted(spec.get("marks", ()))
|
||||
first, t = True, 0.0
|
||||
hz = spec["hours"] * 3600.0
|
||||
while t < hz:
|
||||
now = start + timedelta(seconds=t)
|
||||
clock[0] = now
|
||||
if first or int(t) % 600 == 0:
|
||||
hor = float(m.price_option(c.o, "price_horizon_h"))
|
||||
c.tariff_plan = await m.resolve_tariff(
|
||||
c.o, now, now + timedelta(hours=hor), now=now,
|
||||
history=c.price_history, watch=c.price_watch)
|
||||
c.tariff_at = now
|
||||
first = False
|
||||
hass.t = t
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
hass.advance(5.0, 10.0)
|
||||
if int(t) % 300 == 0:
|
||||
tgt = c.plan_targets()
|
||||
out["soc_at"][now.astimezone(L).strftime("%H:%M")] = (
|
||||
hass.plants["a"].soc, hass.plants["b"].soc,
|
||||
hass.plants["a"].p, c.solar_fc is None,
|
||||
None if tgt is None else tgt.get(A))
|
||||
if int(t) % 60 == 0:
|
||||
out["p_a"].append(hass.plants["a"].p)
|
||||
while marks and now >= marks[0]:
|
||||
marks.pop(0)
|
||||
cards = homeview.battery_rows(c, False, True)
|
||||
blk = homeview.solar_block(c, now)
|
||||
a_card = next(b for b in cards if b["name"] == A)
|
||||
dpl = c.day_plan
|
||||
out["marks"][now.astimezone(L).strftime("%H:%M")] = dict(
|
||||
line=blk["line"], why_a=a_card["why_key"],
|
||||
fc_none=c.solar_fc is None,
|
||||
target_a=(c.plan_targets() or {}).get(A),
|
||||
need_a=None if dpl is None else dpl.need_kwh.get(A),
|
||||
cheap=None if dpl is None else dpl.cheap_kwh,
|
||||
short=None if dpl is None else dpl.short_kwh,
|
||||
soc_a=hass.plants["a"].soc,
|
||||
p_a=hass.plants["a"].p)
|
||||
t += 5.0
|
||||
out["soc"] = (hass.plants["a"].soc, hass.plants["b"].soc)
|
||||
out["latch"] = getattr(c, "_charge_latch", None) is not None
|
||||
finally:
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
return out
|
||||
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
# Round 4 (review F1): three night prices, the cheapest in the middle.
|
||||
PROFILE3 = ('{"periods": ['
|
||||
'{"name": "eve", "days": "all", "from": "22:00", "to": "01:00",'
|
||||
' "eur_per_kwh": 0.15},'
|
||||
'{"name": "low", "days": "all", "from": "01:00", "to": "03:00",'
|
||||
' "eur_per_kwh": 0.10},'
|
||||
'{"name": "dawn", "days": "all", "from": "03:00", "to": "07:00",'
|
||||
' "eur_per_kwh": 0.15},'
|
||||
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
||||
' "eur_per_kwh": 0.34}]}')
|
||||
|
||||
|
||||
def run_replay(variant, fc):
|
||||
"""One 3-day opt11p4 replay (real 2026 prices, 2026-09-02) -> net EUR."""
|
||||
import contextlib
|
||||
import io
|
||||
import opt11p4_replay as p4
|
||||
rp.VARIANTS["own_sf"] = (True, False, {
|
||||
"a": (True, "leftover_solar", False),
|
||||
"b": (True, "solar_first", False)})
|
||||
argv = sys.argv
|
||||
sys.argv = ["p4", "--variant", variant, "--fc", fc, "--prices",
|
||||
"real2026", "--shape", "periods", "--first", "2", "--days",
|
||||
"3"]
|
||||
buf = io.StringIO()
|
||||
try:
|
||||
with contextlib.redirect_stdout(buf):
|
||||
p4.main()
|
||||
finally:
|
||||
sys.argv = argv
|
||||
return json.loads(buf.getvalue().strip().splitlines()[-1])
|
||||
|
||||
|
||||
NIGHT = at(3, 22, 0)
|
||||
HOURLY = [NIGHT + timedelta(minutes=30 * i) for i in range(18)]
|
||||
SCEN = {
|
||||
# The live night: A 60 % Leftover solar, B 94 % Cheap hours, a fresh
|
||||
# figure all night. A must not charge from the grid.
|
||||
"live": dict(start=NIGHT, hours=9, soc={"a": 60.0, "b": 94.0},
|
||||
house_w=700.0, marks=[at(4, 1, 0), at(4, 5, 0)]),
|
||||
# A mid case: both low (25 %), the house 0.8 kW, a 2.5 kW peak limit
|
||||
# leaves B 1.65 kW in a 3 h run (the run is sized to the batteries'
|
||||
# ratings, not the limit), so B counts for part and A buys the rest.
|
||||
"mid": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0},
|
||||
house_w=800.0, marks=HOURLY),
|
||||
# Round 3: the same with one figure for the 4th all night (today 5.0
|
||||
# after midnight = tomorrow 5.0 before): A's level must not move at all.
|
||||
# On round 2's code it went 55 -> 69 -> 41 -> 37 % and A bought to 56 %.
|
||||
"mid_flat": dict(start=NIGHT, hours=9, soc={"a": 25.0, "b": 25.0},
|
||||
house_w=800.0, fresh=(5.0, 5.0), marks=HOURLY),
|
||||
# Round 3, OPT-34 F-1 with B on Cheap hours and counted: a darker fresh
|
||||
# figure at 02:00, then silent (no forecast from 05:00). The level rises,
|
||||
# A charges to it, and the latch holds it after 05:00.
|
||||
# Round 4 (review F1): the three-price night. B's charge after the run is
|
||||
# planned in the next night's 0.10 hours, not in tonight's 0.15 ones, so
|
||||
# those may not count. On 1ac7fe9 the plan counted B to 95 %, B ended at
|
||||
# 67.5 % and the expensive hours were 2.61 kWh short; 0.20.0: none.
|
||||
"three": dict(start=NIGHT, hours=9, soc={"a": 30.0, "b": 20.0},
|
||||
house_w=700.0, cap=10000, fresh=(1.0, 1.0),
|
||||
profile=PROFILE3, marks=HOURLY),
|
||||
"f1_dark_b": dict(start=at(4, 0, 30), hours=6.5, cap=3500,
|
||||
soc={"a": 50.0, "b": 50.0}, house_w=900.0,
|
||||
fresh=(5.0, 5.0), change=at(4, 2, 0),
|
||||
after=(1.0, 1.0), silent_from=at(4, 2, 0),
|
||||
marks=[at(4, 1, 55)] + [at(4, 2, 15) + timedelta(
|
||||
minutes=15 * i) for i in range(19)]),
|
||||
# OPT-34 with the new level: a fresh, brighter figure at 03:10 lowers A's
|
||||
# level below its SoC (A stops), then the sensors stay silent: the
|
||||
# forecast ends and the latch holds the LOWERED level to 07:00.
|
||||
"lowered": dict(start=at(4, 2, 0), hours=5.0, soc={"a": 25.0, "b": 25.0},
|
||||
house_w=1200.0, fresh=(1.0, 5.0), change=at(4, 3, 10),
|
||||
after=(30.0, 30.0), silent_from=at(4, 3, 10),
|
||||
marks=[at(4, 3, 5), at(4, 3, 20), at(4, 6, 30)]),
|
||||
}
|
||||
t0 = time.time()
|
||||
with ProcessPoolExecutor(max_workers=min(4, os.cpu_count() or 2),
|
||||
mp_context=get_context("fork")) as pool:
|
||||
futs = {k: pool.submit(run_night, v) for k, v in SCEN.items()}
|
||||
# Round 4 (review F2): the real-price replays with a Solar first battery.
|
||||
reps = {k: pool.submit(run_replay, *v) for k, v in (
|
||||
("ownsf_hourly_real", ("own_sf", "perfect")),
|
||||
("p4_ls_dark_real", ("p4_ls", "zero")))}
|
||||
R = {k: f.result() for k, f in futs.items()}
|
||||
REP = {k: f.result() for k, f in reps.items()}
|
||||
print(f" {len(R)} nights in {time.time() - t0:.0f} s")
|
||||
|
||||
lv = R["live"]
|
||||
print(f" live: A {lv['soc'][0]:.2f} % B {lv['soc'][1]:.2f} % "
|
||||
f"marks {lv['marks']}")
|
||||
check("live night: A takes no grid charge: it never charges (its power "
|
||||
"stays above -200 W) and ends where it started",
|
||||
min(lv["p_a"]) > -200.0 and abs(lv["soc"][0] - 60.0) < 1.5)
|
||||
check("live night: B fills to the ceiling as before",
|
||||
lv["soc"][1] > 94.0)
|
||||
check("live night: the plan gives A a level at or under its SoC and the "
|
||||
"screen says nothing about 'the rest'",
|
||||
all(mk["need_a"] == 0.0 and mk["cheap"] > 0.0
|
||||
and "rest" not in mk["line"] for mk in lv["marks"].values()))
|
||||
|
||||
md = R["mid"]
|
||||
m1, m2 = md["marks"]["22:30"], md["marks"]["05:00"]
|
||||
print(f" mid: A level {m1['target_a']} % at 22:30, {m2['target_a']} % at "
|
||||
f"05:00; A ends {md['soc'][0]:.2f} %, B {md['soc'][1]:.2f} %; B gives "
|
||||
f"{m1['cheap']}")
|
||||
check("mid night: the plan counts B's give and A's level is a partial one "
|
||||
"(not the ceiling, as 0.20.0 gave: 95 %)",
|
||||
m1["target_a"] is not None and 25.0 < m1["target_a"] < CEIL - 15.0
|
||||
and m1["cheap"] > 3.0)
|
||||
check("mid night: B's counted give is the same at every mark (B reaches the "
|
||||
"ceiling in the night left), and A's level moves only at midnight, "
|
||||
"when the 4th's figure changes from tomorrow's 5.0 to today's 1.0",
|
||||
len({mk["cheap"] for mk in md["marks"].values()}) == 1
|
||||
and len({mk["target_a"] for h, mk in md["marks"].items()
|
||||
if h >= "12"}) == 1
|
||||
and len({mk["target_a"] for h, mk in md["marks"].items()
|
||||
if h < "12"}) == 1)
|
||||
check("mid night: A's commands follow the levels: it charges to its level "
|
||||
"and stops there, well under the ceiling (0.20.0: 95 %)",
|
||||
md["soc"][0] < 85.0 and abs(md["soc"][0] - m2["target_a"]) < 0.5)
|
||||
check("mid night: B fills as before (to the ceiling)", md["soc"][1] > 94.0)
|
||||
mf = R["mid_flat"]
|
||||
lv_f = [mk["target_a"] for mk in mf["marks"].values()]
|
||||
lv0 = nth(lv_f, 0)
|
||||
print(f" mid_flat: A level(s) {sorted(set(map(str, lv_f)))}; A ends "
|
||||
f"{mf['soc'][0]:.2f} %")
|
||||
check("one figure all night: A's level never moves (round 2: 55 -> 69 -> "
|
||||
"41 -> 37 %), A charges to it and stops (round 2: 56 %)",
|
||||
present(lv0) and lv0 is not None and len(set(lv_f)) == 1
|
||||
and abs(mf["soc"][0] - lv0) < 0.5)
|
||||
fd = R["f1_dark_b"]
|
||||
dk = fd["marks"]
|
||||
held = [dk[h]["target_a"] for h in dk if "05:15" <= h < "07:00"]
|
||||
print(f" f1_dark_b: A level 01:55 {dk['01:55']['target_a']} -> 02:15 "
|
||||
f"{dk['02:15']['target_a']}, held {sorted(set(held))}; A "
|
||||
f"{fd['soc_at']['06:55'][0]:.1f} % at 06:55, B {fd['soc'][1]:.1f} %")
|
||||
check("F-1 with B counted: a darker fresh figure at 02:00 raises A's level, "
|
||||
"the level does not move after that (B's give is the same), it is held "
|
||||
"with no forecast from 05:15, and A charges to it",
|
||||
dk["01:55"]["target_a"] is not None
|
||||
and dk["02:15"]["target_a"] > dk["01:55"]["target_a"] + 3.0
|
||||
and dk["01:55"]["cheap"] > 3.0
|
||||
and all(dk[h]["target_a"] == dk["02:15"]["target_a"] for h in dk
|
||||
if h >= "02:15")
|
||||
and all(dk[h]["fc_none"] for h in dk if "05:15" <= h < "07:00")
|
||||
and abs(fd["soc_at"]["06:55"][0] - dk["02:15"]["target_a"]) < 1.5
|
||||
and fd["soc"][1] > 94.0)
|
||||
|
||||
th = R["three"]
|
||||
sh3 = [mk["short"] for mk in th["marks"].values() if mk["short"] is not None]
|
||||
held3 = sum(max(0.0, x - FLOOR) for x in th["soc"]) / 100.0 \
|
||||
* rp.CAP_KWH * EFF
|
||||
sh3_0 = nth(sh3, 0)
|
||||
print(f" three prices: A {th['soc'][0]:.2f} %, B {th['soc'][1]:.2f} %; "
|
||||
f"held at 07:00 {held3:.2f} kWh for a shortfall of {sh3_0} kWh "
|
||||
"(1ac7fe9: 7.20 for 9.81, 0.20.0: 13.89)")
|
||||
check("three night prices: B's charge is not counted in the 0.15 hours "
|
||||
"after its run (the next night's 0.10 hours are cheaper), so A and B "
|
||||
"together hold the expensive hours' shortfall at 07:00 (1ac7fe9: "
|
||||
"2.61 kWh short, bought at 0.34)",
|
||||
present(sh3_0) and held3 >= sh3_0 - 0.05)
|
||||
for k, base_eur in (("ownsf_hourly_real", 4.00), ("p4_ls_dark_real", 3.84)):
|
||||
r_ = REP[k]
|
||||
print(f" replay {k}: {r_['net_eur']} EUR (0.20.0: {base_eur}), end "
|
||||
f"{r_['end_soc']}")
|
||||
check(f"real 2026 prices, 3 days, a Solar first battery counted ({k}): "
|
||||
f"it costs no more than 0.20.0 ({base_eur} EUR; 1ac7fe9: "
|
||||
f"{base_eur + 0.40:.2f})", r_["net_eur"] <= base_eur + 0.005)
|
||||
|
||||
lw = R["lowered"]
|
||||
l0, l1, l2 = (lw["marks"][k] for k in ("03:05", "03:20", "06:30"))
|
||||
print(f" lowered: A level {l0['target_a']} -> {l1['target_a']} -> "
|
||||
f"{l2['target_a']}; SoC {l1['soc_a']:.1f} -> {lw['soc'][0]:.1f} %")
|
||||
check("a fresh, brighter figure lowers A's level below its SoC during the "
|
||||
"charge, and A stops",
|
||||
l0["target_a"] is not None and l1["target_a"] is not None
|
||||
and l1["target_a"] < l0["target_a"] - 5.0
|
||||
and lw["soc_at"]["03:30"][0] < lw["soc_at"]["03:20"][0] + 0.5)
|
||||
check("the sensors go quiet and the forecast ends (06:30): the latch holds "
|
||||
"the LOWERED level, A does not charge back up to the old one",
|
||||
l2["fc_none"] and l2["target_a"] is not None
|
||||
and l2["target_a"] < l0["target_a"] - 5.0 and l2["p_a"] > -200.0
|
||||
and lw["soc_at"]["06:55"][0] < lw["soc_at"]["03:30"][0] + 1.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n" + ("all checks passed" if not fails
|
||||
else f"FAILED: {len(fails)}"))
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1 if fails else 0)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Runnable check for OPT-38. `python3 test_opt38.py`
|
||||
|
||||
Live 0.20.1, 2026-11-06: one of three Forecast.Solar tomorrow sensors last
|
||||
reported 14:54. From 17:54 (over FRESH_S) to 20:00 (BELL_TO_H) nothing stood in
|
||||
for it, the OPT-17 sum was dropped and the Forecast tab had no tomorrow. A kept
|
||||
tomorrow reading now stands in at any hour (`Hold.stand_in()`). Today members,
|
||||
and the OPT-34 morning rules, are as before.
|
||||
|
||||
Mutations (MUTATION.md, purged copies): see the pull request.
|
||||
"""
|
||||
|
||||
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, solarfc # 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)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(kwh, when):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": when.isoformat(), "last_reported": when.isoformat()}
|
||||
|
||||
|
||||
TODAY = [("sensor.t", None)]
|
||||
M = ["sensor.m1", "sensor.m2", "sensor.m3"]
|
||||
|
||||
|
||||
def run(hold, readings, now, today_at=None):
|
||||
t = [("sensor.t", st(0.5, today_at or now - timedelta(minutes=5)))]
|
||||
return solarfc.read_sum(
|
||||
t, [(e, st(k, w)) for e, (k, w) in zip(M, readings)], now, hold)
|
||||
|
||||
|
||||
# The live day: m2 last reported 14:54, the others keep reporting.
|
||||
h = solarfc.Hold()
|
||||
run(h, [(4.0, at(6, 14, 0)), (5.0, at(6, 14, 54)), (6.0, at(6, 14, 0))],
|
||||
at(6, 14, 55))
|
||||
fresh = lambda w: [(4.0, w), (5.0, at(6, 14, 54)), (6.0, w)] # noqa: E731
|
||||
f, why, gaps = run(h, fresh(at(6, 18, 20)), at(6, 18, 30))
|
||||
check("18:30, one member last reported 14:54: tomorrow sum is there (not "
|
||||
"flagged kept before 20:00)",
|
||||
f is not None and f.tomorrow is not None and f.tomorrow.kwh == 15.0
|
||||
and not f.kept and gaps == ())
|
||||
f, _w, _g = run(h, fresh(at(6, 17, 40)), at(6, 17, 50))
|
||||
check("17:50 (just under 3 h): fresh, not kept",
|
||||
f is not None and f.tomorrow.kwh == 15.0 and not f.kept)
|
||||
f, _w, _g = run(h, fresh(at(6, 17, 55)), at(6, 18, 0))
|
||||
check("18:00 (stale since 17:54): tomorrow there",
|
||||
f is not None and f.tomorrow is not None and not f.kept)
|
||||
f, _w, _g = run(h, fresh(at(6, 19, 50)), at(6, 19, 55))
|
||||
check("19:55 (before the bell ends): tomorrow there", f.tomorrow is not None)
|
||||
|
||||
# Too old: over KEPT_MAX_AGE_S (12 h) when used.
|
||||
h2 = solarfc.Hold()
|
||||
run(h2, [(4.0, at(6, 5, 0)), (5.0, at(6, 5, 0)), (6.0, at(6, 5, 0))],
|
||||
at(6, 5, 5))
|
||||
w = at(6, 16, 50)
|
||||
f, why, gaps = run(h2, [(4.0, w), (5.0, at(6, 5, 0)), (6.0, w)],
|
||||
at(6, 17, 10))
|
||||
check("member 12 h 10 min old: no tomorrow sum (OPT-17 rule), today stays",
|
||||
f is not None and f.tomorrow is None
|
||||
and [g[0] for g in gaps] == ["tomorrow"])
|
||||
f, _w, _g = run(h2, [(4.0, w), (5.0, at(6, 5, 0)), (6.0, w)],
|
||||
at(6, 16, 50))
|
||||
check("... and 11 h 50 min old is still kept",
|
||||
f is not None and f.tomorrow is not None and not f.kept)
|
||||
|
||||
# Not a partial sum: a member never read has nothing to stand in.
|
||||
f, _w, gaps = run(solarfc.Hold(), [(4.0, at(6, 18, 0)),
|
||||
(5.0, at(6, 14, 54)),
|
||||
(6.0, at(6, 18, 0))], at(6, 18, 30))
|
||||
check("a member with no kept reading: no tomorrow sum, no partial sum",
|
||||
f is not None and f.tomorrow is None and len(gaps) == 1)
|
||||
|
||||
# Today members are as before: a stale today member at 18:30 is not kept.
|
||||
h3 = solarfc.Hold()
|
||||
solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
|
||||
[(M[0], st(9.0, at(6, 14, 54)))], at(6, 14, 55), h3)
|
||||
f, why, _g = solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
|
||||
[(M[0], st(9.0, at(6, 18, 20)))],
|
||||
at(6, 18, 30), h3)
|
||||
check("stale today member at 18:30: not kept, no forecast",
|
||||
f is None and why == solarfc.STALE)
|
||||
f, _w, _g = solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
|
||||
[(M[0], st(9.0, at(6, 20, 0)))],
|
||||
at(6, 20, 30), h3)
|
||||
check("... and at 20:30 it is kept, as before (the bell has ended)",
|
||||
f is not None and f.kept)
|
||||
|
||||
# After midnight: a tomorrow reading from before midnight is never tomorrow's.
|
||||
h4 = solarfc.Hold()
|
||||
run(h4, [(4.0, at(6, 14, 0)), (5.0, at(6, 14, 0)), (6.0, at(6, 14, 0))],
|
||||
at(6, 14, 5))
|
||||
for label, now in (("00:30", at(7, 0, 30)), ("10:00", at(7, 10, 0)),
|
||||
("15:00", at(7, 15, 0))):
|
||||
f, _w, _g = solarfc.read_sum(
|
||||
[("sensor.t", st(2.0, now - timedelta(minutes=5)))],
|
||||
[(e, st(k, at(6, 14, 0))) for e, k in zip(M, (4.0, 5.0, 6.0))],
|
||||
now, h4)
|
||||
check(f"{label} on the 7th: the 6th's tomorrow figure is not the "
|
||||
"7th's tomorrow", f is not None and f.tomorrow is None)
|
||||
|
||||
# Before 20:00 a kept tomorrow reading is not "kept for tonight": today's
|
||||
# line stays the normal one (EN and NL) and the event log (main.py, from
|
||||
# `fc.kept`) has no "kept".
|
||||
h5 = solarfc.Hold()
|
||||
solarfc.read_sum([("t", st(3.0, at(6, 9, 0)))], [("m", st(12.0, at(6, 9, 5)))],
|
||||
at(6, 9, 6), h5)
|
||||
for hh in (14, 18, 19):
|
||||
f, _w, _g = solarfc.read_sum([("t", st(2.5, at(6, hh - 1, 55)))],
|
||||
[("m", st(12.0, at(6, 9, 5)))],
|
||||
at(6, hh, 0), h5)
|
||||
check(f"{hh}:00, today fresh, tomorrow kept from 09:05: tomorrow there, "
|
||||
"not flagged kept", f is not None and f.tomorrow is not None
|
||||
and f.tomorrow.kwh == 12.0 and f.kept is False)
|
||||
f, _w, _g = solarfc.read_sum([("t", st(2.5, at(6, 13, 55)))],
|
||||
[("m", st(12.0, at(6, 9, 5)))], at(6, 14, 0), h5)
|
||||
for lang in ("en", "nl"):
|
||||
with homeview.in_lang(lang):
|
||||
line = homeview.line_solar(f.today.kwh, f.today.kwh * 0.7,
|
||||
homeview.hhmm(f.at), kept=f.kept)
|
||||
kept_line = homeview.line_solar(1, 1, homeview.hhmm(f.at), kept=True)
|
||||
print(f" {lang}: {line!r}")
|
||||
if lang == "en":
|
||||
check("... EN says 'Solar still to come today'",
|
||||
line.startswith("Solar still to come today"))
|
||||
check(f"14:00 Home line ({lang}) is the normal line, not the kept one",
|
||||
line != kept_line and "2,5" in line.replace(".", ",")
|
||||
and "13:55" in line)
|
||||
f, _w, _g = solarfc.read_sum([("t", st(0.2, at(6, 20, 55)))],
|
||||
[("m", st(12.0, at(6, 9, 5)))], at(6, 21, 0), h5)
|
||||
check("21:00 as before: tomorrow kept from 09:05 is flagged kept",
|
||||
f is not None and f.kept and f.tomorrow.kwh == 12.0)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -34,6 +34,8 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from math import inf
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
from app import arbitrage, packshare
|
||||
from named_absence import first, nth, present
|
||||
@@ -1132,8 +1134,7 @@ check("...and misfiling it ON THE SITE is caught and REFUSED, not consumed. A "
|
||||
# main.py's own AST, the way test_peakguard.py part 12i pins the arguments of
|
||||
# `arbitrage.decide()`, and for the same reason: a call site that stopped
|
||||
# passing it is invisible to every fixture.
|
||||
_main_src = (Path(__file__).resolve().parent / "app" / "main.py").read_text(
|
||||
encoding="utf-8")
|
||||
_main_src = main_files.source()
|
||||
_resolve_calls = [n for n in ast.walk(ast.parse(_main_src))
|
||||
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
|
||||
and n.func.id == "resolve"]
|
||||
@@ -1158,9 +1159,7 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
|
||||
# cycle's value, and logs. What it does NOT grade is the sentence it logs or
|
||||
# that a real cycle reaches it; that is stated here rather than implied.
|
||||
_cycle_src = first(
|
||||
f for cls in ast.walk(ast.parse(
|
||||
(Path(__file__).resolve().parent / "app" / "main.py")
|
||||
.read_text(encoding="utf-8")))
|
||||
f for cls in ast.walk(main_files.tree())
|
||||
if isinstance(cls, ast.ClassDef) and cls.name == "Controller"
|
||||
for f in cls.body
|
||||
if isinstance(f, (ast.FunctionDef, ast.AsyncFunctionDef)) and f.name == "cycle")
|
||||
|
||||
@@ -22,6 +22,7 @@ import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
import safety79_identity as rig # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
@@ -207,9 +208,8 @@ check("...and the user-facing peak notice built from the decision's limit "
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. each call site in main.py")
|
||||
with open(os.path.join(HERE, "app", "main.py"), encoding="utf-8") as f:
|
||||
_src = f.read()
|
||||
_tree = ast.parse(_src)
|
||||
_src = main_files.source()
|
||||
_tree = main_files.tree(_src)
|
||||
|
||||
|
||||
def calls(name):
|
||||
|
||||
@@ -135,6 +135,9 @@ from app.arbiter import (
|
||||
)
|
||||
from app.control import Tuning, compute, peak_at_risk
|
||||
from app.freeze import FreezeWatchdogConfig, looks_frozen, rail_w
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as app_main
|
||||
from app.inverter import Inverter, build_roster
|
||||
from app.main import Controller
|
||||
@@ -1295,7 +1298,7 @@ print(" the wiring's own mutations - a default install, maintenance charging")
|
||||
# `peak_at_risk()` answers False forever. That is the whole point - it is the
|
||||
# configuration on which the old suspension could not fire.
|
||||
MAINT_CHARGING = {"phase": "charge"}
|
||||
_mainsrc = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
||||
_mainsrc = main_files.source()
|
||||
|
||||
|
||||
def maint_cycle(tag, want_floor_w=LIVE_MAX_W * 0.3, **kw):
|
||||
@@ -2709,7 +2712,7 @@ CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
|
||||
# silent gap in the scan.
|
||||
CLAIM_BUILDERS = {"peak_claim": "peak", "arb_claim": "arbitrage"}
|
||||
|
||||
_main_tree = ast.parse((HERE / "app" / "main.py").read_text(encoding="utf-8"))
|
||||
_main_tree = main_files.tree()
|
||||
_cycle_def = first((
|
||||
f
|
||||
for cls in ast.walk(_main_tree)
|
||||
@@ -2762,7 +2765,7 @@ def cycle_claims(run):
|
||||
call site and nowhere else.
|
||||
"""
|
||||
seen = []
|
||||
real = app_main.resolve
|
||||
real = main_files.live.resolve
|
||||
|
||||
def _spy(claims, **kw):
|
||||
# ⚠️ `**kw` PASSES THE ROSTER THROUGH UNREAD, AND THAT IS THE WHOLE
|
||||
@@ -2776,11 +2779,11 @@ def cycle_claims(run):
|
||||
seen.append(list(claims))
|
||||
return real(claims, **kw)
|
||||
|
||||
app_main.resolve = _spy
|
||||
main_files.live.resolve = _spy
|
||||
try:
|
||||
out = run()
|
||||
finally:
|
||||
app_main.resolve = real
|
||||
main_files.live.resolve = real
|
||||
return out, (seen[-1] if seen else [])
|
||||
|
||||
|
||||
@@ -3080,13 +3083,13 @@ _unit_sources_unrouted = [
|
||||
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all SEVENTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
check("all EIGHTEEN of `Controller.cycle()`'s DIRECT appends route through "
|
||||
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
|
||||
"of EVERY one of them. An append that computed its actuator, or omitted "
|
||||
"the argument, or hand-wrote a string, fails HERE rather than being "
|
||||
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
||||
"and 10f is in this file because that already happened once",
|
||||
len(_direct) == 17
|
||||
len(_direct) == 18
|
||||
and all(a is not None and a.startswith("self.actuator_of('")
|
||||
for _s, a, _ln in _routing))
|
||||
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
@@ -4977,7 +4980,11 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()
|
||||
# written, at every roster length, in every configuration, and with no fixture
|
||||
# required.
|
||||
_APP_SRC = {p.stem: p.read_text(encoding="utf-8")
|
||||
for p in sorted((HERE / "app").glob("*.py"))}
|
||||
for p in sorted((HERE / "app").glob("*.py"))
|
||||
if p.name == "main.py" or p.name not in main_files.files()}
|
||||
# MAINT-21: main.py and the files split from it, the mixins read as Controller.
|
||||
_APP_SRC["main"] = re.sub(r"^class \w+Mixin:", "class Controller:",
|
||||
main_files.source(), flags=re.M)
|
||||
_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure.
|
||||
_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed",
|
||||
"filter", "iter", "set", "dict"}
|
||||
|
||||
@@ -118,6 +118,8 @@ from app.static_tariff import resolve_tariff
|
||||
|
||||
UTC = timezone.utc
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
# point these fixtures build, so no period has ended and the whole series counts.
|
||||
@@ -2015,8 +2017,8 @@ check("...so calling it without one is a TypeError, not a quiet regression",
|
||||
transport=transport_for(_body_today),
|
||||
now=NOW)), TypeError))
|
||||
|
||||
_MAIN = open(os.path.join(_APP, "main.py"), encoding="utf-8").read()
|
||||
_main_tree = ast.parse(_MAIN)
|
||||
_MAIN = main_files.source()
|
||||
_main_tree = main_files.tree(_MAIN)
|
||||
_refresh = first((n for n in ast.walk(_main_tree)
|
||||
if isinstance(n, ast.AsyncFunctionDef)
|
||||
and n.name == "run_tariff_refresh"), "run_tariff_refresh in main.py")
|
||||
|
||||
@@ -44,6 +44,8 @@ import tempfile
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from named_absence import nth
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as _main
|
||||
from app import messages, price as P, price_chain as C
|
||||
from app.price import (
|
||||
@@ -686,8 +688,8 @@ def controller(clock, o, tmp=None):
|
||||
return ctl
|
||||
|
||||
|
||||
_REAL_DT, _REAL_GET = _main.datetime, P.https_get
|
||||
_real_resolve = _main.resolve_tariff
|
||||
_REAL_DT, _REAL_GET = main_files.live.datetime, P.https_get
|
||||
_real_resolve = main_files.live.resolve_tariff
|
||||
|
||||
|
||||
class Wired:
|
||||
@@ -701,7 +703,7 @@ class Wired:
|
||||
self.refreshes = 0
|
||||
|
||||
def __enter__(self):
|
||||
_main.datetime = FakeDT
|
||||
main_files.live.datetime = FakeDT
|
||||
P.https_get = self.net
|
||||
W = self
|
||||
|
||||
@@ -709,12 +711,12 @@ class Wired:
|
||||
r = await _real_resolve(*a, **k)
|
||||
W.refreshes += 1
|
||||
return r
|
||||
_main.resolve_tariff = counting
|
||||
main_files.live.resolve_tariff = counting
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
_main.datetime, P.https_get = _REAL_DT, _REAL_GET
|
||||
_main.resolve_tariff = _real_resolve
|
||||
main_files.live.datetime, P.https_get = _REAL_DT, _REAL_GET
|
||||
main_files.live.resolve_tariff = _real_resolve
|
||||
|
||||
async def refresh(self, now=None):
|
||||
if now is not None:
|
||||
|
||||
@@ -692,7 +692,13 @@ def touches(src: str) -> bool:
|
||||
or "from .pv import" in src
|
||||
|
||||
|
||||
_touch = sorted(f.name for f in APP if touches(f.read_text(encoding="utf-8")))
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
# MAINT-21: the files split from app/main.py are counted as main.py.
|
||||
_SPLIT = set(main_files.files()) - {"main.py"}
|
||||
_touch = sorted({"main.py" if f.name in _SPLIT else f.name for f in APP
|
||||
if touches(f.read_text(encoding="utf-8"))})
|
||||
print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
|
||||
check(f"PRECONDITION: the scan found the whole package, not a subset - "
|
||||
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
|
||||
|
||||
@@ -142,8 +142,11 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
|
||||
**LIVE), hass, _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m
|
||||
real, real_dt = m.compute, m.datetime
|
||||
real, real_dt = main_files.live.compute, main_files.live.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -153,9 +156,9 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
if old:
|
||||
m.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
|
||||
main_files.live.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
|
||||
rows, trips, was = [], 0, False
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -174,7 +177,7 @@ def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
|
||||
hass.cmd, c.i_w, c.loop_reason, c.batt_refused))
|
||||
t += TICK
|
||||
finally:
|
||||
m.compute, m.datetime = real, real_dt
|
||||
main_files.live.compute, main_files.live.datetime = real, real_dt
|
||||
return rows, trips, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ from app import control
|
||||
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W
|
||||
from app.main import Controller
|
||||
from app.arbiter import P_LOOP, P_PEAK, P_SAFETY, Claim, resolve
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m
|
||||
|
||||
fails = []
|
||||
@@ -177,7 +180,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
||||
c.auto = True
|
||||
if old:
|
||||
_off(c)
|
||||
real_dt = m.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -185,7 +188,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows = []
|
||||
trips = {u: 0 for u in UNITS}
|
||||
was = {u: False for u in UNITS}
|
||||
@@ -211,7 +214,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
||||
db=c.inverters[1].delivery_wd.tripped))
|
||||
t += dt
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return rows, trips, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ def check(name, cond):
|
||||
print("1. the harness's own preconditions")
|
||||
# --------------------------------------------------------------------------- #
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
import rehearse_roster as RR # noqa: E402
|
||||
|
||||
check("the tool refuses rather than passing when it cannot run: its four exit "
|
||||
@@ -210,6 +211,11 @@ def mutant_tree(edits) -> str:
|
||||
dest / "tools" / "predeploy_check.py")
|
||||
shutil.copyfile(HERE / "config.yaml", dest / "config.yaml")
|
||||
for rel, old, new in edits:
|
||||
if rel == "app/main.py":
|
||||
# MAINT-21: app/main.py stands for the files split from it.
|
||||
where = [n for n in main_files.files() if (dest / "app" / n)
|
||||
.read_text(encoding="utf-8").count(old) == 1]
|
||||
rel = f"app/{where[0]}" if len(where) == 1 else rel
|
||||
path = dest / rel
|
||||
text = path.read_text(encoding="utf-8")
|
||||
hits = text.count(old)
|
||||
|
||||
@@ -35,6 +35,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.arbiter import Claim, P_ARBITRAGE # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -61,7 +62,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
(a writer above the loop), or None."""
|
||||
log, rows, now = [], [], [0.0]
|
||||
real_power, real_tick = rig._Hass.power, rig.TICK
|
||||
real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve,
|
||||
real_log, real_resolve, real_c = (Controller.log_event, main_files.live.resolve,
|
||||
rig.Controller)
|
||||
|
||||
def power(self, u):
|
||||
@@ -108,7 +109,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
extra.update(s79.P1, peak_cap_w=cap)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db")
|
||||
rig._Hass.power, rig.TICK = power, tick
|
||||
Controller.log_event, app_main.resolve = log_event, resolve
|
||||
Controller.log_event, main_files.live.resolve = log_event, resolve
|
||||
rig.Controller = functools.partial(real_c, peak_db_path=db)
|
||||
try:
|
||||
_, c = rig.run(n, extra=extra, dur=dur, load=house,
|
||||
@@ -117,7 +118,7 @@ def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
store=s79.store_charging(n) if maint else rig._Store())
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_power, real_tick
|
||||
Controller.log_event, app_main.resolve = real_log, real_resolve
|
||||
Controller.log_event, main_files.live.resolve = real_log, real_resolve
|
||||
rig.Controller = real_c
|
||||
return rows, log, c
|
||||
|
||||
|
||||
@@ -17,8 +17,11 @@ Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import os # noqa: E402
|
||||
import sys # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as _main
|
||||
from app import messages, pv, validate
|
||||
from app.main import Controller
|
||||
@@ -217,7 +220,7 @@ check(f"one read of the forecast entity per read_inputs() ({h.reads})",
|
||||
|
||||
print("4. maintenance headroom and the charge-only clamp use watts")
|
||||
seen = []
|
||||
_real = _main.maintenance_charge_floor
|
||||
_real = main_files.live.maintenance_charge_floor
|
||||
|
||||
|
||||
def spy(charge_w, fc, cap):
|
||||
@@ -226,7 +229,7 @@ def spy(charge_w, fc, cap):
|
||||
return out
|
||||
|
||||
|
||||
_main.maintenance_charge_floor = spy
|
||||
main_files.live.maintenance_charge_floor = spy
|
||||
try:
|
||||
def cycle(fc, **kw):
|
||||
c, h = make(fc, store={"phase": "charge"},
|
||||
@@ -264,7 +267,7 @@ try:
|
||||
check("no unit: forecast unused, clamp in place, as with the option empty",
|
||||
clamp(st(6000)) is False)
|
||||
finally:
|
||||
_main.maintenance_charge_floor = _real
|
||||
main_files.live.maintenance_charge_floor = _real
|
||||
|
||||
print("5. validator and strings")
|
||||
def notes(unit, key="peak_forecast_entity"):
|
||||
@@ -367,7 +370,8 @@ check("pv.unit_fault: None for a good unit and for a missing reading (has_readin
|
||||
pv.unit_fault(st(1, "kW")) is None and pv.unit_fault(None) is None
|
||||
and pv.unit_fault(st("unavailable")) is None
|
||||
and pv.unit_fault(st(1)) == "" and pv.unit_fault(st(1, "kWh")) == "kWh")
|
||||
src = (Path(__file__).parent / "app" / "main.py").read_text(encoding="utf-8")
|
||||
# MAINT-21: main.py and the files split from it (the forecast read is in ctl_inputs).
|
||||
src = main_files.source()
|
||||
check("the size rule is gone from main.py", "abs(raw) < 50" not in src)
|
||||
|
||||
print()
|
||||
|
||||
@@ -59,6 +59,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import messages # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -413,8 +414,8 @@ _rg = {t: never(t, hw, seed=1, delay=0) for t, hw in RIG.items()}
|
||||
_rg_old = {t: never(t, hw, fix=False, seed=1, delay=0)
|
||||
for t, hw in RIG.items()}
|
||||
# SAFETY-119 as merged (bee7431) at 5 s: a settle of 12 cycles, budget 8.
|
||||
_rg_119 = patched(app_main, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
|
||||
lambda: patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
|
||||
_rg_119 = patched(main_files.live, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
|
||||
lambda: patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)(
|
||||
lambda: never(5.0, 5100.0, seed=1, delay=0)))
|
||||
for _t in _rg:
|
||||
print(f" rig shape, {_t:.0f} s meter, house {RIG[_t]:.0f} W: per "
|
||||
@@ -464,10 +465,10 @@ check("...every command the same as without the fix; without it the false "
|
||||
# the rig's shape (1 s, house 5050 W, seed 1).
|
||||
rig.TICK = 2.0
|
||||
_q25 = (run(house(25), delay=2),
|
||||
patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
|
||||
patched(main_files.live, "ROLLING_BOUND_CYCLES", 8)(
|
||||
lambda: run(house(25), delay=2)))
|
||||
rig.TICK = 1.0
|
||||
_m14 = patched(app_main, "ROLLING_BOUND_CYCLES", 14)(
|
||||
_m14 = patched(main_files.live, "ROLLING_BOUND_CYCLES", 14)(
|
||||
lambda: never(1.0, 5050.0, seed=1, delay=0))
|
||||
_m11 = never(1.0, 5050.0, seed=1, delay=0)
|
||||
print(f" 2 s, quarter edge, seed 25: {notes(_q25[0][1])}, budget 8 "
|
||||
@@ -480,7 +481,7 @@ check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every "
|
||||
and every_quarter(_m11[1]) and not every_quarter(_m14[1]))
|
||||
check("the rule is the one the sweep chose. Changing it means re-running "
|
||||
"the sweep in DECISIONS/SAFETY-121-bound-every-quarter.md",
|
||||
app_main.HANDBACK_CYCLES == 4 and app_main.ROLLING_BOUND_CYCLES == 11
|
||||
main_files.live.HANDBACK_CYCLES == 4 and main_files.live.ROLLING_BOUND_CYCLES == 11
|
||||
and app_main.ROLLING_S == 900.0 and app_main.SHORT_SPAN_S == 22.0)
|
||||
|
||||
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
|
||||
|
||||
@@ -37,6 +37,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
@@ -492,13 +493,13 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
|
||||
"2027-03-28T00:00:05+00:00"):
|
||||
now = datetime.fromisoformat(iso)
|
||||
c, hass = controller(MODES["Leftover solar"], now)
|
||||
real_dt = M.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None, _n=now):
|
||||
return _n if tz is None else _n.astimezone(tz)
|
||||
M.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
try:
|
||||
async def one():
|
||||
await c.read_inputs(now_mono=0.0)
|
||||
@@ -510,7 +511,7 @@ for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
|
||||
except Slow:
|
||||
ok = False
|
||||
finally:
|
||||
M.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a "
|
||||
"day) returns with a day plan", ok)
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
import app.p1 as p1mod # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
@@ -164,14 +165,14 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
|
||||
c.auto = True
|
||||
real = m.datetime, homeview.datetime, p1mod.time
|
||||
real = main_files.live.datetime, homeview.datetime, p1mod.time
|
||||
clock, simt = [t0], [0.0]
|
||||
|
||||
class _DT(real[0]):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
|
||||
real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
@@ -223,7 +224,7 @@ def drive(o, *, peak=None, late=0.0, minutes=31, every=0, start=None,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime, p1mod.time = real
|
||||
main_files.live.datetime, homeview.datetime, p1mod.time = real
|
||||
_price.load_prices, _st.load_prices = real_load
|
||||
return c
|
||||
c = asyncio.run(go())
|
||||
|
||||
@@ -43,6 +43,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
import app.p1 as p1mod # noqa: E402
|
||||
from app import homeview, messages, peakguard, tariff # noqa: E402
|
||||
@@ -179,14 +180,14 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -5000.0, 5000.0
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -4600.0, 4600.0
|
||||
c.auto = True
|
||||
real = m.datetime, homeview.datetime, p1mod.time
|
||||
real = main_files.live.datetime, homeview.datetime, p1mod.time
|
||||
clock, simt = [t0], [0.0]
|
||||
|
||||
class _DT(real[0]):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + simt[0])
|
||||
real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
@@ -237,7 +238,7 @@ def drive(o, *, modes=OWNER, socs=(10.0, 10.0), house=600.0, minutes=46,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime, p1mod.time = real
|
||||
main_files.live.datetime, homeview.datetime, p1mod.time = real
|
||||
_price.load_prices, _st.load_prices = real_load
|
||||
return c
|
||||
out["c"] = asyncio.run(go())
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
"""Runnable check for SAFETY-128: control does not act on late readings.
|
||||
`python3 test_safety128.py`
|
||||
|
||||
When Home Assistant runs behind, its readings arrive late but still change, so
|
||||
every staleness gate passes them. The loop corrected again, each cycle, an
|
||||
error its last command had already fixed, and the evening give run swung with
|
||||
growing export (SAFETY-127 rig: 10 s late, commands -450..+3 790 W, grid to
|
||||
-1 947 W). The peak guard pushed to the inverter limit on the same late figures
|
||||
(OPT-34 rig F-2). Now a command the battery reading has not answered holds the
|
||||
loop and caps the peak guard (app/lateness.py), and readings found late hold,
|
||||
then go to 0 W. DECISIONS/SAFETY-128-late-readings.md.
|
||||
|
||||
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Without the
|
||||
gate" is the same run with `Controller._readings_late` returning None, which
|
||||
is the tree before this card (the rig runs unchanged on 77fe31b).
|
||||
|
||||
1. The give run (two batteries, house 1 800 W), readings 10 s late for 150 s
|
||||
and 30 s late for 300 s: without the gate the swing grows to export below
|
||||
-1 kW; with it the command stays near the last good one, no export below
|
||||
-200 W, and it settles once the lag stops. N=1 too.
|
||||
2. The peak guard in a quarter's last 30 s (a charge at the peak aim, readings
|
||||
30 s late, the house steps): it does not go to the inverter limit.
|
||||
3. Seen: the late hold says so on the page (EN and NL) and logs once per
|
||||
episode.
|
||||
4. Nothing else moves: no-lag runs at 1, 2 and 5 s meters, a load step on a
|
||||
battery with a 6 s dead time (SAFETY-75), start-up, a battery at its
|
||||
cut-out level: within the stated tolerance of the run without the gate.
|
||||
5. `Answer` and `compute(hold=)` at their edges.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import safety128_rig as R # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import control, lateness, messages # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def no_gate(fn):
|
||||
"""`fn()` with the answer gate off: the tree before SAFETY-128."""
|
||||
real = app_main.Controller._readings_late
|
||||
app_main.Controller._readings_late = lambda self, standing_w: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
app_main.Controller._readings_late = real
|
||||
|
||||
|
||||
def window(rows, t0, t1):
|
||||
return [r for r in rows if t0 <= r[0] < t1]
|
||||
|
||||
|
||||
def last_good(rows, t):
|
||||
"""The site command standing just before `t`."""
|
||||
return [r for r in rows if r[0] < t][-1][2]
|
||||
|
||||
|
||||
def settled_at(rows, t0, band=150.0):
|
||||
"""First second from `t0` after which the grid stays within `band` W."""
|
||||
out = None
|
||||
for r in rows:
|
||||
if r[0] < t0:
|
||||
continue
|
||||
if abs(r[1]) > band:
|
||||
out = None
|
||||
elif out is None:
|
||||
out = r[0]
|
||||
return out
|
||||
|
||||
|
||||
def swing(rows, t0, t1):
|
||||
w = window(rows, t0, t1)
|
||||
return max(r[1] for r in w) - min(r[1] for r in w)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the give run with readings late")
|
||||
ON = 300.0
|
||||
CASES = ((10.0, 150.0), (30.0, 300.0))
|
||||
for p1, units in ((2.0, ("a", "b")), (2.0, ("a",)), (1.0, ("a", "b")),
|
||||
(5.0, ("a", "b"))):
|
||||
house = 1800.0 if len(units) == 2 else 1500.0
|
||||
for lag, span in CASES:
|
||||
off = ON + span
|
||||
seen = []
|
||||
new = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
|
||||
units=units, p1_s=p1)
|
||||
new.run(off + 120.0, lambda rig, c, t: seen.append(
|
||||
(t, c.answer.late_seen)) if c.answer.late_seen else None)
|
||||
old = R.Rig(house=house, lag_s=lag, lag_on=ON, lag_off=off,
|
||||
units=units, p1_s=p1)
|
||||
no_gate(lambda: old.run(off + 120.0))
|
||||
n = f"N={len(units)}, {p1:.0f} s meter, {lag:.0f} s late for {span:.0f} s"
|
||||
wo, wn = window(old.rows, ON, off), window(new.rows, ON, off)
|
||||
good = last_good(new.rows, ON)
|
||||
half = ON + span / 2
|
||||
red = min(r[1] for r in wo) < -1000.0
|
||||
found = min([t for t, _l in seen] or [None])
|
||||
print(f" {n}: without the gate cmd {min(r[2] for r in wo):.0f}.."
|
||||
f"{max(r[2] for r in wo):.0f} W, grid {min(r[1] for r in wo):.0f}"
|
||||
f"..{max(r[1] for r in wo):.0f} W (swing {swing(old.rows, ON, half):.0f}"
|
||||
f" then {swing(old.rows, half, off):.0f}); with it cmd "
|
||||
f"{min(r[2] for r in wn):.0f}..{max(r[2] for r in wn):.0f} W "
|
||||
f"(last good {good:.0f}), grid {min(r[1] for r in wn):.0f}.."
|
||||
f"{max(r[1] for r in wn):.0f} W, found late "
|
||||
f"{None if found is None else found - ON} s after the start, "
|
||||
f"settled {settled_at(new.rows, off) - off:.0f} s after")
|
||||
if len(units) == 2 and p1 == 2.0:
|
||||
check(f"{n}: without the gate the export goes below -1 kW and the "
|
||||
f"swing grows (red on 77fe31b)",
|
||||
red and swing(old.rows, half, off) > swing(old.rows, ON, half))
|
||||
if red:
|
||||
check(f"{n}: found late while the lag lasts", found is not None
|
||||
and found < off)
|
||||
if p1 < 5.0:
|
||||
# Found within about a minute: at 2 s the brief's bounds; at 1 s
|
||||
# the swing grows faster before it is found, so twice them.
|
||||
lim = 250.0 if p1 == 2.0 else 500.0
|
||||
check(f"{n}: with it the command stays within {lim:.0f} W of the "
|
||||
f"last good one and no export below -{lim - 50:.0f} W",
|
||||
all(abs(r[2] - good) <= lim for r in wn)
|
||||
and min(r[1] for r in wn) >= -(lim - 50.0))
|
||||
elif red:
|
||||
# 5 s meter: only the batteries' fit runs (too few meter values),
|
||||
# so it is found later; the export is cut to under a third.
|
||||
check(f"{n}: with it the export is under a third of the run "
|
||||
f"without the gate",
|
||||
min(r[1] for r in wn) >= min(r[1] for r in wo) / 3.0)
|
||||
check(f"{n}: settled within 30 s after the lag stops",
|
||||
settled_at(new.rows, off) is not None
|
||||
and settled_at(new.rows, off) - off <= 30.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the peak guard in a quarter's last 30 s")
|
||||
# A charge sized to the peak aim (arbitrage on a cheap hour, the shape of the
|
||||
# owner's night charge), readings 30 s late from 300 s; the house steps
|
||||
# +2 000 W 100 s before the quarter ends (900 s).
|
||||
PTS = [(R.BASE + timedelta(hours=h), 3600, 0.02 if h < 2 else 0.40)
|
||||
for h in range(-3, 30)]
|
||||
|
||||
|
||||
def peak_run(lag, gate=True, step_at=800.0):
|
||||
seen = []
|
||||
|
||||
def sample(rig, c, t):
|
||||
now = rig.start + timedelta(seconds=t)
|
||||
if int(t) % 300 == 0:
|
||||
c.tariff_plan = rp.plan_at(now, PTS)
|
||||
c.tariff_at = now
|
||||
g = c.peak_guard
|
||||
if g is not None and 870.0 <= t < 900.0 and g.engaged:
|
||||
seen.append((t, g.saturated, float(g.floor_w or 0.0),
|
||||
float(c.target)))
|
||||
r = R.Rig(house=lambda t: 1000.0 + (2000.0 if t >= step_at else 0.0),
|
||||
lag_s=lag, lag_on=ON, lag_off=2000.0, soc=30.0,
|
||||
extra={"arbitrage_enabled": True,
|
||||
"cycle_cost_threshold_eur": 0.03})
|
||||
(lambda f: f() if gate else no_gate(f))(lambda: r.run(900.0, sample))
|
||||
return seen, r
|
||||
|
||||
|
||||
RATING = sum(R.MAXW.values())
|
||||
for lag in (10.0, 30.0):
|
||||
pg_new, _r = peak_run(lag)
|
||||
pg_old, _r = peak_run(lag, gate=False)
|
||||
top_new = max([s[2] for s in pg_new] or [0.0])
|
||||
top_old = max([s[2] for s in pg_old] or [0.0])
|
||||
print(f" {lag:.0f} s late: the guard's largest ask in the last 30 s "
|
||||
f"without the gate {top_old:.0f} W ({sum(s[1] for s in pg_old)} "
|
||||
f"cycles at the limit), with it {top_new:.0f} W "
|
||||
f"({sum(s[1] for s in pg_new)})")
|
||||
if lag == 30.0:
|
||||
check("without the gate the guard goes to the inverter limit on the "
|
||||
"late projection (red on 77fe31b)",
|
||||
top_old >= RATING and any(s[1] for s in pg_old))
|
||||
check(f"{lag:.0f} s late: with it the guard never goes to the limit and "
|
||||
f"never says it is saturated",
|
||||
top_new < RATING and not any(s[1] for s in pg_new))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. the page and the log say so")
|
||||
rows_seen = []
|
||||
|
||||
|
||||
def watch(rig, c, t):
|
||||
rows_seen.extend((t, r) for r in c.checks() if r.get("id") == "late")
|
||||
|
||||
|
||||
late = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0)
|
||||
c = late.run(ON + 300.0 + lateness.FIT_GONE_S + 60.0, watch)
|
||||
log = [e for e in c.events if "readings late" in e or "readings on time" in e]
|
||||
n_late = sum(1 for e in log if "readings late" in e)
|
||||
n_back = sum(1 for e in log if "readings on time" in e)
|
||||
first = min([t for t, _r in rows_seen] or [None])
|
||||
lastr = max([t for t, _r in rows_seen] or [None])
|
||||
print(f" 30 s late for 300 s: {n_late} late episode(s), {n_back} end(s), "
|
||||
f"the row from {first} to {lastr} s ({len(rows_seen)} s); "
|
||||
f"{rows_seen[0][1]['text'] if rows_seen else ''}")
|
||||
_ok_texts = {str(messages.row_readings_late(None))} | {
|
||||
str(messages.row_readings_late(d)) for d in range(20, 33)}
|
||||
check("found late within 70 s of the lag starting, and an amber row says "
|
||||
"so in plain words, EN and NL, with the batteries' delay once they have "
|
||||
"shown it (20-32 s for 30 s late; never a figure from the meter alone)",
|
||||
first is not None and first - ON <= 70.0
|
||||
and all(r["warn"] and not r["ok"] for _t, r in rows_seen)
|
||||
and {str(r["text"]) for _t, r in rows_seen} <= _ok_texts
|
||||
and rows_seen[-1][1]["text"].nl.startswith(
|
||||
"Metingen uit Home Assistant tonen vermogensvraag"))
|
||||
rows5 = []
|
||||
late5 = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, p1_s=5.0)
|
||||
late5.run(ON + 300.0, lambda rig, c, t: rows5.extend(
|
||||
str(r["text"]) for r in c.checks() if r.get("id") == "late"))
|
||||
print(f" 5 s meter, 30 s late: {sorted(set(rows5))}")
|
||||
# SAFETY-129: the 5 s meter's fit finds it first (no figure); the hold then
|
||||
# leaves the batteries' fit nothing to measure, so the row names no delay.
|
||||
check("at a 5 s meter the row says the readings are late, with no figure "
|
||||
"(the meter alone found it)",
|
||||
rows5 and set(rows5) == {str(messages.row_readings_late(None))})
|
||||
check("logged once for the episode, and once when it ends, not per cycle",
|
||||
n_late == 1 and n_back == 1)
|
||||
check("the row goes once a fit shows the readings on time, or 2 minutes "
|
||||
"after the last fit, at most 160 s after the lag (a steady site shows "
|
||||
"nothing to fit)",
|
||||
lastr is not None
|
||||
and lastr - (ON + 300.0) <= lateness.FIT_S + lateness.FIT_GONE_S
|
||||
and not [r for r in c.checks() if r.get("id") == "late"])
|
||||
# The late verdict through the real cycle(): readings found late (30 s), then
|
||||
# the gate is frozen with its verdict 5 s old (held) or 20 s old (past
|
||||
# stale_input_s, 15 s).
|
||||
seen_zero = {}
|
||||
|
||||
|
||||
def verdict(after_s):
|
||||
def at(rig, c, t):
|
||||
if t == 500.0:
|
||||
c.answer.observe = lambda *a, **k: None
|
||||
c.answer.late_seen, c.answer.late_at = True, c._now_mono - after_s
|
||||
seen_zero[after_s] = [c.target]
|
||||
if t == 506.0:
|
||||
seen_zero[after_s] += [
|
||||
c.target, c.reason, [r["text"] for r in c.checks()
|
||||
if r.get("id") == "late"], c.why_words]
|
||||
v = R.Rig(lag_s=30.0, lag_on=ON, lag_off=2000.0)
|
||||
v.run(507.0, at)
|
||||
|
||||
|
||||
verdict(5.0)
|
||||
verdict(20.0)
|
||||
held, zero = seen_zero[5.0], seen_zero[20.0]
|
||||
print(f" late verdict 5 s old: {held[:3]}; 20 s old: {zero[:3]}")
|
||||
check("the late verdict holds the request until stale_input_s, then every "
|
||||
"inverter goes to 0 W, and the row says so (EN and NL)",
|
||||
held[1] == held[0] and held[2].startswith("readings late")
|
||||
and zero[1] == 0.0 and zero[4].startswith(
|
||||
"Home Assistant readings arrive late: 0 W")
|
||||
and len(zero[3]) == 1 and "every inverter is at 0 W" in zero[3][0]
|
||||
and "omvormers op 0 W" in zero[3][0].nl)
|
||||
on_time = R.Rig(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
|
||||
c = on_time.run(600.0)
|
||||
check("readings on time, a load step: no late episode, no row",
|
||||
not [e for e in c.events if "readings late" in e]
|
||||
and not c.answer.late_seen)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. nothing else moves")
|
||||
STEP = dict(house=lambda t: 600.0 + (2500.0 if t >= 200.0 else 0.0))
|
||||
|
||||
|
||||
def compare(name, tol_w, **kw):
|
||||
a = R.Rig(**kw)
|
||||
a.run(500.0)
|
||||
b = R.Rig(**kw)
|
||||
no_gate(lambda: b.run(500.0))
|
||||
diff = max(abs(x[2] - y[2]) for x, y in zip(a.rows, b.rows))
|
||||
sa, sb = settled_at(a.rows, 200.0), settled_at(b.rows, 200.0)
|
||||
print(f" {name}: largest command difference {diff:.0f} W, settled "
|
||||
f"{sa - 200.0 if sa else None} s after the step (without the gate "
|
||||
f"{sb - 200.0 if sb else None} s)")
|
||||
return diff, sa, sb, a
|
||||
|
||||
|
||||
for p1 in (1.0, 2.0, 5.0):
|
||||
for units in (("a", "b"), ("a",)):
|
||||
d, sa, sb, _a = compare(f"no lag, {p1:.0f} s meter, N={len(units)}, "
|
||||
f"house 600 -> 3 100 W", 0.0, p1_s=p1,
|
||||
units=units, **STEP)
|
||||
check(f"{p1:.0f} s meter, N={len(units)}: settles within 10 s of the "
|
||||
f"run without the gate",
|
||||
sa is not None and sb is not None and sa - sb <= 10.0)
|
||||
d, sa, sb, a75 = compare("SAFETY-75 shape, 6 s dead time", 0.0, dead_s=6.0,
|
||||
**STEP)
|
||||
check("6 s dead time (SAFETY-75): the same commands as without the gate, and "
|
||||
"no late episode (the fit says 5-7 s, under 8 s)",
|
||||
d == 0.0 and not any("readings late" in e for e in a75.c.events))
|
||||
d, sa, sb, a0 = compare("start-up, the house at 2 000 W from the first second",
|
||||
0.0, house=2000.0)
|
||||
check("start-up: the first 120 s reach the house within 30 s, as without the "
|
||||
"gate, and no late episode",
|
||||
settled_at(a0.rows, 0.0) is not None and settled_at(a0.rows, 0.0) <= 30.0
|
||||
and not any("readings late" in e for e in a0.c.events))
|
||||
for on, off in ((45, 60), (30, 30)):
|
||||
house = (lambda t, on=on, off=off:
|
||||
400.0 + (2000.0 if t % (on + off) < on else 0.0))
|
||||
oven = R.Rig(house=house, noise=80.0)
|
||||
oven.run(1500.0)
|
||||
oven0 = R.Rig(house=house, noise=80.0)
|
||||
no_gate(lambda: oven0.run(1500.0))
|
||||
check(f"an oven cycling 2 kW {on} s on, {off} s off (2 s meter, readings "
|
||||
f"on time): never taken for late readings ({oven.c.answer.fits} "
|
||||
"fits), the same commands as without the gate",
|
||||
oven.c.answer.fits > 100 and not any(
|
||||
"readings late" in e for e in oven.c.events)
|
||||
and [r[2] for r in oven.rows] == [r[2] for r in oven0.rows])
|
||||
# One battery that empties to its cut-out level under an oven (1 s meter):
|
||||
# its fit lined the old movement up as 85 s late (round 2, before the fix).
|
||||
emp = R.Rig(house=lambda t: 400.0 + (2000.0 if t % 105 < 45 else 0.0),
|
||||
noise=150.0, soc=10.6, units=("a",), p1_s=1.0)
|
||||
emp.run(1800.0)
|
||||
check("one battery emptying to its cut-out level under a cycling oven (1 s "
|
||||
"meter): not taken for late readings",
|
||||
not any("readings late" in e for e in emp.c.events))
|
||||
emp2 = R.Rig(house=lambda t: 400.0 + (2000.0 if t % 60 < 30 else 0.0),
|
||||
noise=150.0, soc=10.2, units=("a",), p1_s=2.0)
|
||||
emp2.run(1800.0)
|
||||
check("one empty battery under an oven cycling 30/30 s (2 s meter): its "
|
||||
"requests do not start, but it gives nothing, so that is no evidence "
|
||||
"for the meter fit",
|
||||
not any("readings late" in e for e in emp2.c.events))
|
||||
# The recorded house (2026-09-02, its first 2 hours, modes off) at a 2 s
|
||||
# meter: the meter fit alone read it as up to 65 s late here (review, B1).
|
||||
from datetime import datetime as _dt, timezone as _tz # noqa: E402
|
||||
_tick, rp.TICK = rp.TICK, 2.0
|
||||
_late = []
|
||||
try:
|
||||
_st, _rc = rp.run("modes_off", rp.load_house(["2026-09-02"]),
|
||||
rp.epex_points(),
|
||||
_dt.fromisoformat("2026-09-02T00:00:00+02:00")
|
||||
.astimezone(_tz.utc), 7200.0,
|
||||
check=lambda c, h, now, pr: _late.append(
|
||||
c.answer.late_seen))
|
||||
finally:
|
||||
rp.TICK = _tick
|
||||
check(f"the recorded house, 2 hours at a 2 s meter: {_rc.answer.fits} fits, "
|
||||
"never taken for late readings",
|
||||
_rc.answer.fits > 100 and not any(_late))
|
||||
import safety94_rig as s94 # noqa: E402
|
||||
slow = [(n, tau, tick) for n in (1, 2) for tau in (10.0, 20.0)
|
||||
for tick in (1.0, 2.0)
|
||||
if any("readings late" in e for e in s94.run(
|
||||
n, s94.reversal(300.0, -2500.0, 800.0), 600.0, tau=tau,
|
||||
tick=tick)["c"].events)]
|
||||
check("a slow battery (10 or 20 s lag, SAFETY-85/94's rig) reversing out of a "
|
||||
"charge, readings on time: never taken for late readings, N=1 and N=2, "
|
||||
f"1 and 2 s meters (taken: {slow})", not slow)
|
||||
for name, kw in (("at their cut-out level", dict(soc=10.5)),
|
||||
("A refusing from 200 s",
|
||||
dict(cut=lambda u, t: u == "a" and t >= 200.0))):
|
||||
x = R.Rig(house=1800.0, **kw)
|
||||
x.run(600.0)
|
||||
y = R.Rig(house=1800.0, **kw)
|
||||
no_gate(lambda: y.run(600.0))
|
||||
check(f"readings on time, batteries {name}: not taken for late "
|
||||
f"readings, the same commands as without the gate",
|
||||
not any("readings late" in e for e in x.c.events)
|
||||
and [r[2] for r in x.rows] == [r[2] for r in y.rows])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. the rules at their edges")
|
||||
check("LATE_S is SAFETY-75's slowest healthy answer: 6 + 6 + 10 + 1 = 23 s",
|
||||
lateness.LATE_S == 23.0)
|
||||
check("ANSWER_W is a probe's answer (100 W)",
|
||||
lateness.ANSWER_W == control.PROBE_ANSWER_W == 100.0)
|
||||
|
||||
|
||||
check("late from ON_S 9.5 s, on time again under OFF_S 6.5 s",
|
||||
(lateness.ON_S, lateness.OFF_S) == (9.5, 6.5))
|
||||
|
||||
|
||||
def fit(delay, *, follows=True, step_every=20, dur=300, tau=0.0, units="ab"):
|
||||
"""`Delay` on batteries whose readings, published every 10 s at their own
|
||||
phase, show each request `delay` s late (through a `tau` s lag), or never
|
||||
(`follows` False)."""
|
||||
import math
|
||||
import random
|
||||
rng, d = random.Random(7), lateness.Delay()
|
||||
edges = [0]
|
||||
while edges[-1] < dur:
|
||||
edges.append(edges[-1] + step_every * rng.uniform(0.5, 1.5))
|
||||
cmd = lambda t: 1500.0 + (600.0 if sum(e <= t for e in edges) % 2 else 0.0)
|
||||
act, shown = 1500.0, {}
|
||||
for t in range(0, dur):
|
||||
want = cmd(t - delay) if t >= delay else 1500.0
|
||||
act = want if not tau else act + (want - act) * (1 - math.exp(-1 / tau))
|
||||
for k, u in enumerate(units):
|
||||
if (t + 4 * k) % 10 == 0:
|
||||
shown[u] = round(act + rng.uniform(-3, 3)) if follows else 900
|
||||
d.add(float(t), u, cmd(t), shown.get(u))
|
||||
return d.estimate()
|
||||
|
||||
|
||||
got = {k: fit(k) for k in (0, 6, 10, 30, 60)}
|
||||
print(f" fitted delays: {got}; one battery {fit(10, units='a')}")
|
||||
check("the fit finds the readings' delay (0, 6, 10, 30, 60 s) to within 2 s "
|
||||
"(it reads the request over the 2 s before), also with one battery",
|
||||
all(v is not None and abs(v - k) <= 2.0 for k, v in got.items())
|
||||
and abs((fit(10, units="a") or 0.0) - 10.0) <= 2.0)
|
||||
slow = [fit(0, tau=tau, step_every=s) for tau in (3.0, 10.0, 20.0)
|
||||
for s in (10, 20, 40)]
|
||||
check("a battery that does not follow gives no fit, nor does a request that "
|
||||
f"does not move, and a slow battery (3, 10, 20 s lag) is never late "
|
||||
f"({slow})",
|
||||
fit(10, follows=False) is None and fit(10, step_every=10_000) is None
|
||||
and all((d or 0.0) < lateness.ON_S for d in slow))
|
||||
|
||||
|
||||
def gate(steps, exempt=(), late_seen=True):
|
||||
"""[(t, command, battery reading, meter)] through one Answer, its fit
|
||||
replaced by `late_seen`; after each: (pending, hold, late s, peak cap)."""
|
||||
a = lateness.Answer()
|
||||
a.late_seen = late_seen
|
||||
|
||||
def one(t, cmd, batt, grid):
|
||||
a.observe(float(t), cmd, batt, grid_w=grid, exempt=t in exempt)
|
||||
return (a.ask is not None, a.hold, a.late_s(float(t)),
|
||||
a.peak_cap(cmd))
|
||||
return a, [one(*x) for x in steps]
|
||||
|
||||
|
||||
STEP = [(0, 1000, 1000, 500), (2, 1500, 1000, 500)]
|
||||
HELD = STEP + [(t, 1500, 1000, 500) for t in range(4, 40, 2)]
|
||||
a, out = gate(HELD, late_seen=False)
|
||||
check("readings on time: an unanswered ask holds nothing, caps nothing and "
|
||||
"is never late (a battery at its limit)",
|
||||
out[-1][0] and all(o[1] == 0 and o[2] is None and o[3] is None
|
||||
for o in out))
|
||||
a, out = gate(STEP + [(4, 1500, 1000, 500), (6, 1500, 1450, 60)])
|
||||
check("readings late: the ask holds the loop up and caps the peak guard at "
|
||||
"what stands until the battery reading answers",
|
||||
out[1][1:] == (1, None, 1500.0) and out[2][1:] == (1, None, 1500.0)
|
||||
and out[3] == (False, 0, None, None))
|
||||
a, out = gate(HELD)
|
||||
check("...and with no move towards it for 23 s it is late (control holds, "
|
||||
"then 0 W)",
|
||||
[o[2] for o in out].index(0.0) == len([t for t, *_ in HELD
|
||||
if t - 2 < 23]))
|
||||
a, out = gate(HELD + [(40, 1500, 1000, 380)])
|
||||
check("...which ends when a reading starts towards the ask",
|
||||
out[-2][2] is not None and out[-1][2] is None and out[-1][0])
|
||||
a, out = gate(HELD, exempt=set(range(2, 40, 2)))
|
||||
check("exempt (a battery gives nothing under its ask, or refuses): no hold "
|
||||
"and no late verdict, the cap stays",
|
||||
all(o[1] == 0 and o[2] is None for o in out) and out[-1][3] == 1500.0)
|
||||
a, out = gate([(0, 1000, 1000, 500), (2, 1090, 1000, 500),
|
||||
(4, 1000, 1000, 500)])
|
||||
check("a move under 100 W is not an ask", not any(o[0] for o in out))
|
||||
a, out = gate(STEP + [(4, 1050, 1000, 500)])
|
||||
check("an ask taken back within 100 W of the reading is withdrawn",
|
||||
out[2][0] is False)
|
||||
T = control.Tuning(max_w=5000.0)
|
||||
base = dict(prev_w=1500.0, grid_w=800.0, actual_w=1000.0, tuning=T,
|
||||
i_w=1500.0)
|
||||
check("compute(hold=+1): the loop may not push past the standing command, "
|
||||
"but may come back",
|
||||
control.compute(**base).target_w > 1500.0
|
||||
and control.compute(**base, hold=1).target_w == 1500.0
|
||||
and control.compute(**base, hold=1).reason == control.WAITING
|
||||
and control.compute(**dict(base, grid_w=-800.0), hold=1).target_w
|
||||
< 1500.0)
|
||||
check("compute(hold=-1) mirrors it, and hold=0 is the law as it was",
|
||||
control.compute(**dict(base, grid_w=-800.0), hold=-1).target_w == 1500.0
|
||||
and control.compute(**base, hold=0) == control.compute(**base))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Runnable check for SAFETY-129: late Home Assistant readings found at a 5 s meter.
|
||||
`python3 test_safety129.py`
|
||||
|
||||
SAFETY-128 fitted the meter's delay only at a meter of about 3.5 s or faster
|
||||
(16 new values in 60 s). The owner's P1 reports every 5 s, so there only the
|
||||
batteries' fit ran: with readings 30 s late the loop swung to -420 W export
|
||||
and was found after about 3 minutes (-1 325 W before the hold).
|
||||
`lateness.MeterFit` now also runs at a slow meter over 120 s, and calls a
|
||||
delay late only over `M_SLOW_LATE_S` on two fits in a row, with the same
|
||||
corroboration as before (an ask slow to start, no battery fit on time).
|
||||
DECISIONS/SAFETY-128-late-readings.md.
|
||||
|
||||
The REAL Controller on TEST-23's plant (tools/safety128_rig.py).
|
||||
|
||||
1. 5 s meter, readings 30 s late (N=2 house 1 800 W, N=1 house 1 500 W): found
|
||||
within 100 s, and the export never goes below -200 W (red on c66aa7f: found
|
||||
after 190 s, export to -420 W).
|
||||
2. 5 s meter, readings 10 s late: no swing, nothing to hold; and the limit that
|
||||
goes with it: a battery that answers in 10 s (R2-1) is still not read as
|
||||
late at a 5 s meter, which a lower slow-meter threshold would break.
|
||||
3. No false holds at a slow meter: the owner's shape at 2, 5 and 10 s, HA
|
||||
restarts and meter pauses at 2 and 5 s, 8-10 s dead times: never
|
||||
late, so the commands are those of the run without the gate.
|
||||
4. The slow-meter fit at its edges.
|
||||
|
||||
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
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import lateness # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def no_gate(fn):
|
||||
"""`fn()` with the answer gate off: the tree before SAFETY-128."""
|
||||
real = app_main.Controller._readings_late
|
||||
app_main.Controller._readings_late = lambda self, standing_w: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
app_main.Controller._readings_late = real
|
||||
|
||||
|
||||
def run_late(**kw):
|
||||
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
|
||||
dur = kw.pop("dur")
|
||||
seen = []
|
||||
r = R.Rig(**kw)
|
||||
r.run(dur, lambda rig, c, t: seen.append(t) if c.answer.late_seen
|
||||
else None)
|
||||
return r, len(seen), min(seen) if seen else None
|
||||
|
||||
|
||||
def grid_min(rows, t0, t1):
|
||||
return min(r[1] for r in rows if t0 <= r[0] < t1)
|
||||
|
||||
|
||||
AB, A = ("a", "b"), ("a",)
|
||||
ON = 300.0
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. 5 s meter, readings 30 s late for 300 s")
|
||||
for units, house in ((AB, 1800.0), (A, 1500.0)):
|
||||
n = f"N={len(units)}"
|
||||
r, secs, first = run_late(house=house, lag_s=30.0, lag_on=ON,
|
||||
lag_off=ON + 300.0, units=units, p1_s=5.0,
|
||||
dur=ON + 420.0)
|
||||
low = grid_min(r.rows, ON, ON + 300.0)
|
||||
print(f" {n}: found {None if first is None else first - ON:.0f} s "
|
||||
f"after the start, export to {low:.0f} W")
|
||||
check(f"{n}: found within 100 s of the lag starting",
|
||||
first is not None and first - ON <= 100.0)
|
||||
check(f"{n}: the export never goes below -200 W", low >= -200.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))
|
||||
check(f"{n}: without the gate the export goes below -1 kW",
|
||||
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. 5 s meter, readings 10 s late; a battery that answers in 10 s")
|
||||
r, secs, first = run_late(house=1800.0, lag_s=10.0, lag_on=ON,
|
||||
lag_off=ON + 150.0, units=AB, p1_s=5.0,
|
||||
dur=ON + 200.0)
|
||||
low = grid_min(r.rows, ON, ON + 150.0)
|
||||
print(f" 10 s late: export to {low:.0f} W, verdict stood {secs} s")
|
||||
check("10 s late: no swing, the export never goes below -200 W", low >= -200.0)
|
||||
|
||||
|
||||
def owner(t):
|
||||
w = 400.0
|
||||
if 600 <= t < 780:
|
||||
w += 2200
|
||||
if 1200 <= t < 2400 and ((t - 1200) % 105) < 45:
|
||||
w += 2000
|
||||
return w
|
||||
|
||||
|
||||
for dead in (8.0, 10.0):
|
||||
r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=5.0,
|
||||
dead_s=dead, dur=800.0)
|
||||
print(f" {dead:.0f} s dead time, 5 s meter: late {secs} s")
|
||||
check(f"{dead:.0f} s dead time at a 5 s meter is not read as late "
|
||||
f"(R2-1 does not get worse)", secs == 0)
|
||||
|
||||
# At a 6 s meter an 8 s dead time reads late on the slow fit alone; only the
|
||||
# slow-start and battery-fit check keeps it from a verdict (122 s late
|
||||
# without the check, from 1 084 s).
|
||||
r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=6.0,
|
||||
dead_s=8.0, dur=1500.0)
|
||||
print(f" 8 s dead time, 6 s meter: late {secs} s")
|
||||
check("an 8 s dead time at a 6 s meter is never late (the slow fit needs an "
|
||||
"ask slow to start and no battery fit on time)", secs == 0)
|
||||
|
||||
# A 12 s dead time is already read as late (R2-1, SAFETY-128); the slow-meter
|
||||
# fit must not make that longer. The same run with the slow fit off is the
|
||||
# tree before this card.
|
||||
_slow = lateness.MeterFit.slow
|
||||
secs = {}
|
||||
for label in ("with", "without"): # 12 s dead time: late from about 140 s
|
||||
lateness.MeterFit.slow = _slow if label == "with" else (lambda self: False)
|
||||
try:
|
||||
secs[label] = run_late(house=owner, noise=80.0, units=A, p1_s=5.0,
|
||||
dead_s=12.0, dur=900.0)[1]
|
||||
finally:
|
||||
lateness.MeterFit.slow = _slow
|
||||
print(f" 12 s dead time, N=1, 5 s meter: late {secs['with']} s, with the "
|
||||
f"slow fit off {secs['without']} s")
|
||||
check("a 12 s dead time at a 5 s meter is late no longer than before "
|
||||
"(R2-1 does not get worse)", secs["with"] <= secs["without"])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. no false holds")
|
||||
_view = R.Plant.view
|
||||
|
||||
|
||||
def frozen(windows, meter_only):
|
||||
def view(self, t, lag_s, lag_on, lag_off):
|
||||
cur = _view(self, t, lag_s, lag_on, lag_off)
|
||||
for a, b in windows:
|
||||
if a <= t < b:
|
||||
for row in reversed(self.hist):
|
||||
if row[0] <= a + 1e-9:
|
||||
old = row[1:]
|
||||
return (old[0], old[1], cur[2], cur[3]) \
|
||||
if meter_only else old
|
||||
return cur
|
||||
return view
|
||||
|
||||
|
||||
for p1 in (2.0, 5.0, 10.0):
|
||||
kw = dict(house=owner, noise=80.0, units=AB, p1_s=p1)
|
||||
r, secs, first = run_late(dur=800.0, **kw)
|
||||
new = r
|
||||
old = R.Rig(**kw)
|
||||
no_gate(lambda: old.run(800.0))
|
||||
same = max(abs(x[2] - y[2]) for x, y in zip(new.rows, old.rows))
|
||||
print(f" owner shape, {p1:.0f} s meter: late {secs} s, command "
|
||||
f"difference {same:.0f} W")
|
||||
check(f"{p1:.0f} s meter, owner shape: never late, same commands",
|
||||
secs == 0 and same == 0)
|
||||
for label, windows, meter_only in (
|
||||
("HA restart", ((650.0, 710.0),), False),
|
||||
("meter pause", ((640.0, 700.0),), True)):
|
||||
for p1 in (2.0, 5.0):
|
||||
R.Plant.view = frozen(windows, meter_only)
|
||||
try:
|
||||
r, secs, first = run_late(house=owner, noise=80.0, units=AB,
|
||||
p1_s=p1, dur=800.0)
|
||||
finally:
|
||||
R.Plant.view = _view
|
||||
check(f"{label} at a {p1:.0f} s meter: never late", secs == 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. the slow-meter fit at its edges")
|
||||
|
||||
|
||||
def meter_with(period, pause_after=None, pause_s=40.0, n=24):
|
||||
f = lateness.MeterFit()
|
||||
f.add(0.0, 100.0, 0.0)
|
||||
for k in range(1, n):
|
||||
gap = pause_s if pause_after is not None and k > pause_after else 0.0
|
||||
f.add(period * k + gap, None, float(k))
|
||||
return f
|
||||
|
||||
|
||||
check("a 5 s and a 10 s meter are slow",
|
||||
meter_with(5.0).slow() and meter_with(10.0).slow())
|
||||
check("a 1 s, a 2 s and a 3 s meter are not slow",
|
||||
not any(meter_with(p, n=60).slow() for p in (1.0, 2.0, 3.0)))
|
||||
check("a pause in a 2 s meter does not make it slow",
|
||||
not meter_with(2.0, pause_after=10, n=40).slow())
|
||||
def fed(values):
|
||||
"""Answer.watch() with the slow fit reading `values`, one per fit."""
|
||||
a = lateness.Answer()
|
||||
a.meter.slow = lambda: True
|
||||
a.meter.estimate = a.delay.estimate = lambda: None
|
||||
it = iter(values)
|
||||
a.meter_slow.estimate = lambda: next(it, None)
|
||||
for k in range(len(values)):
|
||||
a.watch(2.0 * k, [], 0.0, 0.0)
|
||||
return a
|
||||
|
||||
|
||||
check("one late slow-meter fit is no fit; two in a row are one, and "
|
||||
"one on time or in between is none",
|
||||
fed([30.0]).fits == 0 and fed([30.0, 30.0]).fits == 1
|
||||
and fed([30.0, None, 30.0]).fits == 0 and fed([30.0, 12.0]).fits == 0
|
||||
and fed([3.0, 3.0]).fits == 0 and not fed([30.0, 30.0]).late_seen)
|
||||
check("the slow fit asks for a longer window and a later delay than the "
|
||||
"fast one", lateness.M_SLOW_FIT_S > lateness.M_FIT_S
|
||||
and lateness.M_SLOW_LATE_S > lateness.M_LATE_S)
|
||||
check("the slow fit's late limit is above the fast one's and above a "
|
||||
"10 s lag", lateness.M_SLOW_LATE_S >= 16.0)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,332 @@
|
||||
"""SAFETY-137: the 0 W of shutdown and of the Stop button is the last write every
|
||||
unit hears. `python3 test_safety137_last_write.py`
|
||||
|
||||
A cycle write already on its way (0.3-0.6 s, or hung) must not land after the
|
||||
0 W, for a GoodWe unit and for a fake unit that does not hand back, at N=1 and
|
||||
N=2, at shutdown and at Stop. The control cycle (and the heartbeat) issue no
|
||||
new write once shutdown has begun. The whole wind-down stays inside
|
||||
HAND_BACK_WORST_S (6 s) when a write hangs.
|
||||
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
The mutants this file was run against are listed at the end.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE))
|
||||
|
||||
from app import main as M # noqa: E402
|
||||
from app.drivers import Driver, Probe, REGISTRY # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.store import DEFAULTS # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class PlainFake(Driver):
|
||||
"""A unit that does not hand back (so, like GoodWe, no release()). A write
|
||||
of anything but 0 W takes `delay` s to land; every write is logged when it
|
||||
LANDS (`landed`) and when it STARTS (`started`)."""
|
||||
name = "plainfake"
|
||||
writer_key = "host"
|
||||
brand = "Fake"
|
||||
hands_back = False
|
||||
delay = 0.0
|
||||
|
||||
def __init__(self, host="", port=502, unit_id=1, rating=2000.0):
|
||||
self.host, self.port, self.unit_id = host, port, unit_id
|
||||
self.rating, self.power = rating, 0.0
|
||||
self.landed, self.started = [], []
|
||||
|
||||
@classmethod
|
||||
def from_entry(cls, entry):
|
||||
return cls(entry.get("host", ""), entry.get("port", 502),
|
||||
entry.get("unit_id", 1))
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, entry):
|
||||
host = str(entry.get("host") or "").strip()
|
||||
return f"{host}:{entry.get('port', 502)}:{entry.get('unit_id', 1)}" \
|
||||
if host else ""
|
||||
|
||||
def configured(self):
|
||||
return bool(self.host)
|
||||
|
||||
def actuator_id(self):
|
||||
return f"{self.host}:{self.port}:{self.unit_id}"
|
||||
|
||||
def soc_source(self):
|
||||
return "fake-soc"
|
||||
|
||||
def batt_source(self):
|
||||
return "fake-power"
|
||||
|
||||
async def limits(self):
|
||||
return -self.rating, self.rating
|
||||
|
||||
async def read(self):
|
||||
return 60.0, self.power, {}
|
||||
|
||||
async def write(self, w):
|
||||
self.started.append((time.monotonic(), float(w)))
|
||||
if w != 0 and self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
self.landed.append((time.monotonic(), float(w)))
|
||||
self.power = w
|
||||
return True
|
||||
|
||||
async def probe(self):
|
||||
return Probe(-self.rating, self.rating, 60.0, self.power, {})
|
||||
|
||||
|
||||
REGISTRY["plainfake"] = PlainFake
|
||||
|
||||
|
||||
class MemStore:
|
||||
def __init__(self):
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, values):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class SlowHass:
|
||||
"""Home Assistant for the GoodWe units. A set_number of anything but 0 W
|
||||
takes `delay` s to land; the log gets the write when it lands."""
|
||||
|
||||
def __init__(self):
|
||||
self.delay = 0.0
|
||||
self.landed, self.started = [], [] # (t, entity, value)
|
||||
self.numbers = {"sensor.grid": 3000.0, "sensor.soc_a": 50.0,
|
||||
"sensor.batt_a": 0.0, "sensor.soc_b": 50.0,
|
||||
"sensor.batt_b": 0.0}
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return self.numbers.get(entity)
|
||||
|
||||
async def limits(self, entity):
|
||||
return (-3000.0, 3000.0, 1.0)
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.started.append((time.monotonic(), entity, value))
|
||||
if value != 0 and self.delay:
|
||||
await asyncio.sleep(self.delay)
|
||||
self.landed.append((time.monotonic(), entity, value))
|
||||
return True
|
||||
|
||||
async def state(self, entity):
|
||||
return None
|
||||
|
||||
async def call(self, *a):
|
||||
return True
|
||||
|
||||
|
||||
def gw(name, suffix):
|
||||
return dict(name=name, setpoint_entity=f"number.sp_{suffix}",
|
||||
soc_entity=f"sensor.soc_{suffix}",
|
||||
batt_entity=f"sensor.batt_{suffix}", max_w=3000,
|
||||
bms_soc_floor=10)
|
||||
|
||||
|
||||
def fk(name):
|
||||
return dict(name=name, driver="plainfake", host="10.9.9.9", port=502,
|
||||
unit_id=3, max_w=2000, bms_soc_floor=10)
|
||||
|
||||
|
||||
ROSTERS = {
|
||||
"N=1 GoodWe": [gw("inverter-a", "a")],
|
||||
"N=1 fake": [fk("inverter-a")],
|
||||
"N=2 GoodWe+GoodWe": [gw("inverter-a", "a"), gw("inverter-b", "b")],
|
||||
"N=2 GoodWe+fake": [gw("inverter-a", "a"), fk("inverter-b")],
|
||||
}
|
||||
|
||||
|
||||
def site(inverters, delay):
|
||||
opts = dict(meter_source="off", meter_entity="sensor.grid", max_w=5000,
|
||||
gain=0.6, slew_w=0, deadband_w=15, target_grid_w=0, step_w=10,
|
||||
heartbeat_s=10, stale_input_s=15, shadow_mode="off",
|
||||
inverters=inverters)
|
||||
hass = SlowHass()
|
||||
hass.delay = delay
|
||||
c = Controller(opts, hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -3000.0, 3000.0
|
||||
if isinstance(inv.driver, PlainFake):
|
||||
inv.driver.delay = delay
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
def per_unit(c, hass, kind="landed"):
|
||||
"""name -> [(t, value)] in the order the writes LANDED (or started)."""
|
||||
out = {}
|
||||
for inv in c.inverters:
|
||||
d = inv.driver
|
||||
if isinstance(d, PlainFake):
|
||||
out[inv.name] = list(getattr(d, kind))
|
||||
else:
|
||||
ent = d.setpoint_entity
|
||||
out[inv.name] = [(t, v) for t, e, v in getattr(hass, kind)
|
||||
if e == ent]
|
||||
return out
|
||||
|
||||
|
||||
async def cycles(c, n=2):
|
||||
for _ in range(n):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
|
||||
|
||||
async def in_flight(c):
|
||||
"""A cycle write on its way to every unit (what rig 5b had): the real
|
||||
cycle, whose writes are authorised and take `delay` s to land."""
|
||||
tasks = [asyncio.ensure_future(cycles(c, 1))]
|
||||
await asyncio.sleep(0.1)
|
||||
assert all(not t.done() for t in tasks)
|
||||
return tasks
|
||||
|
||||
|
||||
async def finish(tasks):
|
||||
for t in tasks:
|
||||
t.cancel()
|
||||
for t in tasks:
|
||||
try:
|
||||
await t
|
||||
except BaseException: # noqa: BLE001 - cancelled or timed out
|
||||
pass
|
||||
|
||||
|
||||
def last_is_zero(c, hass):
|
||||
got = per_unit(c, hass)
|
||||
return all(w and w[-1][1] == 0.0 for w in got.values()), got
|
||||
|
||||
|
||||
def scenario(label, inverters, how, delay):
|
||||
"""how: 'shutdown' or 'stop'. Returns (seconds, ok, detail)."""
|
||||
c, hass = site(inverters, delay)
|
||||
|
||||
async def go():
|
||||
tasks = await in_flight(c)
|
||||
t0 = time.monotonic()
|
||||
if how == "shutdown":
|
||||
await c.shutdown()
|
||||
else:
|
||||
await c.handle_action("auto_toggle") # Stop
|
||||
dt = time.monotonic() - t0
|
||||
# let a write that is still on its way land, to see if it overtakes
|
||||
await asyncio.sleep(0.8 if delay < 5 else 0.0)
|
||||
await finish(tasks)
|
||||
return dt
|
||||
|
||||
dt = asyncio.run(go())
|
||||
ok, got = last_is_zero(c, hass)
|
||||
n_sent = {n: len(w) for n, w in got.items()}
|
||||
check(f"{label}: the cycle write was on its way at {how} (test sees it)",
|
||||
all(len(w) >= 2 for w in per_unit(c, hass, "started").values()))
|
||||
print(f" {label}: {how}, delay {delay} s: {dt:.2f} s, "
|
||||
f"landed per unit {n_sent}, last "
|
||||
f"{ {n: (w[-1][1] if w else None) for n, w in got.items()} }")
|
||||
return dt, ok, got
|
||||
|
||||
|
||||
print("SAFETY-137 - the 0 W is the last write")
|
||||
|
||||
print("1. a slow write on its way (0.3 s, 0.6 s) when shutdown / Stop starts")
|
||||
for label, roster in ROSTERS.items():
|
||||
for how in ("shutdown", "stop"):
|
||||
for delay in (0.3, 0.6):
|
||||
dt, ok, got = scenario(label, roster, how, delay)
|
||||
check(f"{label}, {how}, {delay} s in flight: the last write per "
|
||||
f"unit is 0 W", ok)
|
||||
check(f"{label}, {how}, {delay} s in flight: the slow write "
|
||||
f"landed first, then the 0 W (not dropped, not overtaken)",
|
||||
all(len(w) >= 2 and w[-2][1] != 0.0
|
||||
for w in got.values()))
|
||||
check(f"{label}, {how}: took about the write, not the budget "
|
||||
f"({dt:.2f} s)", dt < delay + 1.0)
|
||||
|
||||
print("2. a hung write on its way: the 0 W still reaches every unit, in time")
|
||||
for label, roster in ROSTERS.items():
|
||||
for how in ("shutdown", "stop"):
|
||||
dt, ok, got = scenario(label, roster, how, 3600.0)
|
||||
check(f"{label}, {how}, hung write: 0 W landed on every unit and is "
|
||||
f"its last write", ok)
|
||||
check(f"{label}, {how}, hung write: inside HAND_BACK_WORST_S "
|
||||
f"({dt:.2f} s <= {M.HAND_BACK_WORST_S:.0f} s)",
|
||||
dt <= M.HAND_BACK_WORST_S)
|
||||
check(f"{label}, {how}, hung write: the 0 W goes after the settle "
|
||||
f"wait ({M.SETTLE_BUDGET_S:.0f} s), not after the write's own "
|
||||
f"budget ({dt:.2f} s)", dt <= M.SETTLE_BUDGET_S + 0.5)
|
||||
|
||||
print("3. no new write once shutdown has begun (cycle, heartbeat)")
|
||||
for label, roster in ROSTERS.items():
|
||||
c, hass = site(roster, 0.4)
|
||||
|
||||
async def go3():
|
||||
tasks = await in_flight(c)
|
||||
s = asyncio.ensure_future(c.shutdown())
|
||||
await asyncio.sleep(0) # shutdown has begun
|
||||
begun = time.monotonic()
|
||||
await cycles(c, 3) # would write: grid 3000 W
|
||||
await c.send_heartbeats(10 ** 9, 10) # every unit is due
|
||||
await asyncio.wait_for(s, 10)
|
||||
await asyncio.sleep(0.8)
|
||||
await finish(tasks)
|
||||
return begun
|
||||
|
||||
begun = asyncio.run(go3())
|
||||
starts = per_unit(c, hass, "started")
|
||||
late = {n: [v for t, v in w if t >= begun and v != 0.0]
|
||||
for n, w in starts.items()}
|
||||
check(f"{label}: no non-zero write started after shutdown began "
|
||||
f"({late})", all(not v for v in late.values()))
|
||||
ok, got = last_is_zero(c, hass)
|
||||
check(f"{label}: and the last write per unit is 0 W", ok)
|
||||
# sanity: the same cycles DO write before shutdown
|
||||
c2, hass2 = site(roster, 0.0)
|
||||
asyncio.run(cycles(c2, 3))
|
||||
before = per_unit(c2, hass2, "started")
|
||||
check(f"{label}: control, same cycles before shutdown, write non-zero "
|
||||
f"(the test can see a write)",
|
||||
all(any(v != 0.0 for _t, v in w) for w in before.values()))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED {len(fails)}:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
|
||||
# MUTANTS this file was run against (each must turn it red):
|
||||
# (a) _await_inflight does nothing (no wait for in-flight writes), GoodWe too
|
||||
# (b) the `elif self.stopping: return` gate removed (cycle not stopped)
|
||||
# (c) `last=True` not passed by command_site_release (Stop/shutdown 0 W is a
|
||||
# plain write)
|
||||
# (d) the wait unbounded (no timeout): red on the settle-bound check
|
||||
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import clamp, messages # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -202,7 +203,7 @@ check("⭐ N=2, A in its HOLD and B, which the loop drives, gives nothing: the "
|
||||
check("...it starts no earlier than the saturation cycles allow (3 x 5 s)",
|
||||
all(e.split(" ", 1)[0] >= "11:10:10" for c in (_c1, _c2)
|
||||
for e in c.events if "OUTPUT_CLAMP" in e))
|
||||
_quiet = patched(app_main, "delivery_short", lambda *a: False)(
|
||||
_quiet = patched(main_files.live, "delivery_short", lambda *a: False)(
|
||||
lambda: run(1, "drain", 1000.0, 20000, dur=1200.0, cut=gone(600, 1200)))
|
||||
check("non-vacuous: without the delivery rule the N=1 drain that gives "
|
||||
"nothing is silent (the loop's clamp never reaches its rail here)",
|
||||
@@ -214,7 +215,7 @@ check("a dip of two cycles is not an alarm", warned(_dip[1]) == (0, 0, 0))
|
||||
print("\n3. control coming back from maintenance does not jump")
|
||||
END = {"maintenance_hold_min": 10}
|
||||
_bounds = []
|
||||
_real_compute = app_main.compute
|
||||
_real_compute = main_files.live.compute
|
||||
|
||||
|
||||
def _spy_compute(*a, **kw):
|
||||
@@ -222,11 +223,11 @@ def _spy_compute(*a, **kw):
|
||||
return _real_compute(*a, **kw)
|
||||
|
||||
|
||||
app_main.compute = _spy_compute
|
||||
main_files.live.compute = _spy_compute
|
||||
try:
|
||||
_h2 = run(2, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
||||
finally:
|
||||
app_main.compute = _real_compute
|
||||
main_files.live.compute = _real_compute
|
||||
_h2_old = no_bound(lambda: run(2, "hold", 4500.0, 20000, dur=900.0,
|
||||
extra=END))
|
||||
_h1 = run(1, "hold", 4500.0, 20000, dur=900.0, extra=END)
|
||||
@@ -380,8 +381,8 @@ def lag_runs():
|
||||
|
||||
|
||||
_lag = lagging(lag_runs)
|
||||
_lag_old = patched(app_main, "SHORT_CYCLES", 1)(
|
||||
lambda: patched(app_main, "SHORT_SPAN_S", 0.0)(
|
||||
_lag_old = patched(main_files.live, "SHORT_CYCLES", 1)(
|
||||
lambda: patched(main_files.live, "SHORT_SPAN_S", 0.0)(
|
||||
lambda: lagging(lag_runs)))
|
||||
_lag_dead = lagging(lambda: run(1, "drain", 1000.0, 20000, dur=900.0,
|
||||
cut=gone(600, 900)))
|
||||
@@ -432,7 +433,7 @@ def both_ways(asked, given, margin):
|
||||
|
||||
|
||||
_tp = taper(charge_runs)
|
||||
_tp_old = patched(app_main, "delivery_short", both_ways)(
|
||||
_tp_old = patched(main_files.live, "delivery_short", both_ways)(
|
||||
lambda: taper(charge_runs))
|
||||
print(f" a charge that tapers: {[warned(c) for _r, c in _tp]}, grading "
|
||||
f"charges too {[warned(c) for _r, c in _tp_old]}")
|
||||
|
||||
@@ -38,6 +38,7 @@ import types
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
import main_files # noqa: E402
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
@@ -703,8 +704,11 @@ check("⭐ the reserve alarm is graded on the real limit, not the aim: a "
|
||||
|
||||
_runs_src = {}
|
||||
for _f in ("main.py", "homeview.py"):
|
||||
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
|
||||
_src = _fh.read()
|
||||
if _f == "main.py":
|
||||
_src = main_files.source()
|
||||
else:
|
||||
with open(os.path.join(HERE, "app", _f), encoding="utf-8") as _fh:
|
||||
_src = _fh.read()
|
||||
for _part in _src.split('out.append({')[1:]:
|
||||
if '"runs": True' in _part.split("})", 1)[0]:
|
||||
_runs_src.setdefault(_f, []).append(
|
||||
|
||||
@@ -127,16 +127,17 @@ def without_fix(fn):
|
||||
"""release/1.0 8c438ab: held only for the peak, a drain stays pinned, the
|
||||
quarter's edge is not rolled over."""
|
||||
real = (Controller._held_back, Controller._drain_rise)
|
||||
import main_files # noqa: E402
|
||||
import app.main as m
|
||||
real_nq = m.next_quarter
|
||||
real_nq = main_files.live.next_quarter
|
||||
Controller._held_back = lambda self: self._peak_reserve()
|
||||
Controller._drain_rise = lambda self, claims, results: None
|
||||
m.next_quarter = lambda p, max_hold_s: p
|
||||
main_files.live.next_quarter = lambda p, max_hold_s: p
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._held_back, Controller._drain_rise = real
|
||||
m.next_quarter = real_nq
|
||||
main_files.live.next_quarter = real_nq
|
||||
|
||||
|
||||
def cmd(r):
|
||||
|
||||
@@ -37,6 +37,7 @@ logging.disable(logging.CRITICAL)
|
||||
|
||||
import safety94_rig as rig # noqa: E402
|
||||
import app.inverter as app_inverter # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.freshness import BOUNDS, Freshness # noqa: E402
|
||||
@@ -59,16 +60,20 @@ class Before94(Freshness):
|
||||
|
||||
def without_fix(fn):
|
||||
"""Run `fn` with the gate's old clock and the old refusal evidence."""
|
||||
real_f, real_c = app_inverter.Freshness, app_main.compute
|
||||
real_p = app_main.REFUSED_PROGRESS_W
|
||||
real_f, real_c = app_inverter.Freshness, main_files.live.compute
|
||||
real_p = main_files.live.REFUSED_PROGRESS_W
|
||||
real_l = app_main.Controller._readings_late
|
||||
# SAFETY-128: that tree had no late-readings gate either.
|
||||
app_main.Controller._readings_late = lambda self, standing_w: None
|
||||
app_inverter.Freshness = Before94
|
||||
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
|
||||
app_main.REFUSED_PROGRESS_W = float("inf")
|
||||
main_files.live.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
|
||||
main_files.live.REFUSED_PROGRESS_W = float("inf")
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
app_inverter.Freshness, app_main.compute = real_f, real_c
|
||||
app_main.REFUSED_PROGRESS_W = real_p
|
||||
app_inverter.Freshness, main_files.live.compute = real_f, real_c
|
||||
main_files.live.REFUSED_PROGRESS_W = real_p
|
||||
app_main.Controller._readings_late = real_l
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
@@ -311,7 +316,7 @@ check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with "
|
||||
and control.compute(**args, gave_nothing=False).refused == 0)
|
||||
|
||||
# The evidence restarts on progress towards the ask, not on jitter.
|
||||
print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span "
|
||||
print(f" REFUSED_PROGRESS_W {main_files.live.REFUSED_PROGRESS_W:.0f} W, span "
|
||||
f"{app_main.REFUSED_SPAN_S:.0f} s")
|
||||
|
||||
|
||||
@@ -352,7 +357,7 @@ check("a command that swaps direction every cycle is never evidence, and a "
|
||||
not any(flap) and 1 in away and away.index(1) == 22)
|
||||
check("REFUSED_PROGRESS_W is under the band's width and over the live "
|
||||
"readings' rest wander (25 W)",
|
||||
25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
|
||||
25.0 < main_files.live.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -36,6 +36,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as app_main, packmode # noqa: E402
|
||||
from app.arbiter import Claim, P_MAINTENANCE, P_SAFETY # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -149,13 +150,13 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, clock = app_main.datetime, [0.0]
|
||||
real_dt, clock = main_files.live.datetime, [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return rig.BASE + timedelta(seconds=clock[0])
|
||||
app_main.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows = []
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -182,7 +183,7 @@ def run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
|
||||
else g.floor_w))
|
||||
t += 2.0
|
||||
finally:
|
||||
app_main.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -68,10 +68,14 @@ def without(fn):
|
||||
Controller._held_out_w = lambda self, live: 0.0
|
||||
Controller._reseed_drop = lambda self, live: setattr(
|
||||
self, "_live_was", frozenset(live))
|
||||
real_late = Controller._readings_late
|
||||
# SAFETY-128: that tree had no late-readings gate either.
|
||||
Controller._readings_late = lambda self, standing_w: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._held_out_w, Controller._reseed_drop = fixed, real_drop
|
||||
Controller._readings_late = real_late
|
||||
|
||||
|
||||
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,750 @@
|
||||
"""SMA-2: the inverter driver seam. `python3 test_sma2_driver.py`
|
||||
|
||||
Part 1 grades the GoodWe driver against a Home Assistant that logs every call:
|
||||
it must make exactly the calls the code it replaced made, in the same order.
|
||||
Part 2 grades what `Inverter` reads from its driver. Part 3 drives a REAL
|
||||
Controller with a roster of one fake-driver unit and one GoodWe unit and
|
||||
checks that it writes, reads, limits, grades, drops and publishes each by its
|
||||
own driver, with a Home Assistant that records every entity it is asked about
|
||||
so a call site that goes round the seam is seen. Part 4 is the SAFETY-28
|
||||
census and rule 9 on the new keys. Part 5 is the schema.
|
||||
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
The seam mutants this file was run against are listed at the end.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import drivers
|
||||
from app.drivers import (DEFAULT_DRIVER, REGISTRY, SELECTABLE, Driver,
|
||||
DriverNotBound, GoodWeEsphomeDriver, Probe,
|
||||
UnavailableDriver, build_driver, census_key)
|
||||
from app.freshness import BOUNDS, Bound
|
||||
from app.inverter import LINK_STALE_S, Inverter, build_roster
|
||||
from app.main import Controller
|
||||
from app.store import DEFAULTS
|
||||
from app.validate import (actuator_writers, validate_startup_config,
|
||||
validate_unit_ranges)
|
||||
|
||||
fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class MemStore:
|
||||
def __init__(self):
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, *a):
|
||||
return None
|
||||
|
||||
def set_time(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class Pub:
|
||||
"""Keeps the last published values, like test_mqtt.py's."""
|
||||
enabled = False
|
||||
|
||||
def __init__(self):
|
||||
self.last = {}
|
||||
|
||||
def publish(self, values):
|
||||
self.last = dict(values)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class LogHass:
|
||||
"""Home Assistant for the GoodWe unit. EVERY call is logged with its entity,
|
||||
so a path that talks to Home Assistant about anything else is on record."""
|
||||
|
||||
def __init__(self, numbers=None, limits=None, accept=True):
|
||||
self.numbers = dict(numbers or {})
|
||||
self.limits_by = dict(limits or {})
|
||||
self.accept = accept
|
||||
self.log = [] # (method, entity, extra)
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
self.log.append(("number", entity, invert))
|
||||
v = self.numbers.get(entity)
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
self.log.append(("limits", entity, None))
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.log.append(("set_number", entity, value))
|
||||
if self.accept:
|
||||
self.numbers[entity.replace("number.sp", "sensor.batt")] = value
|
||||
return self.accept
|
||||
|
||||
async def state(self, entity):
|
||||
self.log.append(("state", entity, None))
|
||||
return None
|
||||
|
||||
async def call(self, *a):
|
||||
self.log.append(("call", repr(a), None))
|
||||
return True
|
||||
|
||||
def entities(self):
|
||||
"""Every entity name Home Assistant was asked about (service calls
|
||||
like notifications name none)."""
|
||||
return {e for m, e, _x in self.log if m != "call"}
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def raises(exc, make):
|
||||
"""Does `run(make())` raise `exc`? `make` builds the coroutine."""
|
||||
try:
|
||||
run(make())
|
||||
except exc:
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# a fake driver: a unit reached by host:port:unit, no Home Assistant at all
|
||||
# ---------------------------------------------------------------------------
|
||||
FAKE_BOUND = Bound(signal="fake", unchanged_s=11.0, move_w=100.0,
|
||||
release_s=5.0, wired=True, derivation="test")
|
||||
|
||||
|
||||
class FakeDriver(Driver):
|
||||
name = "fake"
|
||||
writer_key = "host"
|
||||
brand = "Fake"
|
||||
link_stale_s = 7.0
|
||||
batt_bound = FAKE_BOUND
|
||||
settle_s = 3.0
|
||||
words = {"idle": "handed back"}
|
||||
|
||||
def __init__(self, host="", port=502, unit_id=1, rating=1000.0, soc=60.0):
|
||||
self.host, self.port, self.unit_id = host, port, unit_id
|
||||
self.rating, self.soc, self.power = rating, soc, 0.0
|
||||
self.accept = True
|
||||
self.calls = [] # (what, arg)
|
||||
|
||||
@classmethod
|
||||
def from_entry(cls, entry):
|
||||
return cls(entry.get("host", ""), entry.get("port", 502),
|
||||
entry.get("unit_id", 1), rating=entry.get("_rating", 1000.0))
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, entry):
|
||||
host = str(entry.get("host") or "").strip()
|
||||
return (f"{host}:{entry.get('port', 502)}:{entry.get('unit_id', 1)}"
|
||||
if host else "")
|
||||
|
||||
def configured(self):
|
||||
return bool(self.host)
|
||||
|
||||
def actuator_id(self):
|
||||
return f"{self.host}:{self.port}:{self.unit_id}"
|
||||
|
||||
def soc_source(self):
|
||||
return "fake-soc"
|
||||
|
||||
def batt_source(self):
|
||||
return "fake-power"
|
||||
|
||||
async def limits(self):
|
||||
self.calls.append(("limits", None))
|
||||
return -self.rating, self.rating
|
||||
|
||||
async def read(self):
|
||||
self.calls.append(("read", None))
|
||||
return self.soc, self.power, {"state": "ok"}
|
||||
|
||||
async def write(self, w):
|
||||
self.calls.append(("write", w))
|
||||
if self.accept:
|
||||
self.power = w # the battery follows its command
|
||||
return self.accept
|
||||
|
||||
async def release(self):
|
||||
self.calls.append(("release", None))
|
||||
return True
|
||||
|
||||
async def probe(self):
|
||||
self.calls.append(("probe", None))
|
||||
return Probe(-self.rating, self.rating, self.soc, self.power, {})
|
||||
|
||||
|
||||
def calls(drv, what):
|
||||
return [a for w, a in drv.calls if w == what]
|
||||
|
||||
|
||||
print("SMA-2 - the inverter driver seam")
|
||||
print()
|
||||
# ==============================================================================
|
||||
print("1. the GoodWe driver makes the calls the code it replaced made")
|
||||
|
||||
GW = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a")
|
||||
h1 = LogHass({"sensor.soc_a": 55.0, "sensor.batt_a": 700.0},
|
||||
{"number.sp_a": (-5000.0, 4000.0, 1.0)})
|
||||
d1 = GoodWeEsphomeDriver(**GW).bind(h1)
|
||||
check("a driver built from an entry names its three entities and is configured",
|
||||
GoodWeEsphomeDriver.from_entry(dict(GW)).setpoint_entity == "number.sp_a"
|
||||
and d1.configured() and d1.actuator_id() == "number.sp_a"
|
||||
and (d1.soc_source(), d1.batt_source())
|
||||
== ("sensor.soc_a", "sensor.batt_a"))
|
||||
check("...and one with no setpoint entity is not",
|
||||
not GoodWeEsphomeDriver().configured()
|
||||
and not GoodWeEsphomeDriver(soc_entity="sensor.soc_a").configured())
|
||||
check("limits() is hass.limits(setpoint) and hands back (min, max)",
|
||||
run(d1.limits()) == (-5000.0, 4000.0)
|
||||
and h1.log == [("limits", "number.sp_a", None)])
|
||||
h1.log.clear()
|
||||
check("read() asks for the SoC first, then the power, with the invert flag, "
|
||||
"and nothing else",
|
||||
run(d1.read()) == (55.0, 700.0, {})
|
||||
and h1.log == [("number", "sensor.soc_a", False),
|
||||
("number", "sensor.batt_a", False)])
|
||||
h1.log.clear()
|
||||
d1i = GoodWeEsphomeDriver(batt_invert=True, **GW).bind(h1)
|
||||
check("...and a reversed power sensor is reversed by hass.number, as before",
|
||||
run(d1i.read()) == (55.0, -700.0, {})
|
||||
and h1.log[1] == ("number", "sensor.batt_a", True))
|
||||
h1.log.clear()
|
||||
check("write(w) is hass.set_number(setpoint, w) and says whether it took it",
|
||||
run(d1.write(-250.0)) is True and h1.log == [("set_number", "number.sp_a",
|
||||
-250.0)])
|
||||
h1.log.clear()
|
||||
h1.accept = False
|
||||
check("...and False when Home Assistant refuses",
|
||||
run(d1.write(10.0)) is False)
|
||||
h1.accept = True
|
||||
h1.log.clear()
|
||||
check("release() is write(0): GoodWe is handed back by its board's own watchdog",
|
||||
run(d1.release()) is True
|
||||
and h1.log == [("set_number", "number.sp_a", 0.0)])
|
||||
h1.log.clear()
|
||||
h1.numbers.update({"sensor.soc_a": 55.0, "sensor.batt_a": 700.0})
|
||||
pr = run(d1.probe())
|
||||
check("probe() reads the range, the SoC and the power and WRITES NOTHING",
|
||||
pr == Probe(-5000.0, 4000.0, 55.0, 700.0, {})
|
||||
and all(m != "set_number" for m, _e, _x in h1.log))
|
||||
check("a driver with no Home Assistant is loud, not a silent 'no answer'",
|
||||
raises(DriverNotBound, GoodWeEsphomeDriver(**GW).limits)
|
||||
and raises(DriverNotBound, GoodWeEsphomeDriver(**GW).read)
|
||||
and raises(DriverNotBound, lambda: GoodWeEsphomeDriver(**GW).write(1.0))
|
||||
and not GoodWeEsphomeDriver(**GW).bound())
|
||||
_swap = {"h": h1}
|
||||
d1g = GoodWeEsphomeDriver(**GW).bind_getter(lambda: _swap["h"])
|
||||
h1b = LogHass({"sensor.soc_a": 1.0, "sensor.batt_a": 2.0})
|
||||
_swap["h"] = h1b
|
||||
check("bind_getter() looks Home Assistant up at each use, so a Controller whose "
|
||||
".hass is replaced afterwards is followed",
|
||||
d1g.bound() and run(d1g.read()) == (1.0, 2.0, {}) and h1b.log)
|
||||
check("its numbers are the ones the code it replaced used",
|
||||
(d1.link_stale_s, d1.batt_bound, d1.brand)
|
||||
== (LINK_STALE_S, BOUNDS["batt"], "GoodWe") and LINK_STALE_S == 30.0
|
||||
and d1.settle_s == 18.0 and d1.words == {})
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("2. what Inverter reads from its driver")
|
||||
|
||||
NOW = datetime(2026, 11, 7, 12, 0, tzinfo=timezone.utc)
|
||||
fk = FakeDriver(host="10.9.9.9", unit_id=3)
|
||||
inv_f = Inverter("fake-a", driver=fk, max_w=2000.0)
|
||||
inv_g = Inverter("inverter-b", max_w=3000.0, **{
|
||||
"setpoint_entity": "number.sp_b", "soc_entity": "sensor.soc_b",
|
||||
"batt_entity": "sensor.batt_b"})
|
||||
check("an Inverter built from the old keywords gets the GoodWe driver, with "
|
||||
"the same entities on the same attributes",
|
||||
isinstance(inv_g.driver, GoodWeEsphomeDriver)
|
||||
and (inv_g.setpoint_entity, inv_g.soc_entity, inv_g.batt_entity,
|
||||
inv_g.batt_invert) == ("number.sp_b", "sensor.soc_b",
|
||||
"sensor.batt_b", False))
|
||||
check("...and one given another driver has none of those entities",
|
||||
(inv_f.setpoint_entity, inv_f.soc_entity, inv_f.batt_entity) == ("", "", ""))
|
||||
check("has_writer() is the driver's configured()",
|
||||
inv_f.has_writer() and inv_g.has_writer()
|
||||
and not Inverter("x", driver=FakeDriver(host="")).has_writer()
|
||||
and not Inverter("y").has_writer())
|
||||
check("the freshness gate is built on the DRIVER's bound",
|
||||
inv_f.batt_fresh.bound is FAKE_BOUND
|
||||
and inv_g.batt_fresh.bound is BOUNDS["batt"])
|
||||
for u in (inv_f, inv_g):
|
||||
u.note_write(False, NOW - timedelta(seconds=10))
|
||||
check("is_live() takes its window from the driver: 10 s of rejected writes "
|
||||
"drops a 7 s unit and not a GoodWe one",
|
||||
inv_f.is_live(NOW)[0] is False and inv_g.is_live(NOW)[0] is True)
|
||||
check("...and an explicit window still wins",
|
||||
inv_f.is_live(NOW, stale_s=60.0)[0] is True
|
||||
and inv_g.is_live(NOW, stale_s=5.0)[0] is False)
|
||||
inv_f5 = Inverter("fake-5", driver=FakeDriver(host="h"), max_w=2000.0)
|
||||
inv_f5.note_write(False, NOW - timedelta(seconds=5))
|
||||
check("link_note() says the driver's window: 'after 7 seconds' for the fake "
|
||||
"unit, 'after 30 seconds' for the GoodWe one, and nothing for a healthy unit",
|
||||
"after 7 seconds" in inv_f5.link_note(NOW)
|
||||
and "after 30 seconds" in inv_g.link_note(NOW)
|
||||
and "cannot be controlled" in inv_f.link_note(NOW)
|
||||
and Inverter("z", driver=FakeDriver(host="h")).link_note(NOW) == "")
|
||||
check("an unconfigured unit gets the no-setpoint row, as before",
|
||||
Inverter("x", driver=FakeDriver(host="")).link_note(NOW).startswith("x: ")
|
||||
and Inverter("x", driver=FakeDriver(host="")).link_note(NOW)
|
||||
== Inverter("x").link_note(NOW) != "")
|
||||
check("an entry that names a driver this build lacks is an unconfigured unit, "
|
||||
"not a crash and not GoodWe",
|
||||
isinstance(build_driver({"driver": "nope", "setpoint_entity": "number.x"}),
|
||||
UnavailableDriver)
|
||||
and not build_driver({"driver": "nope"}).configured()
|
||||
and build_roster({"inverters": [{"name": "n", "driver": "nope",
|
||||
"setpoint_entity": "number.x",
|
||||
"max_w": 1000}]}, None)[0].has_writer()
|
||||
is False)
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("3. a roster of one fake unit and one GoodWe unit, in a real Controller")
|
||||
|
||||
REGISTRY["fake"] = FakeDriver
|
||||
OPTS = dict(meter_source="off", meter_entity="sensor.grid", max_w=5000,
|
||||
gain=0.6, slew_w=0, deadband_w=15, target_grid_w=0, step_w=10,
|
||||
heartbeat_s=10, stale_input_s=15, shadow_mode="off",
|
||||
inverters=[
|
||||
dict(name="fake-a", driver="fake", host="10.9.9.9", port=502,
|
||||
unit_id=3, max_w=2000, bms_soc_floor=10),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=3000, bms_soc_floor=15)])
|
||||
B_ENT = {"number.sp_b", "sensor.soc_b", "sensor.batt_b"}
|
||||
|
||||
|
||||
def site(rating_f=1000.0, **over):
|
||||
hass = LogHass({"sensor.grid": 3000.0, "sensor.soc_b": 50.0,
|
||||
"sensor.batt_b": 0.0},
|
||||
{"number.sp_b": (-3000.0, 3000.0, 1.0)})
|
||||
pub = Pub()
|
||||
c = Controller(dict(OPTS, **over), hass, MemStore(), pub,
|
||||
stop=asyncio.Event())
|
||||
f = c.inverters[0].driver
|
||||
f.rating = rating_f
|
||||
c.inverters[0].dev_min, c.inverters[0].dev_max = -rating_f, rating_f
|
||||
c.inverters[1].dev_min, c.inverters[1].dev_max = -3000.0, 3000.0
|
||||
c.auto = True
|
||||
return c, hass, pub, f
|
||||
|
||||
|
||||
async def cycles(c, n=6):
|
||||
for _ in range(n):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
|
||||
|
||||
c3, h3, p3, f3 = site()
|
||||
check("the roster is built through the registry: a fake unit and a GoodWe unit",
|
||||
isinstance(c3.inverters[0].driver, FakeDriver)
|
||||
and isinstance(c3.inverters[1].driver, GoodWeEsphomeDriver)
|
||||
and f3.actuator_id() == "10.9.9.9:502:3")
|
||||
run(cycles(c3))
|
||||
fa, fb = c3.inverters
|
||||
print(f" after 6 cycles: fake-a wrote {calls(f3, 'write')}, GoodWe wrote "
|
||||
f"{[x for m, e, x in h3.log if m == 'set_number']}, shares "
|
||||
f"{c3.allocation.shares if c3.allocation else None}")
|
||||
check("READ: the unit's SoC and power come from its driver, the GoodWe unit's "
|
||||
"from its entities",
|
||||
calls(f3, "read") and fa.soc == 60.0 and fb.soc == 50.0
|
||||
and ("number", "sensor.soc_b", False) in h3.log)
|
||||
check("WRITE: the fake unit's share went to ITS driver and the GoodWe unit's to "
|
||||
"ITS entity, each non-zero, the same direction",
|
||||
calls(f3, "write") and calls(f3, "write")[-1] > 0
|
||||
and [x for m, e, x in h3.log if m == "set_number" and e == "number.sp_b"][-1] > 0)
|
||||
check("ALLOCATE: each unit holds a share inside its own rating, and the shares "
|
||||
"are the site's (3000 W of grid, fake capped at 1000 W by its driver)",
|
||||
c3.allocation is not None
|
||||
and set(c3.allocation.shares) == {"fake-a", "inverter-b"}
|
||||
and 0 < c3.allocation.shares["fake-a"] <= 1000.0
|
||||
and 0 < c3.allocation.shares["inverter-b"] <= 3000.0
|
||||
and calls(f3, "write")[-1] == c3.allocation.shares["fake-a"])
|
||||
check("GRADE: the fake unit's rating is the driver's range (1000 W), the "
|
||||
"GoodWe unit's is its entry's (3000 W), each from its own source",
|
||||
(fa.rating_w(), fb.rating_w()) == (1000.0, 3000.0))
|
||||
check("the fake's power follows its command, so the freshness gate (the fake's "
|
||||
"own bound) lets the reading through",
|
||||
fa.batt == calls(f3, "write")[-1] and fa.batt_fresh.bound is FAKE_BOUND)
|
||||
run(c3.send_heartbeats(10_000.0, 10.0))
|
||||
n_before = len(calls(f3, "write"))
|
||||
check("HEARTBEAT: it re-emits the fake unit's standing command through its driver",
|
||||
n_before >= 2 and calls(f3, "write")[-1] == calls(f3, "write")[-2])
|
||||
run(c3.command_site_zero())
|
||||
check("STOP: command_site_zero() reaches the fake unit through its driver, as "
|
||||
"0 W (the hand-back is SMA-10's, and release() has no caller yet)",
|
||||
calls(f3, "write")[-1] == 0.0 and not calls(f3, "release"))
|
||||
c3.publish()
|
||||
print(f" published unit rows: "
|
||||
f"{sorted(k for k in p3.last if k.startswith('unit/'))}")
|
||||
check("PUBLISH: one set of rows per unit, with that unit's own numbers",
|
||||
p3.last.get("unit/fake_a/soc") == 60.0
|
||||
and p3.last.get("unit/inverter_b/soc") == 50.0
|
||||
and "unit/fake_a/battery" in p3.last and "unit/inverter_b/battery" in p3.last)
|
||||
rows = c3.checks()
|
||||
check("STATUS: the fake unit's sensor rows are not 'unset' (its driver has a "
|
||||
"source), and it has the control row",
|
||||
not [r for r in rows if r.get("id") == "sensor" and not r["ok"]
|
||||
and "fake" in r["text"]])
|
||||
# liveness: the fake's 7 s window, the GoodWe unit's 30 s
|
||||
c4, h4, p4, f4 = site()
|
||||
run(cycles(c4, 2))
|
||||
f4.accept = False # the fake's link is down; GoodWe's is fine
|
||||
_now = datetime.now(timezone.utc)
|
||||
for u in c4.inverters:
|
||||
u.note_write(False, _now - timedelta(seconds=10))
|
||||
check("LIVENESS: after 10 s of rejected writes the fake unit is left out of the "
|
||||
"split and the GoodWe unit stays in it, per its driver's window",
|
||||
c4.live_units() == ("inverter-b",))
|
||||
run(cycles(c4, 4))
|
||||
check("...and the survivor carries the site: the allocation names only it",
|
||||
c4.allocation is not None and set(c4.allocation.shares) == {"inverter-b"}
|
||||
and c4.allocation.shares["inverter-b"] > 1000.0)
|
||||
# the tripwire: nothing in the world was asked about the fake unit
|
||||
asked = h3.entities() | h4.entities()
|
||||
print(f" Home Assistant was asked about: {sorted(asked)}")
|
||||
check("TRIPWIRE: Home Assistant was only ever asked about the GoodWe unit's "
|
||||
"entities and the meter - no empty name, no host - on read, write, "
|
||||
"heartbeat, stop and limits alike",
|
||||
asked <= B_ENT | {"sensor.grid"} and "" not in asked
|
||||
and not any("10.9.9.9" in e or "fake" in e for e in asked))
|
||||
check("...and the GoodWe unit was never touched through the fake",
|
||||
not [1 for w, a in f3.calls + f4.calls
|
||||
if isinstance(a, str) and "number.sp_b" in a])
|
||||
|
||||
# limits and the per-unit range check, through run_control()
|
||||
async def drive(c, secs):
|
||||
try:
|
||||
await asyncio.wait_for(c.run_control(), timeout=secs)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
c5, h5, p5, f5 = site(rating_f=2500.0)
|
||||
f5.rating = 2500.0
|
||||
c5.inverters[0].dev_min = c5.inverters[0].dev_max = None
|
||||
c5.inverters[1].dev_min = c5.inverters[1].dev_max = None
|
||||
run(drive(c5, 3.0))
|
||||
check("LIMITS: run_control() reads the fake unit's range from its driver and "
|
||||
"the GoodWe unit's from its entity",
|
||||
calls(f5, "limits")
|
||||
and (c5.inverters[0].dev_min, c5.inverters[0].dev_max) == (-2500.0, 2500.0)
|
||||
and (c5.inverters[1].dev_min, c5.inverters[1].dev_max) == (-3000.0, 3000.0)
|
||||
and [e for m, e, x in h5.log if m == "limits"] == ["number.sp_b"])
|
||||
c6, h6, p6, f6 = site()
|
||||
f6.rating = 1500.0 # the entry says 2000 W
|
||||
c6.inverters[0].dev_min = c6.inverters[0].dev_max = None
|
||||
run(drive(c6, 10.0))
|
||||
check("RULE 2 PER UNIT: a fake unit rated above what its driver says it accepts "
|
||||
"is refused by name, as a GoodWe one is",
|
||||
c6.refused and any("fake-a" in p and "10.9.9.9:502:3" in p
|
||||
for p in c6.refused)
|
||||
and not calls(f6, "write"))
|
||||
|
||||
# the settings panel has no Controller: it binds each unit's driver itself
|
||||
from app import selfconfig # noqa: E402
|
||||
hs = LogHass({}, {"number.sp_b": (-3000.0, 3000.0, 1.0)})
|
||||
_ps, _unread = run(selfconfig.problems_and_ranges(
|
||||
dict(OPTS, inverters=[dict(OPTS["inverters"][0], _rating=1500.0),
|
||||
OPTS["inverters"][1]]), hs))
|
||||
check("PANEL: the settings panel reads the fake unit's range from its driver "
|
||||
"and the GoodWe unit's from its entity, and grades the fake unit by name",
|
||||
any("fake-a" in p and "10.9.9.9:502:3 accepts at most 1500" in p
|
||||
for p in _ps)
|
||||
and [e for m, e, x in hs.log if m == "limits"] == ["number.sp_b"]
|
||||
and _unread == [])
|
||||
|
||||
# the seam is the only way in: a fake-only roster touches Home Assistant for
|
||||
# nothing but the meter
|
||||
hz = LogHass({"sensor.grid": 800.0})
|
||||
cz = Controller(dict(OPTS, inverters=[OPTS["inverters"][0]]), hz, MemStore(),
|
||||
Pub(), stop=asyncio.Event())
|
||||
cz.inverters[0].dev_min, cz.inverters[0].dev_max = -1000.0, 1000.0
|
||||
cz.auto = True
|
||||
run(cycles(cz, 3))
|
||||
check("A FAKE-ONLY ROSTER never asks Home Assistant for anything but the "
|
||||
"meter, and still reads and writes (nothing on the control path is "
|
||||
"keyed on a GoodWe entity)",
|
||||
hz.entities() <= {"sensor.grid"} and calls(cz.inverters[0].driver, "read")
|
||||
and calls(cz.inverters[0].driver, "write"))
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("4. SAFETY-28's census counts actuator ids; rule 9 on the new keys")
|
||||
|
||||
def entry(**kw):
|
||||
return dict(name="u", max_w=3000, bms_soc_floor=10, **kw)
|
||||
|
||||
|
||||
check("GoodWe entries are counted as before: one entity named twice is one "
|
||||
"actuator with two writers, whatever its case",
|
||||
{k: len(v) for k, v in actuator_writers({"inverters": [
|
||||
entry(setpoint_entity="number.X"),
|
||||
entry(setpoint_entity=" number.x ")]}).items()} == {"number.x": 2}
|
||||
and actuator_writers({"setpoint_entity": "number.a", "inverters": [
|
||||
entry(setpoint_entity="number.a")]})["number.a"]
|
||||
== ["setpoint_entity", "inverters[0].setpoint_entity"])
|
||||
cen = actuator_writers({"inverters": [
|
||||
entry(driver="fake", host="gw", port=502, unit_id=1),
|
||||
entry(driver="fake", host="gw", port=502, unit_id=2),
|
||||
entry(driver="fake", host="gw", port=503, unit_id=1),
|
||||
entry(driver="fake", host="GW", port=502, unit_id=1)]})
|
||||
print(f" census {cen}")
|
||||
check("another driver's actuator is host:port:unit: two units behind one "
|
||||
"gateway are two actuators, and one unit named twice is one",
|
||||
set(cen) == {"gw:502:1", "gw:502:2", "gw:503:1"}
|
||||
and cen["gw:502:1"] == ["inverters[0].host", "inverters[3].host"])
|
||||
check("a GoodWe key on a fake entry STILL declares a writer (the pre-SMA-2 "
|
||||
"census kept, the driver's actuator added to it), and a `host` on a "
|
||||
"GoodWe entry declares none",
|
||||
actuator_writers({"inverters": [entry(driver="fake", host="gw",
|
||||
setpoint_entity="number.x")]})
|
||||
== {"number.x": ["inverters[0].setpoint_entity"],
|
||||
"gw:502:1": ["inverters[0].host"]}
|
||||
and actuator_writers({"inverters": [entry(setpoint_entity="number.x",
|
||||
host="gw")]})
|
||||
== {"number.x": ["inverters[0].setpoint_entity"]})
|
||||
check("an entry naming a driver this build lacks still counts its setpoint "
|
||||
"entity - refusing too much is the safe direction",
|
||||
actuator_writers({"inverters": [entry(driver="nope",
|
||||
setpoint_entity="number.q")]})
|
||||
== {"number.q": ["inverters[0].setpoint_entity"]})
|
||||
check("census_key() folds case and space",
|
||||
census_key(" Number.X ") == "number.x")
|
||||
|
||||
GOOD = dict(entry(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a"))
|
||||
BASEOPTS = {"meter_source": "off", "meter_entity": "sensor.grid"}
|
||||
base_problems = validate_startup_config(dict(BASEOPTS, inverters=[dict(GOOD)]))
|
||||
print(f" baseline problems for a plain GoodWe roster: {base_problems}")
|
||||
|
||||
|
||||
def prob(**kw):
|
||||
return validate_startup_config(dict(BASEOPTS, inverters=[dict(GOOD, **kw)]))
|
||||
|
||||
|
||||
check("an existing entry (no new key) is graded exactly as before",
|
||||
prob() == base_problems)
|
||||
check("...and so is one that says driver goodwe_esphome, or carries every new "
|
||||
"key in a valid shape",
|
||||
prob(driver="goodwe_esphome") == base_problems
|
||||
and prob(driver="", host="", maintenance=False) == base_problems
|
||||
and prob(host="inv.local", port=502, unit_id=3, maintenance=True)
|
||||
== base_problems)
|
||||
def named(problems, key):
|
||||
return [p for p in problems if f"inverters.{key}" in p or f"({key})" in p]
|
||||
|
||||
|
||||
check("only goodwe_esphome can be selected for now",
|
||||
named(prob(driver="sma_modbus"), "driver")
|
||||
and named(prob(driver="fake"), "driver")
|
||||
and SELECTABLE == (DEFAULT_DRIVER,))
|
||||
check("a host with a space, a bad port or unit number and a non-boolean "
|
||||
"maintenance are each refused by key; the right shapes are not",
|
||||
named(prob(host="a b"), "host")
|
||||
and named(prob(port=0), "port") and named(prob(port=70000), "port")
|
||||
and named(prob(port="x"), "port") and named(prob(port=True), "port")
|
||||
and named(prob(unit_id=248), "unit_id")
|
||||
and named(prob(unit_id=1.5), "unit_id")
|
||||
and named(prob(maintenance="yes"), "maintenance")
|
||||
and not named(prob(port=65535, unit_id=0, host="10.0.0.1"), "port"))
|
||||
check("a GoodWe entry with no setpoint entity is refused with the same sentence "
|
||||
"as before",
|
||||
any("has no" in p and "setpoint entity" in p.lower()
|
||||
for p in validate_startup_config(dict(BASEOPTS, inverters=[
|
||||
dict(GOOD, setpoint_entity="")]))))
|
||||
fu = FakeDriver(host="gw")
|
||||
check("rule 2 grades any unit by its driver's actuator id, so a unit with no "
|
||||
"setpoint entity is no longer skipped",
|
||||
validate_unit_ranges([Inverter("a", driver=fu, max_w=2000.0)])
|
||||
== [] and validate_unit_ranges([_u := Inverter("a", driver=fu,
|
||||
max_w=2000.0)]) == []
|
||||
and (setattr(_u, "dev_max", 1000.0) or True)
|
||||
and len(validate_unit_ranges([_u])) == 1
|
||||
and "gw:502:1" in validate_unit_ranges([_u])[0])
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("5. the schema")
|
||||
cfg = io.open(HERE / "config.yaml", encoding="utf-8").read()
|
||||
sch = cfg[cfg.index("\nschema:"):]
|
||||
ent = sch[sch.index("\n inverters:"):]
|
||||
ent = ent[:ent.index("\n\n")]
|
||||
decl = dict(ln.strip().lstrip("- ").split(": ", 1)
|
||||
for ln in ent.splitlines()[1:] if ": " in ln)
|
||||
NEW_KEYS = ("driver", "host", "port", "unit_id", "maintenance")
|
||||
check("HIDDEN FROM CLIENTS (owner, 2026-11-07): config.yaml's per-entry schema "
|
||||
"is what it was on 23ff9d0 - the five driver keys are not in it, and "
|
||||
"setpoint_entity is still required - so the Supervisor form shows "
|
||||
"nothing new",
|
||||
not set(NEW_KEYS) & set(decl) and not decl["setpoint_entity"].endswith("?")
|
||||
and set(decl) == {"name", "setpoint_entity", "max_w", "bms_soc_floor",
|
||||
"soc_entity", "batt_entity", "batt_invert",
|
||||
"capacity_kwh"})
|
||||
_tr = {lg: io.open(HERE / "translations" / f"{lg}.yaml", encoding="utf-8").read()
|
||||
for lg in ("en", "nl")}
|
||||
from app import messages as _msgs # noqa: E402
|
||||
check("...and no translation, label or description was added for them",
|
||||
not any(f" {k}:\n" in t.split(" estop_fitted:")[0].split(" inverters:")[1]
|
||||
for t in _tr.values() for k in NEW_KEYS)
|
||||
and not set(NEW_KEYS) & (set(_msgs.LABELS) | set(_msgs.LABELS_NL)))
|
||||
|
||||
# ==============================================================================
|
||||
print()
|
||||
print("6. round 2: census parity, setters, rule 9(d), NaN ports, hidden keys")
|
||||
|
||||
import copy, itertools, math # noqa: E402
|
||||
from app import uimodel # noqa: E402
|
||||
from app.validate import ACTUATOR_WRITE_KEYS # noqa: E402
|
||||
|
||||
|
||||
def old_census(opts):
|
||||
"""23ff9d0's census, copied: every ACTUATOR_WRITE_KEYS key, wherever it is."""
|
||||
found = []
|
||||
|
||||
def walk(node, path):
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
here = f"{path}.{k}" if path else str(k)
|
||||
if k in ACTUATOR_WRITE_KEYS:
|
||||
e = str(v or "").strip()
|
||||
if e:
|
||||
found.append((here, e))
|
||||
continue
|
||||
walk(v, here)
|
||||
elif isinstance(node, (list, tuple)):
|
||||
for i, v in enumerate(node):
|
||||
walk(v, f"{path}[{i}]")
|
||||
walk(opts, "")
|
||||
cen = {}
|
||||
for where, e in found:
|
||||
cen.setdefault(e.lower(), []).append(where)
|
||||
return cen
|
||||
|
||||
|
||||
check("ACTUATOR_WRITE_KEYS is exactly ('setpoint_entity',): the parity below "
|
||||
"is about that key", tuple(ACTUATOR_WRITE_KEYS) == ("setpoint_entity",))
|
||||
SP = ["number.a", "Number.A", " number.b ", "", None, 5, ["x"]]
|
||||
shapes = []
|
||||
for flat, e0, e1 in itertools.product(SP, SP, SP):
|
||||
shapes.append({"setpoint_entity": flat, "inverters": [
|
||||
{"name": "a", "setpoint_entity": e0}, {"name": "b", "setpoint_entity": e1}]})
|
||||
shapes += [{"x": {"y": [{"setpoint_entity": "number.z"}]}, "setpoint_entity": "n.q"},
|
||||
{"inverters": "junk"}, {"inverters": [{"driver": "nope",
|
||||
"setpoint_entity": "n.k"}]},
|
||||
{"inverters": [{"driver": "goodwe_esphome", "setpoint_entity": "n.k"}]},
|
||||
{}, {"inverters": []}]
|
||||
bad = [i for i, o in enumerate(shapes) if actuator_writers(o) != old_census(o)]
|
||||
check(f"CENSUS PARITY: on {len(shapes)} flat/roster/odd option shapes the "
|
||||
"census equals 23ff9d0's, keys, order and all", not bad)
|
||||
mixed = {"setpoint_entity": "number.a", "inverters": [
|
||||
{"name": "a", "setpoint_entity": "number.a"},
|
||||
{"name": "f", "driver": "fake", "host": "gw", "port": 502, "unit_id": 1,
|
||||
"setpoint_entity": "number.ignored"},
|
||||
{"name": "g", "setpoint_entity": "number.g"}]}
|
||||
new_c, old_c = actuator_writers(mixed), old_census(mixed)
|
||||
check("...and on a mixed config (a fake-driver entry too) the census is a "
|
||||
"SUPERSET of the pre-SMA-2 one: every old writer is still counted, "
|
||||
"plus the driver's own actuator",
|
||||
all(new_c.get(k) and set(v) <= set(new_c[k]) for k, v in old_c.items())
|
||||
and "gw:502:1" in new_c)
|
||||
|
||||
gi = Inverter("s", setpoint_entity="number.s", soc_entity="sensor.a",
|
||||
batt_entity="sensor.b")
|
||||
gi.setpoint_entity, gi.soc_entity, gi.batt_entity, gi.batt_invert = (
|
||||
"number.t", "sensor.c", "sensor.d", True)
|
||||
check("SETTERS: assigning setpoint_entity/soc_entity/batt_entity/batt_invert "
|
||||
"on an Inverter writes through to its GoodWe driver, as it did before",
|
||||
(gi.driver.setpoint_entity, gi.driver.soc_entity, gi.driver.batt_entity,
|
||||
gi.driver.batt_invert) == ("number.t", "sensor.c", "sensor.d", True)
|
||||
and gi.has_writer() and gi.driver.actuator_id() == "number.t")
|
||||
|
||||
|
||||
def _assign():
|
||||
inv_f.setpoint_entity = "x"
|
||||
|
||||
|
||||
try:
|
||||
_assign()
|
||||
_refused = False
|
||||
except AttributeError:
|
||||
_refused = True
|
||||
check("...and on a driver that has no such field it refuses, not loses the value",
|
||||
_refused)
|
||||
|
||||
nogood = validate_startup_config(dict(BASEOPTS, inverters=[
|
||||
{k: v for k, v in GOOD.items() if k != "setpoint_entity"}]))
|
||||
check("RULE 9(d): a GoodWe entry with no setpoint_entity is refused, by name "
|
||||
"and by position, with the sentence it always had",
|
||||
any(p.startswith("inverters[0] (u) has no") and "setpoint entity" in p.lower()
|
||||
and "Set its ESPHome number entity, or remove the entry." in p
|
||||
for p in nogood)
|
||||
and nogood != base_problems)
|
||||
check("...also with `driver: goodwe_esphome` said out loud",
|
||||
any("has no" in p for p in validate_startup_config(dict(
|
||||
BASEOPTS, inverters=[{k: v for k, v in dict(GOOD, driver="goodwe_esphome").items()
|
||||
if k != "setpoint_entity"}]))))
|
||||
|
||||
nan, inf = float("nan"), float("inf")
|
||||
for key in ("port", "unit_id"):
|
||||
for bad_v, nm in ((nan, "NaN"), (inf, "inf"), (-inf, "-inf")):
|
||||
try:
|
||||
got = prob(**{key: bad_v})
|
||||
ok_ = bool(named(got, key))
|
||||
except Exception as e: # noqa: BLE001
|
||||
got, ok_ = repr(e), False
|
||||
check(f"{key} = {nm} is refused by key and does not raise", ok_)
|
||||
|
||||
ent = dict(name="a", setpoint_entity="number.a", max_w=3000, bms_soc_floor=10,
|
||||
soc_entity="sensor.s", batt_entity="sensor.b", driver="goodwe_esphome",
|
||||
host="h", port=502, unit_id=3, maintenance=False)
|
||||
opts_rt = {"inverters": [ent, dict(ent, name="b", setpoint_entity="number.b")]}
|
||||
back = uimodel.ui_to_options(uimodel.options_to_ui(copy.deepcopy(opts_rt)))
|
||||
check("an entry that carries the driver keys (from a caller that skips the "
|
||||
"schema) comes back from a save through the page model unchanged",
|
||||
back.get("inverters") == opts_rt["inverters"])
|
||||
|
||||
del REGISTRY["fake"]
|
||||
|
||||
# THE SEAM MUTANTS THIS FILE WAS RUN AGAINST (twelve, each one edit to a copy of
|
||||
# the tree; all twelve turn it red):
|
||||
# write_unit calls hass.set_number itself -> part 3 WRITE (and the tripwire)
|
||||
# _read_unit calls hass.number(soc/batt entity) -> part 3 READ
|
||||
# run_control's limits keyed on setpoint_entity -> part 3 LIMITS
|
||||
# the panel's range check keyed on setpoint_entity -> part 3 PANEL
|
||||
# has_writer() back to bool(setpoint_entity) -> part 2 has_writer
|
||||
# is_live() window back to LINK_STALE_S -> part 2 is_live
|
||||
# link_note() window back to LINK_STALE_S -> part 2 link_note
|
||||
# Freshness built on BOUNDS["batt"] -> part 2 freshness bound
|
||||
# _write_unit's guard keyed on setpoint_entity -> part 3 WRITE
|
||||
# the census back to the GoodWe key only -> part 4 census
|
||||
# rule 2 per unit back to .setpoint_entity -> part 3 RULE 2 PER UNIT
|
||||
# the status rows' source test back to the entities -> part 3 STATUS
|
||||
# NOT COVERED: the call in amain() that reads the flat setpoint entity's range
|
||||
# before any Controller exists. It runs only inside amain(), which no check
|
||||
# starts; it is two lines and the GoodWe driver's own limits(), graded in part 1.
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,181 @@
|
||||
"""MAINT-21 tripwire: a test must patch a Controller global where it is read.
|
||||
|
||||
The Controller's code lives in app/main.py and in the files split from it
|
||||
(app/ctl_*.py, app/rows.py). A function reads a module global from its own
|
||||
file, so `app.main.NAME = fake` changes only the code that is still in
|
||||
main.py. This file fails when a test or tool still does that:
|
||||
|
||||
1. it patches an `app.main` attribute that main.py's own functions no
|
||||
longer read (a stale patch, silently inert), or
|
||||
2. it patches an `app.main` attribute that another file also reads (the
|
||||
patch reaches only part of the code).
|
||||
|
||||
The way to patch is `tools/main_files.py`: `main_files.live.NAME = fake` sets
|
||||
NAME in every file that reads it and raises when none does.
|
||||
|
||||
Part 3 runs the scan on spoilt sources and the patch helper on the real tree.
|
||||
Read MUTATION.md for the habit this follows. `python3 test_split_patches.py`
|
||||
"""
|
||||
|
||||
import ast
|
||||
import glob
|
||||
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"))
|
||||
import main_files # noqa: E402
|
||||
|
||||
fails = []
|
||||
ALIASES = {"m", "M", "main", "app_main", "_main", "_main_mod", "main_mod"}
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def main_aliases(tree):
|
||||
"""Names bound to the module app.main in one file."""
|
||||
al = set()
|
||||
for n in ast.walk(tree):
|
||||
if isinstance(n, ast.Import):
|
||||
al.update(a.asname for a in n.names
|
||||
if a.name == "app.main" and a.asname)
|
||||
elif isinstance(n, ast.ImportFrom) and n.module == "app":
|
||||
al.update(a.asname or a.name for a in n.names if a.name == "main")
|
||||
for n in ast.walk(tree):
|
||||
if (isinstance(n, ast.Assign) and isinstance(n.value, ast.Name)
|
||||
and n.value.id in al):
|
||||
al.update(t.id for t in n.targets if isinstance(t, ast.Name))
|
||||
# `for mod in (app_main, homeview): mod.datetime = fake`
|
||||
if (isinstance(n, ast.For) and isinstance(n.target, ast.Name)
|
||||
and isinstance(n.iter, (ast.Tuple, ast.List))
|
||||
and any(isinstance(e, ast.Name) and e.id in al
|
||||
for e in n.iter.elts)):
|
||||
al.add(n.target.id)
|
||||
return al
|
||||
|
||||
|
||||
def flat(targets):
|
||||
"""The assignment targets, tuples and lists taken apart."""
|
||||
for t in targets:
|
||||
if isinstance(t, (ast.Tuple, ast.List)):
|
||||
yield from flat(t.elts)
|
||||
else:
|
||||
yield t
|
||||
|
||||
|
||||
def patches(src):
|
||||
"""[(line, name)]: every attribute of app.main a file assigns, or hands to
|
||||
setattr / a patching helper as `(app.main alias, "name", value)`."""
|
||||
tree = ast.parse(src)
|
||||
al = main_aliases(tree)
|
||||
out = []
|
||||
for n in ast.walk(tree):
|
||||
targets = []
|
||||
if isinstance(n, ast.Assign):
|
||||
targets = n.targets
|
||||
elif isinstance(n, (ast.AugAssign, ast.AnnAssign)):
|
||||
targets = [n.target]
|
||||
for t in flat(targets):
|
||||
# `app_main.NAME = x`, or `rig.m.NAME = x` where rig did
|
||||
# `import app.main as m`
|
||||
if isinstance(t, ast.Attribute) and (
|
||||
(isinstance(t.value, ast.Name) and t.value.id in al)
|
||||
or (isinstance(t.value, ast.Attribute)
|
||||
and t.value.attr in ALIASES)):
|
||||
out.append((n.lineno, t.attr))
|
||||
if (isinstance(n, ast.Call) and len(n.args) >= 3
|
||||
and isinstance(n.args[0], ast.Name) and n.args[0].id in al
|
||||
and isinstance(n.args[1], ast.Constant)
|
||||
and isinstance(n.args[1].value, str)
|
||||
and ast.unparse(n.func) != "getattr"):
|
||||
out.append((n.lineno, n.args[1].value))
|
||||
return out
|
||||
|
||||
|
||||
def problems(src, where="<source>", readers=main_files.reading):
|
||||
"""Messages for each patch in `src` that the rule above rejects."""
|
||||
out = []
|
||||
for line, name in patches(src):
|
||||
who = readers(name)
|
||||
if "main" not in who:
|
||||
out.append(f"{where}:{line} patches main.{name}, which main.py "
|
||||
f"does not read" + (f" (read by {who})" if who else ""))
|
||||
elif who != ["main"]:
|
||||
out.append(f"{where}:{line} patches main.{name}, which "
|
||||
f"{[w for w in who if w != 'main']} also read: use "
|
||||
f"main_files.live.{name}")
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. no test and no tool patches a main global the code reads elsewhere")
|
||||
sites = bad = 0
|
||||
for path in sorted(glob.glob(os.path.join(HERE, "test_*.py"))
|
||||
+ glob.glob(os.path.join(HERE, "tools", "*.py"))):
|
||||
if os.path.basename(path) == "test_split_patches.py":
|
||||
continue
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
src = fh.read()
|
||||
found = problems(src, os.path.relpath(path, HERE))
|
||||
sites += len(patches(src))
|
||||
for msg in found:
|
||||
print(" " + msg)
|
||||
bad += len(found)
|
||||
check(f"{sites} patches of an app.main attribute found, {bad} stale or partial",
|
||||
sites > 0 and bad == 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the scan is not blind: spoilt sources are caught")
|
||||
HEAD = "import app.main as m\n"
|
||||
check("main.<a name nothing reads> is stale",
|
||||
len(problems(HEAD + "m.no_such_global_anywhere = 1\n")) == 1)
|
||||
check("main.datetime is partial (the clock is read in more than one file)"
|
||||
if len(main_files.reading("datetime")) > 1 else
|
||||
"main.datetime (one reader here, so nothing to catch yet)",
|
||||
len(problems(HEAD + "m.datetime = 1\n")) ==
|
||||
(1 if len(main_files.reading("datetime")) > 1 else 0))
|
||||
check("the same through setattr, a helper and an alias",
|
||||
len(problems(HEAD + "k = m\nsetattr(k, 'no_such_global_anywhere', 1)\n"
|
||||
"patched(m, 'no_such_global_anywhere', 1)(f)\n")) == 2)
|
||||
check("a patch through main_files.live is not flagged",
|
||||
problems(HEAD + "import main_files\n"
|
||||
"main_files.live.datetime = 1\n") == [])
|
||||
check("a name only main reads may still be patched on main",
|
||||
problems(HEAD + "m.load_options = 1\n") == [])
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. main_files.live sets a global in every file that reads it")
|
||||
import importlib # noqa: E402
|
||||
|
||||
live = main_files.live
|
||||
for name in ("datetime", "compute", "_mono"):
|
||||
readers = main_files.reading(name)
|
||||
mods = [importlib.import_module("app." + r) for r in readers]
|
||||
before = [getattr(m, name) for m in mods]
|
||||
sentinel = object()
|
||||
setattr(live, name, sentinel)
|
||||
try:
|
||||
hit = all(getattr(m, name) is sentinel for m in mods)
|
||||
read_back = getattr(live, name) is sentinel
|
||||
finally:
|
||||
for m, old in zip(mods, before):
|
||||
setattr(m, name, old)
|
||||
check(f"{name}: set in all {len(mods)} reading file(s) {readers}, and "
|
||||
f"read back", bool(mods) and hit and read_back)
|
||||
try:
|
||||
live.no_such_global_anywhere = 1
|
||||
raised = False
|
||||
except AttributeError:
|
||||
raised = True
|
||||
check("a name no file reads raises instead of patching nothing", raised)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -81,6 +81,8 @@ from app.static_tariff import (
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(HERE / "tools"))
|
||||
import main_files # noqa: E402
|
||||
UTC = timezone.utc
|
||||
|
||||
# PRICE-02: `TariffPlan.arbitrage(now=...)` is required. This instant is before every
|
||||
@@ -893,7 +895,7 @@ check("the new module holds no option-defaults dict of its own, so this ticket "
|
||||
check("...and it reads the option through price.price_option(), the framework's "
|
||||
"single read path, rather than through opts.get(key, <a number>)",
|
||||
"price_option(opts" in _SRC and 'opts.get("static_profile"' not in _SRC)
|
||||
_MAIN = (HERE / "app" / "main.py").read_text(encoding="utf-8")
|
||||
_MAIN = main_files.source()
|
||||
check("...and main.py grew no `o.get(\"static_profile\", <a default>)` fallback "
|
||||
"either - it calls static_status(o, now), and the option is read once, "
|
||||
"inside the framework",
|
||||
|
||||
@@ -40,6 +40,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import homeview, modetrade, packmode # noqa: E402
|
||||
from app import price as _price, static_tariff as _st # noqa: E402
|
||||
@@ -196,7 +197,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
clock = [start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -204,7 +205,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
_real_load = _price.load_prices, _st.load_prices
|
||||
_price.load_prices = _st.load_prices = _no_fetch
|
||||
started = task_started(o)
|
||||
@@ -246,7 +247,7 @@ def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
_price.load_prices, _st.load_prices = _real_load
|
||||
return c, started
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from named_absence import first, nth, present # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import arbitrage, homeview, modetrade, solarfc # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
@@ -175,7 +176,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
# forecast IS lost, so it runs with a hold that never covers
|
||||
# (test_opt27.py tests the cover).
|
||||
c.fc_hold = _NoHold()
|
||||
real_dt, real_hv = m.datetime, homeview.datetime
|
||||
real_dt, real_hv = main_files.live.datetime, homeview.datetime
|
||||
clock = [t0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -183,7 +184,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = homeview.datetime = _DT
|
||||
main_files.live.datetime = homeview.datetime = _DT
|
||||
pts = None if points is None else points
|
||||
task = None
|
||||
if pts is None:
|
||||
@@ -248,7 +249,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
m.datetime, homeview.datetime = real_dt, real_hv
|
||||
main_files.live.datetime, homeview.datetime = real_dt, real_hv
|
||||
return c
|
||||
|
||||
return asyncio.run(go()), rows
|
||||
|
||||
@@ -39,7 +39,8 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import ux54_scenarios as S # noqa: E402
|
||||
from app import arbitrage, dayplan, dayview, homeview, modetrade # noqa: E402
|
||||
from app import arbitrage, cheaproll, dayplan, dayview, homeview # noqa: E402
|
||||
from app import modetrade # noqa: E402
|
||||
from app import packmode, packshare, peakguard, solarfc, uitext # noqa: E402
|
||||
from app.main import pack_energy_of # noqa: E402
|
||||
from app.price import Uncertainty # noqa: E402
|
||||
@@ -222,13 +223,29 @@ check("Leftover solar and Cheap hours: a give block exactly on the give run "
|
||||
all([(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
|
||||
== [("give", "17:00", "21:00")] for b in dd)
|
||||
and rp.give[0].start == at(17, 0, 0) and rp.give[0].end == at(21, 0, 0))
|
||||
c_n = S.make("dynamic", at(0, 18))
|
||||
# OPT-36: a larger evening (4.8 kW) so that B (Cheap hours) does not cover the
|
||||
# dear hours alone and A still buys from the grid; A starts at 30 %.
|
||||
c_n = S.make("dynamic", at(0, 18), soc=(30.0, 40.0), evening=4800.0)
|
||||
# The run's end, read from the plan; for this house it is 02:30, as in 0.20.0.
|
||||
END = c_n.mode_runs.cheap.end
|
||||
END_H = (END - DAY0).total_seconds() / 3600.0
|
||||
END_S = S.brussels(END).strftime("%H:%M")
|
||||
check(f"the cheap run is 01:00 to {END_S} (02:30 for this house)",
|
||||
c_n.mode_runs.cheap.start == at(1, 0, 0) and END == at(2, 30, 0))
|
||||
nb = tab(c_n, at(0, 18))["days"][0]["batteries"]
|
||||
check("in the night before the cheap run both charge from the grid in it "
|
||||
"(01:00 to 02:30), then give at 17:00",
|
||||
all([r["kind"] for r in b["blocks"]] == ["grid", "give"] for b in nb)
|
||||
and all(b["blocks"][0]["from"] == "01:00"
|
||||
and b["blocks"][0]["to"] == "02:30" for b in nb))
|
||||
# UX-77: today's block runs to where control stops charging: the 1.5 h run is
|
||||
# sized at the full rating, the peak limit leaves 3.1 kW, so control plans the
|
||||
# next run when it ends (`cheaproll`), and each battery's block ends at its stop.
|
||||
RL = cheaproll.for_today(c_n, c_n.mode_runs, at(0, 18))
|
||||
ENDS = [S.brussels(RL.end_for(inv.name, END)).strftime("%H:%M")
|
||||
for inv in c_n.inverters]
|
||||
check("in the night before the cheap run both charge from the grid from "
|
||||
f"01:00 to where control stops ({ENDS}, the run rolls on after "
|
||||
f"{END_S}), then give at 17:00",
|
||||
RL is not None and RL.end > END and ENDS[0] < ENDS[1]
|
||||
and all([r["kind"] for r in b["blocks"]] == ["grid", "give"] for b in nb)
|
||||
and all(b["blocks"][0]["from"] == "01:00" for b in nb)
|
||||
and [b["blocks"][0]["to"] for b in nb] == ENDS)
|
||||
# UX-53: no grid block while the gate refuses.
|
||||
for why in ("peak", "not_worth"):
|
||||
c_n.mode_gate = why
|
||||
@@ -246,12 +263,17 @@ def at_h(b, h):
|
||||
return dict((round(x, 3), v) for x, v in b["levels"]).get(h, float("nan"))
|
||||
|
||||
|
||||
check("the curve charges in a grid block: A (planned level 64 %) and B "
|
||||
"(Cheap hours, to the end of its 01:30 run) are well above where they "
|
||||
"were at 01:00, and stay put after the run",
|
||||
all(at_h(b, 2.5) - at_h(b, 1.0) > 8.0
|
||||
and abs(at_h(b, 3.0) - at_h(b, 2.5)) < 1e-6
|
||||
for b in nb) and nb[0]["levels"][0][1] == 55.0)
|
||||
LVL_A = c_n.plan_targets()[c_n.inverters[0].name]
|
||||
END_HS = [(S.brussels(RL.end_for(inv.name, END)).replace(tzinfo=None)
|
||||
- S.brussels(DAY0).replace(tzinfo=None)).total_seconds() / 3600.0
|
||||
for inv in c_n.inverters]
|
||||
check(f"the curve charges in a grid block: A (planned level {LVL_A:.0f} %) "
|
||||
"and B (Cheap hours, to the end of its block) are well above where "
|
||||
"they were at 01:00, and stay put after the block",
|
||||
all(at_h(b, eh) - at_h(b, 1.0) > 8.0
|
||||
and all(abs(v - at_h(b, eh)) < 1e-6 for x, v in b["levels"]
|
||||
if eh + 1e-6 < x < eh + 3.0)
|
||||
for b, eh in zip(nb, END_HS)) and nb[0]["levels"][0][1] == 30.0)
|
||||
# Modes off: the verdict's legs.
|
||||
c_f = S.make("off", at(10, 7))
|
||||
fb = tab(c_f, at(10, 7))
|
||||
@@ -681,7 +703,7 @@ check("Dutch: the notes, the totals and the blocks are in Dutch, with a "
|
||||
and say("estimate", "nl") in on["days"][0]["notes"]
|
||||
and on["days"][1]["line"].startswith("Verwachte zon morgen: 16,0 kWh")
|
||||
and oe["days"][0]["line"].startswith("Solar still to come today: 14.4"))
|
||||
c = S.make("dynamic", at(0, 18))
|
||||
c = S.make("dynamic", at(0, 18), soc=(30.0, 40.0), evening=4800.0)
|
||||
nl_b = tab(c, at(0, 18), "nl")["days"][0]["batteries"][0]["blocks"]
|
||||
check("...and so are the plan's words on a block",
|
||||
nl_b[0]["what"].startswith("Laadt maximaal") and " %" in nl_b[0]["what"]
|
||||
@@ -824,14 +846,17 @@ def may_give(inv):
|
||||
return packshare.envelope(pack_energy_of(inv)).hi > 0.0
|
||||
|
||||
|
||||
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
|
||||
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None,
|
||||
evening=None, peak_w=None):
|
||||
"""Walk the real control cycle (`_mode_roles()`) every 15 minutes from
|
||||
`now0` to the end of tomorrow, each battery at the level the tab's curve
|
||||
has at that time, and return the tab and the role seen per battery and
|
||||
quarter. A GIVE role on a pack that control's discharge envelope closes
|
||||
is seen as "closed": the pack is not asked to give. The tab is built
|
||||
once, at `now0`."""
|
||||
c = S.make(name, now0, soc=soc)
|
||||
once, at `now0`. `peak_w`: the site's peak limit (OPT-36, below)."""
|
||||
c = S.make(name, now0, soc=soc, evening=evening)
|
||||
if peak_w is not None:
|
||||
c.o = dict(c.o, peak_cap_w=peak_w)
|
||||
cut_out_at_floor(c)
|
||||
c.tariff_plan = S.quarters(now0, night_evening, days=3)
|
||||
c.tariff_at = now0
|
||||
@@ -852,6 +877,13 @@ def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
|
||||
b = day["batteries"][i]
|
||||
inv.soc = level_at(b, h if h < 24 else h - 24) if b["levels"] \
|
||||
else inv.soc
|
||||
# OPT-36: the tab shows a level to 0.1 % (`dayview.LEVEL_DP`); a
|
||||
# battery whose curve shows its planned level (50.0 for 50.01) is
|
||||
# at it, or control charges it for the last 0.01 %.
|
||||
tg = (c.plan_targets() or {}).get(inv.name)
|
||||
if tg is not None and inv.soc is not None and round(
|
||||
inv.soc, dayview.LEVEL_DP) == round(tg, dayview.LEVEL_DP):
|
||||
inv.soc = max(inv.soc, tg)
|
||||
c.tariff_at = t
|
||||
tick = c._mode_roles(t, None)
|
||||
for inv in c.inverters:
|
||||
@@ -890,7 +922,12 @@ def tomorrow_match(out, seen, d0, role, kind):
|
||||
|
||||
|
||||
t22 = at(22, 0, 5)
|
||||
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0))
|
||||
# OPT-36: the plan counts B's charge under the peak limit, the tab's curve
|
||||
# charges at the batteries' ratings. Walked along the curve, B runs ahead of
|
||||
# the plan and A's level falls behind the tab's block. A limit that does not
|
||||
# bind (15 kW, the option's top) makes the curve the charge control plans.
|
||||
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0), evening=4800.0,
|
||||
peak_w=15000.0)
|
||||
tm1 = o1["days"][1]["batteries"]
|
||||
print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1])
|
||||
check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "
|
||||
|
||||
@@ -36,6 +36,7 @@ logging.disable(logging.WARNING)
|
||||
|
||||
import ux54_scenarios as S # noqa: E402
|
||||
import app.main as M # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import arbitrage, dayplan, dayview, homeview, solarfc # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app.daycurve import SolarToday # noqa: E402
|
||||
@@ -90,7 +91,8 @@ class FakeDT(datetime):
|
||||
return NOW[0].astimezone(tz) if tz else NOW[0].replace(tzinfo=None)
|
||||
|
||||
|
||||
for _mod in (M, homeview, dayview): # what reads the wall clock
|
||||
main_files.live.datetime = FakeDT # the Controller's clock
|
||||
for _mod in (homeview, dayview): # the other readers of the clock
|
||||
_mod.datetime = FakeDT
|
||||
|
||||
TZ_MODULES = (tariff, dayplan, homeview, solarfc)
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
"""UX-77: Home draws today's grid charge for the hours control will charge.
|
||||
|
||||
The real `Controller._mode_roles()` and `homeview.build()`, stepped each
|
||||
minute over a flat night (the walk of the investigation): at five times, every
|
||||
minute control charges battery X in that local day lies inside X's drawn grid
|
||||
block, the block does not run past the hour control stops charging X by more
|
||||
than one slot, and the chart's cheap bars cover every hour with a charge.
|
||||
The peak-bound night is the one that needs the peak limit's room (a mutant that
|
||||
rolls at the full rating fails it, part 3); dynamic prices draw what they drew
|
||||
before (part 4). Mutations (MUTATION.md, purged copies): listed in the pull
|
||||
request. Run: python3 test_ux77.py
|
||||
"""
|
||||
import json
|
||||
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 ux54_scenarios as S # noqa: E402
|
||||
from app import dayplan, homeview, modetrade, packmode, packshare # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.static_tariff import static_plan # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import BILLING_TZ # noqa: E402
|
||||
|
||||
try: # not on the base, where it is red
|
||||
from app import cheaproll
|
||||
except ImportError:
|
||||
cheaproll = None
|
||||
|
||||
UTC = timezone.utc
|
||||
EFF = 0.95
|
||||
AIM = 3450.0
|
||||
SLOT = timedelta(hours=1) # the price slot of the day/night tariff
|
||||
QUARTER = timedelta(minutes=15) # the run's own slot
|
||||
SAMPLES = ("23:30", "00:30", "01:30", "02:30", "04:30")
|
||||
OLD = ("leftover_solar", "cheap_hours") # the owner's old shape
|
||||
NOW = ("leftover_solar", "leftover_solar") # the current one
|
||||
PASS = FAIL = 0
|
||||
|
||||
|
||||
def check(name, ok, detail=""):
|
||||
global PASS, FAIL
|
||||
if ok:
|
||||
PASS += 1
|
||||
else:
|
||||
FAIL += 1
|
||||
print(f"FAIL {name} {detail}")
|
||||
|
||||
|
||||
def no_ties(h):
|
||||
"""Two dips, no two slots at the same price: nothing rolls on."""
|
||||
return (0.05 if 1.0 <= h < 2.0 else 0.08 if 5.0 <= h < 6.0
|
||||
else 0.30) + 0.0001 * h
|
||||
|
||||
|
||||
def shape(night=None):
|
||||
d = {h: 0.973 * (600.0 if h < 6 or h >= 22 else 1200.0 if h < 10
|
||||
else 900.0 if h < 17 else 1800.0) for h in range(24)}
|
||||
if night is not None:
|
||||
for h in list(range(0, 7)) + [22, 23]:
|
||||
d[h] = night
|
||||
return d
|
||||
|
||||
|
||||
def profile(sh, now):
|
||||
d = now.astimezone(BILLING_TZ).date()
|
||||
return dayplan.HouseProfile({(d - timedelta(days=k)).isoformat():
|
||||
{str(h): w for h, w in sh.items()} for k in (1, 2, 3)})
|
||||
|
||||
|
||||
def loc(t):
|
||||
return t.astimezone(BILLING_TZ)
|
||||
|
||||
|
||||
def hm(s, day):
|
||||
h, m = map(int, s.split(":"))
|
||||
return day.replace(hour=h, minute=m)
|
||||
|
||||
|
||||
def split_by_room(w, ch, rating, wt, invs):
|
||||
"""Control's split of `w` over the chargers `ch`: by `packshare.weights()`,
|
||||
no battery over its rating, the surplus to the others."""
|
||||
p, open_, left = [0.0, 0.0], set(ch), w
|
||||
while open_:
|
||||
tot = sum(wt[invs[i].name] for i in open_)
|
||||
over = [i for i in open_
|
||||
if left * wt[invs[i].name] / tot > rating[i] + 1e-9]
|
||||
if not over:
|
||||
for i in open_:
|
||||
p[i] = left * wt[invs[i].name] / tot
|
||||
break
|
||||
for i in over:
|
||||
p[i] = rating[i]
|
||||
left -= rating[i]
|
||||
open_.discard(i)
|
||||
return p
|
||||
|
||||
|
||||
def walk(modes, soc0, house_w, night=None, dynamic=False, samples=SAMPLES,
|
||||
start_h=22, end_h=7, plain=None, by_room=True, fc=(10.2, 10.0)):
|
||||
"""Walk 22:00-07:15. Returns (charging, homes, commands):
|
||||
charging[minute] = set of batteries control charges (power > 0),
|
||||
homes[sample] = Home's `/status` plan at that minute, commands = the
|
||||
(minute, unit, role, size) control gave."""
|
||||
opts = dict(S.BASE)
|
||||
opts.update(battery_modes_enabled=True,
|
||||
pv_forecast_entities=[f"{S.FC1}_{i}" for i in range(3)],
|
||||
pv_forecast_tomorrow_entities=[f"{S.FCT1}_{i}"
|
||||
for i in range(3)],
|
||||
static_profile=json.dumps(S.DN), arbitrage_enabled=False,
|
||||
arbitrage_soc_floor=20, arbitrage_soc_ceiling=95,
|
||||
cycle_cost_threshold_eur=0.04)
|
||||
c = Controller(opts, S.Hass(), S.Store(), S.Pub(), stop=S.asyncio.Event())
|
||||
c.auto = True
|
||||
soc = list(soc0)
|
||||
caps = [inv.capacity_kwh for inv in c.inverters]
|
||||
rating = [inv.max_w for inv in c.inverters]
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.batt, inv.last_write = 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i]
|
||||
c.pv_w = 0.0
|
||||
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0,
|
||||
ceiling_from="cap", projected_w=800.0)
|
||||
start = datetime(2026, 11, 1, start_h, 0, tzinfo=BILLING_TZ).astimezone(UTC)
|
||||
end = datetime(2026, 11, 2, end_h, 15, tzinfo=BILLING_TZ).astimezone(UTC)
|
||||
c.house_profile = profile(shape(night), start)
|
||||
now, plan_at = start, None
|
||||
charging, homes, cmds = {}, {}, []
|
||||
while now < end:
|
||||
lt = loc(now)
|
||||
today, tomorrow = fc if lt.hour < 12 else fc[::-1]
|
||||
c.solar_fc = S.forecast(now, today, tomorrow, members=3)
|
||||
c.solar_fc_why = None
|
||||
if plan_at is None or (now - plan_at).total_seconds() >= 1800:
|
||||
c.tariff_plan = (S.quarters(now, dynamic) if dynamic else
|
||||
static_plan(opts, now, now + timedelta(hours=48),
|
||||
now=now))
|
||||
plan_at = now
|
||||
c.tariff_at = plan_at
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc = round(soc[i], 1)
|
||||
room = AIM - house_w
|
||||
c.arb_headroom_w = room
|
||||
c.grid = house_w
|
||||
tick = c._mode_roles(now, None)
|
||||
size = modetrade.size(tick, (-sum(rating), sum(rating)), room) \
|
||||
if tick.action else 0.0
|
||||
ch = [i for i, inv in enumerate(c.inverters)
|
||||
if tick.roles.get(inv.name) == modetrade.CHARGE]
|
||||
p = [0.0, 0.0]
|
||||
if size < 0 and ch:
|
||||
if by_room: # as control: room left to full, rating caps
|
||||
wt = packshare.weights(c.pack_energy, -1.0)
|
||||
p = split_by_room(-size, ch, rating, wt, c.inverters)
|
||||
else:
|
||||
tot = sum(rating[i] for i in ch)
|
||||
for i in ch:
|
||||
p[i] = -size * rating[i] / tot
|
||||
for i in range(2):
|
||||
soc[i] = min(100.0, soc[i] + p[i] / 1000.0 / 60.0 * EFF
|
||||
/ caps[i] * 100.0)
|
||||
charging[lt.replace(second=0, microsecond=0)] = {
|
||||
i for i in range(2) if p[i] > 0.0}
|
||||
cmds.append((lt.strftime("%H:%M"), tick.action,
|
||||
tuple(sorted(tick.roles.items())), round(size)))
|
||||
if lt.strftime("%H:%M") in samples:
|
||||
homes[lt.replace(second=0, microsecond=0)] = homeview.build(
|
||||
c, level="ok", checks=[], now=now, lang="en")
|
||||
if plain is not None: # the same, with no roll
|
||||
real = cheaproll.for_today
|
||||
cheaproll.for_today = lambda *_a: None
|
||||
try:
|
||||
plain[lt.replace(second=0, microsecond=0)] = \
|
||||
homeview.build(c, level="ok", checks=[], now=now,
|
||||
lang="en")
|
||||
finally:
|
||||
cheaproll.for_today = real
|
||||
now += timedelta(minutes=1)
|
||||
return charging, homes, cmds
|
||||
|
||||
|
||||
def blocks_of(st, i):
|
||||
"""Battery i's drawn grid blocks as (start, end) local datetimes."""
|
||||
return [(loc(datetime.fromisoformat(r["start"])),
|
||||
loc(datetime.fromisoformat(r["end"])))
|
||||
for r in st["plan"]["runs"]
|
||||
if r["kind"] == "grid" and r["battery"] == i]
|
||||
|
||||
|
||||
def today_of(st, i):
|
||||
return [(t["from"], t["to"]) for t in st["batteries"][i]["today"]]
|
||||
|
||||
|
||||
def violations(charging, homes, slot=SLOT):
|
||||
"""What is wrong with Home's drawing at each sample."""
|
||||
bad = []
|
||||
for t, st in sorted(homes.items()):
|
||||
day_end = (t.replace(hour=0, minute=0) + timedelta(days=1))
|
||||
mins = {m: who for m, who in charging.items() if t <= m < day_end}
|
||||
for i in range(2):
|
||||
mine = sorted(m for m, who in mins.items() if i in who)
|
||||
bl = blocks_of(st, i)
|
||||
for m in mine:
|
||||
if not any(a <= m < z for a, z in bl):
|
||||
bad.append(f"{t:%H:%M} battery {i}: charges at "
|
||||
f"{m:%H:%M}, not in a block {bl}")
|
||||
break
|
||||
if mine:
|
||||
stop = mine[-1] + timedelta(minutes=1)
|
||||
if slot == QUARTER: # the last charging quarter's end
|
||||
stop += timedelta(minutes=-stop.minute % 15)
|
||||
for a, z in bl:
|
||||
if z > stop + slot:
|
||||
bad.append(f"{t:%H:%M} battery {i}: block to "
|
||||
f"{z:%H:%M}, stops {stop:%H:%M}")
|
||||
# the Today line says the same as the block
|
||||
tl = today_of(st, i)
|
||||
for m in mine:
|
||||
if not any(f <= m.strftime("%H:%M") < (z if z != "24:00"
|
||||
else "99") for f, z in tl):
|
||||
bad.append(f"{t:%H:%M} battery {i}: Today line {tl} "
|
||||
f"has no {m:%H:%M}")
|
||||
break
|
||||
cheap = {b["label"][:2] for b in st["plan"]["prices"]
|
||||
if b["kind"] == "cheap"}
|
||||
for m in mins:
|
||||
if mins[m] and m.strftime("%H") not in cheap:
|
||||
bad.append(f"{t:%H:%M}: charge at {m:%H:%M}, hour not cheap")
|
||||
break
|
||||
return bad
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Run
|
||||
# --------------------------------------------------------------------------- #
|
||||
def show(tag, ch, homes, want_bad, slot=SLOT):
|
||||
bad = violations(ch, homes, slot)
|
||||
if want_bad:
|
||||
check(f"{tag}: red", bool(bad), "no violation found")
|
||||
else:
|
||||
check(f"{tag}: every charging minute is drawn, no block runs on",
|
||||
not bad, "\n " + "\n ".join(bad))
|
||||
return bad
|
||||
|
||||
|
||||
def charged(ch, i, after):
|
||||
return [m for m, who in ch.items() if i in who and m >= after]
|
||||
|
||||
|
||||
def main():
|
||||
global cheaproll
|
||||
if cheaproll is None:
|
||||
print("no app/cheaproll.py: the base; the walk is red there")
|
||||
cheaproll = type("Off", (), {"for_today": staticmethod(lambda *a: None)})
|
||||
|
||||
print("1. the walk: no-bind (0.6 kW house) and peak-bound (2.85 kW house)")
|
||||
CASES = (("old shape, no bind", OLD, (20.0, 20.0), 584.0, None),
|
||||
("current shape, no bind", NOW, (20.0, 20.0), 584.0, None),
|
||||
("old shape, peak-bound", OLD, (40.0, 90.0), 2850.0, 2850.0),
|
||||
("current shape, peak-bound", NOW, (40.0, 90.0), 2850.0, 2850.0))
|
||||
# the rig's checks 2 and 3 (UX-77 round 2): room left and rating disagree
|
||||
UNEQUAL = (("unequal levels, A+B Leftover solar, no bind", NOW,
|
||||
(30.0, 20.0), 584.0, None, (5.0, 5.0)),
|
||||
("unequal levels, A Leftover solar + B Cheap hours", OLD,
|
||||
(40.0, 88.0), 2850.0, 2850.0, (10.2, 10.0)))
|
||||
walked = {}
|
||||
for tag, modes, soc, hw, night in CASES:
|
||||
ch, homes, _cmds = walk(modes, soc, hw, night=night)
|
||||
walked[tag] = (ch, homes)
|
||||
show(tag, ch, homes, False)
|
||||
# the check has something to check: charging goes on past 00:30
|
||||
day = datetime(2026, 11, 2, 0, 30, tzinfo=BILLING_TZ)
|
||||
check(f"{tag}: control charges after 00:30", bool(
|
||||
charged(ch, 0, day) or charged(ch, 1, day)))
|
||||
|
||||
print("2. a battery with nothing to charge gets no block")
|
||||
ch, homes, _c = walk(OLD, (40.0, 94.0), 2850.0, night=2850.0)
|
||||
st = homes[datetime(2026, 11, 2, 2, 30, tzinfo=BILLING_TZ)]
|
||||
check("B is at its level at 02:30: no block", blocks_of(st, 1) == [],
|
||||
str(blocks_of(st, 1)))
|
||||
check("A still has one at 02:30", bool(blocks_of(st, 0)))
|
||||
|
||||
print("3. a roll at the full rating fails the peak-bound night")
|
||||
if hasattr(cheaproll, "_Charge"):
|
||||
real = cheaproll._Charge._room_w
|
||||
cheaproll._Charge._room_w = lambda self, hour, total: total
|
||||
try:
|
||||
for tag, modes, soc, hw, night in CASES[2:]:
|
||||
ch, homes, _c = walk(modes, soc, hw, night=night)
|
||||
show(f"mutant, {tag}", ch, homes, True)
|
||||
finally:
|
||||
cheaproll._Charge._room_w = real
|
||||
|
||||
print("4. dynamic prices: Home is what it was")
|
||||
for tag, modes, soc, hw, night in (CASES[2], CASES[3], CASES[1]):
|
||||
plain = {}
|
||||
ch, homes, _c = walk(modes, soc, hw, night=night, dynamic=no_ties,
|
||||
plain=plain)
|
||||
same = all(json.dumps(homes[t], sort_keys=True, default=str)
|
||||
== json.dumps(plain[t], sort_keys=True, default=str)
|
||||
for t in homes)
|
||||
check(f"dynamic, {tag}: same as with no roll", same)
|
||||
check(f"dynamic, {tag}: and there are grid blocks to compare",
|
||||
any(r["kind"] == "grid" for st in homes.values()
|
||||
for r in st["plan"]["runs"]))
|
||||
|
||||
print("5. unequal levels: the roll shares the room as control does")
|
||||
for tag, modes, soc, hw, night, fc in UNEQUAL:
|
||||
ch, homes, _c = walk(modes, soc, hw, night=night, fc=fc)
|
||||
show(tag, ch, homes, False, QUARTER)
|
||||
day = datetime(2026, 11, 2, 0, 30, tzinfo=BILLING_TZ)
|
||||
check(f"{tag}: control charges after 00:30", bool(
|
||||
charged(ch, 0, day) or charged(ch, 1, day)))
|
||||
if hasattr(cheaproll, "_Charge") \
|
||||
and hasattr(cheaproll._Charge, "_shares"):
|
||||
real = cheaproll._Charge._shares
|
||||
cheaproll._Charge._shares = lambda self, ch, w: {
|
||||
p.name: w * abs(p.rating_w) / sum(abs(q.rating_w) for q in ch)
|
||||
for p in ch}
|
||||
try:
|
||||
for tag, modes, soc, hw, night, fc in UNEQUAL:
|
||||
ch, homes, _c = walk(modes, soc, hw, night=night, fc=fc)
|
||||
show(f"mutant by rating, {tag}", ch, homes, True, QUARTER)
|
||||
finally:
|
||||
cheaproll._Charge._shares = real
|
||||
|
||||
print("6. the pieces")
|
||||
if hasattr(cheaproll, "Roll"):
|
||||
t = datetime(2026, 11, 2, 1, 46, tzinfo=UTC)
|
||||
check("a stop is rounded up to the slot", cheaproll._ceil_slot(t)
|
||||
== datetime(2026, 11, 2, 2, 0, tzinfo=UTC))
|
||||
t = datetime(2026, 11, 2, 1, 45, tzinfo=UTC)
|
||||
check("a stop on a slot stays", cheaproll._ceil_slot(t) == t)
|
||||
e0 = datetime(2026, 11, 2, 0, 0, tzinfo=UTC)
|
||||
e1 = datetime(2026, 11, 2, 6, 0, tzinfo=UTC)
|
||||
r = cheaproll.Roll(e1, {"a": None, "b": datetime(2026, 11, 2, 2, 0,
|
||||
tzinfo=UTC)})
|
||||
check("a battery still charging ends at the roll", r.end_for("a", e0) == e1)
|
||||
check("a battery at its level ends at its stop",
|
||||
r.end_for("b", e0) == datetime(2026, 11, 2, 2, 0, tzinfo=UTC))
|
||||
check("a battery that does not charge keeps the run's end",
|
||||
r.end_for("c", e0) == e0)
|
||||
check("the chart's bar runs to the last block", r.last(e0) == e1)
|
||||
|
||||
print(f"\n{PASS} passed, {FAIL} failed")
|
||||
return FAIL
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(1 if main() else 0)
|
||||
@@ -1028,6 +1028,11 @@ check("...and the drain SET is not clamped either, so the twin key the card "
|
||||
_srcs = {name: open(os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"app", name), encoding="utf-8").read()
|
||||
for name in ("main.py", "validate.py", "maintenance.py")}
|
||||
# MAINT-21: main.py stands for itself and the files split from it.
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"tools"))
|
||||
import main_files # noqa: E402
|
||||
_srcs["main.py"] = main_files.source()
|
||||
_literals = {name: re.findall(
|
||||
r'"(maintenance_[a-z_]+)"\s*,\s*(-?\d+(?:\.\d+)?)\s*[,)]', src)
|
||||
for name, src in _srcs.items()}
|
||||
|
||||
@@ -1161,9 +1161,12 @@ check("...and it does not raise the same card on every cycle - a per-PROCESS "
|
||||
|
||||
# SAFETY-34: a Core-ONLY restart. The add-on process lives on (measured on
|
||||
# TEST-23), HA's memory is wiped, so the SAME process must re-assert the card.
|
||||
import os # noqa: E402
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools")) # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as _main_mod # noqa: E402
|
||||
_real_mono, _clock = _main_mod._mono, [_main_mod._mono()]
|
||||
_main_mod._mono = lambda: _clock[0]
|
||||
_real_mono, _clock = main_files.live._mono, [main_files.live._mono()]
|
||||
main_files.live._mono = lambda: _clock[0]
|
||||
try:
|
||||
_h3 = _CircHass(a_w=0.0, b_w=0.0)
|
||||
_b.hass = _h3
|
||||
@@ -1173,7 +1176,7 @@ try:
|
||||
asyncio.run(_b.check_circulating())
|
||||
_late = [c for c in _h3.calls if c[0] == "persistent_notification"]
|
||||
finally:
|
||||
_main_mod._mono = _real_mono
|
||||
main_files.live._mono = _real_mono
|
||||
check("SAFETY-34: inside REANNOUNCE_S the same process does not re-raise - no "
|
||||
"storm on the event bus", len(_early) == 0)
|
||||
check("SAFETY-34: a Core-only restart wipes the card and the add-on process "
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""MAINT-21: the Controller lives in app/main.py and the files split from it.
|
||||
|
||||
A test that reads `app/main.py` for a call, a comment or a def reads
|
||||
`source()` instead; a test that replaces a global that `main` used to read
|
||||
calls `patch_global()`, which sets it in each file whose code reads it. A name
|
||||
no file reads is an error: a stale patch cannot pass unnoticed.
|
||||
"""
|
||||
import ast
|
||||
import glob
|
||||
import importlib
|
||||
import os
|
||||
import symtable
|
||||
|
||||
APP = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "app")
|
||||
|
||||
|
||||
def files():
|
||||
"""main.py, rows.py and ctl_*.py, in that order, as bare file names."""
|
||||
split = ["rows.py"] + sorted(os.path.basename(p)
|
||||
for p in glob.glob(os.path.join(APP, "ctl_*.py")))
|
||||
return ["main.py"] + [f for f in split
|
||||
if os.path.exists(os.path.join(APP, f))]
|
||||
|
||||
|
||||
def text(name):
|
||||
with open(os.path.join(APP, name), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def source():
|
||||
"""All the files' text, one after the other."""
|
||||
return "\n".join(text(f) for f in files())
|
||||
|
||||
|
||||
def tree(src=None):
|
||||
"""`ast.parse(source())` with every `*Mixin` class folded into
|
||||
`Controller`, so `Controller` has all its methods. Positions refer to
|
||||
`source()`."""
|
||||
src = source() if src is None else src
|
||||
mod = ast.parse(src)
|
||||
ctl = next(n for n in mod.body
|
||||
if isinstance(n, ast.ClassDef) and n.name == "Controller")
|
||||
keep = []
|
||||
for n in mod.body:
|
||||
if isinstance(n, ast.ClassDef) and n.name.endswith("Mixin"):
|
||||
ctl.body.extend(n.body)
|
||||
else:
|
||||
keep.append(n)
|
||||
mod.body = keep
|
||||
return mod
|
||||
|
||||
|
||||
def _function_reads(table, acc):
|
||||
for s in table.get_symbols():
|
||||
if (table.get_type() == "function" and s.is_global()
|
||||
and s.is_referenced()):
|
||||
acc.add(s.get_name())
|
||||
for child in table.get_children():
|
||||
_function_reads(child, acc)
|
||||
|
||||
|
||||
def reading(name):
|
||||
"""The bare module names (`main`, `ctl_cycle` ...) whose functions read
|
||||
`name` as a global."""
|
||||
out = []
|
||||
for f in files():
|
||||
acc = set()
|
||||
_function_reads(symtable.symtable(text(f), f, "exec"), acc)
|
||||
if name in acc:
|
||||
out.append(f[:-3])
|
||||
return out
|
||||
|
||||
|
||||
class _Live:
|
||||
"""The globals the Controller's code reads, wherever they are read.
|
||||
|
||||
`live.NAME = value` sets NAME in every file whose functions read it;
|
||||
`live.NAME` reads it. A name no file reads is an AttributeError, so a
|
||||
patch that changes nothing cannot pass unnoticed.
|
||||
"""
|
||||
|
||||
def __getattr__(self, name):
|
||||
mods = reading(name)
|
||||
if not mods:
|
||||
raise AttributeError(f"no file under app/ reads the global "
|
||||
f"{name!r}")
|
||||
return getattr(importlib.import_module("app." + mods[0]), name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
mods = reading(name)
|
||||
if not mods:
|
||||
raise AttributeError(f"no file under app/ reads the global "
|
||||
f"{name!r}: a patch of it changes nothing")
|
||||
for stem in mods:
|
||||
setattr(importlib.import_module("app." + stem), name, value)
|
||||
|
||||
|
||||
live = _Live()
|
||||
@@ -5,12 +5,19 @@
|
||||
|
||||
For each FILE (default: every goodwe_controller/app/*.py that differs from
|
||||
BASE_REF, default release/1.0) the ast.dump() of the tree with docstrings
|
||||
removed must be identical at BASE_REF and in the working tree. Also prints
|
||||
code lines vs comment-only + docstring lines (whole lines, via tokenize) before
|
||||
and after. Run from the repository root. Exit 1 on any code difference.
|
||||
removed must be identical at BASE_REF and in the working tree, and so must
|
||||
the `python -OO` compiled code (a wholly removed docstring leaves a no-op
|
||||
there). An added or removed app/*.py file is a code difference. The diff
|
||||
against BASE_REF may touch only goodwe_controller/app/*.py,
|
||||
goodwe_controller/DECISIONS/*.md and this script. Also prints code lines vs
|
||||
comment-only + docstring lines (whole lines, via tokenize) before and after,
|
||||
and the docstrings removed entirely or added. Run from the repository root.
|
||||
Exit 1 on any difference.
|
||||
"""
|
||||
import ast
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tokenize
|
||||
@@ -53,29 +60,87 @@ def counts(src):
|
||||
return len(code), len(doc) + len(com - code - doc)
|
||||
|
||||
|
||||
def qualnames(src):
|
||||
out = {}
|
||||
|
||||
def walk(node, prefix):
|
||||
for n in ast.iter_child_nodes(node):
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.ClassDef)):
|
||||
q = prefix + n.name
|
||||
out[q] = docstring(n) is not None
|
||||
walk(n, q + ".")
|
||||
out["<module>"] = docstring(ast.parse(src)) is not None
|
||||
walk(ast.parse(src), "")
|
||||
return out
|
||||
|
||||
|
||||
def code_sig(co):
|
||||
consts = tuple(code_sig(c) if hasattr(c, "co_code") else c
|
||||
for c in co.co_consts)
|
||||
return (co.co_name, co.co_code, consts, co.co_names, co.co_varnames,
|
||||
co.co_freevars, co.co_cellvars, co.co_flags, co.co_argcount,
|
||||
co.co_stacksize, co.co_exceptiontable)
|
||||
|
||||
|
||||
def oo_same(old, new):
|
||||
return (code_sig(compile(old, "f", "exec", optimize=2))
|
||||
== code_sig(compile(new, "f", "exec", optimize=2)))
|
||||
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], check=True, capture_output=True,
|
||||
text=True).stdout
|
||||
|
||||
|
||||
def exists(base, f):
|
||||
return subprocess.run(["git", "cat-file", "-e", f"{base}:{f}"],
|
||||
capture_output=True).returncode == 0
|
||||
|
||||
|
||||
ALLOWED = re.compile(r"^goodwe_controller/(app/[^/]+\.py|DECISIONS/[^/]+\.md|"
|
||||
r"tools/maint20_same_code\.py)$")
|
||||
|
||||
|
||||
def ratio(m, c):
|
||||
return f"{m / c:.2f}" if c else "n/a"
|
||||
|
||||
|
||||
def main(argv):
|
||||
base = argv[0] if argv else "release/1.0"
|
||||
files = argv[1:] or [
|
||||
f for f in git("diff", "--name-only", base, "--",
|
||||
"goodwe_controller/app").split()
|
||||
if f.endswith(".py")]
|
||||
bad = 0
|
||||
print(f"base {base} = {git('rev-parse', base).strip()}")
|
||||
changed = git("diff", "--name-only", base).split()
|
||||
for f in changed:
|
||||
if not ALLOWED.match(f):
|
||||
print(f"FAIL: diff touches {f}, outside app/*.py, DECISIONS/*.md "
|
||||
"and this script")
|
||||
bad += 1
|
||||
files = argv[1:] or [f for f in changed if re.match(
|
||||
r"^goodwe_controller/app/[^/]+\.py$", f)]
|
||||
for f in files:
|
||||
in_base, in_head = exists(base, f), os.path.exists(f)
|
||||
if in_base != in_head:
|
||||
print(f"CODE DIFFERS {f} "
|
||||
f"{'removed' if in_base else 'added'} file")
|
||||
bad += 1
|
||||
continue
|
||||
old = git("show", f"{base}:{f}")
|
||||
new = open(f, encoding="utf-8").read()
|
||||
same = dump(old) == dump(new)
|
||||
oo = oo_same(old, new)
|
||||
(c0, m0), (c1, m1) = counts(old), counts(new)
|
||||
print(f"{'SAME CODE' if same else 'CODE DIFFERS'} {f} "
|
||||
f"code {c0}->{c1} comment+doc {m0}->{m1} "
|
||||
f"ratio {m0 / c0:.2f}->{m1 / c1:.2f}")
|
||||
bad += not same
|
||||
print("OK: no code change" if not bad else f"FAIL: {bad} file(s) differ")
|
||||
f"ratio {ratio(m0, c0)}->{ratio(m1, c1)} "
|
||||
f"-OO {'same' if oo else 'DIFFERS'}")
|
||||
bad += (not same) + (not oo)
|
||||
qo, qn = qualnames(old), qualnames(new)
|
||||
for q in sorted(q for q in qo if qo[q] and not qn.get(q, True)):
|
||||
print(f" docstring REMOVED: {f}:{q}")
|
||||
for q in sorted(q for q in qn if qn[q] and not qo.get(q, True)):
|
||||
print(f" docstring ADDED: {f}:{q}")
|
||||
print("OK: no code change" if not bad else f"FAIL: {bad} problem(s)")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MAINT-21: prove that splitting app/main.py moved code and changed none.
|
||||
|
||||
python3 goodwe_controller/tools/maint21_same_ast.py [BASE_REF] [--docstrings]
|
||||
|
||||
Every module-level def, class and assignment of app/main.py at BASE_REF
|
||||
(default release/1.0), and every method of its `Controller`, is looked up by
|
||||
qualified name in the working tree: app/main.py, app/rows.py and the mixin
|
||||
classes of app/ctl_*.py (a mixin's methods count as `Controller.<name>`).
|
||||
Each must be found exactly once with an identical `ast.dump()`, docstrings
|
||||
removed unless --docstrings. Allowed extras: the `Controller` class header,
|
||||
`_LOG = logging.getLogger("goodwe")` in a module that logs, and import lines.
|
||||
Prints one line per file and `OK` or the differences. The diff against
|
||||
BASE_REF may touch only goodwe_controller/app, DECISIONS, test_*.py and
|
||||
tools. Run from the repository root on a clean checkout (untracked files are
|
||||
read, but the diff check only sees tracked ones). Exit 1 on any difference.
|
||||
"""
|
||||
import ast
|
||||
import collections
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
APP = "goodwe_controller/app"
|
||||
MIXIN = re.compile(r"^[A-Z][A-Za-z]*Mixin$")
|
||||
|
||||
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], check=True, capture_output=True,
|
||||
text=True).stdout
|
||||
|
||||
|
||||
def bare(n, docstrings):
|
||||
if not docstrings:
|
||||
for x in ast.walk(n):
|
||||
if isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef,
|
||||
ast.ClassDef)) and x.body:
|
||||
e = x.body[0]
|
||||
if (isinstance(e, ast.Expr) and isinstance(e.value, ast.Constant)
|
||||
and isinstance(e.value.value, str)):
|
||||
x.body = x.body[1:] or [ast.Pass()]
|
||||
return ast.dump(n)
|
||||
|
||||
|
||||
def key_of(n):
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
return n.name
|
||||
if isinstance(n, ast.Assign):
|
||||
return "=" + ",".join(ast.unparse(t) for t in n.targets)
|
||||
if isinstance(n, ast.AnnAssign):
|
||||
return "=" + ast.unparse(n.target)
|
||||
return "?" + ast.unparse(n)[:60]
|
||||
|
||||
|
||||
def units(src, docstrings, mixins):
|
||||
"""{qualified name: [dump, ...]} for one file's text."""
|
||||
out = collections.defaultdict(list)
|
||||
tree = ast.parse(src)
|
||||
for n in tree.body:
|
||||
if isinstance(n, (ast.Import, ast.ImportFrom)):
|
||||
continue
|
||||
if (isinstance(n, ast.Expr) and isinstance(n.value, ast.Constant)
|
||||
and isinstance(n.value.value, str)):
|
||||
continue
|
||||
if isinstance(n, ast.ClassDef) and (
|
||||
n.name == "Controller" or (mixins and MIXIN.match(n.name))):
|
||||
for m in n.body:
|
||||
out["Controller." + key_of(m)].append(bare(m, docstrings))
|
||||
continue
|
||||
out[key_of(n)].append(bare(n, docstrings))
|
||||
return out
|
||||
|
||||
|
||||
def main(argv):
|
||||
docstrings = "--docstrings" in argv
|
||||
argv = [a for a in argv if not a.startswith("--")]
|
||||
base = argv[0] if argv else "release/1.0"
|
||||
bad = 0
|
||||
print(f"base {base} = {git('rev-parse', base).strip()}")
|
||||
for f in git("diff", "--name-only", base).split():
|
||||
if not re.match(r"^goodwe_controller/(app/[^/]+|DECISIONS/[^/]+|"
|
||||
r"test_[^/]+\.py|tools/[^/]+)$", f):
|
||||
print(f"FAIL: diff touches {f}")
|
||||
bad += 1
|
||||
old = units(git("show", f"{base}:{APP}/main.py"), docstrings, False)
|
||||
files = ["main.py", "rows.py"] + sorted(
|
||||
os.path.basename(p) for p in glob.glob(f"{APP}/ctl_*.py"))
|
||||
where = collections.defaultdict(list) # key -> [(file, dump)]
|
||||
for fn in files:
|
||||
p = f"{APP}/{fn}"
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
for k, dumps in units(open(p, encoding="utf-8").read(), docstrings,
|
||||
True).items():
|
||||
for d in dumps:
|
||||
where[k].append((fn, d))
|
||||
found = collections.Counter()
|
||||
problems = collections.defaultdict(list)
|
||||
for k, dumps in old.items():
|
||||
if k == "Controller":
|
||||
continue
|
||||
got = where.get(k, [])
|
||||
if k == "=_LOG": # one per module that logs, all the same
|
||||
got = got[:1] * len(dumps) if all(
|
||||
d == dumps[0] for _, d in got) else []
|
||||
if len(got) != len(dumps):
|
||||
problems["?"].append(f"{k}: {len(dumps)} in base, "
|
||||
f"{len(got)} in head")
|
||||
continue
|
||||
for (fn, d), want in zip(got, dumps):
|
||||
if d != want:
|
||||
problems[fn].append(f"{k}: AST differs")
|
||||
else:
|
||||
found[fn] += 1
|
||||
for k, got in where.items():
|
||||
if k not in old and k != "=_LOG":
|
||||
for fn, _ in got:
|
||||
problems[fn].append(f"{k}: not in base")
|
||||
for fn in files:
|
||||
if not os.path.exists(f"{APP}/{fn}"):
|
||||
continue
|
||||
if problems.get(fn):
|
||||
print(f"DIFFERS {fn}")
|
||||
for line in problems[fn]:
|
||||
print(" ", line)
|
||||
bad += len(problems[fn])
|
||||
else:
|
||||
print(f"OK {fn} {found[fn]} units identical")
|
||||
for line in problems.get("?", []):
|
||||
print("DIFFERS ", line)
|
||||
bad += 1
|
||||
total = sum(found.values())
|
||||
print(f"base units {sum(len(v) for k, v in old.items() if k != 'Controller')}"
|
||||
f", identical in head {total}")
|
||||
print("OK: code moved, none changed" if not bad else f"FAIL: {bad} problem(s)")
|
||||
return 1 if bad else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
@@ -26,6 +26,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
@@ -157,7 +158,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt = m.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -165,7 +166,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
|
||||
def now(cls, tz=None):
|
||||
return BASE + timedelta(seconds=clock[0])
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows = []
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -186,7 +187,7 @@ def run(n, *, extra=None, dur=2700.0, store=None, on_cycle=None, load=house,
|
||||
on_cycle(c, t)
|
||||
t += TICK
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from datetime import datetime, timedelta, timezone
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
@@ -348,7 +349,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
if patch is not None:
|
||||
patch(c)
|
||||
c.auto = True
|
||||
real_dt = m.datetime
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@@ -356,7 +357,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
m.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
plan_hour = None
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -416,7 +417,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
check(c, hass, now, pr)
|
||||
t += tick
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return c
|
||||
c = asyncio.run(go())
|
||||
return stats, c
|
||||
|
||||
@@ -22,6 +22,7 @@ sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
from app import main as app_main, packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
@@ -127,13 +128,13 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, clock = app_main.datetime, [0.0]
|
||||
real_dt, clock = main_files.live.datetime, [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return rig.BASE + timedelta(seconds=clock[0])
|
||||
app_main.datetime = _DT
|
||||
main_files.live.datetime = _DT
|
||||
rows = []
|
||||
try:
|
||||
t = 0.0
|
||||
@@ -161,7 +162,7 @@ def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
|
||||
reason=c.loop_reason))
|
||||
t += tick
|
||||
finally:
|
||||
app_main.datetime = real_dt
|
||||
main_files.live.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-128: the REAL Controller with Home Assistant readings arriving late.
|
||||
|
||||
python3 tools/safety128_rig.py # prints a few runs' summaries
|
||||
|
||||
The plant is TEST-23's (SAFETY-127 rig, t127_plant.py): two batteries
|
||||
(A 5 000 W, B 4 600 W) that ramp at 5 000 W/s and then follow a 1 s lag; each
|
||||
battery reading is published every 10 s (A at :x6, B at :x1.6); the meter every
|
||||
2 s; the house is `house` W plus seeded +-`noise` W, new each second.
|
||||
|
||||
`p1_s` sets the meter's period, `dead_s` a command dead time (SAFETY-75's
|
||||
laggy battery), `units` one battery ("a") or two.
|
||||
|
||||
Home Assistant runs `lag_s` seconds behind (a number, or a function of t) between `lag_on` and `lag_off`:
|
||||
every reading the add-on gets (meter, battery power, battery level) is the
|
||||
one published `lag_s` seconds earlier. When the lag starts, nothing new comes
|
||||
for `lag_s` seconds, then the backlog; when it stops, the readings jump to
|
||||
now. Commands reach the plant at once (t127_shim.py's lag mode).
|
||||
|
||||
The add-on reads every 1 s and runs a cycle on a new meter value or after
|
||||
`cycle_floor_s`, as `run_control()` does. Uses only the Controller API that
|
||||
release/1.0 77fe31b has, so the same file runs on the tree before SAFETY-128.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
|
||||
# 18:00 local (CET) on a weekday: the evening give run.
|
||||
BASE = datetime(2026, 11, 4, 17, 0, tzinfo=timezone.utc)
|
||||
UNITS = ("a", "b")
|
||||
MAXW = {"a": 5000.0, "b": 4600.0}
|
||||
CAPWH = {"a": 15360.0, "b": 20480.0}
|
||||
PHASE = {"a": 6.0, "b": 1.6}
|
||||
BPER = 10.0
|
||||
P1PER = 2.0
|
||||
RAMP = 5000.0
|
||||
LAG = 1.0
|
||||
|
||||
def options(units=UNITS):
|
||||
o = dict(OPTIONS)
|
||||
o["inverters"] = [
|
||||
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
|
||||
"max_w": int(MAXW[u]), "bms_soc_floor": 10,
|
||||
"capacity_kwh": CAPWH[u] / 1000.0} for u in units]
|
||||
return o
|
||||
|
||||
|
||||
OPTIONS = {
|
||||
"meter_entity": "sensor.grid", "meter_source": "ha_signed",
|
||||
"p1_net_entity": "sensor.grid", "meter_phases": 1, "meter_max_age_s": 30,
|
||||
"peak_cap_w": 3500, "peak_margin_w": 50, "peak_soc_floor": 20,
|
||||
"gain": 0.6, "slew_w": 0, "target_grid_w": -10, "deadband_w": 15,
|
||||
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
|
||||
"cycle_floor_s": 15, "saturation_w": 500, "saturation_cycles": 3,
|
||||
"auto_start": True, "battery_modes_enabled": False,
|
||||
"arbitrage_enabled": False, "bms_soc_floor": 10,
|
||||
}
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
from app.store import DEFAULTS
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return datetime.fromisoformat(v) if v else None
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.isoformat() if when else None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class Plant:
|
||||
"""The batteries, their registers and the meter, in 0.1 s steps."""
|
||||
|
||||
def __init__(self, house, noise, seed, soc, cut=None, units=UNITS,
|
||||
p1_s=P1PER, dead_s=0.0):
|
||||
self.house_fn, self.noise, self.seed = house, noise, seed
|
||||
self.units, self.p1_s, self.dead_s = units, p1_s, dead_s
|
||||
self.cut = cut or (lambda u, t: False)
|
||||
self.cmd = {u: 0.0 for u in units}
|
||||
self.queue = [] # (t, unit, command), dead time
|
||||
self.tgt = {u: 0.0 for u in units}
|
||||
self.act = {u: 0.0 for u in units}
|
||||
self.soc = {u: float(soc) for u in units}
|
||||
self.reg = {u: 0.0 for u in units}
|
||||
self.slot = {u: None for u in units}
|
||||
self.p1 = None
|
||||
self.p1_n = 0
|
||||
self.t = 0.0
|
||||
self.house = float(house(0.0))
|
||||
self.hist = [] # (t, p1 value, p1 index, {reg}, {soc})
|
||||
|
||||
def grid(self):
|
||||
return self.house - sum(self.act.values())
|
||||
|
||||
def advance(self, t):
|
||||
while self.t < t - 1e-9:
|
||||
self.t = round(self.t + 0.1, 6)
|
||||
sec = int(math.floor(self.t + 1e-9))
|
||||
rs = random.Random(self.seed * 1000003 + sec)
|
||||
self.house = (float(self.house_fn(self.t))
|
||||
+ rs.uniform(-self.noise, self.noise))
|
||||
while self.queue and self.queue[0][0] <= self.t + 1e-9:
|
||||
_t, u, v = self.queue.pop(0)
|
||||
self.cmd[u] = v
|
||||
for u in self.units:
|
||||
want = max(-MAXW[u], min(MAXW[u], self.cmd[u]))
|
||||
if self.cut(u, self.t):
|
||||
want = 0.0
|
||||
if self.soc[u] <= 10.0 and want > 0:
|
||||
want = 0.0
|
||||
if self.soc[u] >= 100.0 and want < 0:
|
||||
want = 0.0
|
||||
d = want - self.tgt[u]
|
||||
self.tgt[u] += math.copysign(min(abs(d), RAMP * 0.1), d)
|
||||
self.act[u] += ((self.tgt[u] - self.act[u])
|
||||
* (1 - math.exp(-0.1 / LAG)))
|
||||
self.soc[u] = max(0.0, min(100.0, self.soc[u] - self.act[u]
|
||||
* 0.1 / 3600.0 / CAPWH[u] * 100.0))
|
||||
slot = math.floor((self.t - PHASE[u]) / BPER + 1e-9)
|
||||
if slot != self.slot[u]:
|
||||
self.slot[u] = slot
|
||||
self.reg[u] = float(round(self.act[u]))
|
||||
if abs(self.t / self.p1_s - round(self.t / self.p1_s)) < 1e-6:
|
||||
self.p1 = round(self.grid(), 1)
|
||||
self.p1_n += 1
|
||||
self.hist.append((self.t, self.p1, self.p1_n, dict(self.reg),
|
||||
{u: round(v, 2) for u, v in self.soc.items()}))
|
||||
del self.hist[:-3000]
|
||||
|
||||
def view(self, t, lag_s, lag_on, lag_off):
|
||||
"""What Home Assistant shows at `t`: (p1, p1 index, regs, socs)."""
|
||||
lag_s = lag_s(t) if callable(lag_s) else lag_s
|
||||
if lag_s and lag_on <= t < lag_off:
|
||||
want = max(lag_on, t - lag_s)
|
||||
for row in reversed(self.hist):
|
||||
if row[0] <= want + 1e-9:
|
||||
return row[1:]
|
||||
return self.hist[0][1:]
|
||||
return (self.p1, self.p1_n, dict(self.reg),
|
||||
{u: round(v, 2) for u, v in self.soc.items()})
|
||||
|
||||
|
||||
class _Hass:
|
||||
def __init__(self, rig):
|
||||
self.rig = rig
|
||||
|
||||
async def limits(self, e):
|
||||
return (-MAXW[e[-1]], MAXW[e[-1]], 1.0)
|
||||
|
||||
async def call(self, *a):
|
||||
return True
|
||||
|
||||
async def state(self, e):
|
||||
return None
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
_p1, _n, regs, socs = self.rig.seen
|
||||
u = e[-1]
|
||||
if e.startswith("sensor.soc"):
|
||||
return socs[u]
|
||||
if e.startswith("sensor.batt"):
|
||||
return regs[u]
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
p = self.rig.plant
|
||||
p.queue.append((p.t + p.dead_s, e[-1], float(v)))
|
||||
self.rig.writes.append((self.rig.plant.t, e[-1], float(v)))
|
||||
return True
|
||||
|
||||
|
||||
class Rig:
|
||||
def __init__(self, house=1800.0, noise=50.0, seed=90, soc=60.0,
|
||||
lag_s=0.0, lag_on=0.0, lag_off=0.0, extra=None, cut=None,
|
||||
start=BASE, units=UNITS, p1_s=P1PER, dead_s=0.0):
|
||||
house_fn = house if callable(house) else (lambda t, h=house: h)
|
||||
self.plant = Plant(house_fn, noise, seed, soc, cut, units, p1_s,
|
||||
dead_s)
|
||||
self.lag = (lag_s, lag_on, lag_off)
|
||||
self.o = dict(options(units), **(extra or {}))
|
||||
self.start = start
|
||||
self.writes = []
|
||||
self.rows = []
|
||||
self.seen = None
|
||||
|
||||
def run(self, dur, sample=None):
|
||||
"""Run `dur` s. Rows: (t, true grid, site command, site actual,
|
||||
loop reason, controller reason). `sample(rig, c, t)` per second."""
|
||||
async def go():
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s128-"), "tariff.db")
|
||||
c = Controller(self.o, _Hass(self), _Store(), _Pub(),
|
||||
stop=asyncio.Event(), peak_db_path=db)
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -MAXW[i.name[-1]], MAXW[i.name[-1]]
|
||||
c.auto = True
|
||||
self.c = c
|
||||
real_dt = main_files.live.datetime
|
||||
clock = [self.start]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return clock[0] if tz is None else clock[0].astimezone(tz)
|
||||
|
||||
main_files.live.datetime = _DT
|
||||
floor = float(self.o.get("cycle_floor_s", 15))
|
||||
last_grid, last_cycle, last_n = object(), None, None
|
||||
try:
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
self.plant.advance(t)
|
||||
now = self.start + timedelta(seconds=t)
|
||||
clock[0] = now
|
||||
self.seen = self.plant.view(t, *self.lag)
|
||||
p1, n = self.seen[0], self.seen[1]
|
||||
if p1 is not None and n != last_n:
|
||||
last_n = n
|
||||
imp, exp = split_signed(p1)
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1, ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
if (c.grid != last_grid or last_cycle is None
|
||||
or t - last_cycle >= floor):
|
||||
last_grid, last_cycle = c.grid, t
|
||||
await c.cycle()
|
||||
self.rows.append((
|
||||
t, self.plant.grid(), sum(self.plant.cmd.values()),
|
||||
sum(self.plant.act.values()), c.loop_reason,
|
||||
c.reason))
|
||||
if sample is not None:
|
||||
sample(self, c, t)
|
||||
t += 1.0
|
||||
finally:
|
||||
main_files.live.datetime = real_dt
|
||||
return c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def summary(rows, t0, t1):
|
||||
"""Over [t0, t1): (min cmd, max cmd, min grid, max grid, s of export
|
||||
below -200 W, s below -1 000 W)."""
|
||||
sel = [r for r in rows if t0 <= r[0] < t1]
|
||||
return (min(r[2] for r in sel), max(r[2] for r in sel),
|
||||
min(r[1] for r in sel), max(r[1] for r in sel),
|
||||
sum(1 for r in sel if r[1] < -200), sum(1 for r in sel
|
||||
if r[1] < -1000))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for lag in (0.0, 10.0, 30.0):
|
||||
r = Rig(lag_s=lag, lag_on=300.0, lag_off=450.0)
|
||||
r.run(600.0)
|
||||
print(f"lag {lag:4.0f} s: before {summary(r.rows, 200, 300)}")
|
||||
print(f" during {summary(r.rows, 300, 450)}")
|
||||
print(f" after {summary(r.rows, 480, 600)}")
|
||||
@@ -33,6 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import opt11p3_identity as p3 # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import main_files # noqa: E402
|
||||
import app.main as m # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
@@ -76,7 +77,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
||||
if modes:
|
||||
extra.update(p3.ARB, battery_modes_enabled=True)
|
||||
seen, extra_rows = [], []
|
||||
real_resolve = m.resolve
|
||||
real_resolve = main_files.live.resolve
|
||||
|
||||
def spy(claims, **kw):
|
||||
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
|
||||
@@ -96,7 +97,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s78-"), "tariff.db")
|
||||
real = rig.Controller
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
m.resolve = spy
|
||||
main_files.live.resolve = spy
|
||||
try:
|
||||
rows, c = rig.run(n, extra=extra, dur=dur, load=load, soc=s79.soc,
|
||||
cut=lambda u, t: False,
|
||||
@@ -105,7 +106,7 @@ def run(n, *, modes=False, dur=DUR, load=load, extra=None):
|
||||
store=store_cheap(n) if modes else None)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
m.resolve = real_resolve
|
||||
main_files.live.resolve = real_resolve
|
||||
return [r + (x,) for r, x in zip(rows, extra_rows)], c
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user