Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
678ea13895 | ||
|
|
984f4335a0 | ||
|
|
89a2eb0b74 | ||
|
|
6bf37597f8 | ||
|
|
63043ea219 | ||
|
|
39ca1df232 | ||
|
|
6c0546beb0 | ||
|
|
79dfcde2bb | ||
|
|
de06a2c6f5 | ||
|
|
9a76f1a968 | ||
|
|
0020756aab | ||
|
|
0381fffbe1 | ||
|
|
b700c0be92 | ||
|
|
e79d403560 | ||
|
|
634001cb26 | ||
|
|
94214d609e | ||
|
|
4c0122a8b6 | ||
|
|
7bc8ad264b | ||
|
|
07d23d2eed | ||
|
|
fb4a449591 | ||
|
|
776b84ca20 | ||
|
|
34b81e8aeb | ||
|
|
f3f9483d9c | ||
|
|
e7706150e8 | ||
|
|
7edf8484d1 | ||
|
|
29a8a05f30 | ||
|
|
f5bd2a3520 | ||
|
|
bc5be4964e | ||
|
|
edf30f345a | ||
|
|
23359a047d | ||
|
|
8640bfcbc1 | ||
|
|
92f0387415 | ||
|
|
4f9aa5223f | ||
|
|
8cd10ee6aa | ||
|
|
1b5f7c9ccb | ||
|
|
33cfc733d3 | ||
|
|
de0790e62d | ||
|
|
b80cfa969f | ||
|
|
6004bfad9a | ||
|
|
b44a648cab | ||
|
|
0f5e513f1d | ||
|
|
54a738dd87 | ||
|
|
02eac3b84e | ||
|
|
4066624cce | ||
|
|
66d8777b39 | ||
|
|
a8ecf171d6 | ||
|
|
0f9c119bcf | ||
|
|
c50e2279dd | ||
|
|
8508ac33f0 |
@@ -1,5 +1,131 @@
|
||||
# Changelog
|
||||
|
||||
### Not in a release yet
|
||||
|
||||
- A new *Car* card on Home shows an Alfen charger: whether a car is connected, its power and today's energy. It only reads the charger, and the car's power no longer counts in the House tile or in what the batteries plan for the night. It is off until you choose a charger in Settings ([[EV-03]]).
|
||||
- During a real peak, a battery in its maintenance charge now stays paused instead of switching between charging and giving, and the batteries give only what keeps the peak under the limit ([[MAINT-28]]).
|
||||
|
||||
## 0.22.0
|
||||
|
||||
**A minor bump: a battery's monthly charge now gets the cheap night, the plan knows about the 7-day wait, a slow meter is followed more gently, and one peak-guard stop no longer costs a night charge its whole period. No option changes.**
|
||||
No option was added, removed or renamed, and no default changed (86 options). **Existing installs need no option change:** options that start 0.21.0 also start 0.22.0, and the Supervisor form is the same. It is a minor bump because control acts differently in the cases below; every saved setting still starts.
|
||||
|
||||
**A battery's monthly charge gets the cheap night.** While one battery is in the charge part of its monthly cycle, the other battery no longer grid-charges ahead of it, and the charge aims at this month's highest peak, as the peak guard does, not at the lower limit. In a rig test with the live setup (two batteries, one in its monthly charge), the cycling battery charged at about 1.5 kW in every cheap quarter (13.3 kWh in a night) and the other battery bought nothing; before, it charged at about 225 W or not at all. The peak limit was kept. At the start of a month, before a peak above the limit has been seen, this charge is much slower ([[SAFETY-111]]).
|
||||
|
||||
**The plan knows about the 7-day wait.** After a failed monthly cycle, Home no longer shows a cycle every night. It says the cycle waits, and shows the day the wait ends. The cycle tries again at the first start hour after the wait ([[MAINT-26]]). In a rig test no cycle was drawn during the wait, and none started.
|
||||
|
||||
**One peak-guard stop no longer costs the night charge its period.** With battery modes on, a night grid charge that the peak guard stops early in a 15-minute period used to stay stopped until the period ended. It may now start again after a minute, once per period, and not in the last minute. A real overload is cut as fast as before. In a rig test of the 01:00 case the night's charge was 0.45 to 0.46 kWh, against 0.42 to 0.44 kWh before. The status says "grid charge paused, it tries again in a minute" while it waits.
|
||||
|
||||
**A slow meter is followed more gently.** With a meter whose readings come about 4 s or more apart, such as a 5 s meter, control now takes smaller steps after a big change until the meter shows it. With Home Assistant readings 10 s late, less power goes to the grid when loads switch on and off: 44 % less in a simulation and 29 % less in a rig test. Meters of 1 or 2 s are left alone by design (the same commands in 63 simulation runs). With readings 15 s late the gain is smaller (19 % in a simulation), and a rig test sent more to the grid than before; see the known issues.
|
||||
|
||||
**No change in behaviour:** the comments in the main program file are shorter, and the file is split into smaller files by job. A rig test saw the same start-up, stops and commands.
|
||||
|
||||
Contents of this release, in merge order since 0.21.0 (`5e00cbb`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `56fc41f` | The comments in the main program file are shorter and clearer. There is no change in behaviour ([[MAINT-20]], #224) |
|
||||
| `8508ac3` | The main program file is split into smaller files by job. There is no change in behaviour; a rig test saw the same start-up, stops and commands ([[MAINT-21]], #243) |
|
||||
| `a8ecf17` | With a 5 s meter, control follows the meter more gently after a change. With Home Assistant readings 10 s late, less power goes to the grid when loads switch on and off. Meters of 1 or 2 s are left alone ([[SAFETY-136]], #244) |
|
||||
| `02eac3b` | A night grid charge that the peak guard stops early in a 15-minute period may start again after a minute, once per period, instead of staying stopped until the period ends. A real overload is cut as fast as before. Battery modes only ([[SAFETY-135]], #245) |
|
||||
| `1b5f7c9` | While a battery's monthly cycle charges, the other battery no longer grid-charges ahead of it, and the charge aims at this month's highest peak, so it gets the cheap night. After a failed cycle, the plan no longer shows a cycle every night and says the cycle waits ([[MAINT-24]], [[MAINT-25]], #246) |
|
||||
| `f3f9483`, `4c0122a` | History only: MAINT-28 was merged (#247) and then taken out again (#249). The two cancel out and the code is the same as at `1b5f7c9`. MAINT-28 is not in this release ([[MAINT-28]]) |
|
||||
|
||||
### Known issues
|
||||
|
||||
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
|
||||
- A battery capped at a low limit with a perfectly flat reading can still trip the stale check ([[SAFETY-74]]).
|
||||
- A battery whose reading at rest is beyond about ±175 W is still not caught when it refuses ([[SAFETY-97]]).
|
||||
- The peak guard can still back off near the end of a 15-minute period when a meter reading is late, by less than 30 s. This is about the guard's discharge only; a grid charge is cut back since 0.19.2 ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long (seen on a site: about 1.2 kW instead of 2.5 kW; the peak limit was kept). At the start of a month, before a peak above the limit, that is about 2 kWh a night in a rig test: the cycle can reach its 36 h limit after about 4 nights and fail, and since 0.22.0 the other battery gets no grid charge on those nights ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
- A steady overload on a 1 s meter can warn, and send the Home Assistant notification, 2 or 3 times at the start of each 15-minute period instead of once. The peak limit is still kept ([[SAFETY-122]]).
|
||||
- A fixed tariff is priced once per hour, so a price window that starts or ends at :15, :30 or :45 gets the wrong price for part of that hour. Windows that start and end on the hour are right ([[TARIFF-23]]).
|
||||
- Step 6 of the setup wizard stays empty because of an error in the page, in English and in Dutch ([[UX-51]]).
|
||||
- On Dutch pages, refusal texts, the last-event line under the Home buttons, the installer details and the trading status sentences are still English, and a few Dutch texts read badly, such as "1 metingen geweigerd" ([[UX-52]]).
|
||||
- Some price texts say "hour" where the add-on acts on a 15-minute period, such as "cheapest hour" ([[UX-47]]).
|
||||
- After the add-on restarts, the Home price chart shows no price for the earlier part of today, and the setup check can say "12 of 24 hours", though the prices are known ([[UX-48]]).
|
||||
- With battery modes on and only batteries on "Leftover solar", the Home price chart can mark a cheap period where no battery charges ([[UX-49]]).
|
||||
- On a phone, the bars of the Home price chart are too thin to tap one 15-minute period. The arrow keys work ([[UX-50]]).
|
||||
- In the last second of a 15-minute period, being a few watts over the aim can make the peak guard ask a battery for full power for one second. When a charge ends on a period edge, the last-second give can fade out over about 10 s, and a rig test exported up to 7.4 kW for about 8 s ([[SAFETY-124]]).
|
||||
- During the night grid charge, a 15-minute period can end 6 to 14 W over the peak aim, because the battery charges harder than asked. This was seen in simulation; a rig test saw 2 to 8 W ([[OPT-35]]).
|
||||
- The test rig could not bring back the false peak alarm that [[SAFETY-126]] fixes, so that fix is shown in simulation only. Watch the notifications for a few nights ([[TEST-35]]).
|
||||
- The night cheap run is sized for the batteries' full charging power, not for what the peak limit leaves. Under a tight limit the run can end before the batteries reach their level, and Home can show a level they do not reach (79.9 % against a shown 95 % in a rig test) ([[OPT-25]]).
|
||||
- By day, a Leftover solar plan that has only a daily forecast figure counts the sun from now, not from when the figure was read. It counts about 22 % too much for a figure one hour old; the night plan is not affected ([[OPT-30]]).
|
||||
- The night charge can buy too much in a house that uses more at night than by day, such as one with a car charger or heat pump, and on a sunny day, because spare sun in one expensive hour does not count for the later ones ([[OPT-31]]).
|
||||
- A cheap run that has already started can overlap a new give run after a re-plan, so a battery can charge from the grid at the dearest price for part of an hour. This was seen in a test case, not on a site. Also in 0.19.2 ([[OPT-32]]).
|
||||
- If the solar forecast sensors stop updating before 17:00, as in winter or further north, there is no forecast from three hours after their last update until 20:00 ([[OPT-33]]).
|
||||
- With a forecast that gives only a daily total, the sun is spread an hour off on the two clock-change days after the change. The day's total is right ([[OPT-24]]).
|
||||
- The add-on learns the house's use only once a solar forecast is set, so a site that adds a forecast later waits one more day for its first plan ([[OPT-26]]).
|
||||
- Home says "No trading on price yet: no prices have arrived" when every battery is on Solar first, though the prices are shown ([[UX-55]]).
|
||||
- The Home plan header can say "Charges in the cheapest hour, gives power in the dearest" when Home draws no charge, and it says "hour" for 15-minute prices ([[UX-56]]).
|
||||
- While the peak guard holds back charging, Home hides the rest of the cheap run and gives no reason. The charge starts again when the guard lets go ([[UX-57]]).
|
||||
- With battery modes on and selling on, Home can draw a "Sells up to" block when the add-on judges the trade not worth it, and then it will not sell ([[UX-58]]).
|
||||
- Home, the plan and the Forecast tab stop drawing a battery's give at the trading floor, though control goes on giving down to the battery's cut-out ([[UX-59]]).
|
||||
- During a price outage, the Batteries screen says "Prices differ too little" and Home says "The price gap is too small" ([[UX-60]]).
|
||||
- With battery modes on and no meter, or a meter that is a Home Assistant entity, a grid charge is not possible and nothing says so ([[UX-61]]).
|
||||
- On the Batteries screen, a charge the add-on refused in a cheap run reads as "Saves its power for ...", for up to 15 minutes ([[UX-62]]).
|
||||
- A Leftover solar battery with no capacity set never charges from the grid when a solar forecast is set, and nothing warns about it ([[UX-63]]).
|
||||
- The "How the modes work" box on the Batteries screen does not describe Cheap hours only ([[UX-64]]).
|
||||
- With battery modes on, Settings still shows "Trade on the price spread", which the modes ignore. A site with modes on and no price source starts, and the Batteries screen says "No prices, so it follows the house" without pointing to Settings ([[UX-65]]).
|
||||
- With battery modes off and several equal prices in a row, Home and the Forecast tab draw one price quarter, while the add-on trades every quarter of the run ([[UX-67]]).
|
||||
- Late in the day, the Forecast tab's "now" line and last bars sit over the axis labels at the right edge ([[UX-68]]).
|
||||
- The Forecast tab's level curve charges at each battery's full rating. Under a peak limit the levels it draws are too high and a charge block can end early (54.6 % shown against 39.7 % in a rig test) ([[UX-70]]).
|
||||
- The peak forecast check says maintenance charging is not kept under the peak limit when no peak forecast is set, but since 0.19.2 it is ([[UX-73]]).
|
||||
- The peak guard row can show the same figure twice, such as "at this rate 3 500 W, over 3 500 W". Also in 0.19.2 ([[UX-74]]).
|
||||
- If the inverter's setpoint entity appears in Home Assistant after the add-on has started, the page keeps saying "Inverter control: not found in Home Assistant" and the batteries say "An alarm stopped control" until a restart, though control runs ([[UX-75]]).
|
||||
- In the last minute of a 15-minute period, with Home Assistant readings 30 s late, the period can end about 80 W over the peak aim and over the limit: 3 530 W against a 3 450 W aim and a 3 500 W limit in a rig test with a 2 kW load added 45 s before the end. The peak guard sees the load too late to hold it back ([[SAFETY-133]]).
|
||||
- With Home Assistant readings 10 s late, the grid charge in a cheap run can swing by several kW. In a test the add-on asked for an 8.7 kW charge (9.6 kW on 0.20.0) ([[SAFETY-132]]).
|
||||
- A plan rebuilt while the battery levels are missing for a moment can drop a cheap run that has not started yet, so the battery does not charge that day. Seen in a replay of real prices, where it cost about EUR 0.40 of extra grid use; the Leftover solar change in 0.20.1 no longer triggers it in those replays, but the cause stays ([[OPT-37]]).
|
||||
- On a 5 s meter, Home Assistant readings 10 s late are still not caught, and readings 15 s late only after about 4 minutes. Control now follows the meter more gently, so less goes to the grid: in a simulation with a changing load, 340 Wh became 189 Wh at 10 s late and 363 Wh became 296 Wh at 15 s late. In a rig test, 15 s late exported 13 to 19 % more than before ([[SAFETY-136]], [[SAFETY-134]]).
|
||||
- With readings 10 to 15 s late, the hold can end in the middle of the delay and the swing can come back. Seen in rig tests with a 2 s and a 5 s meter ([[SAFETY-134]]).
|
||||
- With readings 10 s late on a 1 s meter, the grid can still move between -422 and +340 W while the batteries are held. With a very long delay (30 to 90 s) the request can swing by about 900 W every few seconds. Seen in review tests ([[SAFETY-130]]).
|
||||
- A battery that answers very slowly (10 s or more) can be taken for late Home Assistant readings and held, and the page then blames Home Assistant. A GoodWe battery settles in about 6 s and this has not been seen on one ([[SAFETY-131]]).
|
||||
- The page row for late readings reads awkwardly in English ("Home Assistant readings show requests about N seconds late, so control holds the batteries"), and the Dutch text misses a word ([[UX-78]]).
|
||||
- When the add-on stops, a write that takes more than 2 s can still land after the 0 W, and the 30 s safety timer on the ESPHome board then sets 0 W. After the Stop button, a cycle that had already decided can send its share (560 to 670 W) just after the 0 W; the next cycle sends 0 W again ([[SAFETY-138]]).
|
||||
- In the last hour of a cheap run, Home can draw a battery's grid charge up to an hour past the time control stops charging it. It only draws too much, never too little ([[UX-79]]).
|
||||
- A tomorrow solar forecast sensor that reports just after midnight with yesterday's value can be used as the new day's forecast for up to 12 hours. This has not been seen on a site ([[OPT-39]]).
|
||||
- During a cheap-hours charge, at some 15-minute boundaries the add-on sends 0 W for one tick ("no trade (headroom)") and then goes on. Seen in a rig test, in 5 of 8 cases; once, with a frozen battery reading, it left a battery at 0 W until 23:45 ([[OPT-40]]).
|
||||
- During a real peak, a battery in its maintenance charge can switch between charging and giving, about 400 times in 45 minutes in rig tests. That means more wear, but no higher peak ([[MAINT-28]]).
|
||||
- While one battery's monthly cycle charges, the other battery's card on Home says "Saves its power for higher prices", and the plan still shows its grid charge in the cheap hours, though it will not charge ([[UX-80]]).
|
||||
- After a failed monthly cycle, the plan line shows the day the wait ends, such as 18 November, not when the cycle tries again, such as 19 November at 19:00 ([[MAINT-26]]).
|
||||
- At the start of a cheap window the peak guard can step once although the period is under the aim, and the step can land on a battery in its monthly charge. In a rig test it gave 1.7 to 2.3 kW for 6 s ([[SAFETY-140]]).
|
||||
- A grid charge at full power in the last seconds of a 15-minute period can run into the next period, where the peak guard cuts it back ([[SAFETY-139]]).
|
||||
|
||||
### If you are crossing from a version older than 0.10.0
|
||||
|
||||
`## 0.10.0` below can refuse an old config: `slew_w` above its limit, and the
|
||||
withdrawn `price_provider` ids `engie_dynamic`, `totalenergies_dynamic` and
|
||||
`eneco_eddy`. Read it before you update. Run `tools/predeploy_check.py` with the
|
||||
bare options (`jq '.data.options'`) first.
|
||||
|
||||
|
||||
## 0.21.0
|
||||
|
||||
**A minor bump: control now holds the batteries when Home Assistant readings come late, Home draws the whole night's grid charge, and the shutdown 0 W is sent last. No option changes.**
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# MAINT-24 - a started maintenance charge gets the cheap night
|
||||
|
||||
**Status: for review.** Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13
|
||||
timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3).
|
||||
|
||||
**What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance
|
||||
CHARGE phase for more than 75 h and stalled at 49-50 %. At night B's battery-mode
|
||||
grid charge took the cheap run: A is not idle, so `modetrade.chargers()` never
|
||||
lists it, B is the only charger, and the allocator hands the whole negative
|
||||
target to B. A's own shaping then paused or shrank (the check compared the peak
|
||||
forecast with `cap - margin` = 3450 W, while B's trade and the guard aim at this
|
||||
month's peak, about 4.9 kW), and A gave power back on guard stops against a peak
|
||||
that B's own charge made. The timeout (MAINT-13) counted that starved time as
|
||||
charging time and ended the cycle. Report: `reports/MAINT-24-investigation-report.md`.
|
||||
|
||||
## 1. The maintenance charge goes first in the cheap run
|
||||
|
||||
`modetrade.decide(maint_charging=)`, set in `_mode_roles()` when any pack is in
|
||||
its maintenance CHARGE phase. It is the mirror of `draining` (SAFETY-78): no
|
||||
grid-charge trade for the other packs, why `cycle`, the same Tick. The other
|
||||
packs keep their solar roles, so they may still take spare solar. A refusal probe
|
||||
(SAFETY-73, PROBE_W alone, OPT-20) still goes on: it places no charge on the
|
||||
pack in maintenance, and `test_opt20.py` g7 pins it. The drain still blocks it. B's night
|
||||
charge waits for the nights A needs. At N=1 the only pack is the one in
|
||||
maintenance, so nothing changes.
|
||||
|
||||
## 2. The shaping aims where the guard aims (SAFETY-80)
|
||||
|
||||
The pause (`shaping_suspended()`) and the floor (`maintenance_charge_floor()`)
|
||||
get `peakguard.quarter_target_w(cap, margin, month reading)` instead of
|
||||
`peak_cap_w - peak_margin_w`. With no banked peak above the cap that is the same
|
||||
number. SAFETY-63 (no forecast: no pause, configured rate) and SAFETY-125 (late
|
||||
reading: `charge_room_w`) are untouched. `peak_aim_w()` stays for the day plan.
|
||||
|
||||
## 3. SAFETY-111 is not folded in
|
||||
|
||||
`peak_fc` holds the pack's own charge, so the floor `aim - peak_fc` settles at
|
||||
(aim - house) / 2, here 1475 W instead of 2000 W. Taking the own charge off the
|
||||
forecast was tried: it lifts A to 2000 W, but it changes the N=1 charge that
|
||||
`test_safety119.py` pins (a static 800 W forecast then no longer shapes the
|
||||
charge), so it is a change of its own. 1475 W fills a 10 kWh pack inside the
|
||||
night (sim below). It stays on its card.
|
||||
|
||||
## 4. A gives nothing while no real peak exists
|
||||
|
||||
With 1 and 2 no grid charge of B makes a peak against the lower aim, and the pause
|
||||
comes only when the forecast is over the month aim, so A is not made the only
|
||||
battery that gives while B charges. A guard stop on a real peak
|
||||
still lands on A (SAFETY-07 / MAINT-02, unchanged).
|
||||
|
||||
## Numbers (sim, house 3000 W, cap 3500, margin 50, month peak 6000 W)
|
||||
|
||||
| | base 02eac3b | MAINT-24 |
|
||||
|---|---|---|
|
||||
| A charge | 0 W (B charges 2500 W) | 1475 W = (5950 - 3000) / 2 |
|
||||
| B grid trade | yes, quarters 5500 W | none, why `cycle` |
|
||||
| quarter peak | 5500 W | 4475 W (aim 5950 W) |
|
||||
| A at house 3800 W | 0 W | 1075 W, quarters 4876 W |
|
||||
|
||||
Whole night (sim, `tools`-style replay, N=2, cheap 21:00-06:00, A from 10 %, B
|
||||
from 30 %, 9 h): base A takes 1.41 kWh (10 -> 23 %), B takes 6.84 kWh by grid
|
||||
trade; MAINT-24 A takes 9.47 kWh and is full (100 %) inside the night, B takes
|
||||
no grid charge. Worst 15-minute average 5500 W on base, 4475 W now, A never
|
||||
gives.
|
||||
|
||||
## Not changed
|
||||
|
||||
The MAINT-13 timeout, the peak aim itself, the month peak, `P_MAINT_SHAPE` and
|
||||
`P_ARBITRAGE`. The Home card for B does not say "the other battery is cycling"
|
||||
for a CHARGE phase (`other_cycle` reads "emptying"); that is a wording card.
|
||||
|
||||
MAINT-25 (the Home plan and the back-off) has its own note: `MAINT-25-plan-backoff.md`.
|
||||
@@ -0,0 +1,10 @@
|
||||
# MAINT-25 - the Home plan honours the 7-day back-off
|
||||
|
||||
**Status: for review.** Added to the MAINT-24 PR by the owner (2026-11-12).
|
||||
Tests: `test_maint25.py`. After a failed cycle `wants_start()` waits 7 days
|
||||
(MAINT-13, `backoff_until()`), but `homeview.maintenance_runs()` only asked
|
||||
`due()`, so the plan drew a cycle at the start hour every night of the wait.
|
||||
During the back-off it now draws no run for that battery and shows one line
|
||||
instead: "Battery A: the monthly cycle waits until 19 November." (Dutch: "...
|
||||
wacht tot 19 november."). A cycle completed after the failure ends it, as in
|
||||
`backoff_until()`. The other battery is planned as before.
|
||||
@@ -0,0 +1,55 @@
|
||||
# MAINT-28 - during a real peak the maintenance charge stays paused
|
||||
|
||||
**Status: fix-forward after the rig FAIL (TEST-23, f3f9483).** Tests: `test_maint28.py`, `test_maint28b.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`).
|
||||
|
||||
## What happened (rig x10, house 6.5 kW, month aim 5.95 kW, A in maintenance CHARGE)
|
||||
`peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW.
|
||||
|
||||
## The change
|
||||
`peakguard.shaping_held(suspended, held, forecast, aim, house)`: `suspended` is the old `shaping_suspended()`, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up, and only where MAINT-24 moved the aim (a banked month peak above `peak_cap_w`, so the aim is above cap minus margin). Elsewhere the old rule stays, byte for byte: SAFETY-87's N=1 digests (`n1_charge_low`) and SAFETY-119's N=2 charge at 5050 W over a 5000 W cap with no banked peak are pinned, and 1b5f7c9 behaves as 02eac3b there (a chase that was not a MAINT-24 regression).
|
||||
|
||||
Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It does not stay put when a battery steps: see "Fix-forward" below.
|
||||
|
||||
Why 300 W: the house wanders +-50 W; once the charge resumes it settles at (aim - house) / 2, so 300 W gives at least 150 W, three times the noise, and charging less than that is not worth a restart.
|
||||
|
||||
The flag lives only while the charge-only branch is taken: every cycle that reaches the maintenance loop starts it at False, so a maintenance that ended, a mode change or a missing aim cannot carry a stale True into the next charge (test_maint28.py part 1b). `cycle()` has two returns before that loop, and the flag is kept through them on purpose: "inputs missing" and "readings late" (SAFETY-128) are holds of under `stale_after` seconds that write nothing and decide nothing, so the next full cycle sees the same charge and the same peak. A missed poll must not end a peak pause; a reset there would restart the charge on it (`_maint_shape_suspended` is kept through them the same way).
|
||||
|
||||
It only ever keeps the maintenance charge off. No cut-back is delayed or weakened: the guard's claim is filed as before. While the pause is held, the pack in CHARGE gives only its share of the guard's floor (0 W if the guard holds it at its reserve), and the batteries together give only what the guard needs (see Fix-forward, F-2 and H-1). The first version let A give for the house; it was dropped.
|
||||
|
||||
## Numbers (sim, real `cycle()`, A rated 2500 W)
|
||||
House 3000 -> 6500 W for 45 min -> 3000 W, +-50 W every 5 s, banked month peak 6000 W (aim 5950 W), forecast = quarter average so far plus the rest at the present grid (`scratch/MAINT-28/dev/m28_repro.py`; `test_maint28.py` runs the same shape for 15 min to keep the suite short).
|
||||
|
||||
| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 (f3f9483, without the lag) |
|
||||
|---|---|---|---|
|
||||
| A charge/give reversals | 0 | 52 | 1 (the first trip) |
|
||||
| A charging seconds in the load (of 2700) | 0 | 367 | 301 (before the first trip) |
|
||||
| quarter averages (W) | 5959 5950 5950 5438 5502 | 5356 5923 5924 5041 4479 | 5356 4012 4000 4226 4476 |
|
||||
| A out / in (kWh) | 0.65 / 0 | 0.45 / 0.51 | 1.67 / 0.56 |
|
||||
| charge back after the load | - | 3 s | 3 s |
|
||||
|
||||
Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac3b. Live shape (house 3000 W): no pause, A 1475 W, as MAINT-24.
|
||||
|
||||
## Tests and mutants
|
||||
`test_maint28.py`: part 1 the real peak, 1b the stale hold, 2 `shaping_held()` edges, 3 the unchanged cases (digests equal 1b5f7c9). On 1b5f7c9 itself (hold stubbed out) part 1 and 1b fail. Mutants, each red: M1 hold state not kept (`held` always False: the 1b5f7c9 behaviour); M2 resume line `-` to `+`; M3 house not passed; M4 the per-cycle reset removed; M5 the N=1 gate removed (digest `n1_peak`: N=1, banked peak, house 6500 W); M6 the aim gate removed (digest `n2_nobank`).
|
||||
|
||||
## Not changed
|
||||
MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). The pause for a late or missing forecast.
|
||||
|
||||
## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`)
|
||||
The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`.
|
||||
|
||||
**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 4 s after the load (12 s in test_maint28b), not 0: the first settled estimate. No cut-back is touched.
|
||||
|
||||
**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged (replaced by H-1 below). Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim).
|
||||
|
||||
**H-1 (review 2026-11-14, owner decision 2026-11-14): the batteries together give only what the guard needs.** With the cap on A alone, the loop gave for the whole house with B (Cheap hours): in the live-try shape (A 11 % in CHARGE, B 25 %, house 6.5 kW for 90 min) B gave 4.6 kW, went from 25 to 10 % in 43 min, and two quarters were 6502 and 6499 W against the month peak 6000 W. So while the pause holds, `_m28_share()` also files one site limit (source `charge`, `P_MAINT_SHAPE`, per-site `_m28_site`): `hi` = the largest floor of the period, never under this cycle's floor. The guard's claim is not cut: `hi` is at least the floor, `P_PEAK` outranks `P_MAINT_SHAPE`, and `_peak_on_able` still places the whole floor. Outside the hold nothing is filed. Live try, same shape (test_maint28b): quarters 5357 to 5911 W (aim 5950), B 0.86 kWh and ends at 20.6 % (1b5f7c9: 1.0 kWh, 19.8 %), B switches give/idle once, A never. The re-pinned `n2_charge_banked` quarters are 3349 and 3950 W (aim 3950).
|
||||
|
||||
The rule as built (`Controller._m28_share()`): while the pause holds (N>=2, month aim above the cap) the pack gets a unit limit `hi` = the largest part of the guard's floor placed on it in this 15-minute period. Its part in a cycle is the floor less what the other units are placed at in this cycle's split without that limit, so a part the guard's split puts on the other pack is not this pack's. A pack the guard holds at its reserve (`peak_guard.held`) gets 0 W and its part starts again from 0 W. The largest part is kept per unit (`_m28_hi`, keyed by the unit) and dropped in a new period or when the pause ends; only one pack can be in maintenance CHARGE anyway (`maintenance.arbitrate()`, one slot per site).
|
||||
|
||||
Why the largest of the period and not the present part: the guard alone lands the quarter 9.5-10 W over its aim in the sim (SAFETY-126: its own projection ends on 5950.0 W but the meter reading it counts lags the true grid), and with the present part only, test_maint28 part 1 ends at 5960.1 W; the largest of the period lets the loop keep A up and it ends at 5925 W. Why not `hi` = 0 W: with the other pack under its reserve the guard needs this one (test_maint28b, house 12 kW, B at 18 %: A gives 1.07 kWh for the guard).
|
||||
|
||||
Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (it gives for the house): A's limit stays under the period's largest floor by 500 W or more in 740 of 744 paused cycles, and the quarters stay under the aim.
|
||||
|
||||
Sim, rig shape, 45 min, house 6500 W (load from minute 10), A in CHARGE at 49 %, B 30 % on Cheap hours: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 1, quarters 5358 5897 5911 5176 4477 W, charge back 4 s after the load; B gives 0.23 kWh (before H-1 3.04, f3f9483 0.32). test_safety84 n2_charge_banked: banked quarters 3349 and 3950 W against the aim 3950 W.
|
||||
|
||||
Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W); `_batts_settled()` replaced by True at the hold in ctl_cycle.py (the 45-min rig shape in `test_maint28b.py`, one stale release); the site limit dropped (the live try in `test_maint28b.py`: quarters 6502 and 6499 W, B 2.91 kWh to 10.0 %).
|
||||
@@ -71,7 +71,8 @@ The fits run all the time; the pull request has the counts.
|
||||
**What it costs.**
|
||||
- Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late
|
||||
is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s,
|
||||
SAFETY-129) with the export above -200 W, and 10 s late is not found. Until
|
||||
SAFETY-129) with the export above -200 W, and 10 s late is not found (SAFETY-136 calms
|
||||
the loop at a slow meter instead). Until
|
||||
then control is as before. In a quiet house the "readings late" row stays
|
||||
about 2 minutes longer after the lag ends (126 s against 32 s on base); no
|
||||
harm measured, and it clears as early as on base when the load changes.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# SAFETY-135 - one guard stop does not cost the night charge the whole period
|
||||
|
||||
**Status: for review, round 2.** Code: `Controller._mode_roles()` and
|
||||
`Controller._give_falling()` (`app/ctl_trade.py`), `modetrade.decide(wait=)`,
|
||||
`RESTART_S`, `YOUNG_S`, `GAVE_S`, `WAIT_WORDS` (`app/modetrade.py`). Tests:
|
||||
`test_safety135.py`. Battery modes only. No option, no change to the peak
|
||||
guard.
|
||||
|
||||
**Problem.** A grid charge the peak guard stops stays off to the period's end
|
||||
(the OPT-11 latch). Three cases seen on TEST-23:
|
||||
- *A spike at a period's start* (UX-77 I-1, x10): a 1-3 s spike, or a charge
|
||||
still at full power from the end of the last period, makes the guard project
|
||||
4.3-6.6 kW and cut the charge. The cut is right, but one 2 s spike cost 15
|
||||
minutes of charge, about 0.16 kWh per trip on the rig.
|
||||
- *A start at the tariff edge* (sim, 5 s meter): a give ends at 01:00 and the
|
||||
cheap run starts. The battery reading drops at once, the meter a few
|
||||
seconds later. In between, grid + batteries reads the house too low, the
|
||||
room too big: the charge starts and the guard cuts it within seconds.
|
||||
- *An oven cycling under a cheap-hours charge* (SAFETY-136 rig run C/s5b, x1):
|
||||
the charge started at 01:00:32 in an oven pause and the guard cut it at
|
||||
01:01:31 when the oven came back on (5.3 kW projected). The cut is right,
|
||||
but the charge stayed off to 01:15.
|
||||
|
||||
**Decision.**
|
||||
1. *The first guard stop in a period does not latch.* A charge the peak guard
|
||||
stops (or a measured headroom stops in the period's first `YOUNG_S` = 60 s)
|
||||
may start again `RESTART_S` = 60 s after that stop,
|
||||
under the normal start rule (guard clear, headroom over `START_W`), but not
|
||||
in the period's last 60 s: there the room is the deficit times 900/s left,
|
||||
and a restart overshot by 15 W in the owner-shape night (N=2, 01:00). A
|
||||
second stop latches to the period's end, so there is at most one restart
|
||||
per period and the OPT-11 flip guard keeps its job. A measured headroom
|
||||
stop later in the period latches at once, and an unmeasured headroom
|
||||
(OPT-15) on its second stop, as before. Why only guard stops later on: all
|
||||
three rig cases are guard stops, while a headroom that falls to 0 W mid
|
||||
period is the slow loss of room the OPT-11 latch was made for (the
|
||||
2026-09-09 flips), so freeing it buys no case and risks that flip.
|
||||
Three existing checks pinned "one start per period" and move with this:
|
||||
`test_modes_p3.py` (a house swinging 2.0/3.9 kW each minute: now at most
|
||||
two starts per period), `test_price_run_fixes.py` 1c (a 1-minute 3.7 kW
|
||||
load: one restart after 60 s), and the `n1_ch` digest in
|
||||
`test_modes_p4.py` (only the waiting reason word moved, "(peak)" for
|
||||
"(headroom)"; with this card off it is OPT-15's pin again).
|
||||
2. *No start while a give is still falling.* In a period's first `YOUNG_S`,
|
||||
while the batteries give over `START_W` less than they did in the
|
||||
last `GAVE_S` = 15 s, no charge starts: the meter may not show that drop
|
||||
yet. A smaller drop does not hold the start.
|
||||
|
||||
The guard is not touched (claim, floor, projection), so a real over-aim load
|
||||
is cut on the same cycle as before, and a running charge is never held. While
|
||||
a start waits, the status row says "grid charge paused, it tries again in a
|
||||
minute" or "grid charge waits for the meter to catch up".
|
||||
|
||||
**Options weighed.**
|
||||
- *Round 1: only a stop in the first 60 s does not latch.* Missed the rig
|
||||
oven case, cut at 91 s (review M-1).
|
||||
- *Re-evaluation each cycle with no latch*: brings back the 13 flips in 4
|
||||
minutes the latch was made for (2026-09-09 replay).
|
||||
- *Restart at once after the first stop*: two flips in 20 s with spikes at
|
||||
0/20 s (`test_safety135.py` part 5 fails on it).
|
||||
- *No start in a young period within 60 s of a give or a guard action*:
|
||||
0.018 kWh (N=1) and 0.003 kWh (N=2) less than base on the owner night.
|
||||
- *Cutting the young room by the recent give*: double-counts once the meter
|
||||
catches up and flapped on a 1 s meter where base did not.
|
||||
- *A hysteresis band*: the edge error is the readings' age, not a small room.
|
||||
|
||||
**Numbers** (`scratch/SAFETY-135/dev/s135_sim.py`, real `cycle()`, TEST-23-like
|
||||
lag, 1 s cycle, two 2.5 kW batteries unless said, cap 3 500 W, aim 3 450 W;
|
||||
the sim's grid average per period):
|
||||
|
||||
| Scenario | Base: paused s, kWh, flaps, top period | Head |
|
||||
|---|---|---|
|
||||
| (a) 4 s spike at 01:30 and 01:45, house 2.8 kW | 1 197 s, 0.330 kWh, 1, 3 458 W | 139 s, 0.537 kWh, 0, 3 458 W |
|
||||
| (b) 01:00 edge after a give, 5 s meter, house 3.0 kW | 882 s, 0.006 kWh, 1, 3 009 W | 21 s, 0.119 kWh, 0, 3 464 W |
|
||||
| (b) the same, house 2.4 kW | 0 s, 0.266 kWh, 0, 3 461 W | 11 s, 0.266 kWh, 0, 3 461 W |
|
||||
| Rig s5b (5 kW + 4.6 kW, oven), 1 s / 5 s meter | 838 / 886 s, 0.054 / 0.035 kWh, 2 224 / 2 123 W | 793 / 793 s, 0.075 / 0.084 kWh, 2 299 / 2 286 W |
|
||||
| (c) +4 kW for 5 min from 01:30:20 | cut 01:30:22, 0.387 kWh | cut 01:30:22, 0.492 kWh |
|
||||
| Owner-shape night, N=1 / N=2 | 5.510 / 6.048 kWh, top 3 454 / 3 453 W | 5.510 / 6.169 kWh, 3 454 / 3 453 W |
|
||||
| Owner-shape day, N=1 / N=2 | top 331 / 145 W | the same |
|
||||
|
||||
In s5b the restart at 01:02:32 is cut again by the oven at 01:03:17 and then
|
||||
latches: one restart, as designed. A +4 kW load from second 30, 57, 59, 60 or
|
||||
61 (N=1 and N=2) is cut on the same cycle as base, the charge restarts only
|
||||
after the load ends, and the period lands at 3 450.0 W (base 3 037-3 350 W).
|
||||
The add-on's own period averages in `test_safety135.py` are at or under the
|
||||
aim + 2 W in every part.
|
||||
|
||||
**Sensitivity** (one constant moved, the others as shipped; head):
|
||||
|
||||
| Constant | Spike (a): paused s, kWh | Edge (b) 5 s meter 3.0 kW: paused s, flaps | Rig s5b 1 s: kWh, period W | Owner night N=2: kWh, top W |
|
||||
|---|---|---|---|---|
|
||||
| `RESTART_S` 30 | 79, 0.541 | 21, 0 | 0.082, 2 321 | 6.206, **3 459** |
|
||||
| `RESTART_S` 60 (shipped) | 139, 0.537 | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
|
||||
| `RESTART_S` 90 | 199, 0.532 | 21, 0 | 0.061, 2 250 | 6.169, 3 453 |
|
||||
| `RESTART_S` 120 | 259, 0.528 | 21, 0 | 0.085, 2 338 | 6.169, 3 453 |
|
||||
| `GAVE_S` 5 | - | 75, **1** | 0.075, 2 299 | 6.170, 3 453 |
|
||||
| `GAVE_S` 10 | - | 15, 0 | 0.075, 2 299 | 6.169, 3 453 |
|
||||
| `GAVE_S` 15 (shipped) | - | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
|
||||
| `GAVE_S` 30 | - | 35, 0 | 0.073, 2 290 | 6.168, 3 453 |
|
||||
| `YOUNG_S` 30 / 90 | - | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
|
||||
|
||||
`RESTART_S` 30 restarts the owner night in a period's last 30 s and lands
|
||||
9 W over base's top period; 60 is the shortest value without that. In s5b
|
||||
the value only moves where the restart meets the oven cycle. `GAVE_S` 5 s is
|
||||
shorter than a 5 s meter's age and flaps again; 10-30 s only move the wait.
|
||||
`YOUNG_S` does not move these runs: the give falls in the first seconds.
|
||||
|
||||
On the synthetic night where the house alone is 950 W over the aim (N=1,
|
||||
heat pump 4.4 kW), head's top period is 4 403 W against base's 4 401 W: a
|
||||
charge restarted in the period before runs into the heat pump's start (the
|
||||
carry-over below). Base charges 1.142 kWh there, head 1.560 kWh.
|
||||
|
||||
**Not changed.** With battery modes off, `arbitrage.decide()` has no period
|
||||
latch and sizes the charge to the headroom each cycle (SAFETY-45). A charge at
|
||||
full power in a period's last seconds still runs into the next period (the
|
||||
charge side of SAFETY-124); with rule 1 that costs 60 s, not the period.
|
||||
@@ -0,0 +1,81 @@
|
||||
# SAFETY-136 - a slow meter gets a gentler loop until the last move shows
|
||||
|
||||
**Status: for review.** Code: `app/settle.py`, a call in `Controller.cycle()`
|
||||
(`app/ctl_cycle.py`). Tests: `test_safety136.py`. No option, no text change.
|
||||
|
||||
**Problem.** On a 5 s meter, Home Assistant readings 10-15 s late are not
|
||||
found (SAFETY-129): they look like a slow battery (R2-1), and a hold does not
|
||||
help (a perfect one cut the export of the oven by 7 %, SAFETY-129 review).
|
||||
With moving loads the loop answers the same stale error at each cycle and
|
||||
overshoots: an oven, a kettle and hob, the owner's day or a step export 3-10
|
||||
times what they do with no lag. The fix is a calmer loop, not a detector.
|
||||
|
||||
**Decision.** After a move of 150 W or more, a loop on a slow meter runs at half
|
||||
its gain until the meter has fallen by half of that move, or for 2 cycles.
|
||||
Slow is judged from the readings that arrive: the usual gap
|
||||
between them, over the last 60 s and at least 5 readings, is over 3.75 s (the
|
||||
limit of SAFETY-128's fast meter fit). It counts arrivals, not changes of
|
||||
value: on the recorded day at 1-2 s the value-based test (`MeterFit.slow()`)
|
||||
called 24 % of the cycles slow, because the recorded house repeats a value
|
||||
for 5 s or more, and the commands then differed from base. So there is no
|
||||
option. At a meter with a usual gap of 3.75 s or less (1-3 s) it is false, the tuning is never touched and the
|
||||
commands are those of the tree before. A meter that Home Assistant passes on
|
||||
only when its value changes looks as slow as its changes are far apart, as it
|
||||
does to SAFETY-128's fits: a 1-2 s meter with a value that holds for 5 s or
|
||||
more is then treated as slow, and the loop is gentler for it (the cost below).
|
||||
With no lag the meter shows a move at the next reading, so the wait ends at
|
||||
once.
|
||||
|
||||
**Candidates** (SAFETY-129 sim, real `cycle()`, 5 s meter, sum over oven,
|
||||
kettle and hob, owner shape over the oven and over the heat pump, step down,
|
||||
step up, flat; export Wh, lag 0 / 10 s / 15 s; base 64 / 340 / 363):
|
||||
|
||||
| Candidate, only at a slow meter | 0 s | 10 s | 15 s | Import Wh, 0 / 10 / 15 s |
|
||||
|---|---|---|---|---|
|
||||
| gain 0.4 | 97 | 201 | 299 | 97 / 224 / 301 |
|
||||
| gain 0.3 | 128 | 185 | 322 | 132 / 232 / 293 |
|
||||
| step limit 500 W | 118 | 285 | 293 | 110 / 325 / 324 |
|
||||
| cut fast, raise slowly (0.6 / 0.3) | 55 | 140 | 226 | 140 / 332 / 345 |
|
||||
| ignore 1 cycle after a move | 93 | 216 | 325 | 90 / 240 / 291 |
|
||||
| ignore 2 cycles after a move, unless the meter showed half | 69 | 206 | 277 | 62 / 240 / 354 |
|
||||
| **half gain for 2 cycles after a move, unless the meter showed half** | **67** | **189** | **296** | **62 / 223 / 288** |
|
||||
|
||||
A lower gain for good, a step limit and a fixed ignore all make a 5 s meter
|
||||
with no lag worse (export +30-110 Wh, quarter peak up to 150 W): that is the
|
||||
common case. Cutting fast and raising slowly moves export to import and peak.
|
||||
Waiting only while the last move has not shown costs nothing with no lag.
|
||||
Ignoring the meter (the 2-cycle line) was worse than half gain where the plant
|
||||
is slow, not the readings late: a 12 s dead time with one inverter went from
|
||||
250 to 841 Wh import and a quarter peak of 206 to 1 509 W, so the loop keeps
|
||||
following at half gain.
|
||||
|
||||
**What it gives** (5 s meter; the pull request and the report have each
|
||||
scenario). Export summed over the seven houses: 10 s late 340 -> 189 Wh
|
||||
(-44 %, import 363 -> 223), 15 s late 363 -> 296 Wh (-19 %, import 281 ->
|
||||
288), together -31 %. At 15 s the oven and the owner's oven are not better
|
||||
(92 / 39 Wh against 91 / 37), the kettle and the steps are. No lag: export
|
||||
64.4 -> 66.5 Wh (kettle +2), import and peak the same.
|
||||
|
||||
**What it costs.** The loop follows a real step more slowly: half gain for up
|
||||
to two cycles after each move of 150 W or more. Of the 136 sim runs (every
|
||||
SAFETY-129 review scenario and its 15 harm runs), 56 at 4-10 s meters differ
|
||||
from base. Together their export is 10 363 -> 9 559 Wh (-8 %) and import
|
||||
8 389 -> 7 835 Wh (-7 %). Only 5 export more than 1 Wh and 14 import more:
|
||||
export by at most 11.5 Wh (a 10 s battery lag, +10 %), import by at most
|
||||
17 Wh (a 12 s dead time, one inverter, +7 %), and the quarter average by at
|
||||
most 35 W, except two import quarters: +73 W (10 s meter, 10 s dead time,
|
||||
export -59 Wh) and +40 W (`dead10_p6`, -6 to 34 W, export -68 Wh). Slow
|
||||
batteries with a dead time of 8-10 s export 5-39 % less; a 12 s dead time is
|
||||
the same. `READINGS_LATE` holds are the same seconds in every run.
|
||||
|
||||
**Live, a 1-2 s meter can be judged slow.** Through Home Assistant on change,
|
||||
a value that holds for 4-10 s makes a 1 s meter slow. In 20 rig scenarios the
|
||||
commands were those of base. On the recorded day (1 s, N=1, 1 day, repeats
|
||||
dropped; its values hold about 5 s) it was slow in 33 % of cycles, half gain
|
||||
in 83 cycles, 15 422 commands in both trees, 4 016 with another value (at
|
||||
most 190 W apart), cost 0.61 EUR in both (review of SAFETY-136).
|
||||
|
||||
**Does not change.** Meters of 1-2 s: the same commands in all 63 runs (34
|
||||
at 1 s, 29 at 2 s), and the recorded-day digests (see the report). The late
|
||||
verdict, the hold and the freeze tests. 15 s late is only partly helped, and
|
||||
30 s late is the hold's (SAFETY-129).
|
||||
@@ -251,6 +251,10 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Safety | Opposing inverters time (s) | `circulation_hold_s` | For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged. |
|
||||
| Safety | Not delivering alarm after (s) | `delivery_hold_s` | For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged. |
|
||||
| Safety | Shadow mode | `shadow_mode` | The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never. |
|
||||
| Car charger | Charger | `ev_charger` | Which car charger the add-on reads. Off leaves everything as it is. This version only reads the charger and never changes it. |
|
||||
| Car charger | Current setting entity | `ev_current_entity` | The entity that sets the charger's current. The add-on finds the charger's other entities from its name. |
|
||||
| Car charger | Charger limit (A) | `ev_max_a` | The most current the charger may ever be asked for. Never above what the charger reports. Lower it for a weaker fuse or cable. |
|
||||
| Car charger | Phases | `ev_phases` | How many phases the charger may use: 1, 3, or 1 and 3 (auto). |
|
||||
| Advanced | Start control on boot | `auto_start` | Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart. |
|
||||
| Advanced | Control gain | `gain` | How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it. |
|
||||
| Advanced | Largest change per cycle (W) | `slew_w` | How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended. |
|
||||
@@ -3269,6 +3273,57 @@ row is printed **always**, including when it is zero: a row that reports
|
||||
staleness only once it is stale cannot be told from one that never reports
|
||||
it.
|
||||
|
||||
### Car charger
|
||||
|
||||
The add-on can read your car charger and show it on **Home**, on a *Car* card. In
|
||||
this version it only reads. It never changes the charger, so it is safe to run
|
||||
beside your own automation. Later versions will add charging modes.
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `ev_charger` | `off` (the default: nothing changes at all) or `alfen_ha`, an Alfen charger through its Home Assistant integration. |
|
||||
| `ev_current_entity` | The charger's current setting, for example `number.wallbox_power_connector_max_current_socket_1`. The add-on finds the charger's other entities from this name. |
|
||||
| `ev_max_a` | The most current the charger may ever be asked for. It cannot be higher than the station limit the charger reports. |
|
||||
| `ev_phases` | `1`, `3` or `auto` (1 and 3). |
|
||||
|
||||
**What the Car card shows.** The state (Charging, Connected waiting, No car
|
||||
connected, Car is full, Charger not reachable), the power and current, and the
|
||||
energy charged today. The car's power is taken out of the **House** tile and out
|
||||
of the house profile that plans the batteries' night charge, so the batteries
|
||||
no longer buy energy for a car that charged last night. The peak limit works as
|
||||
before: the car is still part of what the grid meter sees.
|
||||
|
||||
**When the charger does not answer.** After 30 seconds the card says *Charger
|
||||
not reachable* and one amber row appears. After 5 minutes one notification
|
||||
follows. While the car reading is older than 15 seconds the House tile shows no
|
||||
reading, and that moment is left out of the house profile, rather than guessing
|
||||
how much the car took.
|
||||
|
||||
**Another controller.** If the charger's current changes while the add-on writes
|
||||
nothing, something else sets it, usually your own automation. The page then shows
|
||||
the grey row *Another controller changes the charger*. That is expected while
|
||||
you migrate.
|
||||
|
||||
**Moving your own automation to the add-on.** Each step can be undone.
|
||||
|
||||
1. Install this version and set `ev_charger` to `alfen_ha`. The add-on reads the
|
||||
charger and writes nothing. Compare the card with your automation for a few
|
||||
days.
|
||||
2. When a later version adds charging modes, switch your automation **off**
|
||||
(do not delete it) in the evening with the car unplugged, then choose the
|
||||
add-on's Manual mode with the current your helper held. Plug in and check the
|
||||
current on the card and on the charger's display.
|
||||
3. On the next sunny day, choose Solar, with *spare sun goes first to* set as
|
||||
your priority switch was.
|
||||
4. Keep your Home Assistant helpers for a week. To go back, set the add-on mode
|
||||
to Off and switch the automation on. After that you can delete the helpers and
|
||||
the automation.
|
||||
|
||||
Never let the automation and the add-on both write the charger.
|
||||
|
||||
**Home Assistant entities.** With MQTT the add-on adds three sensors:
|
||||
`goodwe_car_power`, `goodwe_car_state` and `goodwe_car_today_kwh`.
|
||||
|
||||
### Site
|
||||
|
||||
| option | meaning |
|
||||
@@ -3388,6 +3443,14 @@ battery goes on as before. Check that the add-on can reach the internet and that
|
||||
the price service token is right. The notification goes by itself when a price
|
||||
is back. It is shown only while the monthly battery cycle is switched on.
|
||||
|
||||
### Charger not reachable
|
||||
|
||||
The add-on has not been able to read the car charger for 5 minutes, because its
|
||||
entities are unavailable or the charger's login is off. The charger keeps its
|
||||
last setting. The add-on changes nothing on the charger in any case. Check the
|
||||
charger's integration in Home Assistant. The notification goes by itself when
|
||||
the charger answers again.
|
||||
|
||||
### For installers: notifications
|
||||
|
||||
A fixed tariff beside the price service raises no notification: it is the
|
||||
@@ -3414,6 +3477,7 @@ default `info` hides it.
|
||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
||||
| Battery cycle did not finish | warning | `goodwe_maint_failed_<name>` | MAINT-13 |
|
||||
| No prices | warning | `goodwe_no_prices` | PRICE-01 |
|
||||
| Charger not reachable | warning | `goodwe_charger_unreachable` | EV-03 |
|
||||
|
||||
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
"""EV-03: the car charger seam, and the Alfen driver behind Home Assistant.
|
||||
|
||||
SHAPED LIKE `app/drivers.py`'s `Driver` (DECISIONS/EV-03-charger-seam.md): one
|
||||
actuator per site, a census key, a read, a clamped write that says whether the
|
||||
charger took it, and a hand-back. A charger is a modulated actuator with a fail
|
||||
state, not one of DEV-01's on/off loads.
|
||||
|
||||
THIS CARD NEVER WRITES TO THE CHARGER. `write_current`, `write_phases` and
|
||||
`release` are defined so the seam is whole, and NOTHING in the controller calls
|
||||
them: Off is the only mode. test_ev03.py greps the controller for a call and
|
||||
proves a full cycle makes no service call that names a charger entity.
|
||||
|
||||
VALUES ARE BELIEVED ONLY IN THEIR OWN UNIT (as TEL-11 does for power): power in
|
||||
a power unit, energy in a kWh unit, current in A. A reading in another unit is
|
||||
absent, never converted by a guess. ABSENCE IS NEVER ZERO.
|
||||
|
||||
The pure half (`CarTracker`, the state words, the entity table) takes its clock
|
||||
as an argument, so the checks need no sleep.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .hass import BAD_STATES
|
||||
from .solarfc import KWH_PER
|
||||
|
||||
OFF = "off"
|
||||
ALFEN_HA = "alfen_ha"
|
||||
#: the `ev_charger` values the options accept
|
||||
SELECTABLE = (OFF, ALFEN_HA)
|
||||
PHASES_CHOICES = ("1", "3", "auto")
|
||||
|
||||
# config.yaml's shipped defaults, mirrored (test_defaults_pin.py checks it).
|
||||
EV_OPTION_DEFAULTS = {"ev_charger": "off", "ev_current_entity": "",
|
||||
"ev_max_a": 16, "ev_phases": "auto"}
|
||||
|
||||
|
||||
def max_a(opts: dict):
|
||||
"""`ev_max_a` as a float, or None when it is not a number."""
|
||||
raw = opts.get("ev_max_a", EV_OPTION_DEFAULTS["ev_max_a"])
|
||||
if raw is None or raw == "":
|
||||
raw = EV_OPTION_DEFAULTS["ev_max_a"]
|
||||
try:
|
||||
return float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
MIN_A = 6.0 # IEC 61851: no car charges below 6 A
|
||||
ENTITY_MAX_A = 40.0 # the Alfen current entity's own range
|
||||
|
||||
# Reachability and the car card's timers, in seconds.
|
||||
UNREACHABLE_S = 30.0 # entities unavailable / login off this long
|
||||
NOTIFY_S = 300.0 # then one warning notification
|
||||
FULL_B_S = 60.0 # mode3 B with current allowed this long: car full
|
||||
FULL_C_S = 600.0 # mode3 C under FULL_C_W this long: car full
|
||||
FULL_C_W = 200.0
|
||||
WRITER_HOLD_S = 3600.0 # "another controller" stays shown this long
|
||||
IDLE_A = 1.0 # a phase under this draws nothing
|
||||
|
||||
# role -> (entity domain, suffix after `<x>_`). `current` is the setpoint, the
|
||||
# one the installer names; the rest are derived by swapping its suffix.
|
||||
SETPOINT_SUFFIX = "power_connector_max_current_socket_1"
|
||||
ROLES = {
|
||||
"current": ("number", SETPOINT_SUFFIX),
|
||||
"readback": ("sensor", "main_active_max_current_socket_1"),
|
||||
"state": ("sensor", "mode3_state_socket_1"),
|
||||
"display": ("sensor", "display_state_socket_1"),
|
||||
"power": ("sensor", "active_power_total_socket_1"),
|
||||
"energy": ("sensor", "meter_reading_socket_1"),
|
||||
"l1": ("sensor", "current_l1_socket_1"),
|
||||
"l2": ("sensor", "current_l2_socket_1"),
|
||||
"l3": ("sensor", "current_l3_socket_1"),
|
||||
"station": ("number", "max_station_current"),
|
||||
"station_active": ("sensor", "main_station_active_max_current_socket_1"),
|
||||
"phases_set": ("select", "installation_max_allowed_phases"),
|
||||
"phases_read": ("sensor", "connector_1_max_allowed_of_phases"),
|
||||
"error": ("sensor", "display_error_number_socket_1"),
|
||||
"login": ("binary_sensor", "https_api_login_status"),
|
||||
}
|
||||
#: a missing one of these refuses the charger (not the add-on)
|
||||
REQUIRED = ("current", "readback", "state")
|
||||
_SETPOINT_RE = re.compile(r"^number\.(?P<x>.+)_" + SETPOINT_SUFFIX + "$")
|
||||
|
||||
_ROLE_ORDER = tuple(ROLES)
|
||||
|
||||
|
||||
def census_key(entity) -> str:
|
||||
"""The SAFETY-28 form: stripped, lowercase."""
|
||||
return str(entity or "").strip().lower()
|
||||
|
||||
|
||||
def entities_for(setpoint) -> dict:
|
||||
"""role -> entity id, derived from the setpoint entity by its suffix.
|
||||
Empty when the setpoint does not end the way the Alfen integration names it."""
|
||||
m = _SETPOINT_RE.match(str(setpoint or "").strip())
|
||||
if not m:
|
||||
return {}
|
||||
x = m.group("x")
|
||||
return {role: f"{dom}.{x}_{suffix}" for role, (dom, suffix) in ROLES.items()}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# What a read gives
|
||||
# --------------------------------------------------------------------------- #
|
||||
@dataclass(frozen=True)
|
||||
class ChargerReading:
|
||||
"""One read of the charger. None means "did not answer", never zero."""
|
||||
|
||||
available: bool = False # the integration answers
|
||||
state: str | None = None # mode3: "A", "B", "C", ... or None
|
||||
connected: bool | None = None
|
||||
charging: bool | None = None
|
||||
power_w: float | None = None
|
||||
energy_kwh: float | None = None # the meter, total increasing
|
||||
current_a: tuple = () # per phase, as many as read
|
||||
setpoint_a: float | None = None # the entity the installer named
|
||||
allowed_a: float | None = None # read-back of the limit the charger applies
|
||||
phases_allowed: int | None = None
|
||||
station_a: float | None = None # the charger's own station limit
|
||||
error: str | None = None # None: no error (or not read)
|
||||
display: str | None = None
|
||||
why: str = "" # for the log only, "" when available
|
||||
|
||||
|
||||
def _state_obj(obj):
|
||||
"""(text, unit, attributes) of a state object, or (None, "", {})."""
|
||||
if not isinstance(obj, dict):
|
||||
return None, "", {}
|
||||
raw = str(obj.get("state", "")).strip()
|
||||
attrs = obj.get("attributes") if isinstance(obj.get("attributes"), dict) else {}
|
||||
unit = str(attrs.get("unit_of_measurement") or "").strip()
|
||||
return (None if raw.lower() in BAD_STATES else raw), unit, attrs
|
||||
|
||||
|
||||
def _amps(obj):
|
||||
raw, unit, _ = _state_obj(obj)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
v = float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
if v != v:
|
||||
return None
|
||||
if unit == "A":
|
||||
return v
|
||||
if unit == "mA":
|
||||
return v / 1000.0
|
||||
return None
|
||||
|
||||
|
||||
_W_PER = {"mW": 0.001, "W": 1.0, "kW": 1000.0, "MW": 1000000.0}
|
||||
|
||||
|
||||
def _watts(obj):
|
||||
"""Power in W, believed only in a power unit (its own copy of the rule: the
|
||||
PV channel's reader stays out of the charger's, test_pv.py part 6)."""
|
||||
raw, unit, _ = _state_obj(obj)
|
||||
if raw is None or unit not in _W_PER:
|
||||
return None
|
||||
try:
|
||||
v = float(raw) * _W_PER[unit]
|
||||
except ValueError:
|
||||
return None
|
||||
return v if v == v else None
|
||||
|
||||
|
||||
def _kwh(obj):
|
||||
raw, unit, _ = _state_obj(obj)
|
||||
if raw is None or unit not in KWH_PER:
|
||||
return None
|
||||
try:
|
||||
v = float(raw) * KWH_PER[unit]
|
||||
except ValueError:
|
||||
return None
|
||||
return v if v == v and v >= 0 else None
|
||||
|
||||
|
||||
def _phases(obj):
|
||||
raw, _u, _ = _state_obj(obj)
|
||||
m = re.match(r"\s*([13])\s*phase", raw or "", re.I)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
_MODE3 = re.compile(r"^(?:STATE[_ ]?)?([A-F])$", re.I)
|
||||
|
||||
|
||||
def mode3_letter(text) -> str | None:
|
||||
m = _MODE3.match(str(text or "").strip())
|
||||
return m.group(1).upper() if m else None
|
||||
|
||||
|
||||
def display_class(text) -> str | None:
|
||||
"""The fallback when mode3 is missing: firmware wording, so only the words
|
||||
the owner's automation keys on. `connected`, `charging`, `none` or None."""
|
||||
t = str(text or "").lower()
|
||||
if not t:
|
||||
return None
|
||||
if "wait vehicle" in t or "ev connected" in t or "cable connected" in t:
|
||||
return "connected"
|
||||
if "charging" in t:
|
||||
return "charging"
|
||||
if t in ("available", "ready", "idle"):
|
||||
return "none"
|
||||
return None
|
||||
|
||||
|
||||
def reading_from_states(states: dict) -> ChargerReading:
|
||||
"""A `ChargerReading` from `{role: state object or None}`. Pure."""
|
||||
def txt(role):
|
||||
return _state_obj(states.get(role))[0]
|
||||
|
||||
login, state_o = txt("login"), states.get("state")
|
||||
# Reachable: the three entities the card cannot do without answer, and the
|
||||
# login sensor is not off.
|
||||
gone = [r for r in REQUIRED if txt(r) is None]
|
||||
if gone or (login or "").lower() == "off":
|
||||
why = ("login off" if not gone else
|
||||
"no answer from " + ", ".join(gone))
|
||||
return ChargerReading(available=False, why=why)
|
||||
letter = mode3_letter(txt("state"))
|
||||
disp = txt("display")
|
||||
cls = display_class(disp)
|
||||
power = _watts(states.get("power"))
|
||||
currents = tuple(a for a in (_amps(states.get(k)) for k in ("l1", "l2", "l3"))
|
||||
if a is not None)
|
||||
if letter is not None:
|
||||
connected, charging = letter in "BCD", letter in "CD"
|
||||
elif cls is not None:
|
||||
connected, charging = cls in ("connected", "charging"), cls == "charging"
|
||||
else:
|
||||
connected = charging = None
|
||||
err = txt("error")
|
||||
err_none = err is None or err.strip().startswith("0")
|
||||
return ChargerReading(
|
||||
available=True, state=letter, connected=connected, charging=charging,
|
||||
power_w=power, energy_kwh=_kwh(states.get("energy")),
|
||||
current_a=currents, setpoint_a=_amps(states.get("current")),
|
||||
allowed_a=_amps(states.get("readback")),
|
||||
phases_allowed=_phases(states.get("phases_read")),
|
||||
station_a=_first(_amps(states.get("station_active")),
|
||||
_amps(states.get("station"))),
|
||||
error=None if err_none else err, display=disp)
|
||||
|
||||
|
||||
def _first(*vals):
|
||||
return next((v for v in vals if v is not None), None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChargerProbe:
|
||||
"""A read-only look: which derived entities exist."""
|
||||
|
||||
setpoint: str = ""
|
||||
found: tuple = () # ((role, entity), ...)
|
||||
missing: tuple = ()
|
||||
station_a: float | None = None
|
||||
derived: bool = True # False: the setpoint's name does not fit
|
||||
|
||||
@property
|
||||
def required_missing(self) -> tuple:
|
||||
return tuple(r for r, _e in self.missing if r in REQUIRED)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.derived and not self.required_missing
|
||||
|
||||
|
||||
def probe_from(setpoint, exists) -> ChargerProbe:
|
||||
"""`exists(entity) -> state object or None`. Pure; the caller reads."""
|
||||
ents = entities_for(setpoint)
|
||||
if not ents:
|
||||
return ChargerProbe(setpoint=str(setpoint or ""), derived=False)
|
||||
found, missing = [], []
|
||||
for role in _ROLE_ORDER:
|
||||
(found if exists(ents[role]) is not None else missing).append(
|
||||
(role, ents[role]))
|
||||
st = {r: exists(e) for r, e in ents.items()}
|
||||
return ChargerProbe(str(setpoint), tuple(found), tuple(missing),
|
||||
station_a=_first(_amps(st.get("station_active")),
|
||||
_amps(st.get("station"))))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The seam
|
||||
# --------------------------------------------------------------------------- #
|
||||
class ChargerNotBound(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ChargerDriver:
|
||||
"""One charger's I/O. Constructed from the options with no Home Assistant;
|
||||
`bind()` gives it one. Everything that talks to the world is a coroutine."""
|
||||
|
||||
name = ""
|
||||
writer_key = "ev_current_entity"
|
||||
brand = ""
|
||||
words: dict = {}
|
||||
|
||||
@classmethod
|
||||
def from_options(cls, opts: dict) -> "ChargerDriver":
|
||||
raise NotImplementedError
|
||||
|
||||
def bind(self, hass) -> "ChargerDriver":
|
||||
return self
|
||||
|
||||
def bind_getter(self, getter) -> "ChargerDriver":
|
||||
return self
|
||||
|
||||
def bound(self) -> bool:
|
||||
return True
|
||||
|
||||
def configured(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def entities(self) -> dict:
|
||||
return {}
|
||||
|
||||
async def limits(self) -> tuple:
|
||||
"""`(min_a, max_a, phases)`; None where unread."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def read(self) -> ChargerReading:
|
||||
raise NotImplementedError
|
||||
|
||||
# --- the write side. DEFINED, UNUSED BY THE CONTROLLER IN THIS CARD. ----
|
||||
async def write_current(self, a: float) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def write_phases(self, n: int) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def release(self, mode: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def probe(self) -> ChargerProbe:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class AlfenHaDriver(ChargerDriver):
|
||||
"""The Alfen behind its Home Assistant integration (`alfen_wallbox`)."""
|
||||
|
||||
name = ALFEN_HA
|
||||
brand = "Alfen"
|
||||
|
||||
def __init__(self, current_entity="", limit_a=16.0):
|
||||
self.current_entity = str(current_entity or "").strip()
|
||||
self.max_a = float(limit_a)
|
||||
self._ents = entities_for(self.current_entity)
|
||||
self._hass = None
|
||||
self._getter = None
|
||||
|
||||
@classmethod
|
||||
def from_options(cls, opts: dict) -> "AlfenHaDriver":
|
||||
amps = max_a(opts)
|
||||
return cls(opts.get("ev_current_entity", ""),
|
||||
EV_OPTION_DEFAULTS["ev_max_a"] if amps is None else amps)
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, opts: dict) -> str:
|
||||
return str(opts.get("ev_current_entity") or "").strip()
|
||||
|
||||
def bind(self, hass):
|
||||
self._hass, self._getter = hass, None
|
||||
return self
|
||||
|
||||
def bind_getter(self, getter):
|
||||
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 ChargerNotBound("the charger has no Home Assistant")
|
||||
return hass
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self._ents)
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
return self.current_entity
|
||||
|
||||
def entities(self) -> dict:
|
||||
return dict(self._ents)
|
||||
|
||||
async def _states(self, roles=_ROLE_ORDER) -> dict:
|
||||
import asyncio
|
||||
hass = self._h()
|
||||
got = await asyncio.gather(*(hass.state(self._ents[r]) for r in roles))
|
||||
return dict(zip(roles, got))
|
||||
|
||||
async def limits(self) -> tuple:
|
||||
st = await self._states(("station", "station_active", "phases_read"))
|
||||
return (MIN_A, _first(_amps(st["station_active"]), _amps(st["station"])),
|
||||
_phases(st["phases_read"]))
|
||||
|
||||
async def read(self) -> ChargerReading:
|
||||
if not self.configured():
|
||||
return ChargerReading(why="not set up")
|
||||
return reading_from_states(await self._states())
|
||||
|
||||
async def probe(self) -> ChargerProbe:
|
||||
st = await self._states()
|
||||
by_ent = {self._ents[r]: o for r, o in st.items()}
|
||||
return probe_from(self.current_entity, by_ent.get)
|
||||
|
||||
# --- the write side, UNUSED in EV-03 --------------------------------------
|
||||
async def write_current(self, a: float) -> bool:
|
||||
a = max(0.0, min(float(a), self.max_a, ENTITY_MAX_A))
|
||||
return await self._h().set_number(self.current_entity, a)
|
||||
|
||||
async def write_phases(self, n: int) -> bool:
|
||||
if n not in (1, 3):
|
||||
return False
|
||||
return await self._h().call("select", "select_option", {
|
||||
"entity_id": self._ents["phases_set"],
|
||||
"option": "1 Phase" if n == 1 else "3 Phases"})
|
||||
|
||||
async def release(self, mode: str) -> bool:
|
||||
return await self.write_current(0.0) if mode == "solar" else True
|
||||
|
||||
|
||||
class UnavailableCharger(ChargerDriver):
|
||||
"""No charger: `ev_charger` is off, or the charger is refused. `why` says
|
||||
which, for the Car card; nothing is read and nothing is written."""
|
||||
|
||||
name = OFF
|
||||
|
||||
def __init__(self, why="off", wanted=""):
|
||||
self.why = why
|
||||
self.wanted = wanted
|
||||
|
||||
@classmethod
|
||||
def from_options(cls, opts: dict):
|
||||
return cls()
|
||||
|
||||
def configured(self) -> bool:
|
||||
return False
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
return ""
|
||||
|
||||
async def limits(self) -> tuple:
|
||||
return None, None, None
|
||||
|
||||
async def read(self) -> ChargerReading:
|
||||
return ChargerReading(why=self.why)
|
||||
|
||||
async def write_current(self, a):
|
||||
return False
|
||||
|
||||
async def write_phases(self, n):
|
||||
return False
|
||||
|
||||
async def release(self, mode):
|
||||
return False
|
||||
|
||||
async def probe(self) -> ChargerProbe:
|
||||
return ChargerProbe()
|
||||
|
||||
|
||||
def mode_of(opts: dict) -> str:
|
||||
v = str((opts or {}).get("ev_charger") or OFF).strip()
|
||||
return v if v in SELECTABLE else OFF
|
||||
|
||||
|
||||
def build(opts: dict) -> ChargerDriver:
|
||||
"""The driver the options name. Off, or a name this build lacks, is
|
||||
`UnavailableCharger`; nothing here touches Home Assistant."""
|
||||
which = mode_of(opts)
|
||||
if which == ALFEN_HA:
|
||||
drv = AlfenHaDriver.from_options(opts)
|
||||
if drv.configured():
|
||||
return drv
|
||||
return UnavailableCharger("not set up", which)
|
||||
return UnavailableCharger("off")
|
||||
|
||||
|
||||
def census_entity(opts: dict) -> str:
|
||||
"""The charger setpoint the SAFETY-28 census counts: only while a charger
|
||||
is switched on (an off charger writes nothing)."""
|
||||
if mode_of(opts) == OFF:
|
||||
return ""
|
||||
return str(opts.get("ev_current_entity") or "").strip()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The car's state, kept over time
|
||||
# --------------------------------------------------------------------------- #
|
||||
CHARGING, WAITING, NONE_, FULL, UNREACH, SETUP, FAULT = (
|
||||
"charging", "waiting", "none", "full", "unreach", "setup", "fault")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CarTracker:
|
||||
"""What the cycle learns about the car from the readings. Pure, clocked by
|
||||
the caller (monotonic seconds for timers, UTC datetimes for the words).
|
||||
|
||||
FRESHNESS (the rule applied, and the one decision EV-03 makes about a stale
|
||||
car reading): the car's power counts only while the last GOOD reading is not
|
||||
older than `stale_s`, the same `stale_input_s` bound the meter reading has.
|
||||
Older than that, `fresh()` is False and the House tile and the house profile
|
||||
say they do not know, instead of keeping the last car figure."""
|
||||
|
||||
stale_s: float = 15.0
|
||||
reading: ChargerReading | None = None
|
||||
last_ok: float | None = None
|
||||
bad_since: float | None = None
|
||||
bad_since_utc: object = None
|
||||
notified: bool = False
|
||||
state_since_utc: object = None
|
||||
last_status: str | None = None
|
||||
last_good: ChargerReading | None = None
|
||||
last_good_utc: object = None
|
||||
b_since: float | None = None
|
||||
c_low_since: float | None = None
|
||||
writer_at_utc: object = None
|
||||
_prev: tuple | None = None
|
||||
# today's energy
|
||||
day: str | None = None
|
||||
today_kwh: float | None = None
|
||||
last_kwh: float | None = None
|
||||
persist: bool = False # set when `today` changed, for the store
|
||||
|
||||
def observe(self, r: ChargerReading, now: float, now_utc, day: str):
|
||||
self.reading = r
|
||||
usable = r.available and r.power_w is not None
|
||||
if r.available:
|
||||
self.bad_since = None
|
||||
self.bad_since_utc = None
|
||||
elif self.bad_since is None:
|
||||
self.bad_since, self.bad_since_utc = now, now_utc
|
||||
if usable:
|
||||
self.last_ok = now
|
||||
self.last_good, self.last_good_utc = r, now_utc
|
||||
if r.available:
|
||||
self._writer(r, now_utc)
|
||||
self._energy(r, day)
|
||||
self._timers(r, now)
|
||||
self._status_edge(now, now_utc)
|
||||
|
||||
# -- reachability ----------------------------------------------------------
|
||||
def unreachable(self, now: float) -> bool:
|
||||
return self.bad_since is not None and now - self.bad_since >= UNREACHABLE_S
|
||||
|
||||
def notify_due(self, now: float) -> bool:
|
||||
return (self.bad_since is not None and not self.notified
|
||||
and now - self.bad_since >= NOTIFY_S)
|
||||
|
||||
def fresh(self, now: float) -> bool:
|
||||
return self.last_ok is not None and now - self.last_ok <= self.stale_s
|
||||
|
||||
def car_w(self, now: float):
|
||||
"""The car's power now, or None (unknown) when the reading is not fresh
|
||||
OR the current reading carries no power. Never the last good figure: a
|
||||
read that says "no power" contradicts it, and absence is never zero. It
|
||||
never raises (telemetry has no foot in the control path)."""
|
||||
r = self.reading
|
||||
if not self.fresh(now) or r is None or not r.available \
|
||||
or r.power_w is None:
|
||||
return None
|
||||
try:
|
||||
return max(0.0, float(r.power_w))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# -- the two-writer detector ----------------------------------------------
|
||||
def _writer(self, r, now_utc):
|
||||
cur = (r.setpoint_a, r.allowed_a)
|
||||
prev = self._prev
|
||||
if prev is not None:
|
||||
for a, b in zip(prev, cur):
|
||||
if a is not None and b is not None and a != b:
|
||||
self.writer_at_utc = now_utc
|
||||
# keep the last known value where one read came back empty
|
||||
self._prev = tuple(b if b is not None else (prev[i] if prev else None)
|
||||
for i, b in enumerate(cur))
|
||||
|
||||
def other_writer(self, now_utc) -> bool:
|
||||
t = self.writer_at_utc
|
||||
return t is not None and (now_utc - t).total_seconds() <= WRITER_HOLD_S
|
||||
|
||||
# -- today's kWh -----------------------------------------------------------
|
||||
def load(self, saved):
|
||||
if isinstance(saved, dict):
|
||||
try:
|
||||
self.day = str(saved["day"])
|
||||
self.today_kwh = float(saved["kwh"])
|
||||
self.last_kwh = (None if saved.get("last") is None
|
||||
else float(saved["last"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
self.day = self.today_kwh = self.last_kwh = None
|
||||
|
||||
def dump(self) -> dict:
|
||||
return {"day": self.day, "kwh": round(self.today_kwh or 0.0, 3),
|
||||
"last": self.last_kwh}
|
||||
|
||||
def _energy(self, r, day):
|
||||
e = r.energy_kwh
|
||||
if self.day != day:
|
||||
self.day, self.today_kwh = day, 0.0
|
||||
self.last_kwh = e
|
||||
self.persist = True
|
||||
return
|
||||
if e is None:
|
||||
return
|
||||
if self.last_kwh is not None:
|
||||
# a meter that goes back has been reset: count from its new zero
|
||||
d = e - self.last_kwh if e >= self.last_kwh else e
|
||||
if d:
|
||||
self.today_kwh = (self.today_kwh or 0.0) + d
|
||||
self.persist = True
|
||||
self.last_kwh = e
|
||||
if self.today_kwh is None:
|
||||
self.today_kwh = 0.0
|
||||
|
||||
# -- the car's state -------------------------------------------------------
|
||||
def _timers(self, r, now):
|
||||
allowed = r.allowed_a is not None and r.allowed_a >= MIN_A
|
||||
self.b_since = (self.b_since if self.b_since is not None else now) \
|
||||
if (r.state == "B" and allowed) else None
|
||||
low = (r.state == "C" and allowed and r.power_w is not None
|
||||
and r.power_w < FULL_C_W)
|
||||
self.c_low_since = (self.c_low_since if self.c_low_since is not None
|
||||
else now) if low else None
|
||||
|
||||
def status(self, now: float, configured: bool = True):
|
||||
"""One of the state words, or None before the first reading."""
|
||||
if not configured:
|
||||
return SETUP
|
||||
if self.unreachable(now):
|
||||
return UNREACH
|
||||
r = self.reading
|
||||
if r is None or not r.available:
|
||||
return None if self.last_good is None else self.last_status
|
||||
if r.state in ("E", "F"): # the charger's own error states
|
||||
return FAULT
|
||||
if r.state == "A" or (r.state is None and r.connected is False):
|
||||
return NONE_
|
||||
if r.state in ("B", "C", "D") or r.connected:
|
||||
if self.b_since is not None and now - self.b_since >= FULL_B_S:
|
||||
return FULL
|
||||
if self.c_low_since is not None and \
|
||||
now - self.c_low_since >= FULL_C_S:
|
||||
return FULL
|
||||
return CHARGING if r.charging else WAITING
|
||||
return None
|
||||
|
||||
def _status_edge(self, now, now_utc):
|
||||
s = self.status(now)
|
||||
if s != self.last_status and s is not None:
|
||||
self.last_status, self.state_since_utc = s, now_utc
|
||||
elif s is None and self.last_status is None:
|
||||
pass
|
||||
|
||||
def phases_in_use(self) -> int | None:
|
||||
r = self.reading
|
||||
if r is None or not r.current_a:
|
||||
return None
|
||||
n = sum(1 for a in r.current_a if a >= IDLE_A)
|
||||
return n or None
|
||||
@@ -401,6 +401,29 @@ class AlarmsMixin:
|
||||
await self.hass.call("persistent_notification", "dismiss",
|
||||
messages.prices_back().data())
|
||||
|
||||
async def check_charger(self) -> None:
|
||||
"""EV-03. One warning notification when the car charger has not
|
||||
answered for 5 minutes, taken down when it does; the WARNING log line
|
||||
is edge-logged. READ ONLY: this raises a notification and calls no
|
||||
service on the charger."""
|
||||
if not self.charger.configured():
|
||||
return
|
||||
now = self.car_now()
|
||||
self.car_log_edges(now)
|
||||
if self.car.notify_due(now) and not self._car_announced:
|
||||
self._car_announced = True
|
||||
self.car.notified = True
|
||||
since = brussels(self.car.bad_since_utc)
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.charger_unreachable(
|
||||
f"{since:%H:%M}",
|
||||
self.charger.brand or "Alfen").data())
|
||||
elif self.car.bad_since is None and self._car_announced:
|
||||
self._car_announced = False
|
||||
self.car.notified = False
|
||||
await self.hass.call("persistent_notification", "dismiss",
|
||||
messages.charger_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
|
||||
|
||||
@@ -29,7 +29,7 @@ from . import shadow
|
||||
from .price import Uncertainty
|
||||
from .tariff import next_quarter
|
||||
from .watchdog import loop_is_following
|
||||
from .ctl_trade import pack_energy_of
|
||||
from .ctl_trade import M28_REASON, pack_energy_of
|
||||
from .rows import why_key, why_raised
|
||||
|
||||
_LOG = logging.getLogger("goodwe")
|
||||
@@ -537,6 +537,10 @@ class CycleMixin:
|
||||
|
||||
# maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST
|
||||
# STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
# MAINT-28: the hold lives only while the charge-only branch is taken;
|
||||
# kept through the two short holds above on purpose
|
||||
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md).
|
||||
_peak_held, self._maint_peak_hold = self._maint_peak_hold, False
|
||||
for inv, result in zip(self.inverters, maint_results):
|
||||
if result.owns_setpoint:
|
||||
# PER-INVERTER: §12's recalibration is a PACK procedure, filed
|
||||
@@ -559,8 +563,23 @@ class CycleMixin:
|
||||
# peakguard.shaping_suspended(), NOT HERE. One suspension flag
|
||||
# per SITE (`arbitrate()` allows one pack in CHARGE).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc, self.peak_aim_w()):
|
||||
# MAINT-24: the guard's aim, month peak included (SAFETY-80).
|
||||
# DECISIONS/MAINT-24-charge-first.md
|
||||
maint_aim = peakguard.quarter_target_w(
|
||||
float(self.o.get("peak_cap_w", 3500)),
|
||||
self.peak_margin_w, _reading)
|
||||
# MAINT-28: hold the pause in a real peak.
|
||||
# DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md
|
||||
house = (None if self.grid is None or self.site_batt_w is None
|
||||
else float(self.grid) + float(self.site_batt_w))
|
||||
_susp = peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc, maint_aim)
|
||||
_m28 = (len(self.inverters) > 1
|
||||
and maint_aim > self.peak_aim_w())
|
||||
self._maint_peak_hold = peakguard.shaping_held(
|
||||
_susp, _peak_held and _m28, self.peak_fc, maint_aim,
|
||||
house, self._batts_settled())
|
||||
if self._maint_peak_hold:
|
||||
# Edge-logged: level-logging here would write one line per
|
||||
# cycle for as long as the event lasts and flush the 40-line
|
||||
# action log of the very context that explains it.
|
||||
@@ -570,6 +589,18 @@ class CycleMixin:
|
||||
"suspended so the peak claim is not "
|
||||
"contradicted",
|
||||
hint=messages.hint("maint_paused"))
|
||||
# MAINT-28: while held this pack gives at most the most of
|
||||
# the guard's floor placed on it this period
|
||||
# (`_m28_share()`).
|
||||
# DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md
|
||||
shape = ((0.0 if self.peak_guard is not None
|
||||
and inv.name in self.peak_guard.held
|
||||
else max([c.lo for c in claims
|
||||
if c.source == peakguard.SOURCE]
|
||||
+ [self._m28_was(self.actuator_of(
|
||||
"maintenance", inv), now)],
|
||||
default=0.0), M28_REASON)
|
||||
if _m28 else None)
|
||||
else:
|
||||
self._maint_shape_suspended = False
|
||||
hi = 0.0
|
||||
@@ -582,14 +613,16 @@ class CycleMixin:
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
floor = maintenance_charge_floor(
|
||||
self.maint.cfg.charge_w,
|
||||
self.peak_fc, self.peak_aim_w())
|
||||
self.peak_fc, maint_aim)
|
||||
floor = self._charge_headroom(floor, claims)
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
shape = (hi, reason)
|
||||
if shape is not None:
|
||||
# PER-INVERTER, like the SET above and for the same reason -
|
||||
# the shaping is about the pack being charged, which is `inv`.
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
|
||||
reason=reason,
|
||||
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE,
|
||||
hi=shape[0], reason=shape[1],
|
||||
actuator=self.actuator_of("maintenance",
|
||||
inv)))
|
||||
|
||||
@@ -734,15 +767,21 @@ class CycleMixin:
|
||||
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
|
||||
# AGAINST. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
loop_prev = self.target
|
||||
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||
# OPT-15: `loop_grid` is the grid, or the target while a run
|
||||
# edge is held.
|
||||
grid_in = (self._drain_grid_w(_live96) if self._drain_tail
|
||||
else loop_grid)
|
||||
# SAFETY-136: on a slow meter, a gentler loop until the last move
|
||||
# shows.
|
||||
slow_meter = self.settle.slow()
|
||||
if moved or mode_trade:
|
||||
self.settle.reset()
|
||||
decision = compute(
|
||||
prev_w=loop_prev,
|
||||
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||
# OPT-15: `loop_grid` is the grid, or the target while a run
|
||||
# edge is held.
|
||||
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
|
||||
else loop_grid),
|
||||
grid_w=grid_in,
|
||||
actual_w=site_batt,
|
||||
tuning=self.tuning,
|
||||
tuning=self.settle.tuning(self.tuning, grid_in, slow_meter),
|
||||
sat_count=self.sat_count,
|
||||
i_w=self.i_w,
|
||||
# SAFETY-52 at one inverter only: with several, OPT-09's pack
|
||||
@@ -772,6 +811,7 @@ class CycleMixin:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
||||
f"{site_batt:.0f} W)",
|
||||
hint=messages.hint("not_keeping_up"))
|
||||
self.settle.moved(grid_in, loop_prev, decision.target_w, slow_meter)
|
||||
self.sat_count = decision.sat_count
|
||||
self.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
@@ -811,6 +851,7 @@ class CycleMixin:
|
||||
else:
|
||||
# Stopped or blind: no accumulation may survive the outage.
|
||||
self.i_w = 0.0
|
||||
self.settle.reset()
|
||||
self.loop_w = None
|
||||
self._live_was = frozenset(_live96)
|
||||
# TEL-10, and it is the same zero the line above writes rather than
|
||||
@@ -829,6 +870,9 @@ class CycleMixin:
|
||||
self.loop_clamp_facts = ()
|
||||
self._loop_decision = None
|
||||
|
||||
# MAINT-28: a paused pack gives at most its largest part of the floor
|
||||
# in this period.
|
||||
self._m28_share(claims, now)
|
||||
# SAFETY-28. A new resolution is a new tick, and the previous record is
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that the
|
||||
# record resolve() builds is stamped with the tick it is for.
|
||||
@@ -1003,6 +1047,7 @@ class CycleMixin:
|
||||
# Not a safety check, so it is one of the cheap ones: an attribute test
|
||||
# and, on a change only, one Home Assistant call.
|
||||
await self.check_price_gap()
|
||||
await self.check_charger()
|
||||
await self.check_fixed_note()
|
||||
|
||||
# SAFETY-31's alarm, and its boundary is the LATCH, not a number: it
|
||||
|
||||
@@ -13,7 +13,9 @@ from . import freshness
|
||||
from . import lateness
|
||||
from . import messages
|
||||
from . import pv as pv_read
|
||||
from . import chargers
|
||||
from .price import brussels, price_option, provider_configured
|
||||
from .hass import OUTAGE
|
||||
from .price_chain import uses_chain
|
||||
from .static_tariff import static_status
|
||||
from .rows import tariff_plan_row
|
||||
@@ -48,6 +50,10 @@ FC_HOLD_KEY = "solar_fc_hold"
|
||||
FORECAST_READ_S = 60.0
|
||||
|
||||
|
||||
# EV-03: the car's energy today, kept so a restart does not lose it.
|
||||
CAR_TODAY_KEY = "car_today"
|
||||
|
||||
|
||||
class InputsMixin:
|
||||
def _readings_late(self, standing_w):
|
||||
"""SAFETY-128: update the answer gate; seconds the late verdict has
|
||||
@@ -102,6 +108,89 @@ class InputsMixin:
|
||||
return "input"
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
# -- EV-03: the car charger, READ ONLY ----------------------------------------
|
||||
@property
|
||||
def car_enabled(self) -> bool:
|
||||
"""`ev_charger` is on, whether or not the charger could be set up."""
|
||||
return chargers.mode_of(self.o) != chargers.OFF
|
||||
|
||||
def car_now(self) -> float:
|
||||
return freshness.monotonic()
|
||||
|
||||
def car_term(self) -> tuple:
|
||||
"""`(car_w, unknown)`: what the House tile and the house profile take
|
||||
out for the car. `(0.0, False)` with no charger, so a site without one
|
||||
is exactly as before. With one, a reading older than `stale_input_s`
|
||||
(the meter's own bound) is UNKNOWN, never the last figure: the tile
|
||||
shows no reading and the profile leaves that moment out."""
|
||||
if not self.charger.configured():
|
||||
return 0.0, False
|
||||
w = self.car.car_w(self.car_now())
|
||||
return (0.0, True) if w is None else (w, False)
|
||||
|
||||
async def _read_charger(self, now_mono: float) -> None:
|
||||
"""One read of the charger, and what the tracker learns from it. It
|
||||
never raises (telemetry has no foot in the control path) and it only
|
||||
reads: nothing here, or anywhere in this card, writes to the charger."""
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
day = brussels(now_utc).date().isoformat()
|
||||
try:
|
||||
if not self._charger_probed:
|
||||
pr = await asyncio.wait_for(self.charger.probe(), 5.0)
|
||||
if OUTAGE.waiting():
|
||||
raise RuntimeError("Home Assistant is not answering")
|
||||
self._charger_probed = True
|
||||
self._judge_probe(pr)
|
||||
if not self.charger.configured():
|
||||
return
|
||||
reading = await asyncio.wait_for(self.charger.read(), 5.0)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOG.debug("The charger could not be read: %s", err)
|
||||
reading = chargers.ChargerReading(why="no answer")
|
||||
self.car.observe(reading, now_mono, now_utc, day)
|
||||
if self.car.persist:
|
||||
self.car.persist = False
|
||||
self.store.set(CAR_TODAY_KEY, self.car.dump())
|
||||
|
||||
def _judge_probe(self, pr) -> None:
|
||||
"""A charger whose setpoint, read-back or state entity is missing, or
|
||||
whose limit is above the station limit, is REFUSED: the add-on starts
|
||||
and the Car card says so. Said once."""
|
||||
why = None
|
||||
if not pr.ok:
|
||||
why = "not_found"
|
||||
_LOG.warning("The car charger is not set up, because Home "
|
||||
"Assistant has no entity it needs: CHARGER_NOT_FOUND.")
|
||||
_LOG.debug("Charger entities missing: %s.",
|
||||
", ".join(e for _r, e in pr.missing) or "the name")
|
||||
else:
|
||||
amps = chargers.max_a(self.o)
|
||||
if pr.station_a is not None and amps is not None \
|
||||
and amps > pr.station_a:
|
||||
why = "limit"
|
||||
_LOG.warning("The charger limit is above what the charger "
|
||||
"allows, so the charger is not used: "
|
||||
"CHARGER_LIMIT.")
|
||||
_LOG.debug("ev_max_a %.0f A, station limit %.0f A.", amps,
|
||||
pr.station_a)
|
||||
if why is not None:
|
||||
self.charger_refused = why
|
||||
self.charger = chargers.UnavailableCharger(why, chargers.ALFEN_HA)
|
||||
|
||||
def car_log_edges(self, now_mono: float) -> None:
|
||||
"""One WARNING when the charger goes unreachable, one INFO when it is
|
||||
back. Edge-logged, never every cycle."""
|
||||
down = self.car.unreachable(now_mono)
|
||||
if down and not self._car_down_logged:
|
||||
self._car_down_logged = True
|
||||
_LOG.warning("The car charger does not answer, so the Car card has "
|
||||
"no reading: CHARGER_UNREACHABLE.")
|
||||
_LOG.debug("The charger entities read unavailable, or its login "
|
||||
"is off, for %.0f s.", chargers.UNREACHABLE_S)
|
||||
elif not down and self._car_down_logged and self.car.bad_since is None:
|
||||
self._car_down_logged = False
|
||||
_LOG.info("The car charger answers again.")
|
||||
|
||||
@property
|
||||
def pv_roster(self) -> tuple:
|
||||
"""Every configured PV production entity, or `()` (TEL-11/TEL-12). ONE
|
||||
@@ -197,6 +286,7 @@ class InputsMixin:
|
||||
# correlates 0.998 with battery power and 0.09 with the real meter
|
||||
# (a controller would regulate against its own output).
|
||||
self.grid = self.p1.net_w
|
||||
self.settle.arrive(now_mono, self.p1.samples) # SAFETY-136
|
||||
else:
|
||||
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
||||
bool(o.get("meter_invert")))
|
||||
@@ -208,6 +298,10 @@ class InputsMixin:
|
||||
_reads = [self._read_unit(inv, now_mono) for inv in self.inverters]
|
||||
if self.pv_roster:
|
||||
_reads.append(self._read_pv())
|
||||
# EV-03: the charger, read only, in the same gather. Nothing is added
|
||||
# without a charger, so a site with none runs exactly as before.
|
||||
if self.charger.configured():
|
||||
_reads.append(self._read_charger(now_mono))
|
||||
# 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 (
|
||||
|
||||
@@ -15,7 +15,8 @@ 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,
|
||||
PAYLOAD_NONE, announced_units, optional_keys, parse_command, unit_slugs,
|
||||
unit_state_key,
|
||||
)
|
||||
from . import peakguard
|
||||
from . import pv as pv_read
|
||||
@@ -135,6 +136,10 @@ class StatusMixin:
|
||||
if "pv" in optional_keys(self.o):
|
||||
values["pv"] = (None if self.pv_w is None
|
||||
else round(self.pv_w, 1))
|
||||
# EV-03: the car, only with a charger switched on (the same predicate
|
||||
# that announces the three sensors). Absence is `None`, never 0.
|
||||
if "car_state" in optional_keys(self.o):
|
||||
values.update(self.car_values())
|
||||
# ⚠️ 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
|
||||
@@ -161,6 +166,41 @@ class StatusMixin:
|
||||
values.update(self.mode_values())
|
||||
self.mqtt.publish(values)
|
||||
|
||||
# -- EV-03: the car charger ------------------------------------------------
|
||||
def car_rows(self) -> list:
|
||||
"""The charger's status rows: amber when it is not set up or does not
|
||||
answer, grey while something else changes it."""
|
||||
if not self.charger.configured():
|
||||
return [{"ok": False, "warn": True, "id": "charger_setup",
|
||||
"text": messages.row_charger_setup()}]
|
||||
out = []
|
||||
if self.car.unreachable(self.car_now()):
|
||||
out.append({"ok": False, "warn": True, "id": "charger",
|
||||
"text": messages.row_charger_unreach(
|
||||
f"{brussels(self.car.bad_since_utc):%H:%M}")})
|
||||
if self.car.other_writer(datetime.now(timezone.utc)):
|
||||
out.append({"ok": True, "warn": True, "id": "charger_writer",
|
||||
"text": messages.row_charger_writer()})
|
||||
return out
|
||||
|
||||
def car_values(self) -> dict:
|
||||
"""The MQTT values for the three car sensors."""
|
||||
now = self.car_now()
|
||||
state = self.car.status(now, self.charger.configured())
|
||||
r = self.car.reading
|
||||
down = state in (None, "unreach", "setup")
|
||||
with homeview.in_lang("en"):
|
||||
word = homeview.car_state_word(state)
|
||||
return {
|
||||
"car_state": word,
|
||||
# `ABSENT_AS_UNKNOWN` stays the one PV key (test_pv.py): an absent
|
||||
# car figure is HA's own "no value" payload, sent by name.
|
||||
"car_power": (PAYLOAD_NONE if down or r is None
|
||||
or r.power_w is None else round(r.power_w, 1)),
|
||||
"car_today_kwh": (PAYLOAD_NONE if self.car.today_kwh is None
|
||||
else round(self.car.today_kwh, 3)),
|
||||
}
|
||||
|
||||
# -- UI ------------------------------------------------------------------
|
||||
def checks(self, when: datetime | None = None) -> list:
|
||||
"""The status rows. `when` is the reference time for anything billed by
|
||||
@@ -613,6 +653,9 @@ class StatusMixin:
|
||||
out.append({"ok": False, "warn": True, "id": "pv",
|
||||
"text": messages.row_pv_stray(
|
||||
_inv_list.index(ent) + 1)})
|
||||
# EV-03: the car charger's rows, only with a charger switched on.
|
||||
if self.car_enabled:
|
||||
out.extend(self.car_rows())
|
||||
return out
|
||||
|
||||
def status_payload(self, lang: str = "en") -> dict:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import collections
|
||||
import dataclasses
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -9,7 +10,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from . import arbitrage
|
||||
from .arbiter import (
|
||||
ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET,
|
||||
pre_resolve,
|
||||
pre_resolve, resolve,
|
||||
)
|
||||
from . import allocator
|
||||
from . import dayplan
|
||||
@@ -18,6 +19,7 @@ from . import packmode
|
||||
from . import priceruns
|
||||
from . import solarfc
|
||||
from . import control as _ctl
|
||||
from . import freshness
|
||||
from . import messages
|
||||
from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING
|
||||
from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key
|
||||
@@ -48,6 +50,13 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
HOUSE_PROFILE_KEY = "house_profile"
|
||||
|
||||
|
||||
# MAINT-28: a step of a battery's command, in W, after which its reading is not
|
||||
# current until a changed one arrives (`Controller._batts_settled()`).
|
||||
MAINT_STEP_W = 100.0
|
||||
# MAINT-28: the reason on a paused pack's cap (`Controller._m28_share()`).
|
||||
M28_REASON = "peak hold"
|
||||
|
||||
|
||||
# OPT-34: the level of a night grid charge that has started, so it does not
|
||||
# drop when the forecast goes (`Controller._latch_charge()`).
|
||||
CHARGE_LATCH_KEY = "night_charge_latch"
|
||||
@@ -379,8 +388,18 @@ class TradeMixin:
|
||||
price_now = None
|
||||
house = (None if self.grid is None or self.site_batt_w is None
|
||||
else float(self.grid) + float(self.site_batt_w))
|
||||
# EV-03: the car is not the house. Out of the profile that plans the
|
||||
# batteries' night charge; with a charger whose reading is not fresh
|
||||
# the moment is left out (None), not guessed.
|
||||
# ONLY the profile's sample: `house` below stays what it was, since
|
||||
# the battery decision's input is not part of this card.
|
||||
house_sample = house
|
||||
if house is not None:
|
||||
_car_w, _car_unknown = self.car_term()
|
||||
house_sample = (None if _car_unknown else max(0.0, house - _car_w)
|
||||
if _car_w else house)
|
||||
if self.house_profile is not None and \
|
||||
self.house_profile.add(now, house):
|
||||
self.house_profile.add(now, house_sample):
|
||||
self.store.set(HOUSE_PROFILE_KEY, self.house_profile.dump())
|
||||
targets = self.plan_targets()
|
||||
if targets is None:
|
||||
@@ -401,6 +420,18 @@ class TradeMixin:
|
||||
quarter = int(now.timestamp() // 900)
|
||||
was = self.mode_tick is not None and \
|
||||
self.mode_tick.action == modetrade.CHARGE
|
||||
# SAFETY-135: the first guard stop in a period (or measured headroom
|
||||
# stop in a young one) does not latch; the charge may start again
|
||||
# RESTART_S after it, but not in the period's last RESTART_S. No
|
||||
# start in a young period while a give falls.
|
||||
# DECISIONS/SAFETY-135-young-period.md
|
||||
st = getattr(self, "_charge_stop", None)
|
||||
paused = (st is not None and st[0] == quarter
|
||||
and (now.timestamp() - st[1] < modetrade.RESTART_S
|
||||
or (quarter + 1) * 900 - now.timestamp()
|
||||
< modetrade.RESTART_S))
|
||||
wait = ("wait" if paused else
|
||||
"settle" if self._give_falling(now) else None)
|
||||
self.mode_tick = modetrade.decide(
|
||||
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
|
||||
trade_why=trade_why, price_now=price_now,
|
||||
@@ -408,7 +439,10 @@ class TradeMixin:
|
||||
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()),
|
||||
wait=wait,
|
||||
maint_charging=any(r.phase == CHARGE
|
||||
for r in maint_results or ()))
|
||||
self._latch_charge(now, runs, targets, packs)
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
@@ -419,11 +453,38 @@ class TradeMixin:
|
||||
gap = why == "headroom" and self.arb_headroom_w is None
|
||||
if why == "peak" or (why == "headroom" and not gap) or (
|
||||
gap and self._charge_gap_q == quarter):
|
||||
self._charge_blocked_q = quarter
|
||||
young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S
|
||||
if (why == "peak" or (young and not gap)) and (
|
||||
st is None or st[0] != quarter):
|
||||
self._charge_stop = (quarter, now.timestamp())
|
||||
else:
|
||||
self._charge_blocked_q = quarter
|
||||
elif gap:
|
||||
self._charge_gap_q = quarter
|
||||
return self.mode_tick
|
||||
|
||||
def _give_falling(self, now) -> bool:
|
||||
"""SAFETY-135: True in a young period while the batteries give over
|
||||
START_W less than they did in the last GAVE_S s: the meter may not
|
||||
show that yet, so a charge room read now is too big.
|
||||
DECISIONS/SAFETY-135-young-period.md"""
|
||||
t = now.timestamp()
|
||||
gave = getattr(self, "_gave", None)
|
||||
if gave is None:
|
||||
gave = collections.deque()
|
||||
self._gave = gave
|
||||
if gave and t < gave[-1][0]:
|
||||
gave.clear() # the clock stepped back
|
||||
if self.site_batt_w is not None:
|
||||
gave.append((t, max(0.0, float(self.site_batt_w))))
|
||||
while gave and t - gave[0][0] >= modetrade.GAVE_S:
|
||||
gave.popleft()
|
||||
if self.site_batt_w is None or not gave \
|
||||
or t % 900 >= modetrade.YOUNG_S:
|
||||
return False
|
||||
drop = max(w for _t, w in gave) - max(0.0, float(self.site_batt_w))
|
||||
return drop > modetrade.START_W
|
||||
|
||||
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
||||
"""The batteries that left run `run` (full, or at the floor); they
|
||||
stay out until it ends, so a level edge does not re-arm them.
|
||||
@@ -475,7 +536,8 @@ class TradeMixin:
|
||||
None, 0.0, "size")
|
||||
if size == 0.0 or runs is None:
|
||||
reason = ("battery modes: no price run now" if tick.why is None
|
||||
else f"battery modes: no trade ({tick.why})")
|
||||
else modetrade.WAIT_WORDS.get(tick.why)
|
||||
or f"battery modes: no trade ({tick.why})")
|
||||
self.arb = arbitrage.ArbitrageDecision(
|
||||
action=None, power_w=None, reason=reason,
|
||||
spread_eur_kwh=None, required_eur_kwh=None,
|
||||
@@ -667,6 +729,80 @@ class TradeMixin:
|
||||
lo = hi = float(inv.batt)
|
||||
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
|
||||
|
||||
def _batts_settled(self) -> bool:
|
||||
"""MAINT-28: False while a battery reading may pre-date a step UP of
|
||||
its command, so `grid + site_batt_w` is a new grid and an old battery.
|
||||
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
|
||||
now, ok = self._now_mono, True
|
||||
if now is None:
|
||||
return True
|
||||
for inv in self.inverters:
|
||||
cmd = float(inv.standing_w())
|
||||
seen = self._m28_step.get(inv.name)
|
||||
if seen is None:
|
||||
seen = self._m28_step[inv.name] = (cmd, None)
|
||||
elif abs(cmd - seen[0]) >= MAINT_STEP_W:
|
||||
seen = self._m28_step[inv.name] = (
|
||||
cmd, now if cmd > seen[0] else seen[1])
|
||||
at = seen[1]
|
||||
if at is None or now - at >= freshness.BATT_OBLIGED_S:
|
||||
continue
|
||||
got = inv.batt_fresh.changed_mono
|
||||
if got is None or got < at + freshness.PLANT_SETTLE_S:
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
def _m28_share(self, claims, now) -> None:
|
||||
"""MAINT-28: each paused pack's cap (0 W if the guard holds it at its
|
||||
reserve) set to the largest part of the guard's floor left to it in
|
||||
this 15-minute period: the floor less what the other units are placed
|
||||
at in this cycle's split without the cap. Kept per unit in `_m28_hi`,
|
||||
dropped while the unit is not paused or is held. Also one site limit,
|
||||
the period's largest floor, so the other pack does not give for the
|
||||
house (`_m28_site`).
|
||||
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
|
||||
floor = max((c.lo for c in claims if c.source == peakguard.SOURCE),
|
||||
default=0.0)
|
||||
g = self.peak_guard
|
||||
held = {self.actuator_of("maintenance", inv) for inv in self.inverters
|
||||
if g is not None and inv.name in g.held}
|
||||
q, seen = int(now.timestamp() // 900), set()
|
||||
site = any(c.source == "maintenance" and c.reason == M28_REASON
|
||||
and c.actuator != ACTUATOR_SITE for c in claims)
|
||||
for i, c in enumerate(claims):
|
||||
if c.source != "maintenance" or c.reason != M28_REASON \
|
||||
or c.actuator == ACTUATOR_SITE or c.actuator in held:
|
||||
continue
|
||||
seen.add(c.actuator)
|
||||
shares = resolve(claims[:i] + claims[i + 1:], units=self.units,
|
||||
live=self.live_units(), weights=self.pack_weights,
|
||||
reserve=self._held_back()).shares
|
||||
part = max(0.0, floor - sum(float(w) for u, w in shares.items()
|
||||
if u != c.actuator))
|
||||
hi = max(part, self._m28_was(c.actuator, now))
|
||||
self._m28_hi[c.actuator] = (q, hi)
|
||||
claims[i] = dataclasses.replace(c, hi=hi)
|
||||
# A unit not paused (or held at its reserve) starts again from 0 W.
|
||||
for u in set(self._m28_hi) - seen:
|
||||
del self._m28_hi[u]
|
||||
# Site: while the pause holds, the packs together give at most the
|
||||
# period's largest floor, never under this cycle's (the guard's claim
|
||||
# and P_PEAK outrank this one anyway).
|
||||
if not site:
|
||||
self._m28_site = (None, 0.0)
|
||||
return
|
||||
hi = max(floor, self._m28_site[1] if self._m28_site[0] == q else 0.0)
|
||||
self._m28_site = (q, hi)
|
||||
claims.append(Claim.limit("charge", P_MAINT_SHAPE, hi=hi,
|
||||
reason=M28_REASON,
|
||||
actuator=ACTUATOR_SITE))
|
||||
|
||||
def _m28_was(self, u, now) -> float:
|
||||
"""MAINT-28: unit `u`'s cap so far in this 15-minute period, 0 W in
|
||||
a new one (`_m28_share()`)."""
|
||||
q, hi = self._m28_hi.get(u, (None, 0.0))
|
||||
return hi if q == int(now.timestamp() // 900) else 0.0
|
||||
|
||||
def _reseed_drop(self, live) -> None:
|
||||
"""SAFETY-96, N>=2: a battery that drops out of the split (stale link)
|
||||
leaves the loop's start point, so the others take over at one slew step
|
||||
|
||||
@@ -72,16 +72,26 @@ def flow(w, *, shadow=False):
|
||||
return "resting"
|
||||
|
||||
|
||||
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow, settled=True):
|
||||
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow, settled=True,
|
||||
car_w=0.0, car_unknown=False):
|
||||
"""The four tiles. `house_w` needs all three readings, so without a solar
|
||||
reading it is None rather than a number that leaves the sun out."""
|
||||
reading it is None rather than a number that leaves the sun out.
|
||||
|
||||
EV-03: `car_w` is what a car charger takes, and it is not the house, so it
|
||||
is taken out of House (the Car card shows it). `car_unknown` is a charger
|
||||
whose reading is not fresh: House then has no figure rather than one that
|
||||
may hold the car. Both default to "no charger", the figure as before."""
|
||||
house, disagree = None, False
|
||||
if has_solar and None not in (grid_w, pv_w, batt_w):
|
||||
house = float(grid_w) + float(pv_w) + float(batt_w)
|
||||
house = float(grid_w) + float(pv_w) + float(batt_w) - float(car_w or 0.0)
|
||||
if car_unknown:
|
||||
house = None
|
||||
# The three are read at slightly different moments, so the sum can dip
|
||||
# under zero. Inside the idle band that is 0 W; beyond it the readings
|
||||
# disagree and no house figure is shown.
|
||||
if house < -IDLE_W:
|
||||
if house is None:
|
||||
pass
|
||||
elif house < -IDLE_W:
|
||||
house, disagree = None, True
|
||||
elif house < 0.0:
|
||||
house = 0.0
|
||||
@@ -630,6 +640,11 @@ def maintenance_runs(c, day0, day1, now, shadow):
|
||||
run_cycle_why(shadow),
|
||||
run_cycle_amount(hours)))
|
||||
continue
|
||||
until = m.backoff_until(now)
|
||||
if until is not None:
|
||||
# MAINT-25: a failed cycle waits (MAINT-13); no run is planned.
|
||||
lines.append(line_cycle_backoff(inv.name, until))
|
||||
continue
|
||||
if not m.due(now) and (m.next_due(now) is None
|
||||
or m.next_due(now) >= day1):
|
||||
continue
|
||||
@@ -1065,6 +1080,7 @@ ATTN_DOCS = {
|
||||
"not_keeping_up": messages.T_LOOP_BOUND,
|
||||
"circulating": messages.T_CIRCULATING,
|
||||
"delivery": messages.S_NOT_DELIVERING,
|
||||
"charger": messages.T_CHARGER_UNREACH,
|
||||
}
|
||||
|
||||
|
||||
@@ -1182,7 +1198,10 @@ def _build(c, *, level, checks, now, held=None, waiting=False):
|
||||
"sub": banner_sub(kind)},
|
||||
"now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w,
|
||||
has_solar=bool(c.pv_roster), shadow=shadow,
|
||||
settled=settled),
|
||||
settled=settled,
|
||||
**dict(zip(("car_w", "car_unknown"),
|
||||
c.car_term()))),
|
||||
"car": car_block(c, now),
|
||||
"batteries": rows,
|
||||
"plan": plan,
|
||||
"modes_on": bool(getattr(c, "modes_on", False)),
|
||||
@@ -1203,6 +1222,85 @@ def _docs_url():
|
||||
messages._site["ha"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# EV-03: the Car card. READ ONLY: Off is the only mode in this version.
|
||||
# --------------------------------------------------------------------------- #
|
||||
_CAR_KIND = {"charging": "ok", "full": "ok", "unreach": "warn",
|
||||
"setup": "warn", "fault": "warn"}
|
||||
|
||||
|
||||
def car_state_word(key) -> str:
|
||||
return _t("car.state." + (key if key else "wait"))
|
||||
|
||||
|
||||
def car_phases_word(n) -> str:
|
||||
return _t("car.phase_one") if n == 1 else _t("car.phase_many", n=n)
|
||||
|
||||
|
||||
def _car_big(key, c, r, good):
|
||||
if key == "charging" and r is not None and r.power_w is not None:
|
||||
parts = [_dec(f"{r.power_w / 1000:.1f}")
|
||||
+ " kW"]
|
||||
amps = (r.allowed_a if r.allowed_a is not None
|
||||
else max(r.current_a) if r.current_a else None)
|
||||
if amps is not None:
|
||||
parts.append(f"{amps:.0f} A")
|
||||
n = c.car.phases_in_use()
|
||||
if n:
|
||||
parts.append(car_phases_word(n))
|
||||
return " · ".join(parts)
|
||||
if key == "unreach":
|
||||
t = c.car.last_good_utc
|
||||
return (_t("car.big.seen", t=hhmm(t)) if t is not None
|
||||
else _t("car.big.no_reading"))
|
||||
if key == "charging": # the reading is too old
|
||||
return _t("car.big.no_reading")
|
||||
return _t("car.big." + (key or "wait"))
|
||||
|
||||
|
||||
def _car_why(key, c, good):
|
||||
brand = (getattr(c.charger, "brand", "") or "Alfen")
|
||||
if key == "unreach":
|
||||
a = None if good is None else good.setpoint_a
|
||||
if a is None and good is not None:
|
||||
a = good.allowed_a
|
||||
if a is not None:
|
||||
return _t("car.why.unreach_a", a=f"{a:.0f}", brand=brand)
|
||||
return _t("car.why.unreach", brand=brand)
|
||||
if key == "setup":
|
||||
why = {"not_found": "not_found", "limit": "limit"}.get(
|
||||
c.charger_refused or "", "name")
|
||||
return _t("car.why." + why)
|
||||
return _t("car.why." + (key if key else "wait"))
|
||||
|
||||
|
||||
def car_block(c, now):
|
||||
"""The Car card for `/status`, or None when no charger is switched on (the
|
||||
page then draws nothing, as before)."""
|
||||
if not getattr(c, "car_enabled", False):
|
||||
return None
|
||||
mono = c.car_now()
|
||||
configured = c.charger.configured()
|
||||
key = c.car.status(mono, configured)
|
||||
r = c.car.reading if configured else None
|
||||
good = c.car.last_good
|
||||
fresh = configured and c.car.fresh(mono)
|
||||
today = None
|
||||
if configured and c.car.today_kwh is not None:
|
||||
today = _t("car.today", kwh=_kwh(c.car.today_kwh))
|
||||
return {
|
||||
"state": key or "wait",
|
||||
"chip": car_state_word(key),
|
||||
"kind": _CAR_KIND.get(key, "muted"),
|
||||
"big": _car_big(key, c, r if fresh else None, good),
|
||||
"today": today,
|
||||
"why": _car_why(key, c, good),
|
||||
"mode": "off",
|
||||
"power_w": (None if not fresh or r is None or r.power_w is None
|
||||
else round(float(r.power_w), 1)),
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# The words. STYLE.md: plain, short, present tense. A plan line is at most
|
||||
# two sentences of at most 12 words each (UX-13 design, section 4); a title at
|
||||
@@ -1529,6 +1627,13 @@ def line_cycle_waits(name) -> str:
|
||||
return _t("cycle_waits", name=name)
|
||||
|
||||
|
||||
def line_cycle_backoff(name, until) -> str:
|
||||
"""MAINT-25: the failed cycle waits until `until` (MAINT-13)."""
|
||||
local = brussels(until)
|
||||
return _t("cycle_backoff", name=name, day=local.day,
|
||||
month=month_name(local))
|
||||
|
||||
|
||||
def line_no_prices(since, now) -> str:
|
||||
"""PRICE-01: no price covers now. `since` is an instant or None."""
|
||||
if since is None:
|
||||
@@ -1669,6 +1774,8 @@ def _samples() -> list:
|
||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||
("plan", run_house_amount()),
|
||||
("plan", line_cycle_waits("Battery B")),
|
||||
("plan", line_cycle_backoff("Battery B", datetime(
|
||||
2026, 11, 19, 12, 0, tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
|
||||
@@ -1701,6 +1808,13 @@ def _samples() -> list:
|
||||
out += [("plan", day_note(k, pct=70, name="Battery A", at="08:15",
|
||||
kwh="9.2 kWh", count="6.4 kWh"))
|
||||
for k in words["day"]]
|
||||
out += [("title", _t("car.state." + k)) for k in words["car"]["state"]]
|
||||
out += [("plan", _t("car.big." + k, t="09:12")) for k in
|
||||
words["car"]["big"]]
|
||||
out += [("plan", _t("car.why." + k, a="10", brand="Alfen"))
|
||||
for k in words["car"]["why"]]
|
||||
out += [("plan", _t("car.today", kwh="14.2 kWh")),
|
||||
("plan", "11.0 kW · 16 A · " + _t("car.phase_many", n=3))]
|
||||
out += [("plan", line_no_trade(k)) for k in
|
||||
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
|
||||
"no_level")]
|
||||
|
||||
@@ -22,6 +22,7 @@ ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and
|
||||
ctl_tasks (MAINT-21), the status-row functions in rows."""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
@@ -41,6 +42,7 @@ from .arbiter import (
|
||||
P_SAFETY, SET, Claim, Resolution, actuator_for, pre_resolve, resolve,
|
||||
)
|
||||
from . import allocator
|
||||
from . import chargers
|
||||
from . import dayplan
|
||||
from . import modetrade
|
||||
from . import packmode
|
||||
@@ -63,6 +65,7 @@ from .clamp import (
|
||||
)
|
||||
from . import freshness
|
||||
from . import lateness
|
||||
from .settle import Settle
|
||||
from . import dayview, homeview
|
||||
from .daycurve import SolarToday
|
||||
from . import handback
|
||||
@@ -503,6 +506,10 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._land_batt_prev = None
|
||||
self._peak_needed = False
|
||||
self._maint_shape_suspended = False
|
||||
self._maint_peak_hold = False # MAINT-28
|
||||
self._m28_step = {} # MAINT-28: unit -> (command, step at)
|
||||
self._m28_hi = {} # MAINT-28: unit -> (period, cap)
|
||||
self._m28_site = (None, 0.0) # MAINT-28: (period, site cap)
|
||||
# OPT-11: battery modes, off by default (SAFETY-10 section 1). Each
|
||||
# battery's mode and on/off switch live in the store, not in options,
|
||||
# so a change applies at once (`pack_use`, `pack_mode`).
|
||||
@@ -527,6 +534,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
|
||||
self._charge_stop = None # SAFETY-135: (quarter, t) first stop
|
||||
self._gave = collections.deque() # SAFETY-135: (t, W given), GAVE_S
|
||||
self._ref_cheap = None
|
||||
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
|
||||
# last battery reading).
|
||||
@@ -556,6 +565,17 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._charge_latch = _load_latch(
|
||||
(getattr(store, "data", None) or {}).get(CHARGE_LATCH_KEY),
|
||||
datetime.now(timezone.utc))
|
||||
# EV-03: the car charger, READ ONLY. Off builds `UnavailableCharger`,
|
||||
# which reads nothing, so a site without a charger is unchanged.
|
||||
self.charger = chargers.build(opts)
|
||||
self.charger.bind_getter(lambda: self.hass)
|
||||
self.car = chargers.CarTracker(
|
||||
stale_s=float(opts.get("stale_input_s", 15) or 15))
|
||||
self.car.load((getattr(store, "data", None) or {}).get("car_today"))
|
||||
self.charger_refused = None
|
||||
self._charger_probed = False
|
||||
self._car_down_logged = False
|
||||
self._car_announced = False
|
||||
self.house_profile = None
|
||||
if self.fc_entity is not None:
|
||||
self.house_profile = dayplan.HouseProfile(
|
||||
@@ -741,6 +761,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._arb_action = None
|
||||
self.inputs_bad_since = None
|
||||
self.answer = lateness.Answer() # SAFETY-128
|
||||
self.settle = Settle() # SAFETY-136
|
||||
self._late_logged = self._late_zero = False
|
||||
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
|
||||
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
|
||||
|
||||
@@ -186,6 +186,8 @@ T_OUTPUT_CLAMP_NL = "Vermogensvraag te hoog"
|
||||
T_PEAK_SATURATED_NL = "Belasting te hoog voor de batterij"
|
||||
T_METER_FROZEN_NL = "Meter staat mogelijk vast"
|
||||
TITLE_NL = {T_LOOP_BOUND: T_LOOP_BOUND_NL, T_OUTPUT_CLAMP: T_OUTPUT_CLAMP_NL}
|
||||
T_CHARGER_UNREACH = "Charger not reachable"
|
||||
T_CHARGER_UNREACH_NL = "Laadpaal onbereikbaar"
|
||||
T_NO_PRICES = "No prices"
|
||||
T_NO_PRICES_NL = "Prijzen ontbreken"
|
||||
|
||||
@@ -198,6 +200,7 @@ ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
||||
ID_MAINT_FAILED = "goodwe_maint_failed_{slug}"
|
||||
ID_NO_PRICES = "goodwe_no_prices"
|
||||
ID_CHARGER_UNREACH = "goodwe_charger_unreachable"
|
||||
# PRICE-01: an earlier build raised a card with this id. Nothing raises it now;
|
||||
# the add-on only dismisses it at start.
|
||||
OLD_FIXED_CARD_ID = "goodwe_fixed_tariff_unused"
|
||||
@@ -465,6 +468,43 @@ def row_no_prices(since=None, now=None) -> str:
|
||||
"Nog geen prijzen, onderhoud wacht.")
|
||||
|
||||
|
||||
def charger_unreachable(since_text_, brand="Alfen") -> Notice:
|
||||
"""EV-03: the car charger has not answered for 5 minutes. Said once."""
|
||||
en = (f"The add-on cannot reach the car charger since {since_text_}, so "
|
||||
f"check the {brand} integration in Home Assistant.")
|
||||
nl = (f"De add-on kan de laadpaal niet bereiken sinds {since_text_}, dus "
|
||||
f"controleer de {brand}-integratie in Home Assistant.")
|
||||
return Notice(ID_CHARGER_UNREACH, WARNING, T_CHARGER_UNREACH, en,
|
||||
body_nl=f"{T_CHARGER_UNREACH_NL}. {nl}")
|
||||
|
||||
|
||||
def charger_back() -> Dismiss:
|
||||
"""EV-03: takes the 'Charger not reachable' notification down."""
|
||||
return Dismiss(ID_CHARGER_UNREACH)
|
||||
|
||||
|
||||
def row_charger_unreach(since_text_) -> str:
|
||||
"""EV-03: the amber row while the charger does not answer."""
|
||||
return Bi(f"The add-on cannot reach the charger since {since_text_}.",
|
||||
f"De add-on kan de laadpaal niet bereiken sinds {since_text_}.")
|
||||
|
||||
|
||||
def row_charger_writer() -> str:
|
||||
"""EV-03: the grey row while something else sets the charger's current."""
|
||||
return Bi("Something else changes the charger, and this add-on only reads "
|
||||
"it.",
|
||||
"Iets anders wijzigt de laadpaal en deze add-on leest alleen.")
|
||||
|
||||
|
||||
def row_charger_setup() -> str:
|
||||
"""EV-03: the amber row when the charger's entities are not found."""
|
||||
return Bi("The add-on cannot find the charger, so check its entity in "
|
||||
"Settings.",
|
||||
"De add-on vindt de laadpaal niet, dus controleer de entiteit "
|
||||
"bij Instellingen.")
|
||||
|
||||
|
||||
|
||||
def row_maint_waits(until_text: str) -> str:
|
||||
"""MAINT-13: the status row while a failed cycle waits."""
|
||||
return Bi(f"Waits until {until_text} after a cycle that did not finish.",
|
||||
@@ -1661,6 +1701,9 @@ def status_samples() -> list:
|
||||
("row_no_prices", AMBER, row_no_prices(*_SINCE_SAMPLE)),
|
||||
("row_no_prices", AMBER, row_no_prices(*_SINCE_OLDER)),
|
||||
("row_no_prices", AMBER, row_no_prices()),
|
||||
("row_charger_unreach", AMBER, row_charger_unreach("09:12")),
|
||||
("row_charger_writer", GREEN, row_charger_writer()),
|
||||
("row_charger_setup", AMBER, row_charger_setup()),
|
||||
]
|
||||
|
||||
|
||||
@@ -1696,6 +1739,7 @@ def samples() -> list:
|
||||
no_prices(*_SINCE_SAMPLE),
|
||||
no_prices(*_SINCE_OLDER),
|
||||
no_prices(),
|
||||
charger_unreachable("09:12"),
|
||||
]
|
||||
|
||||
|
||||
@@ -1758,6 +1802,11 @@ LABELS = {
|
||||
"solar_trust_pct": "Trust in the forecast",
|
||||
"p1_phase_import_entities": "Import entities per phase",
|
||||
"p1_phase_export_entities": "Export entities per phase",
|
||||
# EV-03
|
||||
"ev_charger": "Charger",
|
||||
"ev_current_entity": "Current setting entity",
|
||||
"ev_max_a": "Charger limit",
|
||||
"ev_phases": "Phases",
|
||||
}
|
||||
|
||||
|
||||
@@ -1838,6 +1887,10 @@ LABELS_NL = {
|
||||
"solar_trust_pct": "Vertrouwen in de prognose",
|
||||
"p1_phase_import_entities": "Afname-entiteiten per fase",
|
||||
"p1_phase_export_entities": "Injectie-entiteiten per fase",
|
||||
"ev_charger": "Laadpaal",
|
||||
"ev_current_entity": "Entiteit van de laadstroom",
|
||||
"ev_max_a": "Laadpaalgrens",
|
||||
"ev_phases": "Fasen",
|
||||
}
|
||||
# The inverter list shows two of them under a shorter name.
|
||||
UNIT_LABELS_NL = {"setpoint_entity": "Setpoint-entiteit",
|
||||
|
||||
@@ -36,6 +36,10 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
makes the other batteries stop giving power, so a few watts of charge
|
||||
flipping on and off would swing the house between them and the grid
|
||||
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
||||
SAFETY-135: the first such stop in a period holds the charge RESTART_S
|
||||
only, a second one to the period's end (at most one restart), and in a
|
||||
period's first YOUNG_S s no charge starts while the batteries' give is
|
||||
still falling (`wait`);
|
||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||
@@ -52,7 +56,9 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
the probe (none: PROBE_W alone), so a probe never places charge on a
|
||||
battery in a maintenance phase or on one that rests;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
(`draining`, SAFETY-78): it would charge against the drain; nor while
|
||||
one is in its maintenance CHARGE (`maint_charging`, MAINT-24): the cheap
|
||||
night's room is that pack's.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -69,6 +75,18 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
# would stop covering the house for less than they give.
|
||||
START_W = 500.0
|
||||
|
||||
# SAFETY-135: how long the first stop in a period holds the charge, how long
|
||||
# a period is young, and how far back a give may not be on the meter yet.
|
||||
# DECISIONS/SAFETY-135-young-period.md
|
||||
RESTART_S = 60.0
|
||||
YOUNG_S = 60.0
|
||||
GAVE_S = 15.0
|
||||
# SAFETY-135: the plain status words for `wait` (UX-08).
|
||||
WAIT_WORDS = {
|
||||
"wait": "battery modes: grid charge paused, it tries again in a minute",
|
||||
"settle": "battery modes: grid charge waits for the meter to catch up",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pack:
|
||||
@@ -164,7 +182,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
|
||||
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
||||
blocked=False, done_charge=(), done_sell=(),
|
||||
targets=None, draining=False) -> Tick:
|
||||
targets=None, draining=False, wait=None,
|
||||
maint_charging=False) -> Tick:
|
||||
"""This tick's ranges, roles and site ask.
|
||||
|
||||
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
||||
@@ -177,7 +196,11 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
it going); `blocked` that the guard or the headroom stopped a charge in
|
||||
this 15-minute period. `targets` is the day plan (`charge_to()`).
|
||||
`draining` says a battery drains for its monthly cycle, so none may
|
||||
charge from the grid (why "cycle").
|
||||
charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
|
||||
why) says no charge may start yet (SAFETY-135). `maint_charging` says a
|
||||
battery is in its maintenance CHARGE phase: no grid charge for the others
|
||||
either, same why, but a refusal probe goes on (MAINT-24,
|
||||
DECISIONS/MAINT-24-charge-first.md).
|
||||
"""
|
||||
plan_on = runs is not None and runs.on
|
||||
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
||||
@@ -189,6 +212,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
if in_cheap else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
elif ch and maint_charging:
|
||||
# MAINT-24: a refusal probe (PROBE_W alone, OPT-20) may go on.
|
||||
ch = [p for p in ch if p.refused < 0]
|
||||
why = None if ch else "cycle"
|
||||
charging = False
|
||||
if ch:
|
||||
need = 0.0 if charging_now else START_W
|
||||
@@ -198,6 +225,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
why = trade_why
|
||||
elif headroom_w is None or not headroom_w > need:
|
||||
why = "headroom"
|
||||
elif wait and not charging_now:
|
||||
why = wait
|
||||
else:
|
||||
charging = True
|
||||
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
|
||||
|
||||
@@ -139,6 +139,11 @@ SENSORS = [
|
||||
# a charge-power limit, or the optimiser declining to charge.
|
||||
OPTIONAL_SENSORS = [
|
||||
("pv", "goodwe_pv_power", "PV power", "W", "power", "measurement", None),
|
||||
# EV-03: only with a car charger switched on. Read only: there are no
|
||||
# command entities in this version.
|
||||
("car_power", "goodwe_car_power", "Car power", "W", "power", "measurement", "mdi:car-electric"),
|
||||
("car_state", "goodwe_car_state", "Car state", None, None, None, "mdi:ev-station"),
|
||||
("car_today_kwh", "goodwe_car_today_kwh", "Car energy today", "kWh", "energy", "total_increasing", "mdi:ev-station"),
|
||||
]
|
||||
|
||||
# ⚠️ HA's OWN "this sensor has no value" PAYLOAD, and the one place this add-on
|
||||
@@ -192,7 +197,11 @@ def optional_keys(opts: dict) -> tuple:
|
||||
# `_load()` is the same decision for the same kind of reason. Measured, not
|
||||
# assumed: move this line to module scope and test_mqtt.py part 8 dies.
|
||||
from . import pv as _pv
|
||||
return ("pv",) if _pv.entities(opts) else ()
|
||||
from . import chargers as _ch # EV-03: same reason
|
||||
out = ("pv",) if _pv.entities(opts) else ()
|
||||
if _ch.census_entity(opts):
|
||||
out += ("car_power", "car_state", "car_today_kwh")
|
||||
return out
|
||||
|
||||
|
||||
BASE = "goodwe_ctl"
|
||||
|
||||
@@ -28,6 +28,10 @@ FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
|
||||
|
||||
SOURCE = "peak"
|
||||
|
||||
# MAINT-28. Watts the house must sit under the aim before a paused maintenance
|
||||
# charge resumes. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.
|
||||
MAINT_RESUME_UNDER_W = 300.0
|
||||
|
||||
# Mirror of config.yaml's peak options, pinned to the file by
|
||||
# test_peakguard.py. Not a read: `decide()` takes them as arguments.
|
||||
PEAK_OPTION_DEFAULTS = {
|
||||
@@ -446,6 +450,25 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
|
||||
return decision is not None and decision.engaged
|
||||
|
||||
|
||||
def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w,
|
||||
settled=True) -> bool:
|
||||
"""MAINT-28. `suspended` (`shaping_suspended()`'s answer), kept on while
|
||||
`held` until the house is `MAINT_RESUME_UNDER_W` under `aim_w`
|
||||
(`peak_forecast_w` stands in for an unread house; neither read: not held,
|
||||
as SAFETY-63) and `settled` (no battery reading older than its last
|
||||
command step, so `house_w` is not a new grid plus an old battery). Only
|
||||
ever pauses the maintenance charge, never a cut-back.
|
||||
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md."""
|
||||
if suspended:
|
||||
return True
|
||||
if not held:
|
||||
return False
|
||||
level = house_w if house_w is not None else peak_forecast_w
|
||||
if level is None:
|
||||
return False
|
||||
return level > float(aim_w) - MAINT_RESUME_UNDER_W or not settled
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -82,7 +82,7 @@ import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
from . import chargers, messages
|
||||
from .configui import changed_keys
|
||||
from .inverter import build_roster
|
||||
from .validate import validate_startup_config, validate_unit_ranges
|
||||
@@ -362,6 +362,18 @@ async def problems_and_ranges(opts: dict, hass=None, notes=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]
|
||||
# EV-03: the charger limit may not exceed the station limit the charger
|
||||
# reports. A read only; a charger that does not answer skips the check.
|
||||
if hass is not None and chargers.mode_of(opts) != chargers.OFF:
|
||||
drv = chargers.build(opts)
|
||||
if drv.configured():
|
||||
_lo, station, _ph = await drv.bind(hass).limits()
|
||||
amps = chargers.max_a(opts)
|
||||
if station is not None and amps is not None and amps > station:
|
||||
problems.append(
|
||||
f"{messages.setting('ev_max_a')} is {amps:.0f} A, but the "
|
||||
f"charger allows at most {station:.0f} A. Lower it to "
|
||||
f"{station:.0f} A or less.")
|
||||
return problems, unread
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""SAFETY-136: on a slow meter, follow the meter gently until the last move shows.
|
||||
|
||||
A meter whose usual gap between readings is over 3.75 s (a 4-5 s meter), read
|
||||
through Home Assistant, can be 10-15 s late. The loop then answers the same error again at each cycle
|
||||
and overshoots. After a move of MOVE_W or more the loop therefore runs at
|
||||
WAIT_SHARE of its gain until the meter has fallen SHOWN of the move, or for at
|
||||
most WAIT_CYCLES cycles. Off for a meter with a usual gap of 3.75 s or less, so those sites run as
|
||||
before. DECISIONS/SAFETY-136-slow-meter-settle.md.
|
||||
"""
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import replace
|
||||
|
||||
from .lateness import M_FIT_S, M_ROWS
|
||||
|
||||
# A meter is slow when its readings come further apart than this (the usual
|
||||
# gap, over the last 60 s and at least 5 readings): the fast meter fit's own
|
||||
# limit (SAFETY-128), 3.75 s.
|
||||
SLOW_GAP_S = M_FIT_S / M_ROWS
|
||||
SLOW_ROWS = 5
|
||||
# A move this big is waited for (above the house noise of about 80 W).
|
||||
MOVE_W = 150.0
|
||||
# The meter has shown a move once it fell by this share of it.
|
||||
SHOWN = 0.5
|
||||
# Meanwhile the loop runs at this share of its gain.
|
||||
WAIT_SHARE = 0.5
|
||||
# Wait at most this many cycles (10 s at a 5 s meter).
|
||||
WAIT_CYCLES = 2
|
||||
|
||||
|
||||
class Settle:
|
||||
def __init__(self):
|
||||
self.pend = None # (meter at the move, the move in W, cycles waited)
|
||||
self._n = None # readings taken in so far
|
||||
self._at = deque() # when the last ones arrived, over M_FIT_S
|
||||
|
||||
def arrive(self, now, n):
|
||||
"""Each read: `n` is the count of meter readings taken in. A reading
|
||||
counts when it arrives, not when its value changes, so a steady load on
|
||||
a 1 s meter is not a slow meter."""
|
||||
if n != self._n:
|
||||
self._n = n
|
||||
self._at.append(now)
|
||||
while self._at and self._at[0] < now - M_FIT_S:
|
||||
self._at.popleft()
|
||||
|
||||
def slow(self):
|
||||
"""The usual gap between readings is over SLOW_GAP_S."""
|
||||
ts = list(self._at)
|
||||
if len(ts) < SLOW_ROWS:
|
||||
return False
|
||||
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
|
||||
return gaps[len(gaps) // 2] > SLOW_GAP_S
|
||||
|
||||
def reset(self):
|
||||
self.pend = None
|
||||
|
||||
def tuning(self, tuning, grid_w, slow):
|
||||
"""The tuning for this cycle: `tuning`, or with the gain cut while the
|
||||
last move has not shown on the meter (`grid_w`)."""
|
||||
p = self.pend
|
||||
if p is None:
|
||||
return tuning
|
||||
g0, move, waited = p
|
||||
if not slow or (g0 - grid_w) / move >= SHOWN or waited >= WAIT_CYCLES:
|
||||
self.pend = None
|
||||
return tuning
|
||||
self.pend = (g0, move, waited + 1)
|
||||
return replace(tuning, gain=tuning.gain * WAIT_SHARE)
|
||||
|
||||
def moved(self, grid_w, prev_w, new_w, slow):
|
||||
"""After a cycle: start waiting if the loop moved by MOVE_W or more."""
|
||||
if slow and self.pend is None and abs(new_w - prev_w) >= MOVE_W:
|
||||
self.pend = (float(grid_w), float(new_w - prev_w), 0)
|
||||
@@ -272,3 +272,8 @@ table.fct th,table.fct td{padding:4px 3px;text-align:right;border-bottom:1px sol
|
||||
table.fct th:first-child,table.fct td:first-child{text-align:left}
|
||||
table.fct th{color:var(--muted);font-weight:600}
|
||||
.ymax{position:absolute;top:-2px;left:2px;font-size:10px;color:var(--faint);pointer-events:none}
|
||||
|
||||
/* EV-03: the Car card */
|
||||
.car-big{font-size:20px;font-weight:700;line-height:1.3;margin:8px 0 2px;font-variant-numeric:tabular-nums}
|
||||
.car-lab{margin:12px 0 4px;color:var(--muted)}
|
||||
.car-why{display:grid;grid-template-columns:auto 1fr;gap:10px;margin-top:12px}
|
||||
|
||||
@@ -113,7 +113,8 @@
|
||||
const setup = S.setup_only === true;
|
||||
banner($("#home-banner"), S.level, setup ? t("setup_only.name") : S.banner, structured ? S.state.sub : setup ? t("setup_only.description") : "", S.level === "bad" ? "alert" : null);
|
||||
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
|
||||
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
|
||||
if (structured) { renderNow(); renderCar(); renderPlan(); renderPeak(); renderAttention(); }
|
||||
else $("#c-car").hidden = true;
|
||||
renderControl(structured, setup);
|
||||
renderDetails(!structured && !setup);
|
||||
renderBatteries(force);
|
||||
@@ -145,6 +146,20 @@
|
||||
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
|
||||
}
|
||||
|
||||
/* ---------------- Home: the car (read only) ---------------- */
|
||||
function renderCar() {
|
||||
const c = S.car;
|
||||
$("#c-car").hidden = !c;
|
||||
if (!c) return;
|
||||
const chip = $("#car-chip");
|
||||
chip.textContent = c.chip;
|
||||
chip.className = "chip" + (c.kind === "warn" ? " rs" : c.kind === "ok" ? " now" : "");
|
||||
$("#car-big").textContent = c.big;
|
||||
$("#car-today").textContent = c.today || "";
|
||||
$("#car-today").hidden = !c.today;
|
||||
$("#car-why").textContent = c.why;
|
||||
}
|
||||
|
||||
/* ---------------- Home: today's plan ---------------- */
|
||||
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
|
||||
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
|
||||
|
||||
@@ -39,6 +39,16 @@
|
||||
<div id="home-batts" style="margin-top:6px"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-car" hidden>
|
||||
<div class="row spread"><h2 data-t="car.name"></h2><span class="chip" id="car-chip"></span></div>
|
||||
<div class="car-big" id="car-big"></div>
|
||||
<div class="small muted" id="car-today"></div>
|
||||
<div class="small car-lab" data-t="car.mode"></div>
|
||||
<div class="seg" role="radiogroup" id="car-mode" data-t-label="car.mode"><button type="button" role="radio" aria-checked="true" data-t="car.mode_off"></button></div>
|
||||
<p class="tiny muted" style="margin:6px 0 0" data-t="car.help_off"></p>
|
||||
<div class="car-why small"><span class="muted" data-t="car.why"></span><span id="car-why"></span></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="c-plan">
|
||||
<div class="row spread"><h2 data-t="plan.name"></h2><span class="chip tiny muted" id="verdict"></span></div>
|
||||
<div class="small" id="plan-head"></div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"inverters": {"name": "Inverters and batteries", "description": "The inverters the add-on controls, and your solar."},
|
||||
"tariff": {"name": "Tariff and prices", "description": "What you pay and are paid per kWh."},
|
||||
"safety": {"name": "Safety limits", "description": "The peak limit, starting and stopping, and the safety stops."},
|
||||
"car": {"name": "Car charger", "description": "Which charger the add-on reads, and its limits."},
|
||||
"maintenance": {"name": "Maintenance", "description": "The monthly battery cycle and the log."},
|
||||
"tuning": {"name": "Control tuning", "description": "For support only, because these were measured on real hardware."}
|
||||
},
|
||||
@@ -33,6 +34,8 @@
|
||||
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
|
||||
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"},
|
||||
"meter_source": {"off": "One grid power entity", "ha_dsmr": "Import and export entities", "mqtt_p1": "Meter over MQTT", "ha_signed": "One net power entity", "homewizard_local": "HomeWizard meter on the network"},
|
||||
"ev_charger": {"off": "None", "alfen_ha": "Alfen (Home Assistant integration)"},
|
||||
"ev_phases": {"1": "1 phase", "3": "3 phases", "auto": "1 and 3"},
|
||||
"meter_phases": {"1": "1 phase", "3": "3 phases"},
|
||||
"shadow_mode": {"auto": "Automatic", "on": "Always on", "off": "Off"},
|
||||
"price_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file", "price_service": "Your own price service"},
|
||||
@@ -87,6 +90,7 @@
|
||||
"loading": "Loading the forecast."
|
||||
},
|
||||
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
|
||||
"car": {"name": "Car", "mode": "Mode", "mode_off": "Off", "help_off": "The add-on leaves the charger alone.", "why": "Why"},
|
||||
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
|
||||
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
|
||||
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
|
||||
@@ -296,6 +300,26 @@
|
||||
"waiting": "Control resumes by itself when Home Assistant answers.",
|
||||
"warning": "See Needs attention below."
|
||||
},
|
||||
"car": {
|
||||
"state": {"charging": "Charging", "waiting": "Connected, waiting", "none": "No car connected", "full": "Car is full", "unreach": "Charger not reachable", "setup": "Charger not set up", "fault": "Charger fault", "wait": "No reading yet"},
|
||||
"big": {"waiting": "Connected, not charging.", "none": "Nothing to charge.", "full": "The car is full.", "seen": "Last seen at {t}.", "no_reading": "No reading.", "setup": "Not set up.", "fault": "The charger reports a fault.", "wait": "No reading yet."},
|
||||
"phase_one": "1 phase",
|
||||
"phase_many": "{n} phases",
|
||||
"today": "{kwh} charged today.",
|
||||
"why": {
|
||||
"charging": "The charger is charging the car. The add-on leaves it alone.",
|
||||
"waiting": "The car is connected and not charging. The add-on leaves the charger alone.",
|
||||
"none": "No car is connected.",
|
||||
"full": "The car stopped charging by itself.",
|
||||
"unreach_a": "The charger keeps its last setting, {a} A. Check the {brand} integration in Home Assistant.",
|
||||
"unreach": "The add-on cannot read the charger. Check the {brand} integration in Home Assistant.",
|
||||
"not_found": "The add-on cannot find the charger. Check the current setting entity in Settings.",
|
||||
"limit": "The charger limit is above what the charger allows. Lower it in Settings.",
|
||||
"name": "The current setting entity does not fit the charger. Check it in Settings.",
|
||||
"fault": "The charger reports a fault. Check it at the charger itself.",
|
||||
"wait": "Waiting for the first reading."
|
||||
}
|
||||
},
|
||||
"tile": {
|
||||
"not_set_up": "Not set up",
|
||||
"no_reading": "No reading",
|
||||
@@ -430,6 +454,7 @@
|
||||
"give": "Tomorrow: gives power at {at}, {eur} per kWh.",
|
||||
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
||||
},
|
||||
"cycle_backoff": "{name}: the monthly cycle waits until {day} {month}.",
|
||||
"cycle_waits": "{name} waits its turn for the monthly cycle.",
|
||||
"no_prices_yet": "No prices yet, maintenance waits.",
|
||||
"no_prices_since": "No prices since {at}, maintenance waits.",
|
||||
@@ -498,6 +523,9 @@
|
||||
"estop": "Emergency stop",
|
||||
"pv": "Solar reading",
|
||||
"solar_forecast": "Solar forecast",
|
||||
"charger": "Charger not reachable",
|
||||
"charger_writer": "Something else changes the charger",
|
||||
"charger_setup": "Charger not set up",
|
||||
"none": "Inverter connection"
|
||||
},
|
||||
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"inverters": {"name": "Omvormers en batterijen", "description": "De omvormers die de add-on regelt, en uw zonnepanelen."},
|
||||
"tariff": {"name": "Tarief en prijzen", "description": "Wat u per kWh betaalt en terugkrijgt."},
|
||||
"safety": {"name": "Veiligheidsgrenzen", "description": "De piekgrens, starten en stoppen, en de veiligheidsstops."},
|
||||
"car": {"name": "Laadpaal", "description": "Welke laadpaal de add-on leest, en haar grenzen."},
|
||||
"maintenance": {"name": "Onderhoud", "description": "De maandelijkse batterijcyclus en het logboek."},
|
||||
"tuning": {"name": "Afstelling van de regeling", "description": "Alleen voor support, want deze zijn gemeten op echte hardware."}
|
||||
},
|
||||
@@ -33,6 +34,8 @@
|
||||
"fallback": {"none": "Geen", "fixed": "Eén prijs", "daynight": "Dag en nacht", "custom": "Andere tijdvakken"},
|
||||
"region": {"flanders": "Vlaanderen, Fluvius Antwerpen", "own": "Ik vul zelf een bedrag in"},
|
||||
"meter_source": {"off": "Eén entiteit voor netvermogen", "ha_dsmr": "Entiteiten voor afname en injectie", "mqtt_p1": "Meter via MQTT", "ha_signed": "Eén entiteit voor nettovermogen", "homewizard_local": "HomeWizard-meter op het netwerk"},
|
||||
"ev_charger": {"off": "Geen", "alfen_ha": "Alfen (Home Assistant-integratie)"},
|
||||
"ev_phases": {"1": "1 fase", "3": "3 fasen", "auto": "1 en 3"},
|
||||
"meter_phases": {"1": "1 fase", "3": "3 fasen"},
|
||||
"shadow_mode": {"auto": "Automatisch", "on": "Altijd aan", "off": "Uit"},
|
||||
"price_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand", "price_service": "Uw eigen prijsdienst"},
|
||||
@@ -84,6 +87,7 @@
|
||||
"loading": "De prognose wordt geladen."
|
||||
},
|
||||
"peak": {"name": "Piekgrens", "this_month": "Piek van deze maand", "limit": "Grens", "none": "Nog niets gemeten", "unreadable": "Nu niet leesbaar"},
|
||||
"car": {"name": "Auto", "mode": "Modus", "mode_off": "Uit", "help_off": "De add-on laat de laadpaal ongemoeid.", "why": "Waarom"},
|
||||
"attention": {"name": "Vraagt aandacht", "nothing": "Niets om na te kijken", "to_look": "na te kijken", "details": "Details", "alarm": "Alarm", "warning": "Waarschuwing", "info": "Info"},
|
||||
"control": {"name": "Regeling", "stop": "Regeling stoppen", "start": "Regeling starten", "maint_start": "Nu een batterijcyclus starten", "maint_abort": "Batterijcyclus stoppen", "docs": "Documentatie van de add-on", "more": "Meer acties"},
|
||||
"details": {"name": "Details voor installateurs", "rows": "Statusregels", "checks": "Controles", "event": "Laatste gebeurtenis"},
|
||||
@@ -250,6 +254,26 @@
|
||||
"verdict": {"held": "Op 0 W gehouden", "stopped": "Gestopt", "cycle": "Vandaag maandelijkse cyclus", "trade": "Handelt op prijs", "house": "Volgt het huis"},
|
||||
"pill": {"running": "Actief", "shadow": "Schaduwmodus", "stopped": "Gestopt", "alarm": "Heeft u nu nodig", "waiting": "Wacht op Home Assistant", "warning": "Actief, met een waarschuwing"},
|
||||
"banner_sub": {"running": "De batterijen volgen het plan hieronder.", "shadow": "De plannen hieronder tonen wat de add-on zou doen.", "stopped": "Start de regeling hieronder als u klaar bent.", "alarm": "Zie Vraagt aandacht hieronder voor wat u moet doen.", "waiting": "De regeling gaat vanzelf verder zodra Home Assistant antwoordt.", "warning": "Zie Vraagt aandacht hieronder."},
|
||||
"car": {
|
||||
"state": {"charging": "Laadt", "waiting": "Aangesloten, wacht", "none": "Geen auto aangesloten", "full": "Auto is vol", "unreach": "Laadpaal onbereikbaar", "setup": "Laadpaal niet ingesteld", "fault": "Laadpaal storing", "wait": "Nog geen meting"},
|
||||
"big": {"waiting": "Aangesloten, laadt niet.", "none": "Niets te laden.", "full": "De auto is vol.", "seen": "Laatst gezien om {t}.", "no_reading": "Geen meting.", "setup": "Niet ingesteld.", "fault": "De laadpaal meldt een storing.", "wait": "Nog geen meting."},
|
||||
"phase_one": "1 fase",
|
||||
"phase_many": "{n} fasen",
|
||||
"today": "Vandaag {kwh} geladen.",
|
||||
"why": {
|
||||
"charging": "De laadpaal laadt de auto. De add-on laat haar met rust.",
|
||||
"waiting": "De auto is aangesloten en laadt niet. De add-on laat de laadpaal met rust.",
|
||||
"none": "Er is geen auto aangesloten.",
|
||||
"full": "De auto is zelf gestopt met laden.",
|
||||
"unreach_a": "De laadpaal houdt haar laatste instelling, {a} A. Controleer de {brand}-integratie in Home Assistant.",
|
||||
"unreach": "De add-on kan de laadpaal niet lezen. Controleer de {brand}-integratie in Home Assistant.",
|
||||
"not_found": "De add-on vindt de laadpaal niet. Controleer de entiteit van de laadstroom bij Instellingen.",
|
||||
"limit": "De laadpaalgrens is hoger dan de laadpaal toestaat. Verlaag ze bij Instellingen.",
|
||||
"name": "De entiteit van de laadstroom past niet bij de laadpaal. Controleer ze bij Instellingen.",
|
||||
"fault": "De laadpaal meldt een storing. Controleer ze aan de laadpaal zelf.",
|
||||
"wait": "Wacht op de eerste meting."
|
||||
}
|
||||
},
|
||||
"tile": {"not_set_up": "Niet ingesteld", "no_reading": "Geen meting", "producing": "Produceert", "not_producing": "Produceert niet", "needs_solar": "Zonnemeting nodig", "not_adding_up": "Metingen kloppen niet", "using": "Verbruikt", "taking": "Neemt af van het net", "feeding": "Injecteert", "balanced": "In evenwicht", "giving": "Levert stroom", "charging": "Laadt", "resting": "Rust"},
|
||||
"now": {
|
||||
"no_reading": "Geen meting van deze batterij.",
|
||||
@@ -365,6 +389,7 @@
|
||||
"no_level": "Nu geen handel op prijs: een laadniveau is onbekend."
|
||||
},
|
||||
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
|
||||
"cycle_backoff": "{name}: de maandelijkse cyclus wacht tot {day} {month}.",
|
||||
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
|
||||
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
|
||||
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
|
||||
@@ -419,6 +444,9 @@
|
||||
"estop": "Noodstop",
|
||||
"pv": "Meting van de zonnepanelen",
|
||||
"solar_forecast": "Zonneprognose",
|
||||
"charger": "Laadpaal onbereikbaar",
|
||||
"charger_writer": "Iets anders wijzigt de laadpaal",
|
||||
"charger_setup": "Laadpaal niet ingesteld",
|
||||
"none": "Verbinding met de omvormer"
|
||||
},
|
||||
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
|
||||
|
||||
@@ -77,6 +77,7 @@ _MAINT = (("maintenance_enabled", "==", True),)
|
||||
_ONE = (("inverter_count", "==", 1),)
|
||||
_SEVERAL = (("inverter_count", ">", 1),)
|
||||
_PHASES = (("meter_phases", ">", 1),)
|
||||
_CAR = (("ev_charger", "!=", "off"),)
|
||||
# One inverter stored flat has no name or capacity (uimodel._roster_encode).
|
||||
_LISTED = (("inverter_shape", "!=", "flat"),)
|
||||
|
||||
@@ -222,6 +223,17 @@ GROUPS = (
|
||||
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
|
||||
), collapsed=True),
|
||||
)),
|
||||
# EV-03: the car charger, read only. Everything after the choice shows
|
||||
# only while a charger is switched on.
|
||||
Group("car", (
|
||||
Part("", (
|
||||
Field("ev_charger", CHOICE, simple=True),
|
||||
Field("ev_current_entity", ENTITY, kind="number", simple=True,
|
||||
show=_CAR),
|
||||
Field("ev_max_a", SLIDER, "A", simple=True, show=_CAR),
|
||||
Field("ev_phases", SEGMENTED, simple=True, show=_CAR),
|
||||
)),
|
||||
)),
|
||||
Group("maintenance", (
|
||||
Part("", (
|
||||
Field("maintenance_enabled", TOGGLE),
|
||||
|
||||
@@ -332,7 +332,7 @@ import math
|
||||
import re
|
||||
from typing import NamedTuple
|
||||
|
||||
from . import dayplan, messages, pv, solarfc
|
||||
from . import chargers, 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
|
||||
@@ -667,6 +667,34 @@ def _labels(keys, joiner: str = "and") -> str:
|
||||
return f" {joiner} ".join(S(k) for k in keys)
|
||||
|
||||
|
||||
def _charger_problems(opts: dict, writers: dict) -> list[str]:
|
||||
"""EV-03: the car charger options. A charger whose entities are missing is
|
||||
refused at run time (the Car card says so), not here: it never stops the
|
||||
add-on. What stops it is a setpoint that an inverter also writes."""
|
||||
out: list[str] = []
|
||||
raw = opts.get("ev_charger", chargers.OFF)
|
||||
if raw not in (None, "") and str(raw).strip() not in chargers.SELECTABLE:
|
||||
out.append(f"{S('ev_charger')} is {messages.shown(raw)}. Choose "
|
||||
f"{', '.join(chargers.SELECTABLE)}.")
|
||||
return out
|
||||
if chargers.mode_of(opts) == chargers.OFF:
|
||||
return out
|
||||
ph = opts.get("ev_phases", chargers.EV_OPTION_DEFAULTS["ev_phases"])
|
||||
if str(ph).strip() not in chargers.PHASES_CHOICES:
|
||||
out.append(f"{S('ev_phases')} is {messages.shown(ph)}. Choose 1, 3 or "
|
||||
"auto.")
|
||||
amps = chargers.max_a(opts)
|
||||
if amps is None or not chargers.MIN_A <= amps <= chargers.ENTITY_MAX_A:
|
||||
out.append(f"{S('ev_max_a')} is {messages.shown(opts.get('ev_max_a'))}"
|
||||
f". Set it from {chargers.MIN_A:.0f} to "
|
||||
f"{chargers.ENTITY_MAX_A:.0f} A.")
|
||||
entity = chargers.census_entity(opts)
|
||||
if entity and drivers.census_key(entity) in writers:
|
||||
out.append(f"{S('ev_current_entity')} is {entity}, which an inverter "
|
||||
"also uses. Give the charger its own entity.")
|
||||
return out
|
||||
|
||||
|
||||
def _roster_problems(opts: dict) -> list[str]:
|
||||
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
|
||||
what it says.
|
||||
@@ -956,6 +984,12 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
|
||||
f"{actuator}: {', '.join(who)}. Give each inverter its own "
|
||||
"setpoint entity.")
|
||||
|
||||
# --- EV-03: the car charger's setpoint is a writer too ---------------------
|
||||
# Counted in the census beside the inverters', and kept apart from
|
||||
# `actuator_writers()` so setup-only mode still reads "no inverter". An
|
||||
# inverter's entity named as the charger is two writers on one actuator.
|
||||
problems.extend(_charger_problems(opts, writers))
|
||||
|
||||
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
|
||||
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
|
||||
# roster what one unit may be commanded, so a roster that is nonsense has to
|
||||
@@ -1921,6 +1955,7 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
notes: list[Note] = []
|
||||
if entities is not None:
|
||||
_entity_notes(notes, opts, entities)
|
||||
_charger_notes(notes, opts, entities)
|
||||
cap = _number(opts, "peak_cap_w", None)
|
||||
if month_peak_w is not None and cap is not None and cap < month_peak_w:
|
||||
_advise(notes, "warn", ("peak_cap_w",),
|
||||
@@ -1932,6 +1967,94 @@ def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
return notes
|
||||
|
||||
|
||||
def charger_probe_rows(opts: dict, entities: dict | None) -> dict:
|
||||
"""EV-03: the settings panel's read-only look at the charger. `entities` is
|
||||
Home Assistant's list by id (None: it did not answer). Nothing is written.
|
||||
`{"ok", "found": [[role, entity]], "missing": [[role, entity]], "limit_a",
|
||||
"state"}`; `state` is `off`, `no_name` (the setpoint is not named the way
|
||||
the charger's integration names it), `unread`, `ok` or `missing`."""
|
||||
if chargers.mode_of(opts) == chargers.OFF:
|
||||
return {"ok": False, "found": [], "missing": [], "limit_a": None,
|
||||
"state": "off"}
|
||||
setpoint = str(opts.get("ev_current_entity") or "").strip()
|
||||
if not chargers.entities_for(setpoint):
|
||||
return {"ok": False, "found": [], "missing": [], "limit_a": None,
|
||||
"state": "no_name" if setpoint else "empty"}
|
||||
if entities is None:
|
||||
return {"ok": False, "found": [], "missing": [], "limit_a": None,
|
||||
"state": "unread"}
|
||||
def state_obj(e):
|
||||
row = entities.get(e)
|
||||
return None if row is None else {
|
||||
"state": row.get("state"),
|
||||
"attributes": {"unit_of_measurement": row.get("unit")}}
|
||||
pr = chargers.probe_from(setpoint, state_obj)
|
||||
return {"ok": pr.ok, "found": [list(x) for x in pr.found],
|
||||
"missing": [list(x) for x in pr.missing], "limit_a": pr.station_a,
|
||||
"state": "ok" if pr.ok else "missing"}
|
||||
|
||||
|
||||
# The roles the "Found:" note names, in words (EV-03).
|
||||
_CHARGER_WORDS = (
|
||||
("state", "car state", "autostatus"), ("power", "power", "vermogen"),
|
||||
("energy", "energy", "energie"), ("readback", "read-back", "terugmelding"),
|
||||
("phases_read", "phases", "fasen"),
|
||||
("station", "the charger limit", "de laadpaalgrens"))
|
||||
|
||||
|
||||
def charger_probe_for(setpoint: str, entities: dict | None) -> dict:
|
||||
"""`charger_probe_rows()` for a setpoint entity typed on the page."""
|
||||
return charger_probe_rows({"ev_charger": chargers.ALFEN_HA,
|
||||
"ev_current_entity": setpoint}, entities)
|
||||
|
||||
|
||||
def _charger_notes(notes, opts: dict, entities: dict) -> None:
|
||||
"""EV-03: a note for each charger entity Home Assistant does not have, and
|
||||
one when the limit is above the charger's own. Never a refusal."""
|
||||
look = charger_probe_rows(opts, entities)
|
||||
key = ("ev_current_entity",)
|
||||
if look["state"] == "no_name":
|
||||
_advise(notes, "warn", key,
|
||||
f"{S('ev_current_entity')} must be named like the Alfen "
|
||||
"integration's current setting, so the charger is not read.",
|
||||
nl=f"{SN('ev_current_entity')} moet heten zoals de "
|
||||
"stroominstelling van de Alfen-integratie, dus de laadpaal "
|
||||
"wordt niet gelezen.")
|
||||
return
|
||||
if look["state"] == "empty":
|
||||
_advise(notes, "warn", key,
|
||||
f"{S('ev_current_entity')} is empty, so the charger is not "
|
||||
"read.",
|
||||
nl=f"{SN('ev_current_entity')} is leeg, dus de laadpaal wordt "
|
||||
"niet gelezen.")
|
||||
return
|
||||
if look["state"] not in ("ok", "missing"):
|
||||
return
|
||||
for role, entity in look["missing"]:
|
||||
required = role in chargers.REQUIRED
|
||||
_advise(notes, "warn" if required else "info", key,
|
||||
f"Home Assistant has no entity {entity}, which the charger "
|
||||
f"needs.",
|
||||
nl=f"Home Assistant kent {entity} niet, die de laadpaal "
|
||||
"nodig heeft.")
|
||||
lim = look["limit_a"]
|
||||
if look["ok"]:
|
||||
have = {r for r, _e in look["found"]}
|
||||
words = [(w_en, w_nl) for r, w_en, w_nl in _CHARGER_WORDS if r in have]
|
||||
tail_en = f", {lim:.0f} A" if lim is not None else ""
|
||||
_advise(notes, "ok", key,
|
||||
"Found: " + ", ".join(w[0] for w in words) + tail_en + ".",
|
||||
nl="Gevonden: " + ", ".join(w[1] for w in words) + tail_en
|
||||
+ ".")
|
||||
amps = chargers.max_a(opts)
|
||||
if lim is not None and amps is not None and amps > lim:
|
||||
_advise(notes, "warn", ("ev_max_a",),
|
||||
f"{S('ev_max_a')} is above the {lim:.0f} A the charger "
|
||||
"reports, so lower it.",
|
||||
nl=f"{SN('ev_max_a')} is hoger dan de {lim:.0f} A die de "
|
||||
"laadpaal meldt, dus verlaag ze.")
|
||||
|
||||
|
||||
def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
"""A note for each entity these options read that Home Assistant does not
|
||||
have, or that reads in kW where watts are taken. A missing entity whose
|
||||
|
||||
@@ -51,7 +51,8 @@ from aiohttp import web
|
||||
from . import messages, price, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import priced_hours, region_of
|
||||
from .validate import config_notes, note_text, setup_checks
|
||||
from .validate import (charger_probe_for, config_notes, note_text,
|
||||
setup_checks)
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
||||
problems_by_field, secret_keys)
|
||||
@@ -474,6 +475,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
return web.json_response(await entities.reading(one))
|
||||
return web.json_response(await entities.payload(kind))
|
||||
|
||||
async def config_charger(request):
|
||||
"""EV-03: the settings page's read-only look at the car charger: which
|
||||
entities Home Assistant has and which it lacks. Reads the cached entity
|
||||
list and writes nothing, anywhere."""
|
||||
rows, _age = await entities.get(max_age=5.0)
|
||||
return web.json_response(charger_probe_for(
|
||||
str(request.query.get("entity") or "")[:255], rows))
|
||||
|
||||
app = web.Application()
|
||||
app.add_routes([
|
||||
web.get("/", index),
|
||||
@@ -486,6 +495,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
web.post("/config/save", config_save),
|
||||
web.post("/config/restart", config_restart),
|
||||
web.get("/ha/entities", ha_entities),
|
||||
web.get("/config/charger", config_charger),
|
||||
])
|
||||
return app
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.21.0"
|
||||
version: "0.22.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over its RS485 EMS port by sending it a
|
||||
@@ -872,6 +872,18 @@ options:
|
||||
# be reached by accident.
|
||||
shadow_mode: "auto"
|
||||
|
||||
# ==========================================================================
|
||||
# CAR CHARGER - read only (EV-03)
|
||||
# ==========================================================================
|
||||
# `off` is the shipped value and means NO CHANGE AT ALL: nothing is read,
|
||||
# shown or announced, and the House tile and the house profile are as before.
|
||||
# `alfen_ha` reads the charger through its Home Assistant integration. THIS
|
||||
# VERSION NEVER WRITES TO THE CHARGER; later cards add the modes.
|
||||
ev_charger: "off"
|
||||
ev_current_entity: ""
|
||||
ev_max_a: 16
|
||||
ev_phases: "auto"
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
@@ -1249,6 +1261,17 @@ schema:
|
||||
# long as nobody notices.
|
||||
shadow_mode: list(auto|on|off)
|
||||
|
||||
# ==========================================================================
|
||||
# CAR CHARGER - read only (EV-03)
|
||||
# ==========================================================================
|
||||
# `ev_current_entity` is the charger's current setpoint; the other entities
|
||||
# are found from its name. `ev_max_a` may not exceed the charger's own station
|
||||
# limit (the settings page and the start check read it).
|
||||
ev_charger: list(off|alfen_ha)
|
||||
ev_current_entity: str?
|
||||
ev_max_a: int(6,40)
|
||||
ev_phases: list(1|3|auto)
|
||||
|
||||
# ==========================================================================
|
||||
# ADVANCED - the control law and diagnostics
|
||||
# ==========================================================================
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,257 @@
|
||||
"""Runnable check for MAINT-24. `python3 test_maint24.py`
|
||||
|
||||
A started maintenance charge at N=2 must get the cheap night. On the live site
|
||||
(2026-11-08..11) battery B's grid charge (battery modes) took the night, and
|
||||
A's own charge was switched off or sized at 200-450 W: its pause and its floor
|
||||
compared the peak forecast with `cap - margin`, not with this month's peak
|
||||
that the guard and B's trade aim at (SAFETY-80), and its floor counted its own
|
||||
charge in the forecast (SAFETY-111).
|
||||
|
||||
All on the real Controller through `cycle()` (the lagged N=2 replay, tools/
|
||||
opt20_scen.py): cheap window, house 3000 W, cap 3500 / margin 50, a month peak
|
||||
of 6000 W banked (aim 5950 W), a peak forecast that follows the grid.
|
||||
|
||||
1. A in maintenance CHARGE, B a Cheap hours charger: A charges at the room under
|
||||
the month aim (half of it: the forecast holds A's own charge, SAFETY-111
|
||||
stays on its card), B files no grid trade, B's why is "cycle", every quarter is at or
|
||||
under the month aim.
|
||||
2. Forecast above 3450 W but under the month aim: shaping is not paused and A
|
||||
gives nothing. A real peak (house over the month aim) still pauses it.
|
||||
3. The mirror: B in maintenance CHARGE, A the charger.
|
||||
4. modetrade.decide(): `maint_charging` files no charge, why "cycle"; solar
|
||||
roles stay.
|
||||
5. Unchanged: N=1 with no forecast entity, the SAFETY-78 drain, battery modes
|
||||
off (digests printed on release/1.0 02eac3b).
|
||||
Mutations: DECISIONS/MAINT-24-charge-first.md.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import opt20_scen as sc # noqa: E402
|
||||
from app import modetrade, packmode, tariff # noqa: E402
|
||||
from app.maintenance import DRAIN, MaintResult # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = sc.A, sc.B
|
||||
MONTH_PEAK = 6000.0
|
||||
AIM = MONTH_PEAK - 50.0 # the guard's aim this month
|
||||
DRAIN_R = MaintResult(DRAIN, True, 2000.0, False, [])
|
||||
|
||||
|
||||
def scen(*, a=None, b=None, soc=(49.0, 30.0), house_w=3000.0, peak=MONTH_PEAK,
|
||||
fc=True, n=2, variant="p3_ch_sell", choice=None, hours=0.75):
|
||||
"""`a` / `b`: that pack's forced maintenance result (or None). `peak`: the
|
||||
banked month peak or None. `fc`: a peak forecast entity that follows the
|
||||
grid. Returns (rows, stats, controller)."""
|
||||
rows = []
|
||||
extra = {"cycle_cost_threshold_eur": 0.10}
|
||||
|
||||
def patch(c):
|
||||
for i, res in enumerate((a, b)):
|
||||
if res is not None and i < len(c.maints):
|
||||
c.maints[i].tick = (
|
||||
lambda now, soc_, blocked_by=None, gate_open=True,
|
||||
res=res: res)
|
||||
if peak is not None:
|
||||
orig_peak = c.peak.peak
|
||||
|
||||
def peak_(now):
|
||||
r = orig_peak(now)
|
||||
return tariff.PeakReading(tariff.OBSERVED,
|
||||
getattr(r, "month", ""), peak)
|
||||
c.peak.peak = peak_
|
||||
orig = c.cycle
|
||||
susp = []
|
||||
c._m24_susp = susp
|
||||
|
||||
async def cyc():
|
||||
c.cheap = True
|
||||
if fc and c.grid is not None:
|
||||
c.peak_fc = float(c.grid)
|
||||
await orig()
|
||||
susp.append(bool(c._maint_shape_suspended))
|
||||
c.cycle = cyc
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None \
|
||||
and c.allocation.shares else {}
|
||||
rows.append(dict(
|
||||
now=now, grid=h.grid(), shares=sh,
|
||||
action=getattr(c.arb, "action", None),
|
||||
reason=getattr(c.arb, "reason", None) or "",
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
why=c.mode_tick.why if c.mode_tick else None,
|
||||
p={u: p.p for u, p in h.plants.items()}))
|
||||
|
||||
stats, c = rp.run(variant, [(sc.UTC0.timestamp() - 10, house_w)], sc.WPTS,
|
||||
sc.at(2.0), 3600 * hours, check=cb, lag=sc.LAG, n=n,
|
||||
soc0={"a": soc[0], "b": soc[1]},
|
||||
choice=choice or sc.CH_CH, refuse=(), patch=patch,
|
||||
extra=extra)
|
||||
return rows, stats, c
|
||||
|
||||
|
||||
def mean(rows, u):
|
||||
r = rows[len(rows) // 6:]
|
||||
return sum(x["p"][u] for x in r) / len(r)
|
||||
|
||||
|
||||
def worst_quarter(stats):
|
||||
return max(a[0] / a[1] for a in stats["quarters"].values())
|
||||
|
||||
|
||||
CH = sc.MAINT_CHARGE
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. A in maintenance CHARGE, B a charger in the cheap window")
|
||||
rows, st, c = scen(a=CH)
|
||||
ma, mb = mean(rows, "a"), mean(rows, "b")
|
||||
last = rows[-1] if rows else {}
|
||||
print(f" A {ma:+.0f} W, B {mb:+.0f} W, worst quarter {worst_quarter(st):.0f}"
|
||||
f" W, why {last.get('why')}, roles {last.get('roles')}")
|
||||
check("A charges at (5950 - 3000) / 2 = 1475 W, not 0 or a few hundred W",
|
||||
-1500.0 <= ma <= -1440.0)
|
||||
check("B files no grid trade: no trade action in the run, B near 0 W",
|
||||
all("CHARGE" not in str(r["action"]) for r in rows) and mb > -50.0)
|
||||
check("roles: B does not charge, and the why is 'cycle'",
|
||||
all(r["roles"].get(B) != "charge" for r in rows)
|
||||
and last.get("why") == "cycle")
|
||||
check("every quarter at or under the month aim",
|
||||
worst_quarter(st) <= AIM + 1.0)
|
||||
check("A never gives back while there is no real peak",
|
||||
max(r["p"]["a"] for r in rows) <= 0.0)
|
||||
check("the shaping was never paused", not any(c._m24_susp))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. forecast between 3450 W and the month aim; a real peak")
|
||||
rows2, st2, c2 = scen(a=CH, house_w=3800.0)
|
||||
ma2 = mean(rows2, "a")
|
||||
print(f" house 3800: A {ma2:+.0f} W, worst quarter "
|
||||
f"{worst_quarter(st2):.0f} W")
|
||||
check("forecast 4900 W (over 3450, under 5950): no pause, A charges "
|
||||
"(5950 - 3800) / 2 = 1075 W and gives nothing",
|
||||
not any(c2._m24_susp) and -1100.0 <= ma2 <= -1040.0
|
||||
and max(r["p"]["a"] for r in rows2) <= 0.0
|
||||
and worst_quarter(st2) <= AIM + 1.0)
|
||||
rows3, st3, c3 = scen(a=CH, house_w=6500.0)
|
||||
print(f" house 6500: paused {sum(c3._m24_susp)}/{len(c3._m24_susp)} "
|
||||
f"cycles, A max {max(r['p']['a'] for r in rows3):+.0f} W")
|
||||
check("a real peak (house over the month aim) still pauses the shaping "
|
||||
"(SAFETY-07)", sum(c3._m24_susp) > len(c3._m24_susp) // 2)
|
||||
rows4, st4, c4 = scen(a=CH, house_w=3000.0, peak=None)
|
||||
print(f" no banked peak: A {mean(rows4, 'a'):+.0f} W, worst quarter "
|
||||
f"{worst_quarter(st4):.0f} W")
|
||||
check("no banked peak: the aim is cap - margin (3450 W) as before, so A "
|
||||
"charges (3450 - 3000) / 2 = 225 W, B does not trade, the quarter "
|
||||
"stays under 3450 W",
|
||||
-240.0 <= mean(rows4, "a") <= -210.0 and not any(c4._m24_susp)
|
||||
and all("CHARGE" not in str(r["action"]) for r in rows4)
|
||||
and worst_quarter(st4) <= 3450.0 + 1.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the mirror: B in maintenance CHARGE, A the charger")
|
||||
rowsm, stm, cm = scen(b=CH, soc=(30.0, 49.0))
|
||||
mam, mbm = mean(rowsm, "a"), mean(rowsm, "b")
|
||||
lastm = rowsm[-1] if rowsm else {}
|
||||
print(f" A {mam:+.0f} W, B {mbm:+.0f} W, worst quarter "
|
||||
f"{worst_quarter(stm):.0f} W, why {lastm.get('why')}")
|
||||
check("B charges at 1475 W, A files no grid trade, why 'cycle'",
|
||||
-1500.0 <= mbm <= -1440.0 and mam > -50.0
|
||||
and all("CHARGE" not in str(r["action"]) for r in rowsm)
|
||||
and lastm.get("why") == "cycle"
|
||||
and all(r["roles"].get(A) != "charge" for r in rowsm))
|
||||
check("...every quarter at or under the month aim",
|
||||
worst_quarter(stm) <= AIM + 1.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. modetrade.decide(maint_charging=)")
|
||||
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
|
||||
charge_useful=lambda: True, give_at=lambda n: None,
|
||||
sell=None)
|
||||
|
||||
|
||||
def _dec(packs, **kw):
|
||||
args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1,
|
||||
headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0,
|
||||
house_w=800.0)
|
||||
args.update(kw)
|
||||
return modetrade.decide(_runs, sc.UTC0, packs, **args)
|
||||
|
||||
|
||||
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0,
|
||||
idle=False)
|
||||
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0)
|
||||
_off = _dec([_pa, _pb])
|
||||
_on = _dec([_pa, _pb], maint_charging=True)
|
||||
_dr = _dec([_pa, _pb], draining=True)
|
||||
check("without it B is the charger (as before)",
|
||||
_off.action == modetrade.CHARGE and _off.roles[B] == modetrade.CHARGE)
|
||||
check("with it: no charge, why 'cycle', B keeps a hold role, no ask",
|
||||
_on.action is None and _on.why == "cycle" and _on.ask_w == 0.0
|
||||
and _on.roles[B] == modetrade.HOLD)
|
||||
check("same answer as the SAFETY-78 drain", _on == _dr)
|
||||
_pr = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
|
||||
refused=-1, probing=True)
|
||||
_tp = _dec([_pa, _pr], maint_charging=True)
|
||||
check("a refusal probe beside the maintenance charge goes on: PROBE_W alone "
|
||||
"(OPT-20); beside a drain it does not",
|
||||
_tp.action == modetrade.CHARGE and _tp.ask_w == -modetrade.PROBE_W
|
||||
and _dec([_pa, _pr], draining=True).action is None)
|
||||
check("a probe does not carry a normal charger along",
|
||||
_dec([_pa, _pr, modetrade.Pack("inverter-c", True, packmode.CHEAP_HOURS,
|
||||
False, 2500.0, 30.0, 10.0)],
|
||||
maint_charging=True).ask_w == -modetrade.PROBE_W)
|
||||
check("it files nothing outside a cheap run (why None)",
|
||||
_dec([_pa, _pb], maint_charging=True,
|
||||
).why == "cycle" and modetrade.decide(
|
||||
SimpleNamespace(on=False), sc.UTC0, [_pa, _pb], charge_ok=True,
|
||||
sell_ok=True, trade_why=None, price_now=0.1, headroom_w=3000.0,
|
||||
floor_pct=20.0, ceiling_pct=95.0, house_w=800.0,
|
||||
maint_charging=True).why is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. unchanged: N=1 (no forecast entity), the drain, modes off")
|
||||
d1 = sc.digest(scen(a=CH, n=1, fc=False, peak=None, soc=(49.0, 30.0))[0])
|
||||
dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=False)[0])
|
||||
dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=False)[0])
|
||||
AT_BASE = {
|
||||
"n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4",
|
||||
"drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748",
|
||||
"off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b",
|
||||
}
|
||||
got = {"n1": d1, "drain": dd, "off": dm}
|
||||
for k in AT_BASE:
|
||||
print(f" {k} {'same' if got[k] == AT_BASE[k] else 'DIFFERS'} "
|
||||
f"{got[k][:16]}")
|
||||
check("N=1 with A in maintenance charge and no forecast entity: shares, "
|
||||
"grid, trade, roles and plant powers equal release/1.0 02eac3b",
|
||||
got["n1"] == AT_BASE["n1"])
|
||||
check("the SAFETY-78 drain beside a charger: equal to 02eac3b",
|
||||
got["drain"] == AT_BASE["drain"])
|
||||
check("battery modes off: equal to 02eac3b", got["off"] == AT_BASE["off"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Runnable check for MAINT-25. `python3 test_maint25.py`
|
||||
|
||||
After a failed monthly cycle the add-on waits 7 days (MAINT-13, `backoff_until`).
|
||||
The Home plan ignored that and drew a cycle at the start hour every night of
|
||||
the wait. Through the plan builder (`status()` as in test_home.py), N=1 and N=2:
|
||||
during the back-off no cycle run is drawn and a short line says when it waits
|
||||
until, in English and Dutch; after 7 days the run is drawn again; a cycle
|
||||
completed after the failure ends the back-off.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from app import homeview # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": True, "maintenance_start_hour": 12}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"capacity_kwh": 9.6},
|
||||
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"capacity_kwh": 5.4}])
|
||||
|
||||
|
||||
def make(opts, failed=None, done=None):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.soc, inv.batt, inv.last_write = 50.0, 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = 10.0
|
||||
m = c.maints[0]
|
||||
if failed is not None:
|
||||
m.store.set_time("last_failed", failed)
|
||||
if done is not None:
|
||||
m.store.set_time("last_completed", done)
|
||||
return c
|
||||
|
||||
|
||||
def plan(c, lang=None):
|
||||
s = c.status_payload()
|
||||
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=NOW,
|
||||
**({} if lang is None else {"lang": lang})))
|
||||
p = s["plan"]
|
||||
return [r for r in p["runs"] if r["kind"] == "cycle"], p["lines"]
|
||||
|
||||
|
||||
D = timedelta(days=1)
|
||||
print("1. a cycle failed 1 day ago (N=2: Battery A failed, B never ran)")
|
||||
runs, lines = plan(make(TWO, failed=NOW - D))
|
||||
print(" ", [r["from"] for r in runs], lines)
|
||||
check("the run tonight is B's, not A's (A waits out the back-off)",
|
||||
len(runs) == 1 and not any("Battery A waits its turn" in x for x in lines))
|
||||
check("a plain line says Battery A waits until 3 October",
|
||||
any("Battery A: the monthly cycle waits until 3 October." == x
|
||||
for x in lines))
|
||||
|
||||
print("\n2. N=1")
|
||||
runs1, lines1 = plan(make(ONE, failed=NOW - D))
|
||||
check("no cycle run is drawn, one line says when it waits until",
|
||||
runs1 == [] and any("waits until 3 October" in x for x in lines1))
|
||||
runs1b, lines1b = plan(make(ONE))
|
||||
check("without a failure the run is drawn (non-vacuous)",
|
||||
len(runs1b) == 1 and not any("waits until" in x for x in lines1b))
|
||||
|
||||
print("\n3. after the 7 days, and after a cycle that completed")
|
||||
runs2, lines2 = plan(make(ONE, failed=NOW - timedelta(days=7, hours=1)))
|
||||
check("7 days and an hour after the failure the run is drawn again, no line",
|
||||
len(runs2) == 1 and not any("waits until" in x for x in lines2))
|
||||
runs3, lines3 = plan(make(ONE, failed=NOW - D, done=NOW - D / 2))
|
||||
check("a cycle completed after the failure ends the back-off (the run is "
|
||||
"not drawn only if it is no longer due)",
|
||||
not any("waits until" in x for x in lines3))
|
||||
runs3b, lines3b = plan(make(ONE, failed=NOW - 2 * D, done=NOW - 3 * D))
|
||||
check("a completion BEFORE the failure does not end it",
|
||||
runs3b == [] and any("waits until" in x for x in lines3b))
|
||||
|
||||
print("\n4. Dutch")
|
||||
_, lnl = plan(make(ONE, failed=NOW - D), lang="nl")
|
||||
print(" ", lnl)
|
||||
check("the Dutch line says it plainly",
|
||||
any("de maandelijkse cyclus wacht tot 3 oktober" in x for x in lnl))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,288 @@
|
||||
"""Runnable check for MAINT-28. `python3 test_maint28.py`
|
||||
|
||||
MAINT-24 follow-up. With A in maintenance CHARGE and a real peak (house over
|
||||
the month aim), MAINT-24's pause ended every time the guard idled: the
|
||||
forecast dipped under the aim, the floor charged A, the guard tripped again,
|
||||
and A flipped charge/give every few seconds (rig x10: 468 flips in 45 min,
|
||||
base 02eac3b 11). Now the pause is held (`peakguard.shaping_held()`) until the
|
||||
house is MAINT_RESUME_UNDER_W under the aim.
|
||||
|
||||
All on the real Controller through `cycle()` (tools/opt20_scen.py): cheap
|
||||
window, cap 3500 / margin 50, a month peak of 6000 W banked (aim 5950 W), a
|
||||
peak forecast like the live sensor (quarter average so far, the rest at the
|
||||
present grid), house +-50 W every 5 s.
|
||||
|
||||
1. A real peak (house 3000 W, then 6500 W for 15 min, then 3000 W): once the
|
||||
guard has stepped in, A does not charge and does not flip; every quarter is
|
||||
at or under the aim; A's charge is back within 10 cycles after the load.
|
||||
2. peakguard.shaping_held(): the pure rule, with its edges.
|
||||
3. Unchanged: the MAINT-24 live shape (A 1475 W, no pause), N=1, the SAFETY-78
|
||||
drain and battery modes off (digests of release/1.0 1b5f7c9, as
|
||||
test_maint24.py).
|
||||
Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import opt20_scen as sc # noqa: E402
|
||||
from app import ctl_cycle, peakguard, tariff # noqa: E402
|
||||
from app.maintenance import DRAIN, IDLE, MaintResult # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CH = sc.MAINT_CHARGE
|
||||
MONTH_PEAK = 6000.0
|
||||
AIM = MONTH_PEAK - 50.0
|
||||
DRAIN_R = MaintResult(DRAIN, True, 2000.0, False, [])
|
||||
T0 = sc.at(2.0) # a quarter boundary
|
||||
PRE, LOAD, POST = 120.0, 900.0, 120.0 # s (CI time: short runs)
|
||||
|
||||
|
||||
def stepped_house(base_w, hi_w):
|
||||
"""[(t_utc, W)]: base_w, then hi_w from PRE for LOAD s, +-50 W per 5 s."""
|
||||
rnd = random.Random(28)
|
||||
out = [(sc.UTC0.timestamp() - 10, base_w)]
|
||||
for t in range(0, int(PRE + LOAD + POST), 5):
|
||||
w = hi_w if PRE <= t < PRE + LOAD else base_w
|
||||
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
|
||||
return out
|
||||
|
||||
|
||||
def scen(*, a=None, tick=None, soc=(49.0, 30.0), house=None, house_w=3000.0,
|
||||
peak=MONTH_PEAK, fc="grid", n=2, variant="p3_ch_sell", dur=600.0):
|
||||
"""`a`: A's forced maintenance result. `fc`: "grid" (follows the grid),
|
||||
"quarter" (quarter average so far, the rest at the present grid, like the
|
||||
live sensor) or None (no forecast entity). Returns (rows, stats, c)."""
|
||||
rows = []
|
||||
q = dict(q=None, s=0.0, last=None, start=None)
|
||||
|
||||
def patch(c):
|
||||
if tick is not None:
|
||||
c.maints[0].tick = (
|
||||
lambda now, soc_, blocked_by=None, gate_open=True: tick(now))
|
||||
elif a is not None:
|
||||
c.maints[0].tick = (
|
||||
lambda now, soc_, blocked_by=None, gate_open=True: a)
|
||||
if peak is not None:
|
||||
orig_peak = c.peak.peak
|
||||
|
||||
def peak_(now):
|
||||
r = orig_peak(now)
|
||||
return tariff.PeakReading(tariff.OBSERVED,
|
||||
getattr(r, "month", ""), peak)
|
||||
c.peak.peak = peak_
|
||||
orig = c.cycle
|
||||
susp = []
|
||||
c._m28_susp = susp
|
||||
|
||||
async def cyc():
|
||||
c.cheap = True
|
||||
if fc and c.grid is not None:
|
||||
g = float(c.grid)
|
||||
if fc == "grid":
|
||||
c.peak_fc = g
|
||||
else:
|
||||
# the replay's clock; not main_files.live (it parses
|
||||
# every app file per access: ~0.1 s a cycle).
|
||||
now = ctl_cycle.datetime.now()
|
||||
qi = int(now.timestamp() // 900)
|
||||
if q["q"] != qi:
|
||||
q.update(q=qi, s=0.0, last=now, start=now)
|
||||
el = (now - q["start"]).total_seconds()
|
||||
q["s"] += g * (now - q["last"]).total_seconds()
|
||||
q["last"] = now
|
||||
c.peak_fc = (q["s"] + g * (900.0 - el)) / 900.0
|
||||
await orig()
|
||||
susp.append(bool(c._maint_shape_suspended))
|
||||
c.cycle = cyc
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None \
|
||||
and c.allocation.shares else {}
|
||||
rows.append(dict(
|
||||
now=now, grid=h.grid(), shares=sh,
|
||||
action=getattr(c.arb, "action", None),
|
||||
reason=getattr(c.arb, "reason", None) or "",
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
why=c.mode_tick.why if c.mode_tick else None,
|
||||
p={u: p.p for u, p in h.plants.items()},
|
||||
t=(now - T0).total_seconds(), a=h.plants["a"].p,
|
||||
susp=c._m28_susp[-1], hold=bool(getattr(c, "_maint_peak_hold", False))))
|
||||
|
||||
src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)]
|
||||
stats, c = rp.run(variant, src, sc.WPTS, T0, dur, check=cb, lag=sc.LAG,
|
||||
n=n, soc0={"a": soc[0], "b": soc[1]},
|
||||
choice=sc.CH_CH, refuse=(), patch=patch,
|
||||
extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows, stats, c
|
||||
|
||||
|
||||
def flips(rs):
|
||||
"""Reversals of A between charging and giving (beyond 50 W)."""
|
||||
n, s = 0, 0
|
||||
for r in rs:
|
||||
v = 1 if r["a"] > 50 else -1 if r["a"] < -50 else 0
|
||||
if v and s and v != s:
|
||||
n += 1
|
||||
if v:
|
||||
s = v
|
||||
return n
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a real peak: house 3000 W, 6500 W for 15 min, 3000 W")
|
||||
rows, st, c = scen(a=CH, house=stepped_house(3000.0, 6500.0), fc="quarter",
|
||||
dur=PRE + LOAD + POST)
|
||||
end = PRE + LOAD
|
||||
trip = next((r["t"] for r in rows if r["t"] >= PRE and r["a"] > 50), None)
|
||||
load = [r for r in rows if trip is not None and trip <= r["t"] < end]
|
||||
after = [r for r in rows if r["t"] >= end]
|
||||
back = next((i for i, r in enumerate(after) if r["a"] < -50), None) # cycles
|
||||
print(f" first give at {trip}, flips in the load {flips(load)}, charge s in"
|
||||
f" the load {sum(1 for r in load if r['a'] < -50)}, charge back "
|
||||
f"{back} cycles after it, quarters "
|
||||
f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}")
|
||||
check("the guard stepped in once the projection went over the aim",
|
||||
trip is not None)
|
||||
check("from the first give to the load's end A does not flip",
|
||||
flips(load) == 0)
|
||||
check("...and does not charge at all", not any(r["a"] < -50 for r in load))
|
||||
check("...the pause is on in every one of those cycles",
|
||||
all(r["susp"] for r in load))
|
||||
check("every quarter at or under the month aim",
|
||||
max(a[0] / a[1] for a in st["quarters"].values()) <= AIM + 1.0)
|
||||
check("after the load A's charge is back within 10 cycles (1 s each)",
|
||||
back is not None and back <= 10)
|
||||
tail = [r["a"] for r in after[10:]]
|
||||
check("...and stays on: at least 150 W (half the resume margin), no give",
|
||||
tail and sum(tail) / len(tail) <= -150.0 and max(tail) <= 50.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n1b. the hold does not outlive the charge: a stale True must not "
|
||||
"pause the next charge")
|
||||
# The idle window opens on a quarter boundary, so the new charge meets a
|
||||
# fresh quarter whose projection (house 5700 W) is under the aim.
|
||||
HI_START, HI_END, IDLE_END, B_END = 300.0, 900.0, 1000.0, 1300.0
|
||||
|
||||
|
||||
def _house_b():
|
||||
rnd = random.Random(29)
|
||||
out = [(sc.UTC0.timestamp() - 10, 3000.0)]
|
||||
for t in range(0, int(B_END) + 100, 5):
|
||||
w = 3000.0 if t < HI_START else 6500.0 if t < HI_END else 5700.0
|
||||
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
|
||||
return out
|
||||
|
||||
|
||||
def _tick_b(now):
|
||||
t = (now - T0).total_seconds()
|
||||
return MaintResult(IDLE, False, None, False, []) \
|
||||
if HI_END <= t < IDLE_END else CH
|
||||
|
||||
|
||||
rb, sb, cb_ = scen(tick=_tick_b, house=_house_b(), fc="quarter", dur=B_END)
|
||||
held_before = [r for r in rb if HI_START <= r["t"] < HI_END and r["hold"]]
|
||||
idle_rows = [r for r in rb if HI_END <= r["t"] < IDLE_END]
|
||||
later = [r["a"] for r in rb if IDLE_END + 10.0 <= r["t"] < B_END]
|
||||
print(f" hold on {len(held_before)} cycles before; in the idle window "
|
||||
f"{sum(r['hold'] for r in idle_rows)} of {len(idle_rows)}; A after "
|
||||
f"{sum(later) / len(later):+.0f} W (house 5700 W: 125 W)")
|
||||
check("the hold was on in the peak", len(held_before) > 100)
|
||||
check("it is off in every cycle the charge-only branch is not taken",
|
||||
not any(r["hold"] for r in idle_rows))
|
||||
check("a new maintenance charge at house 5700 W (over the resume line) "
|
||||
"starts at once: (5950 - 5700) / 2 = 125 W, not paused",
|
||||
sum(later) / len(later) <= -60.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. peakguard.shaping_held()")
|
||||
|
||||
|
||||
def held(was, decision, fc, aim, house):
|
||||
return peakguard.shaping_held(
|
||||
peakguard.shaping_suspended(decision, fc, aim), was, fc, aim, house)
|
||||
|
||||
|
||||
U = peakguard.MAINT_RESUME_UNDER_W
|
||||
eng = peakguard.PeakDecision(engaged=True, floor_w=100.0, ceiling_w=6000.0,
|
||||
ceiling_from="x", target_w=AIM)
|
||||
idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0,
|
||||
ceiling_from="x", target_w=AIM)
|
||||
check("the old conjunction still pauses: forecast over the aim; guard engaged",
|
||||
held(False, idle, AIM + 1, AIM, 3000.0)
|
||||
and held(False, eng, None, AIM, 3000.0))
|
||||
check("not held and not suspended: not paused", not held(False, idle, AIM - 1,
|
||||
AIM, 6500.0))
|
||||
check("held: stays paused while the house is over the aim minus the margin",
|
||||
held(True, idle, AIM - 1, AIM, 6500.0)
|
||||
and held(True, idle, AIM - 1, AIM, AIM - U + 1))
|
||||
check("held: resumes when the house is the margin under the aim",
|
||||
not held(True, idle, AIM - 1, AIM, AIM - U)
|
||||
and not held(True, idle, AIM - 1, AIM, 3000.0))
|
||||
check("held, house unread: the forecast stands in; neither read: resumes "
|
||||
"(SAFETY-63)",
|
||||
held(True, idle, AIM - 1, AIM, None)
|
||||
and not held(True, idle, AIM - U, AIM, None)
|
||||
and not held(True, idle, None, AIM, None))
|
||||
check("a guard that is engaged pauses whatever the house reads",
|
||||
held(True, eng, AIM - 1, AIM, 3000.0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. unchanged: the live shape, N=1, the drain, modes off")
|
||||
rl, sl, cl = scen(a=CH)
|
||||
body = rl[len(rl) // 6:]
|
||||
mean_a = sum(r["a"] for r in body) / len(body)
|
||||
print(f" A {mean_a:+.0f} W, paused {sum(cl._m28_susp)} cycles")
|
||||
check("MAINT-24 live shape: A charges 1475 W and the pause never comes",
|
||||
-1500.0 <= mean_a <= -1440.0 and not any(cl._m28_susp))
|
||||
d1 = sc.digest(scen(a=CH, n=1, fc=None, peak=None)[0])
|
||||
dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=None)[0])
|
||||
dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=None)[0])
|
||||
d2n = sc.digest(scen(a=CH, house_w=5000.0, peak=None, fc="grid")[0])
|
||||
d1p = sc.digest(scen(a=CH, n=1, fc=None, house_w=6500.0)[0])
|
||||
AT_BASE = {
|
||||
"n2_nobank": "f9053833891794ead8b3f44db4030ded8c4b43681e25e8a19882105d3593f20d",
|
||||
"n1_peak": "3e451c536bea8270469fffe578c5a7199431e0e7ba4902413406b0afe2bb0fd1",
|
||||
"n1": "02ecc3011c559df050ae5321ceb6b24cf79342ae35f1a289fbd1a749c86f1d10",
|
||||
"drain": "bfcf9a643dfffff21d0a8417426d2088377a8f64afbc544732b1635a77775d8a",
|
||||
"off": "6f4ec14578d8ed59fc7721485d037f3c1fb086abae7c52f28c9e9155cf2107b0",
|
||||
}
|
||||
got = {"n2_nobank": d2n, "n1_peak": d1p, "n1": d1, "drain": dd, "off": dm}
|
||||
for k in AT_BASE:
|
||||
print(f" {k} {'same' if got[k] == AT_BASE[k] else 'DIFFERS'} "
|
||||
f"{got[k]}")
|
||||
check("N=1 with A in maintenance charge and no forecast entity: equal to "
|
||||
"release/1.0 1b5f7c9", got["n1"] == AT_BASE["n1"])
|
||||
check("N=1 in a real peak above a banked month peak (house 6500 W, the guard steps in): equal "
|
||||
"to 1b5f7c9, the hold is for N=2 and up", got["n1_peak"] == AT_BASE["n1_peak"])
|
||||
check("N=2 in a real peak with no banked peak (house 5000 W over the aim "
|
||||
"3450 W): equal to 1b5f7c9, the hold is for a month aim above the cap",
|
||||
got["n2_nobank"] == AT_BASE["n2_nobank"])
|
||||
check("the SAFETY-78 drain beside a charger: equal to 1b5f7c9",
|
||||
got["drain"] == AT_BASE["drain"])
|
||||
check("battery modes off: equal to 1b5f7c9", got["off"] == AT_BASE["off"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Runnable check for MAINT-28 fix-forward. `python3 test_maint28b.py`
|
||||
|
||||
The rig failed MAINT-28 (TEST-23, f3f9483): the hold of the maintenance pause
|
||||
ended at almost every guard step (F-1), and while it stuck, the pack in
|
||||
maintenance CHARGE gave its full rating for the house below its peak reserve
|
||||
(F-2). test_maint28.py missed both because its battery reading has no lag. Here
|
||||
the battery register is published every 10 s and held (`Lag.batt_period_s`),
|
||||
the meter reading is 2 s old, as on the rig; the rating is the rig's A (5 kW).
|
||||
|
||||
All on the real Controller through `cycle()` (tools/opt20_scen.py): cheap
|
||||
window, cap 3500 / margin 50, a month peak of 6000 W banked (aim 5950 W), a
|
||||
peak forecast like the live sensor, house +-50 W every 5 s, 3000 W then
|
||||
6500 W for 13 min (over a quarter boundary), then 3000 W.
|
||||
|
||||
1. A real peak, A in CHARGE above its peak reserve: A flips charge/give a few
|
||||
times (base 1b5f7c9: dozens), no release of the pause on an estimate that
|
||||
mixes a new grid with an old battery reading, quarters at or under the aim,
|
||||
A's charge back after the load.
|
||||
2. F-2 and the cap (owner 2026-11-13): while the pause holds, A gives at most
|
||||
the largest part of the guard's floor placed on it in the 15-min period,
|
||||
at any SoC (never under its reserve; SoC drop within the owner-accepted
|
||||
give); with both packs able, never over the period's largest floor, and
|
||||
when B is placed part of it A's limit is its own part, not the whole floor;
|
||||
one unit's limit is its own (two packs in CHARGE never share it); with the
|
||||
other pack under its reserve, A still gives for the guard.
|
||||
3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing
|
||||
for the house.
|
||||
4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the
|
||||
same lag on.
|
||||
5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`.
|
||||
6. H-1 (owner 2026-11-14): while the pause holds the batteries together give
|
||||
only what the guard needs. The live try (A 11 %, B 25 %, 90 min at 6.5 kW)
|
||||
and, for the F-1 wiring, the 45-min rig shape.
|
||||
Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
import opt20_scen as sc # noqa: E402
|
||||
from app import (ctl_cycle, ctl_trade, freshness, maintenance, # noqa: E402
|
||||
packmode, peakguard, tariff)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
rp.RATING, rp.CAP_KWH = 5000.0, 15.36 # the rig's A
|
||||
LAG = rp.Lag(dead_s=1.0, meter_s=2.0, batt_s=1.0, p1_s=0.35, tick=1.0,
|
||||
tau_s=2.0, ramp_w_s=1e9, batt_period_s=10.0)
|
||||
CH = sc.MAINT_CHARGE
|
||||
MONTH_PEAK = 6000.0
|
||||
AIM = MONTH_PEAK - 50.0
|
||||
LINE = AIM - getattr(peakguard, "MAINT_RESUME_UNDER_W", 300.0)
|
||||
T0 = sc.at(2.0) # a quarter boundary
|
||||
PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs)
|
||||
RESERVE = 20.0 # peak_soc_floor default
|
||||
|
||||
|
||||
def stepped_house(base_w, hi_w, load=LOAD, pre=PRE):
|
||||
rnd = random.Random(28)
|
||||
out = [(sc.UTC0.timestamp() - 10, base_w)]
|
||||
for t in range(0, int(pre + load + POST), 5):
|
||||
w = hi_w if pre <= t < pre + load else base_w
|
||||
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
|
||||
return out
|
||||
|
||||
|
||||
def scen(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0,
|
||||
peak=MONTH_PEAK, dur=PRE + LOAD + POST, fc="quarter", n=2,
|
||||
choice=None):
|
||||
"""`unit` is in maintenance CHARGE (forced result), at `soc`, the other at
|
||||
`other`. Rows: the plants' power and SoC, the pause flag, the add-on's house
|
||||
estimate and the real house."""
|
||||
rows = []
|
||||
q = dict(q=None, s=0.0, last=None, start=None)
|
||||
idx = 0 if unit == "a" else 1
|
||||
o = "b" if unit == "a" else "a"
|
||||
|
||||
def patch(c):
|
||||
c.maints[idx].tick = (
|
||||
lambda now, soc_, blocked_by=None, gate_open=True: CH)
|
||||
if peak is not None:
|
||||
orig_peak = c.peak.peak
|
||||
|
||||
def peak_(now):
|
||||
r = orig_peak(now)
|
||||
return tariff.PeakReading(tariff.OBSERVED,
|
||||
getattr(r, "month", ""), peak)
|
||||
c.peak.peak = peak_
|
||||
orig = c.cycle
|
||||
|
||||
async def cyc():
|
||||
c.cheap = True
|
||||
if fc and c.grid is not None:
|
||||
g = float(c.grid)
|
||||
now = ctl_cycle.datetime.now() # the replay's clock
|
||||
qi = int(now.timestamp() // 900)
|
||||
if q["q"] != qi:
|
||||
q.update(q=qi, s=0.0, last=now, start=now)
|
||||
el = (now - q["start"]).total_seconds()
|
||||
q["s"] += g * (now - q["last"]).total_seconds()
|
||||
q["last"] = now
|
||||
c.peak_fc = (q["s"] + g * (900.0 - el)) / 900.0
|
||||
await orig()
|
||||
c.cycle = cyc
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
est = (None if c.grid is None or c.site_batt_w is None
|
||||
else float(c.grid) + float(c.site_batt_w))
|
||||
g = c.peak_guard
|
||||
floor = (float(g.floor_w) if g is not None and g.engaged
|
||||
and g.floor_w is not None else 0.0)
|
||||
sh = (dict(c.allocation.shares) if c.allocation is not None
|
||||
and c.allocation.shares else {})
|
||||
au = c.actuator_of("maintenance", c.inverters[idx])
|
||||
ao = c.actuator_of("maintenance", c.inverters[1 - idx])
|
||||
rows.append(dict(
|
||||
t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p,
|
||||
o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc,
|
||||
hold=bool(getattr(c, "_maint_peak_hold", False)), est=est,
|
||||
floor=floor, share_u=float(sh.get(au, 0.0)),
|
||||
hi_u=next((float(pl.hi) for pl in (c.allocation.plans
|
||||
if c.allocation else ())
|
||||
if pl.unit == au), None),
|
||||
share_o=float(sh.get(ao, 0.0)),
|
||||
house=h.grid() + sum(p.p for p in h.plants.values())))
|
||||
|
||||
src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)]
|
||||
ch = dict(sc.CH_CH if choice is None else choice)
|
||||
stats, c = rp.run("p3_ch_sell", src, sc.WPTS, T0, dur, check=cb, lag=LAG,
|
||||
n=n, soc0={unit: soc, o: other}, choice=ch,
|
||||
refuse=(), patch=patch,
|
||||
extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows, stats, c
|
||||
|
||||
|
||||
def flips(rs, k="u"):
|
||||
n, s = 0, 0
|
||||
for r in rs:
|
||||
v = 1 if r[k] > 50 else -1 if r[k] < -50 else 0
|
||||
if v and s and v != s:
|
||||
n += 1
|
||||
if v:
|
||||
s = v
|
||||
return n
|
||||
|
||||
|
||||
def releases(rows):
|
||||
"""Cycles in the load where the pause ended: (row, estimate under the
|
||||
release line while the real house was over it)."""
|
||||
out = []
|
||||
for p, r in zip(rows, rows[1:]):
|
||||
if PRE <= r["t"] < PRE + LOAD and p["hold"] and not r["hold"]:
|
||||
out.append((r, r["est"] is not None and r["est"] < LINE
|
||||
and r["house"] >= LINE))
|
||||
return out
|
||||
|
||||
|
||||
def quarters_ok(st):
|
||||
# SAFETY-126: a landing 0-7 W over the aim is not an overrun.
|
||||
return (max(a[0] / a[1] for a in st["quarters"].values())
|
||||
<= AIM + peakguard.ALARM_MISS_W)
|
||||
|
||||
|
||||
def show(tag, rows, st):
|
||||
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
||||
rel = releases(rows)
|
||||
print(f" {tag}: flips {flips(load)}, charge s "
|
||||
f"{sum(1 for r in load if r['u'] < -50)}, releases {len(rel)} "
|
||||
f"(stale {sum(1 for _r, s in rel if s)}), lowest SoC "
|
||||
f"{min(r['soc'] for r in rows):.1f} %, give kWh "
|
||||
f"{sum(r['u'] for r in load if r['u'] > 0) / 3.6e6:.2f}, quarters "
|
||||
f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}")
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a real peak, A in CHARGE at 49 % (above its reserve), the battery "
|
||||
"reading lagging")
|
||||
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
|
||||
show("A 49 %", rows, st)
|
||||
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
||||
rel = releases(rows)
|
||||
after = [r for r in rows if r["t"] >= PRE + LOAD]
|
||||
back = next((i for i, r in enumerate(after) if r["u"] < -50), None)
|
||||
print(f" A's charge back {back} s after the load")
|
||||
check("A flips charge/give 3 times or fewer in the 13 min (1b5f7c9: 60, "
|
||||
"f3f9483: 11)", flips(load) <= 3)
|
||||
check("the pause never ends on an estimate under the release line while the "
|
||||
"real house is over it", not any(s for _r, s in rel))
|
||||
check("every quarter at or under the month aim",
|
||||
quarters_ok(st))
|
||||
check("after the load A's charge is back within 20 cycles (1 s each: a 10 s "
|
||||
"register period plus its settle time, 16 s)",
|
||||
back is not None and back <= 20)
|
||||
|
||||
# M-1 (review 2026-11-14): the F-1 wiring, `_batts_settled()` read at the hold
|
||||
# in ctl_cycle.py. With `settled=True` there the 13-min load above still passes;
|
||||
# the rig shape (the load starts 10 min into the quarter, 45 min long) does not.
|
||||
RIG_PRE, RIG_LOAD = 600.0, 2700.0
|
||||
rows, st, c = scen(house=stepped_house(3000.0, 6500.0, RIG_LOAD, RIG_PRE),
|
||||
dur=RIG_PRE + RIG_LOAD + POST)
|
||||
load45 = [r for r in rows if RIG_PRE <= r["t"] < RIG_PRE + RIG_LOAD]
|
||||
rel45 = [(r, s_) for p, r in zip(rows, rows[1:])
|
||||
if RIG_PRE <= r["t"] < RIG_PRE + RIG_LOAD and p["hold"]
|
||||
and not r["hold"]
|
||||
for s_ in [r["est"] is not None and r["est"] < LINE
|
||||
and r["house"] >= LINE]]
|
||||
print(f" A 49 %, 45 min: flips {flips(load45)}, releases {len(rel45)} "
|
||||
f"(stale {sum(1 for _r, s_ in rel45 if s_)}), quarters "
|
||||
f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}")
|
||||
check("45 min: A flips charge/give 3 times or fewer (f3f9483: 148)",
|
||||
flips(load45) <= 3)
|
||||
check("45 min: no release of the pause on a stale estimate (f3f9483: 83 of 83)",
|
||||
not any(s_ for _r, s_ in rel45))
|
||||
check("45 min: every quarter at or under the month aim", quarters_ok(st))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. the cap: while the pause holds, A gives at most the largest part "
|
||||
"of the guard's floor placed on it this period")
|
||||
rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0))
|
||||
show("A 21 %", rows, st)
|
||||
check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)",
|
||||
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.15)
|
||||
check("A never goes under its peak reserve in the peak (rig: to 10 %)",
|
||||
min(r["soc"] for r in rows) >= RESERVE - 0.5)
|
||||
check("every quarter at or under the month aim", quarters_ok(st))
|
||||
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
|
||||
soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0)
|
||||
drop = soc_start - min(r["soc"] for r in rows if r["t"] >= PRE)
|
||||
print(f" A at 49 %: SoC drop {drop:.2f} point")
|
||||
# Owner 2026-11-13 accepted about 0.37 kWh per 45-min peak with the cap
|
||||
# (DECISIONS): 0.37 * 13 / 45 = 0.107 kWh in this load, 0.70 point of
|
||||
# 15.36 kWh. Measured 0.64.
|
||||
check("A at 49 %: its SoC drops no more than the owner-accepted give allows "
|
||||
"(0.70 point in the 13 min; f3f9483 6.1)", drop <= 0.70)
|
||||
rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0))
|
||||
show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st)
|
||||
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
|
||||
check("the guard's own claim stands: with B under its reserve A gives for "
|
||||
"the peak (over 0.2 kWh; the cap yields to the guard)",
|
||||
sum(r["u"] for r in load if r["u"] > 0) / 3.6e6 > 0.2)
|
||||
g = [("give" if r["u"] > 50 else "idle") for r in load]
|
||||
sw = sum(1 for x, y in zip(g, g[1:]) if x != y)
|
||||
print(f" give/idle switches of A in the load: {sw}")
|
||||
check("...give/idle switches of A at most 10 in the 13 min", sw <= 10)
|
||||
|
||||
def period_max(rows, f):
|
||||
"""Per row: the largest f(row) over the paused rows of its 15-minute
|
||||
period so far (0 W outside the pause), as the owner rule reads."""
|
||||
out, q, m = [], None, 0.0
|
||||
for r in rows:
|
||||
qi = int((T0.timestamp() + r["t"]) // 900)
|
||||
if qi != q or not r["hold"]:
|
||||
q, m = qi, 0.0
|
||||
if r["hold"]:
|
||||
m = max(m, f(r))
|
||||
out.append(m)
|
||||
return out
|
||||
|
||||
|
||||
for tag, ch, hi_w in (("B on Cheap hours", None, 9000.0),
|
||||
("B on Solar first (it gives for the house too)",
|
||||
{"a": sc.CH_CH["a"],
|
||||
"b": (True, packmode.SOLAR_FIRST, False)},
|
||||
12000.0)):
|
||||
rows, st, c = scen(soc=49.0, other=40.0, choice=ch,
|
||||
house=stepped_house(3000.0, hi_w))
|
||||
show(f"A 49 %, B 40 %, both able, {tag}, house {hi_w:.0f} W", rows, st)
|
||||
mf = period_max(rows, lambda r: r["floor"])
|
||||
held_rows = [(r, m) for r, m in zip(rows, mf)
|
||||
if r["hold"] and r["floor"] > 0.0 and r["hi_u"] is not None]
|
||||
n = max(1, len(held_rows))
|
||||
over = sum(1 for r, m in held_rows if r["share_u"] > m + 2.0)
|
||||
# the whole floor, as far as A's rating lets it be A's limit
|
||||
under = sum(1 for r, m in held_rows
|
||||
if r["hi_u"] < min(m, rp.RATING) - 500.0)
|
||||
print(f" {len(held_rows)} paused cycles with a guard floor (mean "
|
||||
f"{sum(r['floor'] for r, _m in held_rows) / n:.0f} W, period's "
|
||||
f"largest {sum(m for _r, m in held_rows) / n:.0f} W): A's limit "
|
||||
f"{sum(r['hi_u'] for r, _m in held_rows) / n:.0f} W, A "
|
||||
f"{sum(r['share_u'] for r, _m in held_rows) / n:.0f} W, B "
|
||||
f"{sum(r['share_o'] for r, _m in held_rows) / n:.0f} W; A over the "
|
||||
f"period's largest floor in {over}; A's limit 500 W or more under it"
|
||||
f" (or A's rating) in {under}")
|
||||
check(f"{tag}: the pause holds with a guard floor for over 5 min",
|
||||
len(held_rows) >= 300)
|
||||
check("...A never gives more than the largest guard floor of the period "
|
||||
"(owner 2026-11-13)", over == 0)
|
||||
if ch is not None:
|
||||
check("...B is placed part of the floor, so A's limit is its own part,"
|
||||
" 500 W or more under the period's largest floor (or A's "
|
||||
"rating) in 9 of 10 cycles (not the whole floor)",
|
||||
under >= 0.9 * len(held_rows))
|
||||
check("...every quarter at or under the month aim", quarters_ok(st))
|
||||
|
||||
rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0))
|
||||
show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st)
|
||||
check("the guard places its claim on the other pack: A under its reserve "
|
||||
"gives under 0.05 kWh and its SoC stays at 18 %",
|
||||
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.05
|
||||
and min(r["soc"] for r in rows) >= 18.0)
|
||||
|
||||
# H-1 (review 2026-11-14, owner decision): while the pause holds, the packs
|
||||
# together give only what the guard needs. The live try: A 11 % in CHARGE (under
|
||||
# its reserve), B 25 % on Cheap hours, 90 min at 6.5 kW.
|
||||
LIVE = 5400.0
|
||||
rp.CAP_KWH = 20.48 # the rig's B; A gives nothing here
|
||||
rows, st, c = scen(soc=11.0, other=25.0,
|
||||
house=stepped_house(3000.0, 6500.0, LIVE, RIG_PRE),
|
||||
dur=RIG_PRE + LIVE + POST)
|
||||
rp.CAP_KWH = 15.36
|
||||
qs = [a[0] / a[1] for a in st["quarters"].values()]
|
||||
load = [r for r in rows if RIG_PRE <= r["t"] < RIG_PRE + LIVE]
|
||||
gb = sum(r["o"] for r in load if r["o"] > 0) / 3.6e6
|
||||
|
||||
|
||||
def switches(k):
|
||||
g = [("give" if r[k] > 50 else "idle") for r in load]
|
||||
return sum(1 for x, y in zip(g, g[1:]) if x != y)
|
||||
|
||||
|
||||
b_low = min((r["soc_o"] for r in rows), default=0.0)
|
||||
print(f" live try: quarters {[round(q) for q in qs]}, B gives {gb:.2f} kWh, "
|
||||
f"B lowest {b_low:.1f} %, give/idle switches A "
|
||||
f"{switches('u')}, B {switches('o')}")
|
||||
check("live try: every quarter at or under the aim + 10 W (07d23d2: 6502 and "
|
||||
"6499 W against the month peak 6000 W)", max(qs) <= AIM + 10.0)
|
||||
check("live try: B gives about its guard part, under 1.5 kWh (1b5f7c9 1.0; "
|
||||
"07d23d2 4.6 kW, 25 -> 10 % in 43 min)", gb < 1.5)
|
||||
check("live try: B's SoC never goes under 19.8 % (1b5f7c9 19.8)",
|
||||
b_low >= 19.8)
|
||||
check("live try: give/idle switches of B at most 40 in the 90 min",
|
||||
switches("o") <= 40)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours")
|
||||
rows, st, c = scen(unit="b", soc=18.2, other=30.0,
|
||||
house=stepped_house(3000.0, 6500.0))
|
||||
show("B 18 %", rows, st)
|
||||
check("B gives nothing for the house: its SoC never goes under 18 %",
|
||||
min(r["soc"] for r in rows) >= 18.0)
|
||||
check("every quarter at or under the month aim", quarters_ok(st))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. unchanged: the MAINT-24 live shape with the lagging reading")
|
||||
rl, sl, cl = scen(house_w=3000.0, dur=600.0)
|
||||
body = rl[len(rl) // 6:]
|
||||
mean_a = sum(r["u"] for r in body) / len(body)
|
||||
mean_b = sum(r["o"] for r in body) / len(body)
|
||||
print(f" A {mean_a:+.0f} W, B {mean_b:+.0f} W, pause "
|
||||
f"{sum(r['hold'] for r in rl)} cycles")
|
||||
check("A charges about 1475 W (-1500..-1440) and the pause never comes",
|
||||
-1500.0 <= mean_a <= -1440.0 and not any(r["hold"] for r in rl))
|
||||
check("B does not trade with the grid (Cheap hours, nothing to charge it)",
|
||||
abs(mean_b) < 100.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. peakguard.shaping_held() with `settled`; Controller._batts_settled()")
|
||||
check("the rule exists (MAINT_STEP_W, _batts_settled)",
|
||||
hasattr(ctl_trade, "MAINT_STEP_W") and hasattr(ctl_trade.TradeMixin,
|
||||
"_batts_settled"))
|
||||
if not hasattr(ctl_trade, "MAINT_STEP_W"):
|
||||
print(f"\n{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
U = peakguard.MAINT_RESUME_UNDER_W
|
||||
idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0,
|
||||
ceiling_from="x", target_w=AIM)
|
||||
|
||||
|
||||
def held(was, fc, house, settled):
|
||||
return peakguard.shaping_held(
|
||||
peakguard.shaping_suspended(idle, fc, AIM), was, fc, AIM, house,
|
||||
settled)
|
||||
|
||||
|
||||
check("held, house under the line but not settled: stays paused",
|
||||
held(True, AIM - 1, AIM - U - 100.0, False))
|
||||
check("held, house under the line and settled: resumes",
|
||||
not held(True, AIM - 1, AIM - U - 100.0, True))
|
||||
check("not held: settled makes no difference",
|
||||
not held(False, AIM - 1, 6500.0, False))
|
||||
check("neither the house nor the forecast read: no pause, settled or not "
|
||||
"(SAFETY-63)",
|
||||
not held(True, None, None, False))
|
||||
|
||||
|
||||
class _Fresh:
|
||||
def __init__(self, changed):
|
||||
self.changed_mono = changed
|
||||
|
||||
|
||||
class _Inv:
|
||||
def __init__(self, name, cmd, changed):
|
||||
self.name, self.cmd = name, cmd
|
||||
self.batt_fresh = _Fresh(changed)
|
||||
|
||||
def standing_w(self):
|
||||
return self.cmd
|
||||
|
||||
|
||||
class _Ctl:
|
||||
_batts_settled = getattr(ctl_trade.TradeMixin, "_batts_settled", None)
|
||||
|
||||
def __init__(self, invs):
|
||||
self.inverters, self._m28_step, self._now_mono = invs, {}, 0.0
|
||||
|
||||
|
||||
S = freshness.PLANT_SETTLE_S
|
||||
a = _Inv("a", -1500.0, 0.0)
|
||||
k = _Ctl([a, _Inv("b", 0.0, 0.0)])
|
||||
check("no step seen yet: settled", k._batts_settled())
|
||||
a.cmd = 500.0
|
||||
k._now_mono = 100.0
|
||||
check("a step up of the command: not settled", not k._batts_settled())
|
||||
k._now_mono = 100.0 + S - 0.5
|
||||
a.batt_fresh.changed_mono = 100.0 + S - 1.0
|
||||
check("...a reading that arrived before step + settle does not count",
|
||||
not k._batts_settled())
|
||||
a.batt_fresh.changed_mono = 100.0 + S
|
||||
k._now_mono = 100.0 + S
|
||||
check("...one that arrived at step + settle does", k._batts_settled())
|
||||
a.cmd = 3000.0
|
||||
k._now_mono = 200.0
|
||||
check("a second step up; the old reading is not current",
|
||||
not k._batts_settled())
|
||||
k._now_mono = 200.0 + freshness.BATT_OBLIGED_S
|
||||
check("...and a reading that never changes is waited for no longer than "
|
||||
"BATT_OBLIGED_S", k._batts_settled())
|
||||
a.cmd = -1500.0
|
||||
k._now_mono = 400.0
|
||||
check("a step down (less give: the estimate errs high) is no step",
|
||||
k._batts_settled())
|
||||
a2 = _Inv("a", -1500.0, 0.0)
|
||||
k2 = _Ctl([a2])
|
||||
k2._batts_settled()
|
||||
a2.cmd, k2._now_mono = -1500.0 + ctl_trade.MAINT_STEP_W - 1.0, 1.0
|
||||
check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled())
|
||||
a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W
|
||||
check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled())
|
||||
|
||||
# Per unit: the cap so far lives under each unit's own key, and only one pack
|
||||
# can be in maintenance CHARGE at all (maintenance.arbitrate(): one slot).
|
||||
au, ab = (cl.actuator_of("maintenance", i) for i in cl.inverters)
|
||||
t = T0 + timedelta(seconds=60)
|
||||
cl._m28_hi = {au: (int(t.timestamp() // 900), 3000.0)}
|
||||
check("the period's cap is kept per unit: A's 3000 W is not B's",
|
||||
cl._m28_was(au, t) == 3000.0 and cl._m28_was(ab, t) == 0.0)
|
||||
check("...and it starts again from 0 W in the next 15-minute period",
|
||||
cl._m28_was(au, t + timedelta(seconds=900)) == 0.0)
|
||||
|
||||
|
||||
class _M:
|
||||
def __init__(self, name):
|
||||
self.name, self.phase = name, maintenance.CHARGE
|
||||
|
||||
|
||||
check("maintenance.arbitrate(): with two packs in CHARGE only the first keeps "
|
||||
"the slot", maintenance.arbitrate([_M("a"), _M("b")], t)[1] != ""
|
||||
and maintenance.arbitrate([_M("a"), _M("b")], t)[0] == "")
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -472,15 +472,36 @@ _sw = [(UTC0.timestamp() + 60 * k, 2000.0 if k % 2 else 3900.0)
|
||||
ROWS_W, _, _ = run_day("p3_ch_sell", hours=1.5, house=_sw,
|
||||
soc0={"a": 60.0, "b": 30.0})
|
||||
_per_q = {}
|
||||
_ev_q = {} # SAFETY-135: per period, the charge's starts and stops
|
||||
for a_, b_ in zip(ROWS_W, ROWS_W[1:]):
|
||||
if a_["action"] != arbitrage.CHARGE and b_["action"] == arbitrage.CHARGE:
|
||||
on_a, on_b = (a_["action"] == arbitrage.CHARGE,
|
||||
b_["action"] == arbitrage.CHARGE)
|
||||
if on_a != on_b:
|
||||
q = int(b_["now"].timestamp() // 900)
|
||||
_ev_q.setdefault(q, []).append((on_b, b_["now"].timestamp()))
|
||||
if not on_a and on_b:
|
||||
q = int(b_["now"].timestamp() // 900)
|
||||
_per_q[q] = _per_q.get(q, 0) + 1
|
||||
|
||||
|
||||
def _one_restart(ev):
|
||||
"""At most one start after a stop in the period, RESTART_S or more after
|
||||
it; a second stop is the last change of the period."""
|
||||
re = [(t0, t1) for (s0, t0), (s1, t1) in zip(ev, ev[1:])
|
||||
if not s0 and s1]
|
||||
stops, late = 0, False
|
||||
for on, _t in ev:
|
||||
stops += 0 if on else 1
|
||||
late = late or (on and stops >= 2)
|
||||
return (len(re) <= 1 and not late
|
||||
and all(t1 - t0 >= modetrade.RESTART_S - 1 for t0, t1 in re))
|
||||
print(f" house swinging 2.0/3.9 kW each minute: charge starts per period "
|
||||
f"{sorted(_per_q.values())}")
|
||||
check("a charge the peak guard or the headroom stopped stays off for the "
|
||||
"rest of that 15-minute period (at most one start per period)",
|
||||
_per_q and max(_per_q.values()) == 1)
|
||||
"rest of that 15-minute period, after at most one restart RESTART_S or "
|
||||
"more after the first stop; a second stop latches (SAFETY-135)",
|
||||
_per_q and max(_per_q.values()) <= 2
|
||||
and all(_one_restart(ev) for ev in _ev_q.values()))
|
||||
|
||||
# At the floor: B starts the sale just above it.
|
||||
ROWS_F, _, _ = run_day("p3_ch_sell", start_h=18.75, hours=1.5,
|
||||
|
||||
@@ -65,11 +65,16 @@ print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
||||
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
|
||||
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
|
||||
# (no charge) never moved.
|
||||
# SAFETY-135: n1_ch re-pinned. A first guard stop no longer latches, so while
|
||||
# the charge waits its reason reads "(peak)" where the latch said
|
||||
# "(headroom)"; no command moves. With SAFETY-135 off it is OPT-15's pin
|
||||
# again (checked below).
|
||||
N1_CH_OPT15 = "97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02"
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
@@ -81,7 +86,7 @@ BASE_DIGESTS = {
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
@@ -98,6 +103,38 @@ for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
|
||||
got.get(k) == v)
|
||||
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
||||
|
||||
|
||||
class _Every:
|
||||
"""SAFETY-135 off: a stop period equal to every period, so every guard
|
||||
stop latches as before."""
|
||||
|
||||
def __eq__(self, other):
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
def _s135_off_digest(scn):
|
||||
from app import main as _m
|
||||
real = _m.Controller.__init__
|
||||
|
||||
def init(self, *a, **k):
|
||||
real(self, *a, **k)
|
||||
self._charge_stop = (_Every(), float("-inf"))
|
||||
young, restart = modetrade.YOUNG_S, modetrade.RESTART_S
|
||||
_m.Controller.__init__ = init
|
||||
modetrade.YOUNG_S = modetrade.RESTART_S = 0.0
|
||||
try:
|
||||
return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0])
|
||||
finally:
|
||||
_m.Controller.__init__ = real
|
||||
modetrade.YOUNG_S, modetrade.RESTART_S = young, restart
|
||||
|
||||
|
||||
check("n1_ch with SAFETY-135 off is OPT-15's pin: only the reason word moved",
|
||||
_s135_off_digest("n1_ch") == N1_CH_OPT15)
|
||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||
got[k] == got[k[:-len('_fc_down')]])
|
||||
|
||||
@@ -248,11 +248,14 @@ print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
|
||||
# now counts those seconds, so ch_ch's quarters land on 3450.0 W where they
|
||||
# landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first,
|
||||
# partial quarter charges a little less at its end (bf9019e: e46e3c66...).
|
||||
# MAINT-24 moved ch_ch_maint again: B no longer grid-charges beside A's
|
||||
# maintenance charge, and A's charge is no longer paused (release/1.0 02eac3b:
|
||||
# e56eddab...).
|
||||
AT_BASE = {
|
||||
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
|
||||
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
|
||||
"ch_ch_maint":
|
||||
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9",
|
||||
"df86a451a3b2d365ca5d35b772cd1eb8502d164e6b63e6844b5cfb82f15aeb46",
|
||||
}
|
||||
_got = sc.no_probe_digests()
|
||||
for _k in AT_BASE:
|
||||
@@ -260,8 +263,8 @@ for _k in AT_BASE:
|
||||
f"{_got[_k][:16]}")
|
||||
check("with no refusal every share, grid, trade sentence, role and plant "
|
||||
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 moved "
|
||||
"them)", _got == AT_BASE)
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 and "
|
||||
"MAINT-24 moved them)", _got == AT_BASE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -1148,7 +1148,10 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
|
||||
"could do - and no fixture anywhere could see that, because every check "
|
||||
"calls `resolve()` for itself. A method, not a dict, because the "
|
||||
"weighting is direction-dependent and the direction is stage 2's output",
|
||||
len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"])
|
||||
# MAINT-28 added a second call (`_m28_share()`'s split without the
|
||||
# paused pack's cap); it must pass the same method, as the cycle's does.
|
||||
len(_resolve_calls) >= 1
|
||||
and _w_arg == ["self.pack_weights"] * len(_resolve_calls))
|
||||
|
||||
# ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY
|
||||
# HALF OF THAT. The other half is main.py putting it in the action log, which no
|
||||
|
||||
@@ -245,14 +245,16 @@ print(f" decide(cap_w={kw(_d, 'cap_w')}, margin_w={kw(_d, 'margin_w')})")
|
||||
print(f" shaping_suspended(..., {arg(_s, 2)})")
|
||||
print(f" maintenance_charge_floor(..., {arg(_m, 2)})")
|
||||
print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})")
|
||||
# MAINT-24: the maintenance forecast checks get the guard's own aim, cap -
|
||||
# margin with this month's peak in (`maint_aim`), not the plain cap - margin.
|
||||
check("test_call_sites: the guard gets the real cap AND the margin; the "
|
||||
"forecast checks get cap - margin; the month-peak tracker gets the "
|
||||
"real cap",
|
||||
"forecast checks get the guard's aim, cap - margin and the month "
|
||||
"peak (MAINT-24); the month-peak tracker gets the real cap",
|
||||
len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w")
|
||||
and kw(_d, "margin_w") == "self.peak_margin_w"
|
||||
and len(_shape) == 1 and len(_maint) == 1
|
||||
and arg(_s, 2) == "self.peak_aim_w()"
|
||||
and arg(_m, 2) == "self.peak_aim_w()"
|
||||
and arg(_s, 2) == "maint_aim" and arg(_m, 2) == "maint_aim"
|
||||
and "maint_aim = peakguard.quarter_target_w(" in _src
|
||||
and len(_tracker) == 1
|
||||
and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))')
|
||||
check("test_aim_w: `peak_aim_w()` is cap - margin, and the forecast sizing "
|
||||
|
||||
@@ -116,6 +116,7 @@ from math import inf, isinf
|
||||
from pathlib import Path
|
||||
|
||||
from app import messages
|
||||
from app import ctl_cycle as _ctl_cycle
|
||||
from app import peakguard
|
||||
from named_absence import first, given, nth, present
|
||||
|
||||
@@ -2787,9 +2788,22 @@ def cycle_claims(run):
|
||||
return out, (seen[-1] if seen else [])
|
||||
|
||||
|
||||
def run_cycle(ctl):
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
ctl.cycle())
|
||||
def run_cycle(ctl, at=None):
|
||||
"""One `Controller.cycle()`. `at` pins the `now` that cycle() takes
|
||||
(ctl_cycle.py:49) so a fixture laid up to `at` does not age with host load.
|
||||
Other modules still read the real clock. CI-17."""
|
||||
real_dt = _ctl_cycle.datetime
|
||||
if at is not None:
|
||||
class _Pinned(datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return at if tz is None else at.astimezone(tz)
|
||||
_ctl_cycle.datetime = _Pinned
|
||||
try:
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
|
||||
ctl.cycle())
|
||||
finally:
|
||||
_ctl_cycle.datetime = real_dt
|
||||
return ctl
|
||||
|
||||
|
||||
@@ -2801,11 +2815,15 @@ def run_cycle(ctl):
|
||||
EVE_MEAN_W = sum(EVE_W) / len(EVE_W)
|
||||
c_src, _ = built(TMP / "sources.db")
|
||||
c_src.auto, c_src.batt, c_src.soc = True, 0.0, 100.0
|
||||
# CI-17: ONE read of the clock, and every cycle on c_src runs AT it. The
|
||||
# telegrams end here; `cycle()` used to read the real clock again, 1.3 s later
|
||||
# per cycle under --cpus 0.25, so by the 23rd cycle of the sweep the meter's
|
||||
# last telegram was older than `max_hold_s` (30 s), the guard counted the gap at
|
||||
# its ceiling and filed `peak@site` (ctl_cycle.py:49 -> p1.py max_hold_s).
|
||||
_SRC_NOW = datetime.now(timezone.utc)
|
||||
_blk_src = datetime.fromtimestamp(
|
||||
int(datetime.now(timezone.utc).timestamp() // QUARTER_S) * QUARTER_S,
|
||||
timezone.utc)
|
||||
for _i in range(0, int((datetime.now(timezone.utc)
|
||||
- _blk_src).total_seconds()) + 1, 5):
|
||||
int(_SRC_NOW.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc)
|
||||
for _i in range(0, int((_SRC_NOW - _blk_src).total_seconds()) + 1, 5):
|
||||
c_src.p1.submit(sample(_blk_src + timedelta(seconds=_i), EVE_MEAN_W))
|
||||
c_src.grid = EVE_MEAN_W
|
||||
|
||||
@@ -2817,7 +2835,7 @@ SRC_SWEEP = sorted({float(s) for s in range(0, 101, 5)}
|
||||
IDLE_SRC = {}
|
||||
for _soc in SRC_SWEEP:
|
||||
c_src.soc = _soc
|
||||
_, _cl = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
IDLE_SRC[_soc] = frozenset(c.source for c in _cl)
|
||||
IDLE_SETS = set(IDLE_SRC.values())
|
||||
print(f" a real idle cycle at {len(SRC_SWEEP)} states of charge "
|
||||
@@ -3141,7 +3159,7 @@ _b2, _restore_b2 = second_inverter(c_src)
|
||||
_b2.bms_soc_floor = c_src.inverters[0].bms_soc_floor
|
||||
_n2_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
_n2_target, _n2_reason = c_src.target, c_src.reason
|
||||
_n2_events = list(c_src.events)
|
||||
_n2_writes = c_src.hass.per_entity[_n2_writes_before:]
|
||||
@@ -3337,7 +3355,7 @@ check("...and the action log no longer carries the 'no writer' refusal, "
|
||||
_nw, _restore_nw = second_inverter(c_src, name="inverter-c", setpoint="")
|
||||
_nw_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl_nw = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl_nw = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
_nw_target, _nw_reason = c_src.target, c_src.reason
|
||||
_nw_events = list(c_src.events)
|
||||
_nw_writes = c_src.hass.per_entity[_nw_writes_before:]
|
||||
@@ -3382,16 +3400,21 @@ check("...and the 0 W is NOT what the site target happened to be - the same "
|
||||
# method is asked, which is what makes this a check on `live_units()` instead of
|
||||
# a check on a lambda this file wrote.
|
||||
_bs, _restore_bs = second_inverter(c_src)
|
||||
_bs.rejected_since = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=app_main.LINK_STALE_S + 5)
|
||||
# CI-17: from `_SRC_NOW`, the instant the pinned cycles run at; the real clock
|
||||
# is later, so `live_units()` (real clock) and the cycle (pinned) agree.
|
||||
_bs.rejected_since = _SRC_NOW - timedelta(seconds=app_main.LINK_STALE_S + 5)
|
||||
_bs.last_write_ok = False
|
||||
_stale_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
check("CI-17: the pinned clock and the real clock agree that inverter B's "
|
||||
"link is stale, so the cycle and `live_units()` see the same roster",
|
||||
not _bs.is_live(_SRC_NOW)[0]
|
||||
and not _bs.is_live(datetime.now(timezone.utc))[0])
|
||||
check("PRECONDITION: the SHIPPED `live_units()` - not a lambda - drops the "
|
||||
"inverter whose writes have been rejected for a whole layer-1 window, "
|
||||
"and keeps the healthy one",
|
||||
c_src.live_units() == ("inverter-a",))
|
||||
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl_stale = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
_stale_target = c_src.target
|
||||
_stale_alloc = c_src.allocation
|
||||
_stale_events = list(c_src.events)
|
||||
@@ -3433,14 +3456,13 @@ check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY, which is the whole of AC 6 and
|
||||
# state `live_units()`'s own docstring warns about - the sole inverter dropping
|
||||
# takes the SITE to 0 W - so it is driven here rather than left as a sentence.
|
||||
_bd, _restore_bd = second_inverter(c_src)
|
||||
_gone = datetime.now(timezone.utc) - timedelta(
|
||||
seconds=app_main.LINK_STALE_S + 5)
|
||||
_gone = _SRC_NOW - timedelta(seconds=app_main.LINK_STALE_S + 5) # CI-17: as above
|
||||
_bd.rejected_since, _bd.last_write_ok = _gone, False
|
||||
c_src.inverters[0].rejected_since = _gone
|
||||
c_src.inverters[0].last_write_ok = False
|
||||
_dead_writes_before = len(c_src.hass.per_entity)
|
||||
try:
|
||||
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src))
|
||||
_, _cl_dead = cycle_claims(lambda: run_cycle(c_src, at=_SRC_NOW))
|
||||
_dead_target, _dead_reason = c_src.target, c_src.reason
|
||||
_dead_alloc = c_src.allocation
|
||||
_dead_events = list(c_src.events)
|
||||
|
||||
@@ -6,7 +6,8 @@ with the lagged model (`Lag`: command dead time, meter and battery reading
|
||||
age, the P1 debounce, 1 s cycles), which reproduces all three on 5a4cdc2.
|
||||
|
||||
1. F1: a quarter edge while charging does not stop the charge (the guard
|
||||
projects the quarter `now` is in), and a real guard stop still latches.
|
||||
projects the quarter `now` is in), and a real guard stop still holds it
|
||||
(SAFETY-135: one restart a minute later, then a second stop latches).
|
||||
At two inverters SAFETY-91's `next_quarter()` already does that for the
|
||||
guard; at one inverter only the rolled projection does.
|
||||
2. F2: the export overshoot at a charge end stays within 0.15 x rating in
|
||||
@@ -252,7 +253,8 @@ check("two inverters: SAFETY-91 already holds the edge (no stop with the old "
|
||||
"projection), and rolling it changes no banked quarter",
|
||||
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1))
|
||||
|
||||
# 1c. A real guard stop still latches, and the 500 W minimum stays.
|
||||
# 1c. A real guard stop holds the charge (SAFETY-135: one restart after
|
||||
# RESTART_S, a second stop latches), and the 500 W minimum stays.
|
||||
# House: 800 W; 3700 W at 02:20-02:21; 3000 W from 02:26 (headroom 450 W
|
||||
# in the next quarter); 2850 W from 02:40 (600 W).
|
||||
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[
|
||||
@@ -266,11 +268,24 @@ _after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
|
||||
print(f" guard stop at "
|
||||
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
|
||||
f" ({_first['reason'][:40] if _first else ''})")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays "
|
||||
"off for the rest of that quarter, the house back at 800 W",
|
||||
# SAFETY-135: the first guard stop in a quarter holds the charge RESTART_S,
|
||||
# then it may start once more; a second stop latches to the quarter's end.
|
||||
_hold2 = [r for r in _after2
|
||||
if (r["now"] - _first["now"]).total_seconds() < modetrade.RESTART_S]
|
||||
_on2 = [b for a, b in zip(_after2, _after2[1:])
|
||||
if a["action"] != arbitrage.CHARGE and b["action"] == arbitrage.CHARGE]
|
||||
_off2 = [b for a, b in zip(_after2, _after2[1:])
|
||||
if a["action"] == arbitrage.CHARGE and b["action"] != arbitrage.CHARGE]
|
||||
print(f" after it: {len(_on2)} restart(s), {len(_off2)} more stop(s)")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak; it stays off "
|
||||
"for RESTART_S, then starts again at most once that quarter, and a "
|
||||
"second stop holds it off to the quarter's end (SAFETY-135)",
|
||||
_first is not None and _first["now"].astimezone(L).minute == 20
|
||||
and ("peak" in _first["reason"] or "headroom" in _first["reason"])
|
||||
and _after2 and all(r["action"] is None for r in _after2))
|
||||
and _hold2 and all(r["action"] is None for r in _hold2)
|
||||
and len(_on2) <= 1 and len(_off2) <= 1
|
||||
and not any(r["action"] == arbitrage.CHARGE for r in _after2
|
||||
if _off2 and r["now"] >= _off2[0]["now"]))
|
||||
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
|
||||
if a["action"] != arbitrage.CHARGE
|
||||
and b["action"] == arbitrage.CHARGE]
|
||||
|
||||
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
|
||||
import safety128_rig as R # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import lateness # noqa: E402
|
||||
from app import settle # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
@@ -58,6 +59,17 @@ def no_gate(fn):
|
||||
app_main.Controller._readings_late = real
|
||||
|
||||
|
||||
def no_settle(fn):
|
||||
"""`fn()` without SAFETY-136's gentler loop: the tree this card was written
|
||||
against (with it the same run exports -645 W, not -1 325 W)."""
|
||||
real = settle.Settle.moved
|
||||
settle.Settle.moved = lambda self, *a, **k: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
settle.Settle.moved = real
|
||||
|
||||
|
||||
def run_late(**kw):
|
||||
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
|
||||
dur = kw.pop("dur")
|
||||
@@ -91,7 +103,7 @@ for units, house in ((AB, 1800.0), (A, 1500.0)):
|
||||
if len(units) == 2:
|
||||
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0,
|
||||
units=units, p1_s=5.0)
|
||||
no_gate(lambda: old.run(ON + 300.0))
|
||||
no_settle(lambda: no_gate(lambda: old.run(ON + 300.0)))
|
||||
check(f"{n}: without the gate the export goes below -1 kW",
|
||||
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
|
||||
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Runnable check for SAFETY-135: a young 15-minute period does not cost the
|
||||
night charge the whole period. `python3 test_safety135.py`
|
||||
|
||||
The REAL Controller (tools/opt11p3_replay.py, read_inputs + cycle, battery
|
||||
modes, the peak guard on a P1 meter) with TEST-23-like lag: 1 s cycles,
|
||||
readings 1 s old, a 5 s old meter in part 2. "Off" is the same run with
|
||||
every measured stop latching and no wait for a falling give: the tree before
|
||||
this card.
|
||||
DECISIONS/SAFETY-135-young-period.md.
|
||||
|
||||
1. A peak-bound night charge; a period starts with a 4 s kettle spike. The
|
||||
guard cuts at once, and the charge resumes in that period (off: it stays
|
||||
off to the period's end). The period stays at or under the aim.
|
||||
2. A cheap run starts at 01:00 right after a give run, with a 5 s meter: no
|
||||
start-then-cut within 60 s (off: one), and no period over the aim.
|
||||
3. A real over-aim load: the guard and the charge stop on the same cycle as
|
||||
off, and the charge stays off while the load is on.
|
||||
4. The SAFETY-136 rig run C/s5b (house 1 800 W, an oven 2 kW 45 s in every
|
||||
105 s, A Leftover solar 5 kW, B Cheap hours 4.6 kW): the charge starts at
|
||||
about 01:00:32 and the guard cuts it about 60 s later on the oven. It
|
||||
starts again 60 s after that cut (off: never in that period).
|
||||
5. Repeated spikes in one period: at most one restart, and none sooner than
|
||||
RESTART_S after the first stop.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, modetrade, packmode # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1))
|
||||
D0 = datetime(2026, 11, 3, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
# Local quarters: dear 00:00-01:00 (a give run), cheap 01:00-07:00, 0.30 else.
|
||||
PRICES = [0.40 if q < 4 else 0.10 if q < 28 else 0.30 for q in range(96)] * 2
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(PRICES)]
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
CH = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.CHEAP_HOURS, False)}
|
||||
T1 = D0 + timedelta(hours=1) # 01:00 local, the cheap edge
|
||||
RESTART = getattr(modetrade, "RESTART_S", 60.0) # 60.0 on older trees
|
||||
|
||||
|
||||
class _Every:
|
||||
"""A period that equals every period: `_charge_stop` set to it makes
|
||||
every measured stop latch, as before this card."""
|
||||
|
||||
def __eq__(self, other):
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
def _off(c):
|
||||
# With RESTART_S 0 (see `off()`) nothing waits; every guard stop latches.
|
||||
c._charge_stop = (_Every(), float("-inf"))
|
||||
|
||||
|
||||
def off(fn):
|
||||
"""`fn()` with SAFETY-135 off: every measured stop latches and no start
|
||||
waits for a falling give, as on the tree before this card."""
|
||||
names = [n for n in ("YOUNG_S", "RESTART_S") if hasattr(modetrade, n)]
|
||||
real = {n: getattr(modetrade, n) for n in names}
|
||||
OFF[0] = True
|
||||
for n in names: # RESTART_S 0: no wait at a period's end
|
||||
setattr(modetrade, n, 0.0)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
OFF[0] = False
|
||||
for n in names:
|
||||
setattr(modetrade, n, real[n])
|
||||
|
||||
|
||||
OFF = [False]
|
||||
|
||||
|
||||
def run(house_w, start, secs, meter_s=1.0, *, choice=None, soc0=None,
|
||||
limits=None, rating=None):
|
||||
"""Rows per cycle and the Controller, for `house_w(seconds since 01:00)`."""
|
||||
t0 = start.timestamp()
|
||||
house = [(t0 + k, house_w(t0 + k - T1.timestamp()))
|
||||
for k in range(int(secs) + 5)]
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
g = c.peak_guard
|
||||
rows.append(dict(now=now, s=now.timestamp() - T1.timestamp(),
|
||||
grid=h.grid(),
|
||||
charge=getattr(c.arb, "action", None)
|
||||
== arbitrage.CHARGE,
|
||||
engaged=g is not None and g.engaged,
|
||||
reason=getattr(c.arb, "reason", None) or ""))
|
||||
was = rp.RATING
|
||||
if rating is not None:
|
||||
rp.RATING = rating
|
||||
try:
|
||||
_st, c = rp.run("p3_ch_sell", house, WPTS,
|
||||
start.astimezone(timezone.utc), secs, check=cb,
|
||||
soc0=soc0 or {"a": 30.0, "b": 30.0},
|
||||
choice=choice or CH, limits=limits,
|
||||
n=len(choice or CH),
|
||||
patch=_off if OFF[0] else None,
|
||||
extra={"slew_w": 0, "cycle_cost_threshold_eur": 0.03},
|
||||
lag=rp.Lag(meter_s=meter_s))
|
||||
finally:
|
||||
rp.RATING = was
|
||||
return rows, c
|
||||
|
||||
|
||||
def banked(c, frm):
|
||||
"""The add-on's own P1 period averages from `frm` (seconds after 01:00)."""
|
||||
return [b.offtake_avg_w for b in c.p1.blocks
|
||||
if b.start.timestamp() - T1.timestamp() >= frm]
|
||||
|
||||
|
||||
def flaps(rows, frm, to):
|
||||
"""Charge starts in [frm, to) s that stopped again within 60 s."""
|
||||
out = []
|
||||
for i in range(1, len(rows)):
|
||||
r = rows[i]
|
||||
if frm <= r["s"] < to and r["charge"] and not rows[i - 1]["charge"]:
|
||||
if any(not x["charge"] for x in rows[i:]
|
||||
if x["s"] - r["s"] <= 60):
|
||||
out.append(r["s"])
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. a 4 s spike at the start of a peak-bound night charge's period")
|
||||
|
||||
|
||||
def spike(s):
|
||||
return 2800.0 + (2500.0 if s >= 1800 and (s - 1800) % 900 < 4 else 0.0)
|
||||
|
||||
|
||||
R1, C1 = run(spike, T1 + timedelta(minutes=20), 1560)
|
||||
R1o, C1o = off(lambda: run(spike, T1 + timedelta(minutes=20), 1560))
|
||||
|
||||
|
||||
def period(rows, frm):
|
||||
return [r for r in rows if frm <= r["s"] < frm + 900]
|
||||
|
||||
|
||||
for tag, rows, c in (("on", R1, C1), ("off", R1o, C1o)):
|
||||
p = period(rows, 1800)
|
||||
cut = next((r["s"] - 1800 for r in p if r["engaged"]), None)
|
||||
on = sum(r["charge"] for r in p)
|
||||
print(f" {tag}: guard cut at +{cut} s, charging {on} of {len(p)} s, "
|
||||
f"period {banked(c, 1800)[:1]}")
|
||||
_p = period(R1, 1800)
|
||||
_cut = next((i for i, r in enumerate(_p) if r["engaged"]), None)
|
||||
check("the spike trips the guard in the period's first seconds and the "
|
||||
"charge stops there",
|
||||
_cut is not None and _p[_cut]["s"] - 1800 <= 5
|
||||
and not _p[_cut]["charge"])
|
||||
_back = [r["s"] - 1800 for r in _p[(_cut or 0):] if r["charge"]]
|
||||
check("the charge resumes in the same period, within 90 s of its start",
|
||||
_back and _back[0] <= 90)
|
||||
_po = period(R1o, 1800)
|
||||
check("off, the same spike keeps the charge off to the period's end (the "
|
||||
"defect this card fixes)",
|
||||
any(r["engaged"] for r in _po[:5])
|
||||
and not any(r["charge"] for r in _po[10:]))
|
||||
check("every period with the charge is at or under the aim (lag allows 2 W)",
|
||||
banked(C1, 0) and max(banked(C1, 0)) <= AIM + 2.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. a cheap run at 01:00 right after a give run, 5 s meter")
|
||||
for house in (2400.0, 3000.0):
|
||||
def flat(s, w=house):
|
||||
return w
|
||||
R2, C2 = run(flat, T1 - timedelta(minutes=5), 1260, meter_s=5.0)
|
||||
R2o, C2o = off(lambda: run(flat, T1 - timedelta(minutes=5), 1260,
|
||||
meter_s=5.0))
|
||||
f2, f2o = flaps(R2, 0, 900), flaps(R2o, 0, 900)
|
||||
p2, p2o = banked(C2, 0)[:1], banked(C2o, 0)[:1]
|
||||
on2 = sum(r["charge"] for r in period(R2, 0))
|
||||
on2o = sum(r["charge"] for r in period(R2o, 0))
|
||||
print(f" house {house:.0f} W: on flaps {f2} charging {on2} s period "
|
||||
f"{p2}; off flaps {f2o} charging {on2o} s period {p2o}")
|
||||
check(f"house {house:.0f} W: no charge start is cut within 60 s in the "
|
||||
f"01:00 period", not f2)
|
||||
check(f"house {house:.0f} W: the 01:00 period is at or under the aim "
|
||||
f"(lag allows 2 W)", p2 and p2[0] <= AIM + 2.0)
|
||||
check(f"house {house:.0f} W: the charge still runs in the 01:00 period",
|
||||
on2 >= 800)
|
||||
if house == 3000.0:
|
||||
check("off, the 3 000 W house starts on the young period and is cut "
|
||||
"within 60 s (the defect this card fixes)", bool(f2o))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. a real over-aim load still cuts on the same cycle")
|
||||
|
||||
|
||||
def load(s):
|
||||
return 2800.0 + (4000.0 if 1820 <= s < 2120 else 0.0)
|
||||
|
||||
|
||||
R3, C3 = run(load, T1 + timedelta(minutes=20), 1500)
|
||||
R3o, C3o = off(lambda: run(load, T1 + timedelta(minutes=20), 1500))
|
||||
|
||||
|
||||
def first(rows, key, val, frm):
|
||||
return next((r["s"] for r in rows if r["s"] >= frm and r[key] == val),
|
||||
None)
|
||||
|
||||
|
||||
_g, _go = first(R3, "engaged", True, 1800), first(R3o, "engaged", True, 1800)
|
||||
_s, _so = first(R3, "charge", False, 1800), first(R3o, "charge", False, 1800)
|
||||
print(f" guard cut at {_g} s (off {_go} s), charge stop at {_s} s "
|
||||
f"(off {_so} s)")
|
||||
check("the guard cuts on the same cycle as off, and the charge stops on it",
|
||||
_g is not None and _g == _go and _s == _so == _g)
|
||||
check("the charge stays off while the load is on",
|
||||
not any(r["charge"] for r in R3 if _g <= r["s"] < 2120))
|
||||
check("every period is at or under the aim (lag allows 2 W)",
|
||||
banked(C3, 0) and max(banked(C3, 0)) <= AIM + 2.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the SAFETY-136 rig run C/s5b: start at 01:00:32, oven cut a minute "
|
||||
"later")
|
||||
# The rig's house (s5b.ctl.json), in seconds from 01:00: 1 800 W, an oven of
|
||||
# 2 000 W for 45 s in every 105 s from 00:30 to 01:05:45, 3 000 W 00:56-00:58.
|
||||
|
||||
|
||||
def s5b(s):
|
||||
w = 1800.0
|
||||
if -1800 <= s < 345 and (s + 1800) % 105 < 45:
|
||||
w += 2000.0
|
||||
if -240 <= s < -120:
|
||||
w += 3000.0
|
||||
return w
|
||||
|
||||
|
||||
S5B = dict(choice={"a": (True, packmode.LEFTOVER_SOLAR, False),
|
||||
"b": (True, packmode.CHEAP_HOURS, False)},
|
||||
soc0={"a": 53.6, "b": 27.4}, limits={"b": (-4600.0, 4600.0)},
|
||||
rating=5000.0)
|
||||
R4, C4 = run(s5b, T1 - timedelta(minutes=5), 1260, **S5B)
|
||||
R4o, C4o = off(lambda: run(s5b, T1 - timedelta(minutes=5), 1260, **S5B))
|
||||
|
||||
|
||||
def edges(rows, frm):
|
||||
"""(start s, stop s) lists of the charge in the period from `frm`."""
|
||||
p = period(rows, frm)
|
||||
on = [round(b["s"] - frm) for a, b in zip(p, p[1:])
|
||||
if b["charge"] and not a["charge"]]
|
||||
of = [round(b["s"] - frm) for a, b in zip(p, p[1:])
|
||||
if a["charge"] and not b["charge"]]
|
||||
return on, of
|
||||
|
||||
|
||||
_on4, _of4 = edges(R4, 0)
|
||||
_on4o, _of4o = edges(R4o, 0)
|
||||
print(f" on: starts {_on4}, stops {_of4}, period {banked(C4, 0)[:1]}; "
|
||||
f"off: starts {_on4o}, stops {_of4o}, period {banked(C4o, 0)[:1]}")
|
||||
check("off, as on the rig: the charge starts about 30 s in, the guard cuts "
|
||||
"it about a minute later, and it stays off to the period's end",
|
||||
len(_on4o) == 1 and 25 <= _on4o[0] <= 40 and len(_of4o) == 1
|
||||
and 80 <= _of4o[0] <= 100)
|
||||
check("the same start and the same cut as off (the cut is not delayed)",
|
||||
_on4[:1] == _on4o[:1] and _of4[:1] == _of4o[:1])
|
||||
_cut4 = nth(_of4, 0, "the guard's cut in s5b")
|
||||
check("the charge starts again in that period, 60 s or more after the cut, "
|
||||
"once only",
|
||||
present(_cut4) and len(_on4) == 2 and _on4[1] - _cut4 >= RESTART - 1)
|
||||
check("the period is at or under the aim (lag allows 2 W)",
|
||||
banked(C4, 0) and banked(C4, 0)[0] <= AIM + 2.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. repeated spikes in one period: one restart, not too soon")
|
||||
_SP = (0, 20, 40, 62, 80, 300, 500)
|
||||
|
||||
|
||||
def spikes(s):
|
||||
return 2800.0 + (2500.0 if any(0 <= s - 1800 - k < 4 for k in _SP)
|
||||
else 0.0)
|
||||
|
||||
|
||||
for n_ch in (CH, {"a": CH["a"]}):
|
||||
R5, C5 = run(spikes, T1 + timedelta(minutes=20), 1560, choice=n_ch,
|
||||
soc0={k: 30.0 for k in n_ch})
|
||||
_on5, _of5 = edges(R5, 1800)
|
||||
print(f" N={len(n_ch)}: starts {_on5}, stops {_of5}, "
|
||||
f"period {banked(C5, 1800)[:1]}")
|
||||
check(f"N={len(n_ch)}: at most one restart in the period, none sooner "
|
||||
f"than RESTART_S after the first stop, and the last stop latches",
|
||||
_of5 and len(_on5) <= 1 and len(_of5) == len(_on5) + 1
|
||||
and all(t - _of5[0] >= RESTART - 1 for t in _on5))
|
||||
check(f"N={len(n_ch)}: the period is at or under the aim (lag allows "
|
||||
f"2 W)", banked(C5, 1800) and banked(C5, 1800)[0] <= AIM + 2.0)
|
||||
_w = [r["reason"] for r in period(R5, 1800) if not r["charge"]]
|
||||
check(f"N={len(n_ch)}: the wait says it in plain words (UX-08)",
|
||||
any("tries again in a minute" in x for x in _w)
|
||||
and not any("(young)" in x or "(wait)" in x for x in _w))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
sys.exit(1)
|
||||
print("all SAFETY-135 checks passed")
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Runnable check for SAFETY-136: a calmer loop on a slow meter.
|
||||
`python3 test_safety136.py`
|
||||
|
||||
With a 5 s meter and Home Assistant readings 10-15 s late the loop answers the
|
||||
same error again at each cycle and overshoots: with an oven, a kettle and hob,
|
||||
or a step the export was 3-10 times that of a meter with no lag. After a move
|
||||
of 150 W or more `app/settle.py` now runs the loop at half its gain (at most
|
||||
2 cycles) until the meter shows half of it. It is off for a meter under 4 s.
|
||||
DECISIONS/SAFETY-136-slow-meter-settle.md.
|
||||
|
||||
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Off" is the
|
||||
same run with `Settle.moved` doing nothing: the tree before this card.
|
||||
|
||||
1. `Settle` alone.
|
||||
2. 5 s meter, readings 10 s late: oven, kettle and hob, a step down and a step
|
||||
up export clearly less than with it off.
|
||||
3. 5 s meter, no lag: the same houses are no worse than a small margin.
|
||||
4. 1 s and 2 s meters: the commands are those of the run with it off, and the
|
||||
loop never waits.
|
||||
5. The slow meter is told by its observed interval, at 4 s and 5 s.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import safety128_rig as R # noqa: E402
|
||||
from app.control import Tuning # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
from app import settle # noqa: E402
|
||||
from app.lateness import MeterFit # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def off(fn):
|
||||
"""`fn()` with the settle wait off: the tree before this card."""
|
||||
real = settle.Settle.moved
|
||||
settle.Settle.moved = lambda self, *a, **k: None
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
settle.Settle.moved = real
|
||||
|
||||
|
||||
def oven(t):
|
||||
return 1800.0 + (2000.0 if (t >= 300.0 and (t - 300.0) % 105 < 45) else 0.0)
|
||||
|
||||
|
||||
def kettle(t):
|
||||
w = 600.0
|
||||
if t % 400 < 120:
|
||||
w += 2200
|
||||
if t >= 900 and t % 90 < 30:
|
||||
w += 1500
|
||||
return w
|
||||
|
||||
|
||||
def step(t):
|
||||
return 1800.0 if t < 320.0 else 300.0
|
||||
|
||||
|
||||
def stepup(t):
|
||||
return 300.0 if t < 320.0 else 2500.0
|
||||
|
||||
|
||||
# name: (house, noise, run length, lag end)
|
||||
HOUSES = {"oven": (oven, 80.0, 900.0, 900.0),
|
||||
"kettle": (kettle, 80.0, 1500.0, 1500.0),
|
||||
"step": (step, 50.0, 700.0, 600.0),
|
||||
"stepup": (stepup, 50.0, 700.0, 600.0)}
|
||||
ON = 300.0
|
||||
|
||||
|
||||
def wh(rows, t0, t1, sign):
|
||||
"""Export (sign -1) or import (sign +1) energy in Wh over [t0, t1)."""
|
||||
return sum((-min(r[1], 0.0) if sign < 0 else max(r[1], 0.0))
|
||||
for r in rows if t0 <= r[0] < t1) / 3600.0
|
||||
|
||||
|
||||
def run(name, lag, p1_s=5.0, units=("a", "b"), dur=None):
|
||||
house, noise, length, lag_off = HOUSES[name]
|
||||
r = R.Rig(house=house, noise=noise, p1_s=p1_s, units=units, lag_s=lag,
|
||||
lag_on=ON, lag_off=lag_off)
|
||||
waits = []
|
||||
r.run(dur or length + 20.0,
|
||||
lambda rig, c, t: waits.append(1) if c.settle.pend else None)
|
||||
return r, len(waits)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. Settle alone")
|
||||
T = Tuning()
|
||||
s = settle.Settle()
|
||||
check("no move: the tuning is untouched", s.tuning(T, 500.0, True) is T)
|
||||
s.moved(500.0, 0.0, 100.0, True)
|
||||
check("a move under 150 W is not waited for", s.pend is None)
|
||||
s.moved(500.0, 0.0, 600.0, False)
|
||||
check("a meter that is not slow is not waited for", s.pend is None)
|
||||
s.moved(500.0, 0.0, 600.0, True)
|
||||
check("a move of 600 W is waited for: half the gain",
|
||||
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
|
||||
check("the meter fell 200 W of 600 W: still half the gain",
|
||||
s.tuning(T, 300.0, True).gain == T.gain * 0.5)
|
||||
check("two cycles waited: the third has the whole gain",
|
||||
s.tuning(T, 300.0, True) is T and s.pend is None)
|
||||
s.moved(500.0, 0.0, 600.0, True)
|
||||
check("the meter went the other way by half the move: the wait stands",
|
||||
s.tuning(T, 800.0, True).gain == T.gain * 0.5)
|
||||
s.reset()
|
||||
s.moved(500.0, 0.0, -600.0, True)
|
||||
check("a move down, the meter rose half the move: the whole gain",
|
||||
s.tuning(T, 800.0, True) is T)
|
||||
s.reset()
|
||||
s.moved(500.0, 0.0, 600.0, True)
|
||||
check("the meter fell half the move: the whole gain at once",
|
||||
s.tuning(T, 200.0, True) is T and s.pend is None)
|
||||
s.moved(500.0, 0.0, -600.0, True)
|
||||
check("a move down is waited for too",
|
||||
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
|
||||
s.reset()
|
||||
check("reset ends the wait", s.pend is None)
|
||||
s.moved(500.0, 0.0, 600.0, True)
|
||||
check("a meter that turns out not to be slow ends the wait",
|
||||
s.tuning(T, 500.0, False) is T and s.pend is None)
|
||||
check("the numbers are pinned",
|
||||
(settle.MOVE_W, settle.SHOWN, settle.WAIT_SHARE, settle.WAIT_CYCLES)
|
||||
== (150.0, 0.5, 0.5, 2))
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. 5 s meter, readings 10 s late")
|
||||
tot_on = tot_off = 0.0
|
||||
for name, (_h, _n, length, lag_off) in HOUSES.items():
|
||||
a, waited = run(name, 10.0)
|
||||
b, _w = off(lambda: run(name, 10.0))
|
||||
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
|
||||
tot_on, tot_off = tot_on + ea, tot_off + eb
|
||||
print(f" {name}: export {ea:.1f} Wh, off {eb:.1f} Wh, waited {waited} s")
|
||||
check(f"{name}: waits at some point", waited > 0)
|
||||
check(f"{name}: exports at most 75 % of the run with it off",
|
||||
ea <= 0.75 * eb)
|
||||
print(f" sum: {tot_on:.1f} Wh, off {tot_off:.1f} Wh")
|
||||
check("the four together export at most 65 % of the run with it off",
|
||||
tot_on <= 0.65 * tot_off)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2b. Another writer overrides the loop: the wait ends")
|
||||
halved = []
|
||||
real_tuning = settle.Settle.tuning
|
||||
|
||||
|
||||
def watch_tuning(self, tuning, grid_w, slow):
|
||||
r = real_tuning(self, tuning, grid_w, slow)
|
||||
halved.append(r is not tuning)
|
||||
return r
|
||||
|
||||
|
||||
settle.Settle.tuning = watch_tuning
|
||||
try:
|
||||
mark = []
|
||||
|
||||
def bump(rig, c, t):
|
||||
# right after a cycle that started a wait, another writer moves the
|
||||
# command, so the next cycle sees `target != loop_w`
|
||||
if not mark and c.settle.pend and t > 330.0:
|
||||
mark.append(len(halved))
|
||||
c.target = c.target + 300.0
|
||||
|
||||
r = R.Rig(house=oven, noise=80.0, p1_s=5.0, lag_s=10.0, lag_on=ON,
|
||||
lag_off=900.0)
|
||||
r.run(500.0, bump)
|
||||
finally:
|
||||
settle.Settle.tuning = real_tuning
|
||||
nxt = nth(halved, mark[0] if mark else 10 ** 6)
|
||||
check("the cycle after an override has the whole gain",
|
||||
bool(mark) and present(nxt) and nxt is False)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n3. 5 s meter, no lag")
|
||||
for name in ("oven", "kettle", "step", "stepup"):
|
||||
_h, _n, length, lag_off = HOUSES[name]
|
||||
a, _w = run(name, 0.0)
|
||||
b, _w = off(lambda: run(name, 0.0))
|
||||
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
|
||||
ia, ib = wh(a.rows, ON, lag_off, 1), wh(b.rows, ON, lag_off, 1)
|
||||
print(f" {name}: export {ea:.1f} Wh (off {eb:.1f}), import {ia:.1f} Wh "
|
||||
f"(off {ib:.1f})")
|
||||
check(f"{name}: export at most 6 Wh over the run with it off",
|
||||
ea <= eb + 6.0)
|
||||
check(f"{name}: import at most 1 Wh over the run with it off",
|
||||
ia <= ib + 1.0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n4. 1 s and 2 s meters")
|
||||
for p1 in (1.0, 2.0):
|
||||
for lag in (0.0, 10.0):
|
||||
for units in (("a", "b"), ("a",)):
|
||||
a, waited = run("oven", lag, p1_s=p1, units=units, dur=700.0)
|
||||
b, _w = off(lambda: run("oven", lag, p1_s=p1, units=units,
|
||||
dur=700.0))
|
||||
same = ([(r[0], r[1], r[2]) for r in a.rows]
|
||||
== [(r[0], r[1], r[2]) for r in b.rows])
|
||||
check(f"{p1:.0f} s meter, {lag:.0f} s late, N={len(units)}: "
|
||||
f"same commands, never waits", same and waited == 0)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n5. The slow meter is told by its observed interval")
|
||||
s = settle.Settle()
|
||||
for k in range(30):
|
||||
s.arrive(float(k), k)
|
||||
check("a reading each second is not a slow meter", not s.slow())
|
||||
s = settle.Settle()
|
||||
for k in range(30):
|
||||
s.arrive(5.0 * k, k)
|
||||
check("a reading every 5 s is a slow meter", s.slow())
|
||||
s = settle.Settle()
|
||||
for k in range(4):
|
||||
s.arrive(5.0 * k, k)
|
||||
check("fewer than 5 readings in a minute tell nothing", not s.slow())
|
||||
s = settle.Settle()
|
||||
for k in range(30):
|
||||
s.arrive(5.0 * k, 0 if k else 1)
|
||||
check("the same count again is no new reading", not s.slow())
|
||||
for p1, expect in ((1.0, False), (2.0, False), (3.0, False), (4.0, True),
|
||||
(5.0, True), (10.0, True)):
|
||||
r = R.Rig(house=1800.0, p1_s=p1)
|
||||
seen = []
|
||||
r.run(120.0, lambda rig, c, t: seen.append(c.settle.slow()))
|
||||
last = nth(seen, -1)
|
||||
check(f"{p1:.0f} s meter: slow is {expect}",
|
||||
present(last) and last is expect)
|
||||
# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5.
|
||||
# Where every reading arrives (the rig, a polled meter) it is not slow; where
|
||||
# Home Assistant sends a reading only when the value changes (ha_signed) the
|
||||
# same meter is slow (T3 of the review; DECISIONS).
|
||||
s, m, h = settle.Settle(), MeterFit(), settle.Settle()
|
||||
for k in range(60):
|
||||
s.arrive(float(k), k)
|
||||
h.arrive(float(k), k // 5)
|
||||
m.add(float(k), 0.0, 1800.0 + 100.0 * (k // 5))
|
||||
check("every reading arrives, value changes every 5 s: not slow (the value "
|
||||
"fit says slow)", not s.slow() and m.slow())
|
||||
check("the same meter through Home Assistant on change (a reading per "
|
||||
"change): slow", h.slow())
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -481,6 +481,17 @@ print("\n8. the N=1 and N=2 digests")
|
||||
# quarter's room, as at N=2, so the guard no longer steps in and discharges.
|
||||
# The banked quarters go 3461.9 -> 3450.0 W (aim 3450) and 3938.9 -> 3950.0
|
||||
# W (aim 3950). n1_charge_dear (no cheap window) is unchanged.
|
||||
# MAINT-28 re-pinned n2_charge_banked: in the 5000 W load (over the 3950 W
|
||||
# aim) the maintenance charge no longer restarts at -125 W after each guard
|
||||
# stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone
|
||||
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward
|
||||
# re-pinned it again: while the pause holds A gives at most the largest part
|
||||
# of the guard's floor placed on it this period, so rows 288-479 (t = 1440 s
|
||||
# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Round 5
|
||||
# (H-1) re-pinned it again: one site limit, the period's largest floor, so the
|
||||
# batteries give only what the guard needs. Its banked quarters stay at or
|
||||
# under the aim 3950 W (3349 and 3950 W; f3f9483 3349 and 2527 W), graded
|
||||
# below. The other five are equal.
|
||||
PINNED = {
|
||||
"n1_charge":
|
||||
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
|
||||
@@ -493,8 +504,14 @@ PINNED = {
|
||||
"n2_charge_dear":
|
||||
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
|
||||
"n2_charge_banked":
|
||||
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
|
||||
"358ebd2a23fe7c7f52f441b60391e40c6605e32bcfb109fa0ff4502d4070745e",
|
||||
}
|
||||
_nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"])
|
||||
print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}"
|
||||
f" W (aim 3950)")
|
||||
check("...and in the re-pinned n2_charge_banked every banked quarter is at or "
|
||||
"under the aim 3950 W",
|
||||
len(banked(_nb)) >= 2 and all(v <= 3950.0 + 1.0 for v in banked(_nb)))
|
||||
_now = s84.digests()
|
||||
for k in PINNED:
|
||||
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
|
||||
|
||||
@@ -637,9 +637,10 @@ _routes = sorted({(r.method, r.get_info().get("path"))
|
||||
for r in _app.router.routes() if r.method != "HEAD"})
|
||||
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
|
||||
check("the configuration surface adds five routes, UX-13 one GET and "
|
||||
"UX-54 one more GET (the Forecast tab), and none touches the three "
|
||||
"UX-54 one more GET (the Forecast tab), EV-03 one read-only GET (the car "
|
||||
"charger probe), and none touches the three "
|
||||
"that were there",
|
||||
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
|
||||
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/charger"), ("GET", "/config/model"),
|
||||
("GET", "/forecast"), ("GET", "/ha/entities"),
|
||||
("GET", "/status"),
|
||||
("POST", "/action"), ("POST", "/config/restart"),
|
||||
|
||||
@@ -80,12 +80,13 @@ for _g in LAYOUT["groups"]:
|
||||
_flat += [(_g["id"], _s) for _s in _f.get("fields", [])]
|
||||
_keys = [f["key"] for _g, f in _flat]
|
||||
FIELD = {f["key"]: f for _g, f in _flat}
|
||||
check("the six groups, in the design's order",
|
||||
check("the seven groups, in the design's order",
|
||||
[g["id"] for g in LAYOUT["groups"]]
|
||||
== ["meter", "inverters", "tariff", "safety", "maintenance", "tuning"]
|
||||
== ["meter", "inverters", "tariff", "safety", "car", "maintenance",
|
||||
"tuning"]
|
||||
and [g["name"] for g in LAYOUT["groups"]]
|
||||
== ["Meter", "Inverters and batteries", "Tariff and prices",
|
||||
"Safety limits", "Maintenance", "Control tuning"])
|
||||
"Safety limits", "Car charger", "Maintenance", "Control tuning"])
|
||||
check(f"every field of the table is on the page once ({len(_keys)} fields)",
|
||||
sorted(_keys) == sorted(f.key for _g, _p, f in L.fields())
|
||||
and len(_keys) == len(set(_keys)))
|
||||
@@ -146,7 +147,7 @@ check("a page-only field names the option keys it writes, for the grey key "
|
||||
== ["pv_entities", "pv_entity", "pv_invert_entities"]
|
||||
and "static_profile" in field(FIELD, "ui.contract")["writes"])
|
||||
check("without a Supervisor schema the page still draws, with no ranges",
|
||||
len(uipage.layout([], {})["groups"]) == 6)
|
||||
len(uipage.layout([], {})["groups"]) == 7)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n2. the page script names no option key")
|
||||
|
||||
@@ -205,10 +205,11 @@ print("\n4. task ordering, and the labels that carry it")
|
||||
# prefixes appear in the file as CONTIGUOUS RUNS in the task order. A group
|
||||
# whose members are scattered renders as a jumble however good each label is.
|
||||
GROUPS = ["Hardware", "Meter", "Limits", "Tariff", "Maintenance", "Safety",
|
||||
"Advanced"]
|
||||
"Car charger", "Advanced"]
|
||||
NL_GROUPS = {"Hardware": "Hardware", "Meter": "Meter", "Limits": "Grenzen",
|
||||
"Tariff": "Tarief", "Maintenance": "Onderhoud",
|
||||
"Safety": "Veiligheid", "Advanced": "Geavanceerd"}
|
||||
"Safety": "Veiligheid", "Car charger": "Laadpaal",
|
||||
"Advanced": "Geavanceerd"}
|
||||
_prefixes = []
|
||||
for key in ORDER:
|
||||
name = TR["en"]["configuration"][key]["name"]
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
#!/usr/bin/env python3
|
||||
"""EV-03: a model of an Alfen car charger behind Home Assistant, for the unit
|
||||
tests and, later, for TEST-23.
|
||||
|
||||
WHERE IT LIVES, AND WHY. The rig's hardware sims (`hwsim.py`, `plantsim.py`) are
|
||||
in the infra repo admin/goodwe under `sim/`, pinned by `.gitea/infra-pin`; there
|
||||
is no `sim/` in this repository. This card may write only to this repository, so
|
||||
the model sits beside the add-on's other tools. Moving it next to `hwsim.py` is a
|
||||
copy: it imports nothing from the add-on.
|
||||
|
||||
WHAT IT MODELS (every entity the add-on's Alfen driver reads, with the names and
|
||||
units the leeyuentuen/alfen_wallbox integration gives, from the owner's export):
|
||||
|
||||
* mode3 state A (no car), B (connected, not charging), C (charging)
|
||||
* power that RAMPS to what the car takes (`RAMP_W_S`), 1 or 3 phases
|
||||
* the current setpoint, its read-back, the station limit (16 A at the owner's)
|
||||
* a full car (it stops by itself: state B with the current still allowed)
|
||||
* the integration going unavailable, and the login sensor off
|
||||
|
||||
from chargersim import SimCharger, FakeHa
|
||||
c = SimCharger("wallbox"); c.plug_in(); c.set_current(10); c.tick(30)
|
||||
ha = FakeHa(c) # a Home Assistant that serves its states
|
||||
|
||||
`FakeHa` has the methods the add-on calls (`state`, `number`, `states`,
|
||||
`set_number`, `call`) and RECORDS every write, so a test can prove none happened.
|
||||
`python3 chargersim.py --selftest` runs the model's own checks. `serve(port)`
|
||||
answers the REST calls the add-on makes (`GET /api/states[/<id>]`), for a rig.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
VOLTS = 230.0
|
||||
RAMP_W_S = 3000.0 # how fast the power follows its target
|
||||
MIN_A = 6.0
|
||||
|
||||
|
||||
class SimCharger:
|
||||
def __init__(self, name="wallbox", station_a=16.0, setpoint_a=10.0,
|
||||
phases_allowed=1, kwh=4004.85):
|
||||
self.x = name
|
||||
self.station_a = float(station_a)
|
||||
self.setpoint_a = float(setpoint_a)
|
||||
self.readback_a = float(setpoint_a)
|
||||
self.phases_allowed = phases_allowed
|
||||
self.car_phases = 3 # what the car can use
|
||||
self.state = "A"
|
||||
self.full = False
|
||||
self.power_w = 0.0
|
||||
self.kwh = float(kwh)
|
||||
self.unavailable = False
|
||||
self.login_on = True
|
||||
self.error = "0: No Error"
|
||||
|
||||
# -- the world acts on it -------------------------------------------------
|
||||
def plug_in(self, car_phases=3):
|
||||
self.car_phases = car_phases
|
||||
self.full = False
|
||||
self.state = "B"
|
||||
|
||||
def unplug(self):
|
||||
self.state, self.power_w = "A", 0.0
|
||||
|
||||
def car_full(self):
|
||||
self.full = True
|
||||
|
||||
def set_current(self, a):
|
||||
"""What an automation (or, later, this add-on) does to the setpoint."""
|
||||
a = max(0.0, min(float(a), self.station_a))
|
||||
self.setpoint_a = self.readback_a = a
|
||||
|
||||
def set_phases(self, n):
|
||||
self.phases_allowed = 3 if n == 3 else 1
|
||||
|
||||
def fault(self, letter="E"):
|
||||
"""mode3 E or F: the charger's own error states."""
|
||||
self.state, self.power_w = letter, 0.0
|
||||
|
||||
def go_unavailable(self, on=True):
|
||||
self.unavailable = bool(on)
|
||||
|
||||
def login(self, on=True):
|
||||
self.login_on = bool(on)
|
||||
|
||||
# -- time -----------------------------------------------------------------
|
||||
@property
|
||||
def phases_used(self):
|
||||
return min(self.car_phases, self.phases_allowed)
|
||||
|
||||
def target_w(self):
|
||||
if self.state == "A" or self.full or self.setpoint_a < MIN_A:
|
||||
return 0.0
|
||||
return self.setpoint_a * VOLTS * self.phases_used
|
||||
|
||||
def tick(self, seconds=1.0):
|
||||
for _ in range(int(max(1, round(seconds)))):
|
||||
if self.state in ("E", "F"): # a fault: nothing flows, it stays
|
||||
self.power_w = 0.0
|
||||
continue
|
||||
if self.state != "A":
|
||||
self.state = "C" if self.target_w() > 0 else "B"
|
||||
tgt = self.target_w()
|
||||
step = RAMP_W_S
|
||||
if self.power_w < tgt:
|
||||
self.power_w = min(tgt, self.power_w + step)
|
||||
else:
|
||||
self.power_w = max(tgt, self.power_w - step)
|
||||
self.kwh += self.power_w / 3.6e6
|
||||
|
||||
# -- what Home Assistant would serve ---------------------------------------
|
||||
def _amps(self):
|
||||
per = (self.power_w / (VOLTS * self.phases_used)
|
||||
if self.phases_used and self.power_w else 0.0)
|
||||
return [per if i < self.phases_used else 0.0 for i in range(3)]
|
||||
|
||||
def states(self) -> dict:
|
||||
x = self.x
|
||||
bad = "unavailable" if self.unavailable else None
|
||||
|
||||
def st(eid, value, unit=None, **attrs):
|
||||
a = dict(attrs)
|
||||
if unit:
|
||||
a["unit_of_measurement"] = unit
|
||||
return eid, {"entity_id": eid,
|
||||
"state": bad or str(value), "attributes": a}
|
||||
amps = self._amps()
|
||||
rows = [
|
||||
st(f"number.{x}_power_connector_max_current_socket_1",
|
||||
f"{self.setpoint_a:g}", "A", min=0, max=40, step=1),
|
||||
st(f"sensor.{x}_main_active_max_current_socket_1",
|
||||
f"{self.readback_a:g}", "A"),
|
||||
st(f"sensor.{x}_mode3_state_socket_1", f"STATE_{self.state}"),
|
||||
st(f"sensor.{x}_display_state_socket_1",
|
||||
{"A": "Available", "B": "EV Connected",
|
||||
"C": "Charging Normal"}.get(self.state, "Error")),
|
||||
st(f"sensor.{x}_active_power_total_socket_1",
|
||||
f"{self.power_w:.0f}", "W"),
|
||||
st(f"sensor.{x}_meter_reading_socket_1", f"{self.kwh:.2f}",
|
||||
"kWh"),
|
||||
st(f"sensor.{x}_current_l1_socket_1", f"{amps[0]:.1f}", "A"),
|
||||
st(f"sensor.{x}_current_l2_socket_1", f"{amps[1]:.1f}", "A"),
|
||||
st(f"sensor.{x}_current_l3_socket_1", f"{amps[2]:.1f}", "A"),
|
||||
st(f"number.{x}_max_station_current", f"{self.station_a:g}", "A"),
|
||||
st(f"sensor.{x}_main_station_active_max_current_socket_1",
|
||||
f"{self.station_a:g}", "A"),
|
||||
st(f"select.{x}_installation_max_allowed_phases",
|
||||
"3 Phases" if self.phases_allowed == 3 else "1 Phase"),
|
||||
st(f"sensor.{x}_connector_1_max_allowed_of_phases",
|
||||
"3 Phases" if self.phases_allowed == 3 else "1 Phase"),
|
||||
st(f"sensor.{x}_display_error_number_socket_1", self.error),
|
||||
(f"binary_sensor.{x}_https_api_login_status",
|
||||
{"entity_id": f"binary_sensor.{x}_https_api_login_status",
|
||||
"state": bad or ("on" if self.login_on else "off"),
|
||||
"attributes": {"device_class": "connectivity"}}),
|
||||
]
|
||||
return dict(rows)
|
||||
|
||||
|
||||
class FakeHa:
|
||||
"""A Home Assistant that serves a `SimCharger` (and any `extra` states) and
|
||||
records every service call and number write it is given."""
|
||||
|
||||
session = None
|
||||
token = ""
|
||||
read_only = False
|
||||
|
||||
def __init__(self, charger=None, extra=None):
|
||||
self.charger = charger
|
||||
self.extra = dict(extra or {})
|
||||
self.calls = [] # (domain, service, data)
|
||||
self.sets = [] # (entity, value)
|
||||
|
||||
def _all(self):
|
||||
out = dict(self.extra)
|
||||
if self.charger is not None:
|
||||
out.update(self.charger.states())
|
||||
return out
|
||||
|
||||
async def state(self, entity_id):
|
||||
return self._all().get(entity_id)
|
||||
|
||||
async def states(self):
|
||||
return list(self._all().values())
|
||||
|
||||
async def number(self, entity_id, invert=False):
|
||||
s = self._all().get(entity_id)
|
||||
try:
|
||||
v = float(s["state"])
|
||||
except (TypeError, ValueError, KeyError):
|
||||
return None
|
||||
return -v if invert else v
|
||||
|
||||
async def limits(self, _entity_id):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def set_number(self, entity_id, value):
|
||||
self.sets.append((entity_id, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data=None):
|
||||
self.calls.append((domain, service, data))
|
||||
return True
|
||||
|
||||
def charger_writes(self):
|
||||
"""Every write or service call that names a charger entity."""
|
||||
x = self.charger.x if self.charger else "\0"
|
||||
hits = [s for s in self.sets if x in s[0]]
|
||||
hits += [c for c in self.calls if x in json.dumps(c[2] or {})]
|
||||
return hits
|
||||
|
||||
|
||||
def serve(sim, port=8123):
|
||||
"""Answer `GET /api/states` and `GET /api/states/<id>` from `sim`, for a
|
||||
rig. Reads only: any POST answers 405, so nothing can write the model."""
|
||||
import http.server
|
||||
|
||||
class H(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
rows = sim.states()
|
||||
path = self.path.split("?")[0].rstrip("/")
|
||||
if path.endswith("/api/states"):
|
||||
body = list(rows.values())
|
||||
else:
|
||||
body = rows.get(path.rsplit("/api/states/", 1)[-1])
|
||||
self.send_response(200 if body is not None else 404)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(body).encode())
|
||||
|
||||
def do_POST(self):
|
||||
self.send_response(405)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *_a):
|
||||
pass
|
||||
|
||||
srv = http.server.ThreadingHTTPServer(("0.0.0.0", port), H)
|
||||
return srv
|
||||
|
||||
|
||||
def _selftest():
|
||||
c = SimCharger()
|
||||
assert c.states()[f"sensor.wallbox_mode3_state_socket_1"]["state"] == "STATE_A"
|
||||
c.plug_in(); c.tick(1)
|
||||
assert c.state == "B" or c.state == "C"
|
||||
c.set_current(10); c.set_phases(3); c.tick(5)
|
||||
assert c.state == "C" and abs(c.power_w - 10 * 230 * 3) < 1, c.power_w
|
||||
c.set_phases(1); c.tick(5)
|
||||
assert abs(c.power_w - 2300) < 1
|
||||
c.set_current(5); c.tick(5)
|
||||
assert c.state == "B" and c.power_w == 0 # under 6 A: paused
|
||||
c.set_current(10); c.car_full(); c.tick(5)
|
||||
assert c.state == "B" and c.power_w == 0 and c.readback_a == 10
|
||||
c.go_unavailable()
|
||||
assert c.states()[f"sensor.wallbox_active_power_total_socket_1"]["state"] \
|
||||
== "unavailable"
|
||||
c.go_unavailable(False); c.login(False)
|
||||
assert c.states()["binary_sensor.wallbox_https_api_login_status"]["state"] == "off"
|
||||
print("chargersim selftest ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--selftest" in sys.argv:
|
||||
_selftest()
|
||||
else:
|
||||
srv = serve(SimCharger(), int(sys.argv[1]) if len(sys.argv) > 1 else 8123)
|
||||
print("serving the charger model, GET only; Ctrl-C stops it")
|
||||
srv.serve_forever()
|
||||
@@ -116,10 +116,15 @@ class Lag:
|
||||
after the reading (the HA source's debounce), so a cycle just after a
|
||||
quarter boundary can still see the old block; `tick` the cycle period;
|
||||
`tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1,
|
||||
no lag or ramp)."""
|
||||
no lag or ramp). `batt_period_s` (MAINT-28): the battery register is
|
||||
published every that many seconds and held between (the rig: 10 s), so its
|
||||
reading is `batt_s` plus up to a period older than the meter's; None keeps
|
||||
the constant age."""
|
||||
|
||||
def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35,
|
||||
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0):
|
||||
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0,
|
||||
batt_period_s=None):
|
||||
self.batt_period_s = batt_period_s
|
||||
self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s
|
||||
self.p1_s, self.tick, self.sub = p1_s, tick, sub
|
||||
self.tau_s, self.ramp_w_s = tau_s, ramp_w_s
|
||||
@@ -211,6 +216,11 @@ class Hass:
|
||||
if e.startswith("sensor.batt"):
|
||||
# A real reading jitters by a few watts; a dead-flat one is a
|
||||
# stale register to the add-on (SAFETY-37), which is not modelled.
|
||||
if self.lag is not None and self.lag.batt_period_s:
|
||||
per = self.lag.batt_period_s
|
||||
ts = math.floor((self.t - self.lag.batt_s) / per) * per
|
||||
j = (ord(u) * 31 + int(ts)) % 7 - 3
|
||||
return float(round(self._past(self.t - ts)[1][u]) + j)
|
||||
j = (ord(u) * 31 + int(self.t)) % 7 - 3
|
||||
p = self.plants[u].p if self.lag is None else \
|
||||
self._past(self.lag.batt_s)[1][u]
|
||||
|
||||
@@ -36,7 +36,7 @@ sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
from aiohttp import web as aioweb # noqa: E402
|
||||
|
||||
from app import homeview, packmode, web # noqa: E402
|
||||
from app import chargers, freshness, homeview, packmode, web # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN # noqa: E402
|
||||
from app.setuponly import SetupOnly # noqa: E402
|
||||
@@ -44,6 +44,7 @@ from app.peakguard import PeakDecision # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
import translations_lint as TL # noqa: E402
|
||||
from chargersim import SimCharger # noqa: E402
|
||||
|
||||
PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
|
||||
.14, .16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
|
||||
@@ -104,6 +105,61 @@ class Hass:
|
||||
return STATES
|
||||
|
||||
|
||||
# EV-03: the car states. A charger model behind the demo's Home Assistant; the
|
||||
# real `Controller._read_charger()` reads it, so the card is what the add-on
|
||||
# draws, not a picture.
|
||||
CAR_STATES = ("car", "car_none", "car_waiting", "car_full", "car_unreach",
|
||||
"car_setup")
|
||||
CAR_ENTITY = "number.wallbox_power_connector_max_current_socket_1"
|
||||
SIM = SimCharger()
|
||||
|
||||
|
||||
class CarHass(Hass):
|
||||
async def state(self, e):
|
||||
return SIM.states().get(e) or await super().state(e)
|
||||
|
||||
async def states(self):
|
||||
return list(STATES) + list(SIM.states().values())
|
||||
|
||||
|
||||
def car_setup(state):
|
||||
"""Put the model in the state's situation."""
|
||||
global SIM
|
||||
SIM = SimCharger(name="other" if state == "car_setup" else "wallbox")
|
||||
if state in ("car", "car_unreach"):
|
||||
SIM.plug_in()
|
||||
SIM.set_phases(3)
|
||||
SIM.set_current(16)
|
||||
elif state == "car_waiting":
|
||||
SIM.plug_in()
|
||||
SIM.set_current(0)
|
||||
elif state == "car_full":
|
||||
SIM.plug_in()
|
||||
SIM.set_current(10)
|
||||
SIM.car_full()
|
||||
SIM.tick(12)
|
||||
|
||||
|
||||
async def car_feed(c, state):
|
||||
"""The cycle's own read, once a second, like the control loop."""
|
||||
backdated = False
|
||||
n = 0
|
||||
while True:
|
||||
n += 1
|
||||
if state == "car_unreach" and n == 4:
|
||||
SIM.go_unavailable()
|
||||
SIM.tick(1)
|
||||
await c._read_charger(freshness.monotonic())
|
||||
if state == "car_unreach" and c.car.bad_since is not None \
|
||||
and not backdated:
|
||||
backdated = True # 12 minutes in, so the row and the note show
|
||||
c.car.bad_since -= 720.0
|
||||
c.car.bad_since_utc -= timedelta(seconds=720)
|
||||
if state == "car_full" and c.car.b_since is not None and n < 3:
|
||||
c.car.b_since -= 120.0 # the full dwell has passed
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
|
||||
KETTLE_READS = (-10, 1990, 1990, -10)
|
||||
KETTLE_COUNT = [0]
|
||||
|
||||
@@ -244,7 +300,13 @@ def controller(state):
|
||||
opts = dict(OPTS, shadow_mode="on" if state == "shadow" else "off")
|
||||
if state in P3:
|
||||
opts["battery_modes_enabled"] = True
|
||||
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
|
||||
hass = Hass()
|
||||
if state in CAR_STATES:
|
||||
car_setup(state)
|
||||
opts.update(ev_charger="alfen_ha", ev_current_entity=CAR_ENTITY,
|
||||
ev_max_a=16, ev_phases="auto")
|
||||
hass = CarHass()
|
||||
c = Controller(opts, hass, Store(), Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
now = datetime.now(timezone.utc)
|
||||
day0, _day1 = homeview.local_day(now)
|
||||
@@ -264,6 +326,12 @@ def controller(state):
|
||||
inv.last_write = 0.0
|
||||
c.grid = -10.0 if state != "shadow" else -2810.0
|
||||
c.pv_w = 3900.0
|
||||
if state in CAR_STATES:
|
||||
# 11 kW to the car on top of a 1.6 kW house, all from the roof
|
||||
c.grid, c.pv_w = 0.0, (12600.0 if state == "car" else 2400.0)
|
||||
for inv in c.inverters:
|
||||
inv.batt = 0.0
|
||||
c.car.stale_s = 15.0
|
||||
if state == "alarm":
|
||||
c.inverters[0].sign_wd.tripped = True
|
||||
if state in P3:
|
||||
@@ -327,9 +395,17 @@ async def main(state, port, host="127.0.0.1"):
|
||||
battery_modes_enabled=state in P3,
|
||||
inverters=[dict(i, bms_soc_floor=10)
|
||||
for i in OPTS["inverters"]])
|
||||
runner = aioweb.AppRunner(web.build_app(c, Supervisor(stored)))
|
||||
stored.update(ev_charger=c.o.get("ev_charger", "off"),
|
||||
ev_current_entity=c.o.get("ev_current_entity", ""),
|
||||
ev_max_a=c.o.get("ev_max_a", 16),
|
||||
ev_phases=c.o.get("ev_phases", "auto"))
|
||||
app = web.build_app(c, Supervisor(stored))
|
||||
runner = aioweb.AppRunner(app)
|
||||
await runner.setup()
|
||||
await aioweb.TCPSite(runner, host, port).start()
|
||||
if state in CAR_STATES:
|
||||
asyncio.get_running_loop().create_task(car_feed(c, state))
|
||||
main.app = app
|
||||
print(f"serving {state} on http://127.0.0.1:{port}/", flush=True)
|
||||
await asyncio.Event().wait()
|
||||
|
||||
|
||||
@@ -283,6 +283,18 @@ configuration:
|
||||
shadow_mode:
|
||||
name: "Safety · Shadow mode"
|
||||
description: "The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never."
|
||||
ev_charger:
|
||||
name: "Car charger · Charger"
|
||||
description: "Which car charger the add-on reads. Off leaves everything as it is. This version only reads the charger and never changes it."
|
||||
ev_current_entity:
|
||||
name: "Car charger · Current setting entity"
|
||||
description: "The entity that sets the charger's current. The add-on finds the charger's other entities from its name."
|
||||
ev_max_a:
|
||||
name: "Car charger · Charger limit (A)"
|
||||
description: "The most current the charger may ever be asked for. Never above what the charger reports. Lower it for a weaker fuse or cable."
|
||||
ev_phases:
|
||||
name: "Car charger · Phases"
|
||||
description: "How many phases the charger may use: 1, 3, or 1 and 3 (auto)."
|
||||
auto_start:
|
||||
name: "Advanced · Start control on boot"
|
||||
description: "Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart."
|
||||
|
||||
@@ -264,6 +264,18 @@ configuration:
|
||||
shadow_mode:
|
||||
name: "Veiligheid · Schaduwmodus"
|
||||
description: "De add-on neemt elke beslissing maar houdt elke omvormer op 0 W. 'auto' doet dit zolang de regeling gestopt is, 'on' altijd, 'off' nooit."
|
||||
ev_charger:
|
||||
name: "Laadpaal · Laadpaal"
|
||||
description: "Welke laadpaal de add-on leest. Uit laat alles zoals het is. Deze versie leest de laadpaal alleen en wijzigt niets."
|
||||
ev_current_entity:
|
||||
name: "Laadpaal · Entiteit van de laadstroom"
|
||||
description: "De entiteit die de stroom van de laadpaal instelt. De add-on vindt de andere entiteiten van de laadpaal via de naam."
|
||||
ev_max_a:
|
||||
name: "Laadpaal · Laadpaalgrens (A)"
|
||||
description: "De hoogste stroom die aan de laadpaal gevraagd mag worden. Nooit hoger dan wat de laadpaal meldt. Verlaag ze voor een zwakkere zekering of kabel."
|
||||
ev_phases:
|
||||
name: "Laadpaal · Fasen"
|
||||
description: "Hoeveel fasen de laadpaal mag gebruiken: 1, 3, of 1 en 3 (auto)."
|
||||
auto_start:
|
||||
name: "Geavanceerd · Regeling starten bij opstarten"
|
||||
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is, zodat iemand na elke herstart op Start drukt."
|
||||
|
||||
Reference in New Issue
Block a user