Compare commits
66
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b7d6b7f05 | ||
|
|
a0e091cca3 | ||
|
|
44cd1fde2a | ||
|
|
e7c83d3a57 | ||
|
|
004f859ec7 | ||
|
|
ccdb9dc17a | ||
|
|
06339a2446 | ||
|
|
bf9019e70d | ||
|
|
6862dea52a | ||
|
|
c68fd275cc | ||
|
|
2f261f8ba4 | ||
|
|
0801776645 | ||
|
|
4eb88f7fda | ||
|
|
87ad958842 | ||
|
|
9ac3a9d2bc | ||
|
|
0ce7d35f80 | ||
|
|
6aa1c32cbb | ||
|
|
5ee1061398 | ||
|
|
fd059126cb | ||
|
|
21d8a944bc | ||
|
|
79dd0770f9 | ||
|
|
7f0f00b01c | ||
|
|
065792aad3 | ||
|
|
e3e4ba674d | ||
|
|
df6bc0f76b | ||
|
|
d94cb0c263 | ||
|
|
3050fa5208 | ||
|
|
92a80c09b2 | ||
|
|
9b1fa758ad | ||
|
|
1b6f16dba2 | ||
|
|
e36565fe39 | ||
|
|
808ec04dc4 | ||
|
|
35432ce065 | ||
|
|
580c200527 | ||
|
|
35b2a699de | ||
|
|
cd1bf2a694 | ||
|
|
db9e2315d1 | ||
|
|
848140c29f | ||
|
|
d12228d986 | ||
|
|
bf3ac7998c | ||
|
|
61a61941bb | ||
|
|
ef35f4b925 | ||
|
|
f39e52efd3 | ||
|
|
8a17711f60 | ||
|
|
ea31289ec0 | ||
|
|
6836f61bd4 | ||
|
|
f7b83cc75c | ||
|
|
6f05b2e4be | ||
|
|
28eff568b5 | ||
|
|
b527e99f13 | ||
|
|
c6f23a69d4 | ||
|
|
98dd110a4f | ||
|
|
138e45b53f | ||
|
|
df154a26d1 | ||
|
|
451729c9b5 | ||
|
|
1c607a961a | ||
|
|
756b50ddbb | ||
|
|
340c7eef42 | ||
|
|
21599938ea | ||
|
|
3a54b253cb | ||
|
|
8b9680fc35 | ||
|
|
c38dd1e969 | ||
|
|
fae6681e19 | ||
|
|
3ce18708b6 | ||
|
|
78acb558ff | ||
|
|
0f14c2a5de |
@@ -1,5 +1,172 @@
|
||||
# Changelog
|
||||
|
||||
## 0.18.0
|
||||
|
||||
**A minor bump: new behaviour, three new options and one new value for an
|
||||
existing option.** No option was removed or renamed, and no default of an
|
||||
existing option changed (83 options -> 86). **Existing installs need no option
|
||||
change:** options that start 0.17.0 also start 0.18.0, and the new options are
|
||||
off or empty until you use them. The changes below apply with one inverter or
|
||||
two, unless a line says "Two inverters".
|
||||
|
||||
New options, and what they do if you leave them alone:
|
||||
|
||||
| option | default | left alone |
|
||||
|---|---|---|
|
||||
| `pv_forecast_entities`, `pv_forecast_tomorrow_entities` | `[]` | the single forecast sensors work as before ([[OPT-17]]) |
|
||||
| `price_service_token` | empty | not used; it is read only with `price_provider: price_service` ([[PRICE-01]]) |
|
||||
|
||||
`price_provider` also accepts a new value, `price_service`. Its default is
|
||||
unchanged, so the price source stays as it is.
|
||||
|
||||
**Price service.** The new price source `price_service` reads prices from the
|
||||
add-on's price server, with the token you were given. If it does not answer,
|
||||
prices come from energy-charts.info, then from the prices the add-on saved.
|
||||
Sites on the Home Assistant price sensors or a fixed tariff need no change. To
|
||||
move a site over, see `DOCS.md`, "Moving a site from the Home Assistant price
|
||||
sensors to `price_service`".
|
||||
|
||||
Contents of this release, in merge order since 0.17.0 (`e36565f`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3050fa5` | The solar forecast can come from several sensors, added up, for example one per inverter. If one of them has no reading, that day's forecast is not used, and the status says which one. The single sensor still works ([[OPT-17]]) |
|
||||
| `e3e4ba6` | While Home Assistant is still starting, the page now says "Waiting for Home Assistant" and the log says the add-on waits, instead of looking like a fault. Control itself is unchanged: it starts when Home Assistant answers ([[SAFETY-112]]) |
|
||||
| `79dd077` | Battery modes on only: a charge in a cheap run no longer stops at the start of a new 15-minute period, and with two inverters a battery that refused to charge is tried again every 15 min. In one test, the export at the end of a charge was 48 W where the code before this fix gave 746 W ([[OPT-15]]) |
|
||||
| `0ce7d35` | Two inverters: each battery card has Start cycle and Stop cycle buttons, so you can start or stop the cycle on the battery you choose. Only one battery can cycle at a time, and a second start is refused with a short message ([[MAINT-11]]) |
|
||||
| `bf9019e` | New price source `price_service`. If it fails, prices come from energy-charts.info, then from saved prices. With no price at all and the monthly cycle on, a notice shows and the cycle waits ([[PRICE-01]]) |
|
||||
| `004f859` | Two inverters, battery modes on: the 15-minute retry of a battery that refused to charge now asks that battery for 200 W, and no longer puts the rest of the charge on another battery. In the test, without this fix, a battery in its maintenance charge took about 2,300 W for about 34 s at each retry ([[OPT-20]]) |
|
||||
|
||||
### 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 ([[SAFETY-99]]).
|
||||
- Two inverters: the maintenance charge can switch on and off while the house is above the peak aim ([[SAFETY-88]]).
|
||||
- Two inverters: the "circulating" figure counts a battery's reading at rest ([[SAFETY-98]]).
|
||||
- Two inverters: a drain that starts during a big step in house use can send the other battery to full power, and about 1 kW goes to the grid for about 10 s ([[SAFETY-104]]).
|
||||
- Two inverters: when a battery comes back during a maintenance charge, the grid can step by several kW at once ([[SAFETY-101]]).
|
||||
- Two inverters: when a battery drops out during a solar surplus, the others can swing by up to 1.5 kW in a simulation ([[SAFETY-105]]).
|
||||
- Two inverters: after a drain ends, a change in house use can go unanswered for about 9 s, because some readings are old ([[SAFETY-106]]).
|
||||
- Two inverters: when one battery's level is missing, both batteries are held at 0 W, and for about 15 s the last command stays on ([[SAFETY-108]]).
|
||||
- Two inverters: if one battery's level stays missing for 12 h, the other battery's cycle can be marked failed, with a 7-day wait and a notice, though it is healthy. This comes from reading the code; it has not been seen on a site ([[MAINT-17]]).
|
||||
- When the 15-minute demand forecast sensor counts the battery's own charge, the maintenance charge runs at about half the power the peak limit leaves, so it takes about twice as long. Seen on a site: about 1.2 kW instead of 2.5 kW, and the peak limit was kept ([[SAFETY-111]]).
|
||||
- One inverter: after a link drop, the battery's own safety stop to 0 W can be taken as a refusal, and the site stays at 0 W for up to 15 min ([[SAFETY-102]]).
|
||||
- After a Home Assistant restart, control still waits until Home Assistant has finished starting, which can take several minutes, though the readings are already there ([[SAFETY-115]]).
|
||||
- While Home Assistant is not answering, the page can list inverters as refusing commands and sensors as missing, and send you to Developer Tools, though they are fine ([[UX-37]]).
|
||||
- If Home Assistant hangs or stops, the page keeps saying "Waiting for Home Assistant" and never turns into an alarm ([[SAFETY-113]]).
|
||||
- Right after Home Assistant has started, the page can say for about 4 s that an inverter cannot be controlled, and the first command can come late ([[SAFETY-114]]).
|
||||
- After a Home Assistant restart, the controller status sensor can still say "running" while the add-on cannot control. It was seen once, and the cause is not known ([[SAFETY-116]]).
|
||||
- Battery modes on: after a planned top-up from the grid, the Batteries screen can say "Enough sun is forecast" ([[UX-32]]).
|
||||
- Battery modes on: the forecast line "we count on" can show more sun than the plan counts, because the plan takes house use off first ([[UX-33]]).
|
||||
- Battery modes on: before a planned top-up, the Today list shows a smaller "gives" amount than the plan will use; it grows as the charge happens ([[UX-34]]).
|
||||
- Two inverters, battery modes on: while the other battery is in its maintenance charge, a battery's card can say it charges from the grid while it stays at 0 W ([[OPT-18]]).
|
||||
- Two inverters, battery modes on: while a battery refuses to charge in a cheap run, the action log on the page gets about 8 lines an hour for its retries, and the page hint says "waiting for a better price" ([[UX-38]]).
|
||||
- A battery in a safety stop, or one the add-on cannot reach, can still be started for a cycle, and its drain then waits about 12 h and ends in a failed-cycle notice. This comes from reading the code; it has not been seen on a site ([[MAINT-19]]).
|
||||
- On the Settings page, the rows of the solar forecast lists are labelled "Phase 1", "Phase 2" instead of "Sensor 1", "Sensor 2" ([[UX-27]]).
|
||||
- In the solar forecast list, a blank or repeated row makes "source 2 of 2" count differently from the rows on the Settings page ([[UX-28]]).
|
||||
- The solar forecast message says "has no reading" also when the sensor shows a number that is too old or in the wrong unit ([[UX-29]]).
|
||||
- If you use only the forecast lists, two Settings notes sit on the single-sensor field, and one of them tells you to set that field ([[UX-30]]).
|
||||
- After a restart during a price outage, the "No prices" message can say "yet" when prices did come in earlier ([[UX-39]]).
|
||||
- On the new settings page, you cannot see whether a price service token is set. Check the Configuration tab ([[UX-41]]).
|
||||
- On the classic settings page, the first click on Save after editing a field can do nothing. Click again ([[UX-42]]).
|
||||
|
||||
### 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.17.0
|
||||
|
||||
**A minor bump: changed behaviour, and no option changes.** No option was
|
||||
added, removed or renamed, and no default changed (83 options). Options you
|
||||
saved work as before. The changes below apply with one inverter or two, unless
|
||||
a line says "Two inverters".
|
||||
|
||||
**Maintenance with no battery level.** When the battery level is missing,
|
||||
battery maintenance now ends on its own time limits instead of waiting for the
|
||||
level to come back (MAINT-14, MAINT-15). You do not need to change any option.
|
||||
|
||||
Contents of this release, in merge order since 0.16.0 (`451729c`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `b527e99` | Two inverters: after a drain ends, the other battery no longer swings on old readings. In the test, it held steady for 23 s where 0.16.0 moved it by 720 W ([[SAFETY-103]]) |
|
||||
| `8a17711` | A full battery that stops at 98 or 99 % no longer counts as a failed charge. After 60 min with no new high, the charge counts as done; a charge that truly fails now waits 7 days and shows one notice ([[MAINT-13]]) |
|
||||
| `bf3ac79` | With no battery level, a maintenance charge still ends at its time limit and a drain waits at 0 W. The log no longer repeats a line every second ([[MAINT-14]]) |
|
||||
| `db9e231` | One inverter: when maintenance charges a battery that is already full, the log no longer repeats "The battery refuses to give power" every second. In the test, 1 line in 16 min where the code before this fix wrote about 1,700 in 15 min; the power does not change ([[SAFETY-107]]) |
|
||||
| `580c200` | The balancing hold after a charge now ends on time when the battery level is missing. Before, it waited until the level came back ([[MAINT-15]]) |
|
||||
|
||||
### 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 ([[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]]).
|
||||
- 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]]).
|
||||
|
||||
### 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.16.0
|
||||
|
||||
**A minor bump: changed behaviour, no option added or removed, and one default
|
||||
and its meaning changed.** There are still 83 options. `maintenance_start_hour`
|
||||
used to be read as UTC and its default was 10. It is now a local hour (in Home
|
||||
Assistant's time zone) and its default is 19. The other changes below apply
|
||||
with one inverter or two, unless a line says "Two inverters".
|
||||
|
||||
**Before you update (MAINT-10).** A saved `maintenance_start_hour` is kept, and
|
||||
it now means local time. If you set it in UTC to get an evening start (for
|
||||
example 17 for 19:00 in summer), set it back to the local hour (19). A saved
|
||||
10 now starts at 10:00, not 12:00 in summer. The new default 19 only reaches
|
||||
installs that have no hour saved.
|
||||
|
||||
Contents of this release, in merge order since 0.15.0 (`3ed3302`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `7ef7347` | After a gap in the P1 meter, the peak guard counts the missed time as high use and keeps the 15-minute period near the limit; before, it was blind until the next period. In the test, a 5 min meter silence at one inverter ended the period at 3.3 kW, against 4.8 kW on 0.15.0 ([[SAFETY-90]]) |
|
||||
| `1bf6b7f` | The maintenance start hour is now a local hour, and its default is 19 (was 10, read as UTC). The log says which time zone it used; with two inverters, both use that hour and still cycle one at a time ([[MAINT-10]]) |
|
||||
| `b2f5573` | Two inverters: when one battery's link drops, the other takes over in small steps, and control no longer stalls. The battery that comes back rejoins within 2 s, without a false "battery refuses" ([[SAFETY-96]]) |
|
||||
| `340c7ee` | Two inverters: when a drain ends (done, switched Off, timeout, lost link), the other battery covers the house right away, without jumping to 1.6 kW, and then moves in small steps. It no longer gives into a solar surplus ([[SAFETY-95]]) |
|
||||
|
||||
### 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 ±170 W is still not caught when it refuses, as in 0.14.0 ([[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 ([[SAFETY-99]]).
|
||||
- A charge to 100 % can time out at 98 %. The add-on then counts the cycle as failed and drains the battery again the next day ([[MAINT-13]]).
|
||||
- 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: after a drain ends, the other battery can swing by about 800 W for about 5 s, because some readings are old ([[SAFETY-103]]).
|
||||
- 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]]).
|
||||
- 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]]).
|
||||
|
||||
### 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.15.0
|
||||
|
||||
**A minor bump: changed behaviour, and no option changes.** No option was
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# MAINT-11 - start or stop the maintenance cycle per battery
|
||||
|
||||
**Status: for review.** Tests: `test_maint11.py`. No option added.
|
||||
|
||||
**The fault.** At two or more batteries the page had one "start a battery cycle"
|
||||
button, and `_maint_button()` chose the battery. An operator could not cycle a
|
||||
chosen one (inverter B, new after storage, needed its first cycle).
|
||||
|
||||
## Rule
|
||||
|
||||
1. `maint_start` and `maint_abort` take an optional `unit`. With no unit they
|
||||
run the old code unchanged. The Batteries screen sends the unit from a
|
||||
per-battery Start cycle and Stop cycle button, at two or more batteries only.
|
||||
2. **A start while any battery holds the maintenance slot is refused, not
|
||||
queued.** The message names the battery that is cycling. One slot per site
|
||||
stays the rule, and the scheduler is untouched.
|
||||
3. Also refused, with a plain message: an unknown unit, a start on a battery
|
||||
that is switched off (battery modes on), and a stop on a battery that is not
|
||||
cycling. Nothing changes on a refusal.
|
||||
4. A stop on the cycling battery is today's abort: the cycle ends, then every
|
||||
inverter goes to 0 W.
|
||||
|
||||
## Why refuse and not queue
|
||||
|
||||
A queued start is a cycle that begins hours later, unseen, after the operator
|
||||
has moved on. A refusal is immediate and says why. The scheduler already
|
||||
queues by waiting time, so nothing is lost.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- Today's bare button never refused a battery that is refusing to give power
|
||||
(SAFETY-73); its drain waits. The per-battery start does the same.
|
||||
- A start on the battery that is already cycling is refused as "busy", where
|
||||
the bare button restarts its drain. The bare button is unchanged.
|
||||
- At one battery, a request that names the battery uses the new path, so a
|
||||
second start is refused instead of restarting the drain. Nothing sends a name
|
||||
at one battery, and the bare request is unchanged.
|
||||
@@ -0,0 +1,109 @@
|
||||
# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge
|
||||
|
||||
**Status: for review.** Tests: `test_maint13.py`. No option added; every number
|
||||
is a constant in `app/maintenance.py`.
|
||||
|
||||
**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge
|
||||
began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00,
|
||||
fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never
|
||||
reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock
|
||||
after the charge began) it aborted "charge stuck at 98 %". `last_completed` was
|
||||
not set, so B was still due and would have drained again from 98 % to 11 % at the
|
||||
next start hour, with no notice. Three faults: a pack may never report 100 %
|
||||
while the BMS balances; a failed cycle came back the next day; and the 36 h
|
||||
counted hours the price gate kept the pack waiting.
|
||||
|
||||
## 1. The charge is complete when
|
||||
|
||||
1. SoC >= target (as before), or
|
||||
2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its
|
||||
best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or
|
||||
3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3.
|
||||
|
||||
Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and
|
||||
3 logs one plain line ("the battery takes no more charge at 98 %, so the charge
|
||||
counts as done", "charge time is up at 98 %, close enough to full ...").
|
||||
|
||||
**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the
|
||||
one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power
|
||||
was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be
|
||||
invented. SoC also covers the case where power flows but nothing is stored.
|
||||
**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat
|
||||
at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of
|
||||
the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to
|
||||
06:24) without a new high, so 60 min is well inside it and long enough that a
|
||||
pack still creeping up (one new high within the hour) keeps resetting the wait.
|
||||
On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00
|
||||
UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to
|
||||
step up more slowly than that, raise the constant. Falling back by one point
|
||||
does not reset the wait; only a new best does.
|
||||
|
||||
## 2. A failed cycle backs off 7 days
|
||||
|
||||
A drain stuck 12 h or a charge that times out below target - 3 is a failure
|
||||
(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is
|
||||
stored, and `wants_start()` is false until 7 days later. The operator's forced
|
||||
start ignores it; a cycle that completes after the failure ends it. An operator
|
||||
abort, an interlock stop and a switched-off pack are not failures.
|
||||
|
||||
**Why 7 days, not the 28 day interval.** A judgement: a failure may have a
|
||||
short cause (several dull days with no cheap hours, a pack switched off), and
|
||||
waiting the full interval leaves the coulomb counter uncalibrated for a month
|
||||
more. A week is long enough that a bad pack costs one drain a week, with one
|
||||
notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks;
|
||||
the notice says so each time.
|
||||
|
||||
**Notice, once.** The tick that fails carries `failed`; the Controller sends one
|
||||
persistent notification "Battery cycle did not finish" (EN, with the Dutch
|
||||
below it; per pack at N=2). It is not repeated, and Home Assistant restarts
|
||||
wipe it, so the status page shows a "Maintenance paused" row with the date for
|
||||
as long as the wait lasts. The operator may also see the log lines.
|
||||
|
||||
## 3. The timeout counts only allowed time
|
||||
|
||||
`Controller._charge_gate_open()` says whether the pack may charge now. It is
|
||||
true when no price gate is set up (no price entities, no fixed tariff: nothing
|
||||
holds the charge back, and `cheap` is always false there), in a cheap window, or
|
||||
while the pack is really taking charge (battery power at or below -50 W, which is
|
||||
sun outside cheap hours). It is false only when a gate exists, is shut, and the
|
||||
pack is not charging; a pack asked for charge that delivers nothing then does not
|
||||
count, which is the case the clock cannot judge. A site with a price provider
|
||||
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
|
||||
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
|
||||
top-up there and only the sun charges the pack, as on a site with no tariff. Its
|
||||
time counts in full. When a provider plan starts to drive `cheap`,
|
||||
`_price_gated` must become true for it. Whether a fixed tariff is set up is
|
||||
worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick
|
||||
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
|
||||
while the gate is closed. The accounting is written to the state file at most
|
||||
every 5 min (`charge_track`); a restart loses at most that. A state file from
|
||||
before this change, or with no record for the current charge, starts from the
|
||||
wall-clock minutes so far, which is the old behaviour.
|
||||
|
||||
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
|
||||
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
|
||||
complete at >= target - 3, a failure below.
|
||||
|
||||
**MAINT-12.** This is the smallest change that makes the timeout honest: one
|
||||
boolean in, one counter. MAINT-12's block plan can replace the boolean (the
|
||||
minutes it plans are the allowed ones) without touching the rest. Two things it
|
||||
must keep: the back-off is in `wants_start()`, so any new entry point (such as
|
||||
`price_forecast.begin_phase`, which is not wired today) must go through it or
|
||||
check `backoff_until()`; and `gate_open` must stay "the pack is allowed to
|
||||
charge now". The peak guard pausing the charge shaping is not seen as a closed
|
||||
gate: that time still counts.
|
||||
|
||||
## 4. What does not change, and what this does not cover
|
||||
|
||||
- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A
|
||||
backed-off pack does not want a start, so at N=2 the other pack may take the
|
||||
slot. One pack at a time at N=1 and N=2.
|
||||
- `last_failed` is a maintenance key and moves with the others when a second
|
||||
inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it
|
||||
restarts from the wall-clock minutes.
|
||||
- It does not find out why a pack stopped. A pack that stops at 96 % and never
|
||||
gets near is still a failure.
|
||||
- The Dutch text is only in the notification. The status row and the log lines
|
||||
are English, like the rest of the status page.
|
||||
- It does not change the target: a site that wants a different stop point sets
|
||||
`maintenance_soc_target`.
|
||||
@@ -0,0 +1,68 @@
|
||||
# MAINT-14 - no SoC reading in a maintenance cycle
|
||||
|
||||
**Status: for review.** Tests: `test_maint14.py`. No option added; the one new
|
||||
number is a constant in `app/maintenance.py`.
|
||||
|
||||
**The fault.** `Maintenance.tick()` returned early when the SoC was `None`,
|
||||
before any timeout check. (1) A charge on a pack whose SoC reading was lost
|
||||
never ended: no timeout, no back-off, no notice, and the 7-day backstop of
|
||||
MAINT-13 sat behind the same return. (2) A drain kept commanding `discharge_w`
|
||||
with an unknown level, which breaks the TEL-08 rule that an unknown level never
|
||||
allows discharge.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Charge, no SoC.** The charge timeout (`CHARGE_TIMEOUT_MIN`, gate-open
|
||||
minutes) and the 7-day wall backstop still run. When one fires: if the last
|
||||
SoC seen was >= target - 3 the charge counts as done (the MAINT-13 "close
|
||||
enough to full" rule, HOLD follows); otherwise it fails: MAINT-13 back-off
|
||||
and its one notification. If no SoC was ever seen, it fails.
|
||||
2. **Drain, no SoC.** DRAIN holds at 0 W and owns the setpoint, exactly as HOLD
|
||||
does. The 12 h drain timeout still runs and fails as before.
|
||||
3. **SoC comes back.** Nothing is recorded; the drain continues at the drain
|
||||
rate, the charge carries on, and the stall wait of MAINT-13 restarts from
|
||||
what it was: it paused while the reading was missing, so a gap never counts
|
||||
as "no more charge".
|
||||
4. **HOLD** is unchanged (0 W).
|
||||
5. **Log.** One line when a drain starts holding for this reason ("the battery
|
||||
level is unknown, so the drain holds at 0 W until it is back"), and one line
|
||||
when the level has been missing for `NOSOC_NOTE_MIN` = 15 min. Both are said
|
||||
once per episode; the old line that repeated every tick is gone.
|
||||
|
||||
## Why
|
||||
|
||||
- **"Last seen", not "best seen".** A pack that was at 99 and then dropped to 50
|
||||
before the reading went is not close to full. Only the last reading counts.
|
||||
It is saved with the charge record (`charge_track.last`), and saved at once
|
||||
when the reading first goes missing, so a restart mid-gap still knows it
|
||||
(within the usual 5 min save interval otherwise).
|
||||
- **Failure when nothing was seen.** With no level we cannot call a pack full.
|
||||
The failure costs a 7-day wait and one notice; calling it done costs a
|
||||
missed balance and a month-long false "complete". The first is the honest one.
|
||||
- **Timeouts count the same minutes as with a reading.** Gate-open charging
|
||||
minutes only; the wall backstop for a gate that never opens.
|
||||
- **15 min.** A judgement. A poll glitch of a few minutes should not write a
|
||||
line; a reading lost for a quarter of an hour is worth one. Raise it if the
|
||||
action log shows noise.
|
||||
- **0 W, not "stop the phase".** Aborting a drain on a short gap would throw
|
||||
away the part already drained. Holding at 0 W loses nothing and the 12 h
|
||||
timeout is the limit.
|
||||
|
||||
## Cost
|
||||
|
||||
- A drain that loses its reading stops draining, and the 12 h clock keeps
|
||||
running. A gap of many hours can therefore fail a drain that was working. The
|
||||
back-off then applies (7 days).
|
||||
- A charge whose reading is lost early (last SoC below target - 3) fails at the
|
||||
timeout, even if the pack was in fact filling. The same 7-day wait applies.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- **HOLD with no SoC**: closed by MAINT-15 (`MAINT-15-hold-ends-without-soc.md`).
|
||||
- It does not tell the operator why the reading is missing (sensor, register,
|
||||
link). It does not reuse the stale-input gate; it is the cycle's own rule.
|
||||
- The charge itself is still driven by the control loop, which has its own
|
||||
handling of a missing SoC (TEL-08 for the loop is a separate card).
|
||||
- A restart loses at most 5 min of accounting, as in MAINT-13. The "unknown for
|
||||
long" line may be said again after a restart: the episode start is in memory.
|
||||
- Text added is two log lines, English only, like the other log lines.
|
||||
@@ -0,0 +1,47 @@
|
||||
# MAINT-15 - the balancing hold ends on time with no SoC reading
|
||||
|
||||
**Status: for review.** Tests: `test_maint15.py`. No option added.
|
||||
|
||||
**The fault.** `step()` sent every tick with no SoC to `_no_soc()`, and for
|
||||
HOLD that returned 0 W without the time check. A hold that lost its reading
|
||||
never ended: `last_completed` was never written and the next due date never
|
||||
moved. Harmless for power (0 W), but the cycle was stuck. This was left open
|
||||
in MAINT-14.
|
||||
|
||||
## Rule
|
||||
|
||||
1. **HOLD ends at `hold_min` whether or not there is a reading.** One helper,
|
||||
`Maintenance._hold()`, holds the time check and is used by both paths. When
|
||||
the time is up it writes `last_completed`, goes IDLE and logs the same
|
||||
"cycle COMPLETE" line. Before the time is up it commands 0 W as before.
|
||||
2. **Text.** The 15-min "no battery level" line in HOLD now reads "The hold
|
||||
still ends at its normal time." Still once per episode.
|
||||
|
||||
## Why
|
||||
|
||||
HOLD is reached only after the charge counted as done. Every entry into HOLD
|
||||
is in `step()` or `_no_soc()` and follows a charge that reached the target,
|
||||
stalled near full (MAINT-13) or timed out near full (MAINT-13/14). Nothing else
|
||||
sets the phase to HOLD (`_enter(HOLD` has four call sites, all after a done
|
||||
charge). The hold is a wait for the BMS to balance; it never needed the level.
|
||||
So a hold that ends with no reading is a completed cycle. The "cycle COMPLETE"
|
||||
line is true without a reading: it names minutes of hold, not a level.
|
||||
|
||||
## Cost
|
||||
|
||||
- If the level was lost during the hold, the cycle is recorded as complete
|
||||
without anyone seeing the final level. The charge had counted as done, so this
|
||||
is the same trust MAINT-14 already gives a charge that ends with no reading.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- It does not check that the pack is still full at the end of the hold.
|
||||
- It does not say why the reading was lost.
|
||||
- With a reading, nothing changes. `test_maint15.py` section 7 compares the new
|
||||
HOLD tick by tick with a frozen copy of the release/1.0 HOLD branch (phase,
|
||||
setpoint, owner, events, `last_completed`) at four `hold_min` values. A wider
|
||||
trace of the whole cycle (800 seeded runs against db9e231) was run outside the
|
||||
repo; its script and log are kept under `scratch/MAINT-15/` of the dev
|
||||
workspace, not in this repository.
|
||||
- A state file written by an older version with `phase: hold` loads and ends on
|
||||
time; the file is not changed in format.
|
||||
@@ -50,7 +50,7 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
needs its own run to clear the spread (without: a night charge was spent
|
||||
on flat morning prices, then charging went on at them);
|
||||
- a run edge reseeds the loop at the house's level (a 3 kW import spike
|
||||
removed; about 0.1 x rating still overshoots as export).
|
||||
removed) and, since OPT-15, holds it there until the meter shows the step.
|
||||
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
|
||||
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
|
||||
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
|
||||
@@ -75,6 +75,80 @@ P1 is its section 6 P1, and the pull request lists what was measured.
|
||||
P2's 19.33; Solar first + Cheap hours only 19.57.
|
||||
- Not done in P3: the forecast (P4), and a headroom too short for the run
|
||||
moving the rest to the next cheap periods.
|
||||
- OPT-15 (TEST-23 findings on 5a4cdc2, merged up to e3e4ba6,
|
||||
`test_price_run_fixes.py`):
|
||||
- F1, a charge stopped at a quarter edge and latched for the quarter. The
|
||||
P1 averager opens a quarter only when a sample arrives, and the HA source
|
||||
builds one 0.35 s after the reading, so the cycle on the boundary still
|
||||
saw the old block: "no seconds left", headroom None, "no trade
|
||||
(headroom)", latched for the new quarter. With modes on the guard now
|
||||
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
|
||||
crediting the held sample as the averager will), and only a measured
|
||||
stop latches. Modes off still reads the old block (identity).
|
||||
Since SAFETY-91, `next_quarter()` does the same for the guard at two
|
||||
inverters (no stop there without the roll, same banked quarters); the
|
||||
roll is what covers one inverter, where the old projection stops the
|
||||
charge at every edge. With modes on at one inverter the roll also removes
|
||||
the 1 s 0 W command at each quarter edge that SAFETY-81's sim showed, so
|
||||
its modes-on digests are re-pinned (modes off equal to e3e4ba6). A
|
||||
rolled projection is not graded by SAFETY-81's alarm (the old edge tick
|
||||
read "no seconds left"), so a standing overload is still notified once
|
||||
per quarter.
|
||||
- F2, -1120 W export at a charge end against -257 W modelled. The replay
|
||||
acted every 5 s on readings of that instant, and by then the lagged
|
||||
battery had nearly arrived, so it never acted on a reading that still
|
||||
showed the trade. The add-on cycles on each meter update (1 s on the rig)
|
||||
on readings 1-2 s old: the two cycles after the reseed saw the trade's
|
||||
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
|
||||
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
|
||||
still moves). The replay gained `Lag` (dead time, reading age, the P1
|
||||
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
|
||||
lag variants; without the hold -1370 W (meter 2 s). On e3e4ba6 the same
|
||||
numbers hold. The hold gives the loop the target as its grid, so it
|
||||
yields to SAFETY-95's drain tail, and any other claim that moves the
|
||||
command ends it.
|
||||
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
|
||||
stands only while the site asks that way, and nothing asked to charge.
|
||||
Probing was chosen over changing the card: the probe is 200 W for 34 s
|
||||
each 15 min under the same peak gates, and it wins back the run. In its
|
||||
probe window the refusing battery is a charger, pinned at 200 W by its
|
||||
own claim, and the others rest. A monthly drain (SAFETY-78) still comes
|
||||
first, and with a day plan a probing Leftover solar battery goes only to
|
||||
its planned level. At one inverter SAFETY-52 already ends a charge
|
||||
refusal as soon as the house asks for power.
|
||||
- Not fixed, same on e3e4ba6: with lagged readings a headroom-sized quarter
|
||||
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
|
||||
reading older than the meter can make a charge start look like a house
|
||||
over the aim, a real guard stop.
|
||||
- Review: an unmeasured stop that never latched let a headroom missing
|
||||
every other cycle flip the charge 1799 times in 30 min, so the second one
|
||||
in a quarter latches (at most 4 changes a quarter). A house that rises
|
||||
1, 2 or 3 kW as the charge ends, from 30 s into the quarter to 15 s
|
||||
before its end (two inverters, lagged readings): the hold adds at most
|
||||
+33 W to that quarter against the loop without it, and the most any
|
||||
quarter banks is 3431 W with the charge sized to the aim (3450 W; the cap
|
||||
is 3500 W), 3456 W with a slow 8 s battery (the lag's own, the same on
|
||||
e3e4ba6). The import during the hold is the rise, below the charge's
|
||||
own, and a rise past that is the peak floor's, which ends the hold.
|
||||
A maintenance drain, hold or charge that starts during the hold ends it
|
||||
on that tick (replayed at edge + 3 s).
|
||||
- OPT-20 (TEST-23 OPT-15 g7, `test_opt20.py`): a probe asks only PROBE_W.
|
||||
The trade was sized at the probing battery's rating (2500 W), B's pin held
|
||||
200 W of it, and the allocator put the other 2300 W on A, in its
|
||||
maintenance charge phase (no mode range there, so nothing kept it out):
|
||||
grid 3100 W for 34 s. Now the ask is PROBE_W for the probing battery plus
|
||||
what the other chargers would have had without the probe (none: 200 W
|
||||
alone), and it carries the pin only on the ticks the pin is filed (the
|
||||
window's first and last cycle moved another battery's share by 200 W).
|
||||
- Chosen: the probe sits on top of a headroom-sized charge, so beside a
|
||||
normal charger its command is the same and the grid rises 200 W (about
|
||||
7 W on the quarter) rather than that charger giving 200 W up. A probe
|
||||
that the headroom or the bound cannot hold at 200 W is not filed.
|
||||
- Unchanged, same on bf9019e: with a charge ask the other batteries cannot
|
||||
give (one sign), so an idle-looking A that gives the house stops for the
|
||||
window and the grid rises by what it gave. The trade line now says 200 W
|
||||
for a probe alone; its words are UX-38's. The base P4 F4 path (a normal
|
||||
trade on a battery in maintenance charge) is not touched.
|
||||
|
||||
## P4: the solar forecast and the day plan (2026-09-29)
|
||||
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# OPT-17 - the solar forecast from several entities, summed
|
||||
|
||||
**Status: for review.** Tests: `test_opt17.py`. Two options added.
|
||||
|
||||
A site whose forecast comes as several Home Assistant sensors (one per
|
||||
inverter or roof side) had to build a template sensor to sum them. Now each
|
||||
day can list them.
|
||||
|
||||
## Options
|
||||
|
||||
- `pv_forecast_entities` and `pv_forecast_tomorrow_entities`: lists of
|
||||
sensors, default `[]`, the same shape as `pv_entities`.
|
||||
- `pv_forecast_entity` and `pv_forecast_tomorrow_entity` are unchanged. An
|
||||
install that uses them needs no edit.
|
||||
- **Why new keys, not "string or list" in the old ones.** Measured on
|
||||
Supervisor 2026.08.0 by `tools/supervisor_shape_probe.py` §J, as TEL-12's
|
||||
§G did for `pv_entity`. Declared as a list, each forecast key refuses the
|
||||
string an install already stores (`Invalid list for option
|
||||
'pv_forecast_entity'`), and a `[]` default does not rescue it. The one
|
||||
spelling that takes both, `match(.*)?`, also takes the integer 417 and
|
||||
shows a plain text box, so a second sensor cannot be added in the UI.
|
||||
- §J also shows: a 0.17.0 blob validates unchanged with the lists at `[]`;
|
||||
the lists render as repeatable fields; a downgrade drops them and keeps
|
||||
the single keys.
|
||||
- `[]` in `options:` is load-bearing, as for `pv_entities`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **One key per day.** Setting the single key and the list for the same day
|
||||
is refused at start, with one message: keep the list, clear the single one.
|
||||
The list can hold that entity too. (TEL-12 lets `pv_entities` replace
|
||||
`pv_entity` with a caution row. Here the brief asked for a refusal: a
|
||||
doubled or dropped forecast changes what a battery buys, and no install
|
||||
has both keys yet, so the refusal breaks nothing.)
|
||||
2. **Each member in its own unit.** Every member is read as one forecast
|
||||
sensor is today (`solarfc._day()`): Wh, kWh or MWh, never guessed. Then the
|
||||
day's kWh are added up. A repeated id counts once.
|
||||
3. **One member not usable means the day is not usable.** Missing,
|
||||
unavailable, unknown, stale, not an energy unit, or a bad number: that
|
||||
day's sum is not used, exactly as when the single sensor reads that way.
|
||||
No today means no forecast (P3 option A). No tomorrow means today only.
|
||||
4. **Hourly split.** If every member has a split, the splits are joined
|
||||
(overlapping periods add up). If any member has none, the sum is spread
|
||||
over the bell, as for a state-only sensor.
|
||||
5. **Time.** The forecast is as old as its oldest member.
|
||||
6. **One read.** All members of both days are read in one gather, inside the
|
||||
gather that reads the inverters, so a slow sensor does not delay control.
|
||||
7. **Shown and logged once.** With two or more members, each one not usable
|
||||
is named on the status page by its place in the list (STYLE.md: no entity
|
||||
ids on the page), as a caution row titled "Solar forecast" on Home. The
|
||||
action log names the entity id, once when the set of failing members
|
||||
changes.
|
||||
8. **One member is the single key.** A one-member list reads, plans, shows
|
||||
and logs exactly as the single key with that entity.
|
||||
|
||||
## Why
|
||||
|
||||
- A partial sum is a smaller number that looks real. Two of three inverters'
|
||||
forecasts read as a duller day, and Leftover solar would then buy grid
|
||||
energy the sun was going to give. TEL-12 took the same rule for the same
|
||||
reason. No forecast is a known state with a tested fallback.
|
||||
- The bell for a mixed list: joining one member's hours with another's total
|
||||
has no honest shape, and the bell is the existing state-only rule.
|
||||
|
||||
## Cost
|
||||
|
||||
- One flaky member costs the whole day's forecast. A site with three
|
||||
sensors loses the plan more often than one with a template sum that hides
|
||||
gaps.
|
||||
- Two more options (85).
|
||||
|
||||
## What this does not cover
|
||||
|
||||
- No weighting or calibration per member; FCST-01 is still the own forecast.
|
||||
- Members on different integrations with different refresh times: each must
|
||||
be fresh (3 h) on its own; there is no wait for a lagging one.
|
||||
- The day plan itself, the trust and the house profile are unchanged.
|
||||
- The Batteries screen still says only "no solar forecast now". The member
|
||||
is named on the status page.
|
||||
@@ -0,0 +1,117 @@
|
||||
# PRICE-01 - the owner's price server as the price source, with fallbacks
|
||||
|
||||
**Status: for review.** Tests: `test_price_service.py`. One option added,
|
||||
`price_service_token` (password). Follows [[TARIFF-10]], whose section 4 this
|
||||
card answers for one provider. Design: the owner's price service note of
|
||||
2026-10-05. Owner go 2026-10-05 and 2026-10-14.
|
||||
|
||||
## What it is
|
||||
|
||||
`price_provider: price_service` reads `GET https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`
|
||||
with `Authorization: Bearer <token>`. The answer is the energy-charts layout
|
||||
plus `covers`, so the existing columnar parser reads it. The price is wholesale
|
||||
energy only; the framework adds markup and taxes, as for `generic_dayahead`.
|
||||
|
||||
## Rules
|
||||
|
||||
1. **The host is a constant in the plugin**, declared as its only egress host,
|
||||
HTTPS only. The operator's `price_feed_url` is not read by this provider.
|
||||
*Cost:* every client talks to the one host, and moving the server is a new
|
||||
add-on version. *What replaces the per-site setting:* the egress allow-list,
|
||||
TLS, and a token per client that the server can revoke.
|
||||
2. **The chain is server, then energy-charts, then the saved prices**
|
||||
(`app/price_chain.py`). A step counts only if it has a price for *now*: a
|
||||
server that answers with old prices is skipped like one that is down. A step
|
||||
down and a step up are each one log line, written when the step changes. The
|
||||
operator's `price_feed_url` is cleared for the energy-charts step, which is
|
||||
asked for its own feed.
|
||||
3. **The saved prices are `/data/price_history.json`**, the file `PriceHistory`
|
||||
already keeps (the last three Brussels days of what was published). They are
|
||||
the all-in prices as stored; a change of markup shows from the next fetch
|
||||
that works. A read from the store is not filed again.
|
||||
4. **No price for now** means no published price covers the present moment. Not
|
||||
"none for the coming hours": before about 13:00 tomorrow's prices are not
|
||||
published on any healthy day, and carried hours are already marked estimated.
|
||||
It sets one status row (*Prices*), one Home plan line (English and Dutch),
|
||||
and one notification (*No prices*, English and Dutch), taken down when a
|
||||
price returns. A start-up before the first refresh finished is not a gap.
|
||||
The notification is not re-asserted after a Home Assistant core restart
|
||||
(SAFETY-34 does that for latched safety alarms); the status row stays.
|
||||
*Open for the owner:* if "coming hours" should mean a look-ahead of N hours,
|
||||
that is a change to `gate_for()` and `PriceWatch`, not to the chain.
|
||||
5. **The maintenance charge gate** (owner's decision, 2026-10-14): the charge may
|
||||
run when the *current 15-minute price is at or below today's average*, today
|
||||
being the Brussels day, computed from the provider's prices. This is the rule
|
||||
the Home Assistant pair gave (`price_service.yaml`), so a site that moves from
|
||||
the pair to the provider does not change when the maintenance charge runs.
|
||||
It is not `TariffPlan.cheap_now()` (strictly below the mean of the firm
|
||||
hours of the whole plan). This replaces, for this provider only,
|
||||
TARIFF-10 section 4: its three reasons are answered as follows. (1) Two
|
||||
writers: the pair beside a provider stays refused; `static_profile` beside
|
||||
`price_service` is accepted and no longer writes `cheap`. (2) An expired or
|
||||
absent plan shuts the gate (the charge waits), as an unavailable HA sensor
|
||||
did. (3) The basis is the one above, not `cheap_now()`.
|
||||
6. **The token is a secret.** `password?` in the schema; hidden on the settings
|
||||
page (`uilayout.HIDDEN`), which can keep `price_service` but not choose it;
|
||||
never put in a message by the plugin; taken out of any reason text by
|
||||
`price.scrub()`; the real transport reports only a status or an exception's
|
||||
name. validate.py refuses `price_service` with no token, a token with a
|
||||
space or line break, and one under 16 characters (so `scrub()`, which skips
|
||||
values under 4 characters, always has something to take out). Round 2: the
|
||||
token is in no page and no JSON answer of the web app. Every `password`
|
||||
option of the schema is shown as `********` (set) or empty, in
|
||||
`/config/model` (`options`, `running`, `ui`) and in a stale refusal; the
|
||||
classic page draws it as a password field; a save or a validate that sends
|
||||
`********` or nothing keeps the stored value, any other value replaces it.
|
||||
A token can therefore be changed but not cleared from the page.
|
||||
7. **A fixed tariff beside `price_service` is allowed and quietly noted.** It is
|
||||
still the plan's last fallback (`resolve_tariff` -> `_degrade`); only the
|
||||
cheap window and the gate ignore it, because they read the provider's
|
||||
prices. Keeping it set is the recommended setup, and clearing it triggers the
|
||||
"static_profile is empty" warning, so no notification (round 2's *Fixed
|
||||
tariff not used* was false and fired on every sound start; round 3 removed
|
||||
it). Instead one INFO line at start (English, as every log line) and an info
|
||||
note beside the setting (EN and NL, "With the price service, the fixed
|
||||
tariff is only used if no prices are available."), both
|
||||
only with a non-blank `static_profile` (whitespace counts as blank). At
|
||||
start the add-on dismisses the old notification id, in case a pre-release
|
||||
build raised it.
|
||||
8. **The no-price words show only with the maintenance charge on** (owner,
|
||||
2026-10-14): the status row, the Home line and the notification say
|
||||
'maintenance waits', so with `maintenance_enabled` off none is shown.
|
||||
|
||||
## Where the gate equals the Home Assistant pair, and where it does not
|
||||
|
||||
Equal: `test_price_service.py` section 8 feeds the same day both ways, to the
|
||||
rules in `price_service.yaml` (price at the quarter, average of today's
|
||||
quarters rounded to 0.00001 EUR/kWh, `<=`), over 6 days including both
|
||||
clock-change days, at three instants in every period, and through a real
|
||||
`Controller` for a whole day. Every answer is the same.
|
||||
|
||||
Not equal, by design or by arithmetic:
|
||||
|
||||
- The comparison is made on all-in prices. With a flat taxes figure and a
|
||||
markup and taxes of at most five decimals the offset cancels and the answer
|
||||
is the same. With `price_taxes_profile` (an hour-varying levy) the all-in
|
||||
prices differ by hour, so the answer can differ from the wholesale comparison
|
||||
the HA sensors made. A site on that profile should check before moving.
|
||||
- A quarter within 0.00001 EUR/kWh of the average can differ if markup or taxes
|
||||
have more than five decimals.
|
||||
- The plan is retired when no refresh has succeeded for 90 minutes
|
||||
(`TARIFF_MAX_AGE_S`) and the gate shuts then. How long the HA pair kept an
|
||||
answer after its REST sensor stopped updating was not measured here.
|
||||
- The average is over the firm prices the provider holds for today. After a
|
||||
restart in the middle of a day with only the saved part of today, it is the
|
||||
average of that part.
|
||||
|
||||
## Not covered
|
||||
|
||||
- **No real answer of the server was seen.** No token was available. The
|
||||
layout is the design's; `test_price_service.py` uses a mock and, for the
|
||||
parser, the real energy-charts capture reshaped with the design's extra keys.
|
||||
The first capture from the live server (rig test) should replace the mock.
|
||||
- The 429 limit (about 60 calls an hour) is not approached: one fetch per
|
||||
30 minutes, and at most two requests when the server fails.
|
||||
- Battery modes read the plan as for `generic_dayahead` and were not changed.
|
||||
- The `price_service` REST and template sensors in Home Assistant are not
|
||||
touched; they stop being read when the pair is cleared.
|
||||
@@ -0,0 +1,80 @@
|
||||
# SAFETY-103 - no swing on old battery readings after a drain ends
|
||||
|
||||
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
|
||||
|
||||
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
|
||||
from its ended drain, SAFETY-95 steers on the house's point: grid + every
|
||||
battery's reading - aim. The meter is 1 s old; a battery register can be
|
||||
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
|
||||
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
|
||||
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
|
||||
|
||||
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
|
||||
while a drain's tail settles):
|
||||
|
||||
1. Each cycle, per battery: the range its power can be in. That is its last
|
||||
changed reading, widened by every command on its wire since (a lagging
|
||||
battery moves from one toward the others, never beyond). A new reading
|
||||
starts the range again.
|
||||
2. A reading that changed this cycle is taken as it reads. So is the side a
|
||||
battery refuses (SAFETY-73): it is not heading that way.
|
||||
3. The house point is held (first cycle: the loop's start from SAFETY-95).
|
||||
It moves only as far as the meter proves: to the nearest value of grid +
|
||||
the batteries' ranges - aim.
|
||||
|
||||
So the loop no longer moves on a reading that has not caught up. A real
|
||||
house step still moves it, as soon as the meter shows more than the
|
||||
batteries can explain.
|
||||
|
||||
**Why not the tester's rule** ("a battery whose reading is older than its
|
||||
last command counts at its command"). B's command changes almost every
|
||||
cycle, so B is almost always at its command. That is the plain meter, which
|
||||
chases A's tail: B swings 1420 W. A lag model needs the plant's time
|
||||
constant, which is not known for a client's inverter. Measured on the plant
|
||||
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
|
||||
|
||||
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|
||||
|---|---|---|---|---|
|
||||
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
|
||||
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
|
||||
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
|
||||
| lag model, tau 5 s, plant 20 s | | | 600 | |
|
||||
| hold all window | 0 | 0 | 0 | 0 |
|
||||
| **this rule** | **0** | **0** | **0** | **130** |
|
||||
|
||||
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
|
||||
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
|
||||
exported 16.9 Wh (release 8.5, this rule 8.9).
|
||||
|
||||
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
|
||||
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
|
||||
at -100 W; summed swing of the others in the 23 s after the end):
|
||||
|
||||
| case | release/1.0 | after |
|
||||
|---|---|---|
|
||||
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
|
||||
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
|
||||
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
|
||||
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
|
||||
| registers as fresh as the meter (1 s) | | rows byte-identical |
|
||||
| refusal end | | rows byte-identical |
|
||||
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
|
||||
|
||||
**Cost.** In the window, a house change smaller than what the batteries
|
||||
could still be doing is not followed until a reading catches up (at most a
|
||||
register period) or the window ends (23 s). Under a surplus the others
|
||||
still swing up to 130 W, a third of a slew step.
|
||||
|
||||
**Not covered.**
|
||||
- Outside the drain tail the loop steers on the meter; readings only reach
|
||||
the saturation freeze (SAFETY-96), unchanged.
|
||||
- A lost link or a refusal ends the tail at once (A is out of the split, or
|
||||
reads ~0 W). The swing after them is the loop's own on a lagging plant,
|
||||
the same with 1 s registers and on release/1.0: under a surplus up to
|
||||
1.5 kW at N=2 and 3.9 kW summed at N=3; after a lost link about 1 kW
|
||||
even without one. Not this card.
|
||||
- A battery that overshoots its command, or a reading offset beyond the
|
||||
range, can put the true power outside the range; the meter then moves the
|
||||
point, as before.
|
||||
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
|
||||
batteries were steady during the drain, so they are current there.
|
||||
@@ -0,0 +1,96 @@
|
||||
# SAFETY-107 - a refusal held under another writer is learned once
|
||||
|
||||
**Status: for review.** Tests: `test_safety107.py`. One inverter only; nothing
|
||||
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
|
||||
|
||||
**The defect** (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a
|
||||
maintenance charge in a cheap window takes no charge. At N=1 the action log then
|
||||
showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another
|
||||
writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head,
|
||||
652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line
|
||||
log flooded and the hint under the buttons changed every second, so the
|
||||
MAINT-13 notice was gone at once.
|
||||
|
||||
**Cause** (`main.py`, the SAFETY-52 block before `compute()`). The end tested
|
||||
`self.target != self.loop_w`: the wire differs from the loop's ask. A held
|
||||
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
|
||||
charge's `hi`, the peak guard's `lo`, a trade's SET) differs from it on every
|
||||
cycle. The refusal was ended against the same writer it had been learned
|
||||
against. `sat_count` is not reset by that end, so `compute()` learned the same
|
||||
refusal again in the same cycle. Nothing about the battery or the wire had
|
||||
changed; only the log and the hint did.
|
||||
|
||||
**Other writers.** The same flap comes from any writer that holds the wire
|
||||
while the loop is refused, as long as the evidence lasts: a trade (a SET at
|
||||
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
|
||||
refusal lines in 15 min each at a 5 s meter (`test_safety107.py` part 2, run on
|
||||
release/1.0).
|
||||
A maintenance drain or hold does not flap: its SET resets `sat_count` each cycle.
|
||||
|
||||
**The rule** (`_refusal_update()`, `rejudged`). When another writer held the
|
||||
wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no
|
||||
probe, exactly as before the change. Then:
|
||||
|
||||
1. it finds the same refusal against that writer's ask: the same episode
|
||||
continues, with no line and no hint. The probe clock restarts, as it did on
|
||||
the old end and start: a probe is the loop's ask, and it is not on the wire
|
||||
while another writer is;
|
||||
2. it does not: the refusal ends, logged "another writer", or "the battery
|
||||
gives power" when the reading shows it (it was "another writer" before);
|
||||
3. it finds the other direction: end, then a new start, as before. In
|
||||
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
|
||||
sign, so a writer that turns round ends the refusal at once and the other
|
||||
way is learned ~12 cycles later (`test_safety107.py` part 3e).
|
||||
|
||||
**Why this rule.** The end now means what SAFETY-52 meant by it: the refusal no
|
||||
longer describes what is on the wire, judged by the same test that learns it.
|
||||
`compute()` sees the same inputs as before and the state after each cycle is
|
||||
the same (`batt_refused`, `batt_refused_at`), so every command is unchanged.
|
||||
Keeping the held refusal into `compute()` instead (mutation M5) would also
|
||||
stop the lines, but it holds the loop at 0 W where the old law let it ask past
|
||||
a writer's limit, which changes commands; it also removes SAFETY-52's
|
||||
owner-decided "another writer" exit.
|
||||
|
||||
**Evidence.**
|
||||
- Differential trace (`goodwe/scratch/SAFETY-107/probe/trace.py`, not in this
|
||||
repo; the real Controller in the CI image, release/1.0 bf3ac79 against this
|
||||
tree): 300 seeds, N=1 (153) and
|
||||
N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping
|
||||
house, maintenance charge with the cheap window opening and closing, P1 and
|
||||
the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a
|
||||
refusal held. Every command, site target, integrator, `sat_count`, refusal
|
||||
state and reason is identical. Refusal lines: 100 748 before, 2 188 after.
|
||||
- The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house
|
||||
importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
|
||||
|
||||
**Mutations** (`tools/mutation_run.sh` on purged copies; reds in
|
||||
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
|
||||
commands differ from release/1.0):
|
||||
|
||||
| | mutation | reds | trace |
|
||||
|---|---|---|---|
|
||||
| M1 | `now == rejudged` -> `False` (every such cycle ends: the old flap) | 26 / 0 / 0 | 0 |
|
||||
| M2 | `rejudged` always 0 (another writer never ends it) | 5 / 1 / 2 | 29 |
|
||||
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
|
||||
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
|
||||
| M5 | the held refusal goes into `compute()` while rejudged | 2 / 1 / 2 | 27 |
|
||||
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
|
||||
|
||||
M4 is equivalent: a probe matters only if `compute()` engages in that call,
|
||||
which needs `SETTLE_CYCLES` saturated against the writer, and each of those
|
||||
cycles restarted the clock. The line stays because it states the rule.
|
||||
|
||||
**Cost.** None in power. A takeover by a different writer while the battery
|
||||
still refuses is logged as an end and, ~12 cycles later, a new start against
|
||||
that writer (3 lines for the episode, `test_safety107.py` part 3a).
|
||||
|
||||
**Not covered.**
|
||||
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
|
||||
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
|
||||
writer's ask returns fresh, `compute()` has no saturation history against it,
|
||||
so the refusal ends at the first cycle and the next trip seeds it again: 2
|
||||
lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0.
|
||||
The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it
|
||||
changes commands. None of the rig runs showed it: real readings move.
|
||||
- At N=1 a refusal shapes only the loop. A writer above it still asks the
|
||||
battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.
|
||||
@@ -0,0 +1,98 @@
|
||||
# SAFETY-112 - control waits for Home Assistant to finish starting
|
||||
|
||||
**Status: for review.** Tests: `test_safety112.py`, `test_messages.py`.
|
||||
Control is unchanged. The add-on now says plainly that it is waiting.
|
||||
|
||||
## Cause
|
||||
|
||||
The Supervisor's proxy refuses every add-on request with 502 until Core's
|
||||
state is `RUNNING`. The add-on reads every battery reading and writes every
|
||||
power request through that proxy, so it cannot control until then. Readings
|
||||
that are already live inside Home Assistant do not help.
|
||||
|
||||
Source, Supervisor 2026.08.0 (the image the live site ran at CFG-04):
|
||||
|
||||
- `supervisor/api/proxy.py`, `APIProxy.api()` (REST, used for reads and
|
||||
`number.set_value`) and `APIProxy.websocket()` both start with
|
||||
`if not await self.sys_homeassistant.api.check_api_state(): raise HTTPBadGateway`.
|
||||
- `supervisor/homeassistant/api.py`, `check_api_state()` returns
|
||||
`state.core_state == "RUNNING" or state.offline_db_migration`. The state
|
||||
comes from Core's `/api/core/state`.
|
||||
- Upstream `main` has the same gate in both handlers (read 2026-10-13).
|
||||
- A Core restart restarts only the Core container
|
||||
(`homeassistant/core.py`, `restart()`). The add-on keeps running.
|
||||
|
||||
The add-on itself has no wait. Nothing in `app/` reads Core's state or waits
|
||||
for an event. The loop reads its inputs every second, a 502 gives `None`,
|
||||
and missing inputs give 0 W. P1 backoff is not involved: the live site reads
|
||||
the meter straight from the HomeWizard (`homewizard_local`), not through Home
|
||||
Assistant.
|
||||
|
||||
## Measured
|
||||
|
||||
Repro stack `safety112-*`, all removed afterwards:
|
||||
|
||||
- Home Assistant 2026.9.4, as live.
|
||||
- A test integration whose `async_setup` sleeps, so Core stays `NOT_RUNNING`.
|
||||
- A fake meter and two fake inverters that update every 2 s from load.
|
||||
- The Supervisor's own `APIProxy` and `HomeAssistantAPI` from the 2026.08.0
|
||||
image. Only the add-on token lookup, Core's access token and the container
|
||||
check are stubbed.
|
||||
- The add-on on `meter_source: homewizard_local` against a fake HomeWizard.
|
||||
|
||||
Times are seconds after `docker restart` of Home Assistant:
|
||||
|
||||
| run | Core `NOT_RUNNING` | entities live in HA | Core `RUNNING` | proxy 502 | first write | wait from live readings |
|
||||
|---|---|---|---|---|---|---|
|
||||
| base 3050fa5, N=2 | 13.1 | 17.1 | 253.1 | 0.9-252.7 (1 132 requests) | 254.7 | 237.6 |
|
||||
| base 3050fa5, N=1 | 13.1 | 18.1 | 256.1 | 1.5-255.1 (573 requests) | 257.2 | 239.1 |
|
||||
| this change, N=2 | 12.2 | 17.2 | 313.2 | 1.4-312.5 (1 406 requests) | 317.6 | 300.4 |
|
||||
| this change, N=1 | 12.2 | 17.2 | 255.2 | 1.2-254.1 (571 requests) | 257.1 | 240.0 |
|
||||
|
||||
The first write comes 1.0-4.4 s after `RUNNING`, so the whole wait is the
|
||||
proxy's. With a 400 s sleep Core moved on at 300.88 s on its own (bootstrap's
|
||||
stage timeout), so one slow integration holds a start stage for about 5
|
||||
minutes. The whole start can take longer: the live one took 414 s.
|
||||
|
||||
Before this change the page said "Not ready — Battery level: no usable
|
||||
reading", which sends the reader to Developer Tools for an entity that is
|
||||
fine.
|
||||
|
||||
## Rule
|
||||
|
||||
1. No way around the Supervisor. The add-on still talks to Home Assistant
|
||||
only through the proxy.
|
||||
2. While the proxy does not answer on the API (`hass.OUTAGE.waiting()`), the
|
||||
first status row is red: "Home Assistant is not answering, perhaps it is
|
||||
still starting. Control resumes by itself when it answers." The banner
|
||||
reads "Not ready — Waiting for Home Assistant to finish starting". The Home
|
||||
pill reads "Waiting for Home Assistant", in English and Dutch.
|
||||
3. A safety stop, a stalled control loop or shadow mode keeps its own banner
|
||||
and state over the wait, because none of them clears by itself.
|
||||
4. The log has one WARNING when Home Assistant stops answering, an INFO line
|
||||
every 5 minutes while it is down, and an INFO line with the length of the
|
||||
wait when it answers again.
|
||||
5. Only the API counts. The meter websocket alone does not, because the meter
|
||||
has its own row.
|
||||
|
||||
## Why it is safe
|
||||
|
||||
The change is text only. `cycle()`, the writes, the heartbeat, the stand-down
|
||||
on shutdown and every watchdog are untouched. `test_safety112.py` part 3 shows
|
||||
the same targets and writes with the flag on and off, for N=1 and N=2.
|
||||
|
||||
## Open, for the owner
|
||||
|
||||
The add-on cannot control while Home Assistant starts. These ways around it
|
||||
were not taken:
|
||||
|
||||
- **Talk to Core directly with a long-lived token** (`http://homeassistant:8123`).
|
||||
Core's own API answers while `NOT_RUNNING`. In the repro `/api/core/state`
|
||||
answered from 12-13 s, and a direct `number.set_value` 10 s into
|
||||
`NOT_RUNNING` returned 200 and set the value. The cost is a secret stored
|
||||
at every client site, which `hass.py` exists to avoid.
|
||||
- **Talk to the control board directly** (ESPHome native API), so control
|
||||
does not need Home Assistant at all. This is a large change.
|
||||
- **Remove the slow start at the site.** On 2026-10-12 the Solis
|
||||
integration's discovery held the start. Fixing or disabling it shortens the
|
||||
wait there, but not on a client site with another slow integration.
|
||||
+163
-10
@@ -186,6 +186,8 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Hardware | Battery modes | `battery_modes_enabled` | Give each battery an on and off switch and a mode, set on the Batteries screen. |
|
||||
| Hardware | Solar forecast, today (kWh) | `pv_forecast_entity` | Optional. A forecast sensor with today's remaining solar energy, such as Forecast.Solar or Solcast. A battery then buys only what the sun will not give. |
|
||||
| Hardware | Solar forecast, tomorrow (kWh) | `pv_forecast_tomorrow_entity` | Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight. |
|
||||
| Hardware | Several solar forecasts, today (kWh) | `pv_forecast_entities` | Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up. |
|
||||
| Hardware | Several solar forecasts, tomorrow (kWh) | `pv_forecast_tomorrow_entities` | Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used. |
|
||||
| Hardware | Trust in the forecast (%) | `solar_trust_pct` | Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast. |
|
||||
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
|
||||
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
|
||||
@@ -220,6 +222,7 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
|
||||
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
|
||||
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
|
||||
| Tariff | Price service token | `price_service_token` | For the price service only. The token you were given for it. Leave it empty with any other price source. |
|
||||
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
|
||||
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
|
||||
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
|
||||
@@ -288,6 +291,7 @@ When the add-on refuses to start, each reason names the setting by its label and
|
||||
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
|
||||
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
|
||||
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
|
||||
- **Price service token** (`price_service_token`): a password field. Every page of the add-on, and every answer its web app gives, shows only `********` when a token is set and nothing when not; the token itself is never sent to a page. A save that leaves the field empty or as `********` keeps the stored token, and a new value replaces it. It must be at least 16 characters. It is needed with `price_provider: price_service`, and the add-on refuses to start without it. It never appears in the log, the status page, a refusal or a notification. See [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
|
||||
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
|
||||
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
|
||||
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
|
||||
@@ -539,6 +543,9 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
- With no prices, or too small a gap, every battery follows the house.
|
||||
|
||||
**Solar forecast** (`pv_forecast_entity`, optional). A Home Assistant sensor with the solar energy still to come today, in kWh. These work: Forecast.Solar (`sensor.energy_production_today_remaining`), Solcast PV Forecast (`sensor.solcast_pv_forecast_forecast_remaining_today`) and Open-Meteo Solar Forecast (`sensor.energy_production_today_remaining`). Set `pv_forecast_tomorrow_entity` to the matching *tomorrow* sensor to plan a cheap run before midnight.
|
||||
- **Several forecasts.** If your forecast comes as several sensors (one per inverter or roof side), list them in `pv_forecast_entities` and, for tomorrow, `pv_forecast_tomorrow_entities`, instead of the single key. They are added up, each in its own unit (Wh, kWh or MWh). Set the list or the single key for a day, not both.
|
||||
- If one of them has no reading, is stale or is not in an energy unit, that day's forecast is not used at all. A part of the sun is not counted as all of it. The status page names the source by its place in the list.
|
||||
- The hourly shape is kept only if every sensor has one. Otherwise the total is spread over the day as for Forecast.Solar.
|
||||
- Before each cheap run the add-on asks: will the sun fill the batteries before the expensive hours? It counts on `solar_trust_pct` of the forecast (70 %), less what the house uses. Solar first batteries get that sun first.
|
||||
- *Leftover solar* then charges from the grid only the shortfall: what the house will draw from it in the expensive hours, less its share of the sun. Not up to the top level.
|
||||
- *Cheap hours only* ignores the forecast and fills to the top level.
|
||||
@@ -550,10 +557,10 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
**What always comes first.**
|
||||
- The peak limit, the safety stops and the monthly cycle come before a battery's mode.
|
||||
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period.
|
||||
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period. The start of a new period does not stop it.
|
||||
- While one battery charges from the grid, the others rest, and the house uses the grid.
|
||||
- While one battery empties for its monthly cycle, no battery charges from the grid.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction.
|
||||
- A battery that refuses to charge or give power takes no part in a run in that direction. In a cheap run it is still tested every 15 minutes: it charges at 200 W for about half a minute while the others rest.
|
||||
|
||||
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
|
||||
|
||||
@@ -561,7 +568,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
|
||||
|
||||
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
|
||||
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts, and holds there until the meter shows the change (at most 18 s, up to 36 s while the battery is still moving). This removes the ~3 kW import spike after a 2.5 kW charge and the export overshoot that readings a second or two old caused (-1120 W on the test rig); with the hold it stays within 0.15 × the battery's power. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
|
||||
|
||||
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
|
||||
|
||||
@@ -605,6 +612,10 @@ still moving towards the request, such as one turning from charging to
|
||||
giving (SAFETY-94). "Gives" is counted from what the battery's reading shows
|
||||
at rest, which the add-on learns after two quiet minutes and keeps: some
|
||||
inverters show about -100 W at rest (SAFETY-97).
|
||||
While another task sets the power, such as a maintenance charge, the peak
|
||||
guard or a trade, the refusal stays as long as the battery still refuses that
|
||||
task's request. It is logged once, and the 200 W test waits until control sets
|
||||
the power again (SAFETY-107).
|
||||
|
||||
**With two or more inverters** the same rule works per inverter. An inverter
|
||||
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
|
||||
@@ -618,7 +629,7 @@ If the battery refuses to give power while its level reads above its “Battery
|
||||
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
|
||||
A battery that refuses is not reported as *not delivering*.
|
||||
|
||||
*For installers:* SAFETY-52 and SAFETY-73. The event log reads
|
||||
*For installers:* SAFETY-52, SAFETY-73 and SAFETY-107. The event log reads
|
||||
`BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
|
||||
N>1), and the INFO line ends in `BATTERY_REFUSES`.
|
||||
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
|
||||
@@ -1667,6 +1678,21 @@ hour saved. If you set an earlier hour to get an evening start in UTC (for
|
||||
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
|
||||
value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
|
||||
|
||||
**When the charge counts as done.** A battery may stop at 98 or 99 % while it
|
||||
balances, so it need not reach `maintenance_soc_target`. The charge counts as
|
||||
done when the battery is within 3 points of the target and has taken no more
|
||||
charge for 60 minutes of charging time, or when the charging time (36 hours) runs
|
||||
out within 3 points of the target. Only the minutes the cheap-hours gate let the
|
||||
battery charge count, and the charge ends after 7 days at the
|
||||
latest. Then the usual hold follows.
|
||||
|
||||
**When a cycle fails.** If the battery does not empty in 12 hours, or does not
|
||||
get within 3 points of the target in 36 hours of charging time, the cycle
|
||||
stops, you get the notification *Battery cycle did not finish*, and it is not
|
||||
tried again for 7 days. The status page shows *Maintenance paused* with the
|
||||
date. Starting a cycle by hand ignores the wait. *For installers:*
|
||||
DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
|
||||
### Tariff (all optional)
|
||||
|
||||
| option | meaning |
|
||||
@@ -1704,7 +1730,8 @@ When a battery's link drops, the others take over one slew step a cycle,
|
||||
and control does not stall on the dropped battery's power (SAFETY-96).
|
||||
When a drain ends (done, Off, timeout, abort, lost link, refused), the
|
||||
other batteries cover the house at once but do not export its drain; then
|
||||
they move one slew step a cycle (SAFETY-95).
|
||||
they move one slew step a cycle (SAFETY-95). While that battery settles,
|
||||
an old battery reading does not move them (SAFETY-103).
|
||||
|
||||
#### Capacity-tariff exposure of the grid-following loop
|
||||
|
||||
@@ -2173,20 +2200,24 @@ does, and only after you have read why.
|
||||
> `static_profile` below needs no network at all, so its cheap window is
|
||||
> computed on every cycle and reaches the maintenance cycle's grid top-up
|
||||
> whether or not arbitrage is armed. ⚠️ **And it is still the only source of
|
||||
> that window.** A provider-backed plan now exists on every configured site,
|
||||
> but *Cheap window* and the maintenance cycle's grid top-up read
|
||||
> `static_profile` and nothing else — a deliberate limit, recorded with what it
|
||||
> would take to change in `DECISIONS/TARIFF-10-fetching-is-not-trading.md`.
|
||||
> that window, with one exception.** A provider-backed plan exists on every
|
||||
> configured site, but *Cheap window* and the maintenance cycle's grid top-up
|
||||
> read `static_profile` and nothing else — a deliberate limit, recorded with
|
||||
> what it would take to change in
|
||||
> `DECISIONS/TARIFF-10-fetching-is-not-trading.md`. The exception is
|
||||
> `price_service` (PRICE-01): with it, the window comes from the provider's own
|
||||
> prices, as described under [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to).
|
||||
|
||||
| option | meaning |
|
||||
|---|---|
|
||||
| `price_provider` | `off` (default) or one of the three plugins below |
|
||||
| `price_provider` | `off` (default) or one of the plugins below |
|
||||
| `price_markup_eur_kwh` | Your supplier's margin, EUR/kWh. **Added only when the chosen plugin's feed does not already contain it** |
|
||||
| `price_taxes_eur_kwh` | Distribution, levies and VAT, EUR/kWh. Same rule. Defaults to **0.11736** — a real Flemish figure, and **yours is probably different**: see *What `price_taxes_eur_kwh` is made of* below |
|
||||
| `export_price_eur_kwh` | The **flat injection** price, EUR/kWh. May be negative — see below |
|
||||
| `price_export_formula` | The **injection formula**, if your contract has one. Same shape as `price_formula`, its own coefficients, its own `vat_pct` — see below |
|
||||
| `price_csv_path` | Where `manual_csv` reads its file |
|
||||
| `price_feed_url` | Override the plugin's default feed. Used **exactly as written** — the add-on appends nothing to it, including the date range, so an override gets whatever horizon that URL returns. Only a host the plugin itself declares is accepted |
|
||||
| `price_service_token` | The token for `price_service`. A password field. Empty with any other source |
|
||||
| `price_horizon_h` | How far ahead to ask for, hours |
|
||||
| `price_timeout_s` | Per-request timeout |
|
||||
| `static_profile` | A static day/night or fully-fixed tariff. Empty by default — see below |
|
||||
@@ -2199,6 +2230,7 @@ arbitrage decision wrong by that amount, in a direction nothing reports.
|
||||
| plugin | product | the published figure contains | the add-on adds |
|
||||
|---|---|---|---|
|
||||
| `generic_dayahead` | day-ahead | energy only (wholesale EPEX BE) | markup **and** taxes |
|
||||
| `price_service` | day-ahead | energy only (wholesale, from your own price server) | markup **and** taxes |
|
||||
| `custom_formula` | day-ahead | energy **and** your contract's markup — your card's `Tariefformule` line | taxes |
|
||||
| `manual_csv` | day-ahead | everything, all-in | nothing |
|
||||
|
||||
@@ -2228,6 +2260,86 @@ from. Any alias would have to invent the numbers your battery is priced against.
|
||||
Eneco's dynamic product is *Zon & Wind Dynamisch*, and it is indexed to
|
||||
Belpex/EPEX **day-ahead**, not to imbalance.)
|
||||
|
||||
#### `price_service` — your own price server, and what it falls back to
|
||||
|
||||
*For installers: PRICE-01. The decision is in
|
||||
`DECISIONS/PRICE-01-own-price-server.md`.*
|
||||
|
||||
`price_provider: price_service` reads the day-ahead prices for Belgium from the
|
||||
owner's price server, `https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE`.
|
||||
The host is fixed in the add-on and only HTTPS is used. The token goes in
|
||||
`price_service_token` and is sent as `Authorization: Bearer <token>`. The server
|
||||
answers the energy-charts layout, so the same parser reads it. The price is the
|
||||
wholesale energy price and nothing else, so the add-on adds your markup and
|
||||
taxes exactly as for `generic_dayahead`.
|
||||
|
||||
**Where the prices come from.** Each step is used only if it has a price for
|
||||
now. A server that answers with last night's prices is treated as down.
|
||||
|
||||
| Step | Source | When it is used | What the log says (once per change) |
|
||||
|---|---|---|---|
|
||||
| 1 | The price server | It answers, with a price for now | `The price service answers again.` on the way back. |
|
||||
| 2 | energy-charts.info | Step 1 fails: down, timeout, refused token (401), error (5xx), a damaged answer, or no price for now | `The price service does not answer, so prices come from energy-charts.` Or `The price service refused the token, so prices come from energy-charts.` |
|
||||
| 3 | The prices saved in `/data/price_history.json` | Steps 1 and 2 fail | `No price source answers, so the add-on uses the prices it saved.` |
|
||||
| none | Nothing | No saved price covers now either | `No price source answers, and no saved price covers now.` |
|
||||
|
||||
The saved prices are the last answers that worked, kept on disk, so a site
|
||||
whose server and energy-charts are both down for a day goes on planning from
|
||||
what it has. It keeps the days the history holds (today, tomorrow once it was
|
||||
fetched, and a day back). They are the all-in prices as stored: a change of
|
||||
markup or taxes shows from the next fetch that works. Going back up the chain
|
||||
is logged once as well: `Prices come from energy-charts again.` and `The price
|
||||
service answers again.` The refresh runs every 30 minutes.
|
||||
|
||||
**When there is no price for now (step none).** One status line, `Prices: No
|
||||
prices since 00:00, maintenance waits.` (on the add-on page; the Home screen says
|
||||
the same in English and Dutch), and one notification, *No prices*. Both go when a
|
||||
source has a price for now again. They show only while **Run the monthly battery cycle**
|
||||
(`maintenance_enabled`) is on; with it off, neither is shown. "Since" is the moment the last price ran out.
|
||||
Before the first refresh has finished there is no line, so a start-up is not a
|
||||
gap. Control is not touched: the battery follows the meter as ever, battery
|
||||
modes and trading read what prices there are, and only the maintenance charge
|
||||
waits.
|
||||
|
||||
**The maintenance charge and the cheap window.** With this provider the
|
||||
maintenance charge gate reads the provider's prices and no Home Assistant
|
||||
entity. It keeps the rule the Home Assistant pair gave: the charge may run
|
||||
when the **current 15-minute price is at or below today's average**, today
|
||||
being the Brussels day. Moving a site from the pair to `price_service` does not
|
||||
change when the charge runs. Where the two can differ is listed in the decision
|
||||
record: the Home Assistant average was rounded to 0.00001 EUR/kWh, and so is
|
||||
this one before it is compared. With no price for now the gate is shut, as it
|
||||
was when an entity was unavailable. *Cheap window* on the status page shows this
|
||||
answer. This is not the *cheap hours* rule of battery modes and not
|
||||
`cheap_now()`.
|
||||
|
||||
**Moving a site from the Home Assistant price sensors to `price_service`.**
|
||||
Do this on the Configuration tab, in one save, and restart once:
|
||||
|
||||
1. Set **Dynamic price source** (`price_provider`) to `price_service`.
|
||||
2. Paste the token into **Price service token** (`price_service_token`).
|
||||
3. **Clear** **Current price entity** (`price_now_entity`) and **Average price
|
||||
entity** (`price_avg_entity`). The add-on refuses to start with a price
|
||||
source and these two together.
|
||||
4. Check **Supplier markup** and **Distribution, levies and VAT**. With the
|
||||
Home Assistant pair they decided nothing about the maintenance charge. With
|
||||
the provider they are added to the wholesale price, so they set the prices
|
||||
shown on the page and, if battery modes or trading are on, the prices those
|
||||
decide on. The maintenance charge gate does not depend on them.
|
||||
5. Keep **Fixed or day/night tariff** (`static_profile`) if you have one; it
|
||||
is fine to leave it set. It is the plan's last fallback: it is used,
|
||||
marked as estimated and without trading, only when no source has a price.
|
||||
*Cheap window* and the maintenance charge never read it: the provider's
|
||||
prices decide them. The add-on logs one INFO line at start ("Fixed tariff
|
||||
is only used if no prices are available.") and shows the same note beside
|
||||
the setting. Nothing needs clearing, and clearing it only removes the
|
||||
fallback (the settings page then warns that the site has no tariff while the
|
||||
price feed is down).
|
||||
6. Remove the `price_service` REST and template sensors from Home Assistant
|
||||
when you are satisfied. They are no longer read.
|
||||
|
||||
To go back: put the two entities back, set the source to `off`, and restart.
|
||||
|
||||
#### So what should a Belgian dynamic-tariff customer do today?
|
||||
|
||||
**Honestly: `generic_dayahead` gets you close, and you should know exactly how
|
||||
@@ -2784,6 +2896,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
|
||||
— configure two of them and the add-on refuses to start, naming the conflict,
|
||||
because the add-on reads exactly one and the loser is silent. Use one.
|
||||
|
||||
To move a site off them onto the owner's price server, see [`price_service`](#price_service--your-own-price-server-and-what-it-falls-back-to):
|
||||
it lists which options to set and which to clear.
|
||||
|
||||
### The three buckets — which price components can move a decision
|
||||
|
||||
*For installers: card TARIFF-17.*
|
||||
@@ -3249,8 +3364,30 @@ reading is stuck. Control keeps running and keeps following the reading, so a
|
||||
stuck reading can raise this month's peak. Check the meter. If it is stuck,
|
||||
stop control on the add-on page.
|
||||
|
||||
### Battery cycle did not finish
|
||||
|
||||
The monthly battery cycle stopped because the battery did not empty, or did not
|
||||
fill up, in time. The battery is back under normal control. The add-on waits 7
|
||||
days before it tries again, so it does not empty the battery every night. To
|
||||
try sooner, start a cycle on the add-on page. If it keeps happening, check that
|
||||
the battery follows its commands and that the cheap hours leave time to charge.
|
||||
|
||||
### No prices
|
||||
|
||||
No price source has a price for the present moment: not the price server,
|
||||
not energy-charts, and nothing saved. The monthly battery cycle waits, because
|
||||
it will not charge from the grid without a price to judge by. Control of the
|
||||
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.
|
||||
|
||||
### For installers: notifications
|
||||
|
||||
A fixed tariff beside the price service raises no notification: it is the
|
||||
plan's last fallback, not a fault. An earlier pre-release build raised a card
|
||||
with the id `goodwe_fixed_tariff_unused`; the add-on takes that id down at every
|
||||
start, which does nothing when it is absent.
|
||||
|
||||
This part is for installers. The texts live in `app/messages.py` and follow
|
||||
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
|
||||
replaces the card. The ERROR or WARNING line of the add-on log names the
|
||||
@@ -3268,6 +3405,8 @@ default `info` hides it.
|
||||
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
|
||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
||||
| 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 |
|
||||
|
||||
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||||
@@ -3379,6 +3518,11 @@ day's price runs.
|
||||
An Off battery that the peak limit still needs says so, with the power it
|
||||
gives: "Off, but gives 0.5 kW for the peak limit."
|
||||
|
||||
With two or more batteries, each card also has **Start cycle** and **Stop
|
||||
cycle**, to run the monthly cycle on that battery now. Only one battery cycles
|
||||
at a time: a start while another battery is cycling, or on a battery that is
|
||||
switched off, is refused and the message names the battery.
|
||||
|
||||
**Settings** opens on the **Simple** view, or on the view you last picked:
|
||||
the peak limit, the batteries' switches and modes, and shadow mode, each
|
||||
marked "Applies right away" or "Applies after a restart". **Installer** shows
|
||||
@@ -3444,6 +3588,7 @@ for a new install*.
|
||||
|
||||
| Row starts with | What it tells you |
|
||||
|---|---|
|
||||
| Home Assistant is not answering | Home Assistant is not passing readings or power requests on, usually because it is still starting. The add-on cannot control until then. It resumes by itself. Nothing to do unless it lasts. |
|
||||
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
|
||||
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. |
|
||||
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
|
||||
@@ -3459,6 +3604,7 @@ for a new install*.
|
||||
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
|
||||
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
||||
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
|
||||
| Prices | Only while no price source has a price for now: *No prices since 00:00, maintenance waits.* See *No prices* under Notifications. |
|
||||
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
|
||||
|
||||
### For installers: status page
|
||||
@@ -3502,6 +3648,13 @@ reason code, and its full figures are on the DEBUG line right after it and in
|
||||
(TARIFF-11), in words.
|
||||
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
|
||||
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
|
||||
- **Home Assistant is not answering** (SAFETY-112): the Supervisor answers
|
||||
502 to every add-on read, write and websocket until Home Assistant is
|
||||
RUNNING, even when its entities are already live. A slow integration at
|
||||
start-up therefore holds control for as long as it holds Home Assistant's
|
||||
start. The log has one WARNING, a "Still waiting" INFO line every 5
|
||||
minutes and "answers again after …" at the end. See
|
||||
`DECISIONS/SAFETY-112-waiting-for-home-assistant.md`.
|
||||
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
|
||||
(circulating power, or a site or per-inverter sign trip), and that latch keeps
|
||||
its own red row.
|
||||
|
||||
@@ -221,6 +221,11 @@ function makeInput(field, value, onchange){
|
||||
input.step = field.type === 'integer' ? '1' : 'any';
|
||||
if(field.min !== undefined) input.min = field.min;
|
||||
if(field.max !== undefined) input.max = field.max;
|
||||
} else if(field.type === 'password'){
|
||||
input.type = 'password';
|
||||
input.autocomplete = 'new-password';
|
||||
input.autocapitalize = 'none';
|
||||
input.spellcheck = false;
|
||||
} else {
|
||||
input.type = 'text';
|
||||
input.autocapitalize = 'none';
|
||||
|
||||
@@ -206,6 +206,8 @@ def _one_field(node, translations, lang, path=()):
|
||||
"multiple": bool(node.get("multiple")),
|
||||
"unit": unit_of(key),
|
||||
}
|
||||
if node.get("format") == "password":
|
||||
field["type"] = "password"
|
||||
if node.get("lengthMin") is not None:
|
||||
field["min"] = node["lengthMin"]
|
||||
if node.get("lengthMax") is not None:
|
||||
@@ -306,6 +308,52 @@ def problems_by_field(problems, keys):
|
||||
return by_key, unanchored
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# SECRETS (PRICE-01 F1). A `password` option never leaves this process in a page
|
||||
# or a JSON answer. The pages get SECRET_SHOWN when one is set and "" when not.
|
||||
# A save that sends SECRET_SHOWN or nothing keeps the stored value; any other
|
||||
# value replaces it. The names come from the Supervisor's own schema, so a
|
||||
# password option added later is covered without an edit here.
|
||||
# --------------------------------------------------------------------------- #
|
||||
SECRET_SHOWN = "********"
|
||||
|
||||
|
||||
def secret_keys(schema, extra=()):
|
||||
"""Names of the options whose schema type is `password`, plus `extra`."""
|
||||
# The Supervisor may spell it `type: password` or `type: string` with
|
||||
# `format: password`; both count.
|
||||
return frozenset(str(n.get("name")) for n in schema or ()
|
||||
if isinstance(n, dict) and (
|
||||
n.get("type") == "password"
|
||||
or n.get("format") == "password")
|
||||
) | frozenset(extra)
|
||||
|
||||
|
||||
def hide_secrets(value, keys):
|
||||
"""A copy of `value` with every secret key's value replaced by
|
||||
SECRET_SHOWN (set) or "" (empty), at any depth."""
|
||||
if isinstance(value, dict):
|
||||
return {k: ((SECRET_SHOWN if v else "") if k in keys
|
||||
else hide_secrets(v, keys))
|
||||
for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [hide_secrets(v, keys) for v in value]
|
||||
return value
|
||||
|
||||
|
||||
def keep_secrets(proposed, stored, keys):
|
||||
"""`proposed` with each secret the page did not change put back from
|
||||
`stored`: absent, empty or SECRET_SHOWN means 'leave it'."""
|
||||
out = dict(proposed)
|
||||
for key in keys:
|
||||
if out.get(key) in (None, "", SECRET_SHOWN):
|
||||
if isinstance(stored, dict) and key in stored:
|
||||
out[key] = stored[key]
|
||||
elif key in out and out[key] == SECRET_SHOWN:
|
||||
out[key] = ""
|
||||
return out
|
||||
|
||||
|
||||
def changed_keys(left, right):
|
||||
"""Sorted keys whose values differ between two option blobs.
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import time
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
|
||||
_LOG = logging.getLogger("goodwe.hass")
|
||||
|
||||
CORE_API = "http://supervisor/core/api"
|
||||
@@ -19,6 +21,7 @@ BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
# What the Supervisor's proxy answers while Home Assistant restarts.
|
||||
UNAVAILABLE = (502, 503, 504)
|
||||
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
|
||||
STILL_WAITING_S = 300 # SAFETY-112: one reminder line per 5 minutes down
|
||||
|
||||
|
||||
class Outage:
|
||||
@@ -29,15 +32,30 @@ class Outage:
|
||||
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
|
||||
not str"). Now the first channel to fail says it once at WARNING, the raw
|
||||
text goes to DEBUG, and the last channel to recover says so at INFO.
|
||||
|
||||
SAFETY-112: the Supervisor answers 502 for every read, write and
|
||||
websocket until Home Assistant has finished starting, however live its
|
||||
entities already are (DECISIONS/SAFETY-112-waiting-for-home-assistant.md).
|
||||
So an outage also says, every STILL_WAITING_S, that it is still on, and
|
||||
`waiting()` puts it on the status page.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.down_on = set()
|
||||
self.since = None # monotonic start of this outage
|
||||
self._told = None # when the last "still waiting" line went
|
||||
|
||||
def down(self, channel: str, what: str, detail) -> None:
|
||||
now = time.monotonic()
|
||||
if not self.down_on:
|
||||
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
|
||||
"restarting. The add-on keeps trying.", what)
|
||||
"still starting. Control waits and resumes by itself.",
|
||||
what)
|
||||
self.since = self._told = now
|
||||
elif now - self._told >= STILL_WAITING_S:
|
||||
self._told = now
|
||||
_LOG.info("Still waiting for Home Assistant to finish starting, "
|
||||
"%s so far.", messages.duration(now - self.since))
|
||||
self.down_on.add(channel)
|
||||
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
|
||||
|
||||
@@ -45,7 +63,13 @@ class Outage:
|
||||
if channel in self.down_on:
|
||||
self.down_on.discard(channel)
|
||||
if not self.down_on:
|
||||
_LOG.info("Home Assistant answers again.")
|
||||
_LOG.info("Home Assistant answers again after %s.",
|
||||
messages.duration(time.monotonic() - self.since))
|
||||
|
||||
def waiting(self) -> bool:
|
||||
"""True while Home Assistant's API does not answer. The API, not the
|
||||
meter websocket: every battery reading and every write rides on it."""
|
||||
return "api" in self.down_on
|
||||
|
||||
|
||||
OUTAGE = Outage()
|
||||
|
||||
@@ -582,6 +582,8 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
|
||||
runs += cyc
|
||||
lines += waits
|
||||
if getattr(c, "price_gap", False):
|
||||
lines.append(line_no_prices(c.price_gap_since, now))
|
||||
runs += peak_run(c, day0, day1, now, shadow, limit_w)
|
||||
if c.p1_enabled:
|
||||
# The guard's ceiling: the cap, or this month's peak once above it.
|
||||
@@ -909,13 +911,14 @@ def month_peak(c, now):
|
||||
return out
|
||||
|
||||
|
||||
def state_of(c, level, shadow):
|
||||
def state_of(c, level, shadow, waiting=False):
|
||||
"""One word for the pill and the banner's colour: running, shadow,
|
||||
stopped, alarm or warning."""
|
||||
stopped, alarm, waiting or warning. `waiting`: the red headline is
|
||||
Home Assistant still starting (SAFETY-112), which needs nobody."""
|
||||
if shadow:
|
||||
return "shadow"
|
||||
if level == "bad":
|
||||
return "alarm"
|
||||
return "waiting" if waiting else "alarm"
|
||||
if not c.auto:
|
||||
return "stopped"
|
||||
if level == "warn":
|
||||
@@ -924,20 +927,22 @@ def state_of(c, level, shadow):
|
||||
|
||||
|
||||
def build(c, *, level: str, checks: list, now: datetime | None = None,
|
||||
lang: str = "en", held: bool | None = None) -> dict:
|
||||
lang: str = "en", held: bool | None = None,
|
||||
waiting: bool = False) -> dict:
|
||||
"""The structured half of `/status`, in `lang` (UX-13 P5). Pure reads of
|
||||
the controller."""
|
||||
_LANG[0] = uitext.lang_of(lang)
|
||||
try:
|
||||
return _build(c, level=level, checks=checks, now=now, held=held)
|
||||
return _build(c, level=level, checks=checks, now=now, held=held,
|
||||
waiting=waiting)
|
||||
finally:
|
||||
_LANG[0] = "en"
|
||||
|
||||
|
||||
def _build(c, *, level, checks, now, held=None):
|
||||
def _build(c, *, level, checks, now, held=None, waiting=False):
|
||||
now = now or datetime.now(timezone.utc)
|
||||
shadow = bool(c.shadow)
|
||||
kind = state_of(c, level, shadow)
|
||||
kind = state_of(c, level, shadow, waiting)
|
||||
# A red banner means every inverter is at 0 W (the status cascade), so
|
||||
# nothing is planned until it clears. Shadow mode still plans: it is
|
||||
# what the add-on would do. `held` False: an alarm while control runs
|
||||
@@ -1265,6 +1270,17 @@ def line_cycle_waits(name) -> str:
|
||||
return _t("cycle_waits", name=name)
|
||||
|
||||
|
||||
def line_no_prices(since, now) -> str:
|
||||
"""PRICE-01: no price covers now. `since` is an instant or None."""
|
||||
if since is None:
|
||||
return _t("no_prices_yet")
|
||||
local, today = brussels(since), brussels(now)
|
||||
if local.date() == today.date():
|
||||
return _t("no_prices_since", at=f"{local:%H:%M}")
|
||||
return _t("no_prices_since_day", day=local.day, month=month_name(local),
|
||||
at=f"{local:%H:%M}")
|
||||
|
||||
|
||||
def line_peak_limit(limit_w, shadow) -> str:
|
||||
return _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
|
||||
@@ -1332,7 +1348,7 @@ def _samples() -> list:
|
||||
out += [("title", mode_word(k)) for k in words["modes"]]
|
||||
out += [("title", mode_house()), ("title", attn_shadow()),
|
||||
("line", attn_shadow_body())]
|
||||
for k in ("running", "shadow", "stopped", "alarm", "warning"):
|
||||
for k in ("running", "shadow", "stopped", "alarm", "waiting", "warning"):
|
||||
out += [("title", pill(k)), ("line", banner_sub(k))]
|
||||
for sh in (False, True):
|
||||
out += [("title", tile_solar(v, s)) for v, s in
|
||||
@@ -1384,6 +1400,16 @@ def _samples() -> list:
|
||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||
("plan", run_house_amount()),
|
||||
("plan", line_cycle_waits("Battery B")),
|
||||
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
|
||||
tzinfo=timezone.utc),
|
||||
datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 13, 22, 0,
|
||||
tzinfo=timezone.utc),
|
||||
datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
|
||||
{"eur_kwh": 0.36, "label": "19:00"})),
|
||||
("plan", line_prices(None, None, None)),
|
||||
|
||||
+446
-59
@@ -69,7 +69,7 @@ from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
from .hass import CORE_API, HomeAssistant
|
||||
from .hass import CORE_API, OUTAGE, HomeAssistant
|
||||
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
|
||||
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
|
||||
# window an operator would look up here is the one the roster actually uses.
|
||||
@@ -77,7 +77,7 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
roster_entries, roster_names)
|
||||
from .maintenance import (
|
||||
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
|
||||
MaintResult, arbitrate as maint_arbitrate, site_zone,
|
||||
FAIL_BACKOFF_DAYS, MaintResult, arbitrate as maint_arbitrate, site_zone,
|
||||
)
|
||||
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
|
||||
MqttPublisher, announced_units, mode_configs,
|
||||
@@ -90,8 +90,10 @@ from .p1 import P1Ingest, build_source, is_enabled
|
||||
from .price import Absence, Uncertainty, brussels, price_option, provider_configured
|
||||
from .price import required_spread as price_required_spread
|
||||
from .price import uncertainty_of as price_uncertainty_of
|
||||
from .price_chain import (PriceWatch, fixed_tariff_beside_service, gate_for,
|
||||
uses_chain)
|
||||
from .price_history import PriceHistory
|
||||
from .static_tariff import resolve_tariff, static_status
|
||||
from .static_tariff import resolve_tariff, static_gated, static_status
|
||||
from .tariff import PeakStore, PeakTracker, next_quarter
|
||||
from .store import migrate_maint_keys, migrate_unit_keys
|
||||
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
||||
@@ -238,6 +240,9 @@ def load_options() -> dict:
|
||||
# OPT-11 P3: how long the last cheap run stays the price stored energy is
|
||||
# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
|
||||
REF_CHEAP_KEEP = timedelta(hours=12)
|
||||
# OPT-15: the longest a run edge holds the loop at E waiting for the meter to
|
||||
# show the step: the plant's measured step time (control.PLANT_SETTLE_S).
|
||||
EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
|
||||
|
||||
# OPT-11 P4: the store key of the house profile (dayplan.HouseProfile), and
|
||||
# how often the forecast sensors are read (they change hourly at most).
|
||||
@@ -968,6 +973,7 @@ class Controller:
|
||||
self.peak_unmeasured_s = 0.0
|
||||
self._peak_alarm_block = None
|
||||
self._peak_high_since = None
|
||||
self._peak_rolled = False # OPT-15
|
||||
self._peak_floor_limited = False
|
||||
# SAFETY-87: the reserve alarm's hold, as SAFETY-81's above.
|
||||
self._reserve_batt_prev = None
|
||||
@@ -990,14 +996,23 @@ class Controller:
|
||||
self._run_done = {}
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
|
||||
self._ref_cheap = None
|
||||
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
|
||||
# last battery reading).
|
||||
self._edge = None
|
||||
# OPT-11 P4: the solar forecast and the day plan. Only with battery
|
||||
# modes on AND `pv_forecast_entity` set; otherwise none of this runs
|
||||
# and every mode acts as in P3 option A.
|
||||
self.fc_entity = (opts.get("pv_forecast_entity") or None) \
|
||||
if self.modes_on else None
|
||||
self.fc_tomorrow_entity = opts.get("pv_forecast_tomorrow_entity") \
|
||||
or None
|
||||
# modes on AND a forecast for today set; otherwise none of this runs
|
||||
# and every mode acts as in P3 option A. OPT-17: each day may name
|
||||
# several entities, summed (`solarfc.entities()`); `fc_entity` is
|
||||
# the first, and None means no forecast.
|
||||
self.fc_entities = (solarfc.entities(opts) if self.modes_on
|
||||
else ())
|
||||
self.fc_tomorrow_entities = solarfc.entities(opts, tomorrow=True)
|
||||
self.fc_entity = self.fc_entities[0] if self.fc_entities else None
|
||||
# OPT-17: the members that are not usable, as `solarfc.read_sum()`
|
||||
# gives them, for the status page and the action log.
|
||||
self.solar_fc_gaps = ()
|
||||
self.solar_trust_pct = opts.get("solar_trust_pct",
|
||||
dayplan.DEFAULT_TRUST_PCT)
|
||||
self.solar_fc = None
|
||||
@@ -1031,6 +1046,11 @@ class Controller:
|
||||
# SAFETY-95: batteries settling after their drain ended; meanwhile
|
||||
# the loop's error counts every battery at its command.
|
||||
self._drain_tail = set()
|
||||
# SAFETY-103: per battery, the range its power can be in since its
|
||||
# reading last changed (that reading and every command since), and
|
||||
# the house point held while a drain's tail settles.
|
||||
self._read_span = {}
|
||||
self._drain_house = None
|
||||
# SAFETY-96: last cycle's commandable batteries, this cycle's start
|
||||
# point per battery where a reseed moved it, and batteries settling
|
||||
# after a step the loop did not make ({name: since, monotonic}).
|
||||
@@ -1183,6 +1203,19 @@ class Controller:
|
||||
self.grid = None
|
||||
self.peak_fc = None
|
||||
self.cheap = False
|
||||
self._price_gated = False # MAINT-13: is a price gate set up
|
||||
# PRICE-01. Which step of the price chain the prices come from (logged
|
||||
# once per change), the last series that was published (so a gap can say
|
||||
# since when), and the gap itself: True while a refresh has finished and
|
||||
# no published price covers now. The notification is raised once per
|
||||
# gap and taken down when it ends.
|
||||
self.price_watch = PriceWatch()
|
||||
self._last_published = None
|
||||
self.price_gap = False
|
||||
self.price_gap_since = None
|
||||
self._price_gap_announced = False
|
||||
self._fixed_note_said = False
|
||||
self._gated_key = None # MAINT-13: (options, hour) of the cache
|
||||
# TARIFF-03. One line of prose for the status page saying WHICH tariff
|
||||
# window we are in and why it is or is not cheap - so "Cheap window:
|
||||
# no" is an evidenced answer rather than a bare no.
|
||||
@@ -1272,9 +1305,26 @@ class Controller:
|
||||
_LOG.info("The battery refusal ends, control asks for power again: %s.",
|
||||
why)
|
||||
|
||||
def _refusal_update(self, decision, asked_w, batt_w, probe) -> None:
|
||||
"""Follow `compute()`'s carried refused direction, and say so."""
|
||||
def _refusal_update(self, decision, asked_w, batt_w, probe,
|
||||
rejudged: int = 0) -> None:
|
||||
"""Follow `compute()`'s carried refused direction, and say so.
|
||||
|
||||
`rejudged`: the refusal held while another writer had the wire, so
|
||||
`compute()` judged it afresh against that writer's ask (SAFETY-107).
|
||||
"""
|
||||
now = decision.refused or 0
|
||||
if rejudged:
|
||||
if now == rejudged:
|
||||
# Still refused against the ask on the wire: the same episode.
|
||||
# The probe clock restarts, as it did when this was an end and
|
||||
# a new start: a probe is the loop's ask, and it is not on the
|
||||
# wire while another writer is.
|
||||
self.batt_refused_at = self._now_mono
|
||||
return
|
||||
gave = (rejudged * (batt_w - self._site_idle_w())
|
||||
>= _ctl.PROBE_ANSWER_W)
|
||||
self._refusal_end("the battery gives power" if gave
|
||||
else "another writer")
|
||||
if now and not self.batt_refused:
|
||||
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
|
||||
f"gave {batt_w:+.0f} W for "
|
||||
@@ -2187,10 +2237,15 @@ class Controller:
|
||||
or maint_results[i].phase == IDLE)
|
||||
refused = (inv.refused if len(self.inverters) > 1
|
||||
else self.batt_refused) or 0
|
||||
# OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood
|
||||
# when last decided; its pin is filed after this, in cycle().
|
||||
probing = (len(self.inverters) > 1 and refused < 0
|
||||
and inv.probe_from is not None)
|
||||
out.append(modetrade.Pack(
|
||||
inv.name, pm.use, pm.mode, pm.sell and pm.can_sell,
|
||||
float(inv.max_w), inv.soc, inv.capacity_kwh,
|
||||
live=inv.name in live, idle=idle, refused=int(refused)))
|
||||
live=inv.name in live, idle=idle, refused=int(refused),
|
||||
probing=probing))
|
||||
return out
|
||||
|
||||
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
|
||||
@@ -2348,9 +2403,17 @@ class Controller:
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
if was and self.mode_tick.action != modetrade.CHARGE and \
|
||||
self.mode_tick.why in ("peak", "headroom"):
|
||||
self._charge_blocked_q = quarter
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
# (None) in a quarter stops this tick only, so a gap cannot cost the
|
||||
# whole quarter. A second one latches, so gaps cannot flip the charge.
|
||||
if was and self.mode_tick.action != modetrade.CHARGE:
|
||||
why = self.mode_tick.why
|
||||
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
|
||||
elif gap:
|
||||
self._charge_gap_q = quarter
|
||||
return self.mode_tick
|
||||
|
||||
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
||||
@@ -2378,6 +2441,19 @@ class Controller:
|
||||
tick = self.mode_tick
|
||||
if tick is None:
|
||||
return False
|
||||
if tick.action == modetrade.CHARGE:
|
||||
# OPT-20: the ask carries PROBE_W for exactly the probes whose pin
|
||||
# is filed this tick. `decide()` ran before the pin was (or the
|
||||
# window closed since), so the two can differ by one cycle; left
|
||||
# alone, the allocator takes the pin's watts out of another
|
||||
# battery's share, or hands them to one it can reach.
|
||||
pins = _ctl.PROBE_W * sum(
|
||||
1 for c in claims if c.source == "refused"
|
||||
and c.kind == "set" and c.value < 0)
|
||||
if pins != tick.probe_w:
|
||||
tick = self.mode_tick = dataclasses.replace(
|
||||
tick, ask_w=tick.ask_w + tick.probe_w - pins,
|
||||
probe_w=pins)
|
||||
size = modetrade.size(tick, self.loop_bound(claims)
|
||||
if tick.action else None, self.arb_headroom_w)
|
||||
runs = self.mode_runs
|
||||
@@ -2421,18 +2497,18 @@ class Controller:
|
||||
else "trade_off"))
|
||||
return self.arb.action is not None
|
||||
|
||||
def _reseed_run_edge(self, claims) -> None:
|
||||
def _reseed_run_edge(self, claims, now) -> None:
|
||||
"""OPT-11 P3: a mode trade just ended, so the loop takes over from a
|
||||
command no loop chose. Start it at the house's own point E, the
|
||||
command that brings the grid to its target now (`site battery + grid
|
||||
- target`, inside the loop bound), instead of at the trade's watts
|
||||
(the trade began with the same unslewed step). This tick's error still
|
||||
measures the trade's command (it is exactly E - battery), so the
|
||||
integrator is seeded that much short and lands on E after its step.
|
||||
Measured (tools/opt11p3_replay.py, lagged battery, 2.5 kW charge
|
||||
ending under an 800 W house): no reseed -258 W export then a 2976 W
|
||||
import, settled in 55 s; this -257 W / +615 W, settled in 20 s.
|
||||
- target`, inside the loop bound), instead of at the trade's watts.
|
||||
OPT-15: then HOLD it at E (`_edge_grid()`) until the meter shows the
|
||||
step or `EDGE_HOLD_S` passes. The readings still describe the trade for
|
||||
a second or two (meter and battery lag), and stepping on them sent
|
||||
E 810 -> 1370 -> 1920 W on TEST-23: -1120 W export at a charge end.
|
||||
Measured: DECISIONS/OPT-11-battery-modes.md.
|
||||
"""
|
||||
self._edge = None
|
||||
batt = self.site_batt_w
|
||||
if batt is None or self.grid is None:
|
||||
return
|
||||
@@ -2441,8 +2517,34 @@ class Controller:
|
||||
if bound is not None:
|
||||
want = max(bound[0], min(bound[1], want))
|
||||
want = float(round(max(-self.site_max_w, min(self.site_max_w, want))))
|
||||
self.target = want
|
||||
self.i_w = want - self.tuning.gain * (want - float(batt))
|
||||
self.target = self.i_w = want
|
||||
if want != float(batt):
|
||||
self._edge = (want, 1.0 if want > float(batt) else -1.0,
|
||||
now + timedelta(seconds=EDGE_HOLD_S), float(batt))
|
||||
|
||||
def _edge_grid(self, end, now):
|
||||
"""OPT-15: the grid reading the loop acts on this tick. While a run
|
||||
edge is held, the target itself (no error, the command stays at E).
|
||||
The hold ends when the meter reaches the target in the step's
|
||||
direction (or passes it), on `end` (a trade started, or another claim
|
||||
moved the command), or after `EDGE_HOLD_S` - later, up to twice that,
|
||||
while the battery reading is still moving towards E."""
|
||||
e = self._edge
|
||||
if e is None:
|
||||
return self.grid
|
||||
held_w, sign, until, last_b = e
|
||||
tg, db = self.tuning.target_grid_w, self.tuning.deadband_w
|
||||
b = self.site_batt_w
|
||||
moving = (b is not None and sign * (float(b) - last_b) >= db
|
||||
and sign * (held_w - float(b)) >= db
|
||||
and now < until + timedelta(seconds=EDGE_HOLD_S))
|
||||
if (end or (now >= until and not moving)
|
||||
or sign * (float(self.grid) - tg) < db):
|
||||
self._edge = None
|
||||
return self.grid
|
||||
self._edge = (held_w, sign, until, last_b if b is None else float(b))
|
||||
self.i_w = held_w
|
||||
return tg
|
||||
|
||||
def _charge_track(self, claims, maint_results) -> None:
|
||||
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
|
||||
@@ -2527,13 +2629,56 @@ class Controller:
|
||||
most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and
|
||||
the others' climb, so the loop would chase them (B down, then up
|
||||
again). Meanwhile the batteries count at the site command: the error
|
||||
is the house's own point minus the loop's start."""
|
||||
is the house's own point minus the loop's start.
|
||||
|
||||
SAFETY-103: a reading can be up to a register period (10 s) older
|
||||
than the meter, so the house point is held, and moved only as far as
|
||||
the meter proves: to the nearest value the batteries can be at (each
|
||||
between its last changed reading and its commands since; a reading
|
||||
that changed this cycle, or the side a battery refuses, is taken as
|
||||
it reads). DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
|
||||
"""
|
||||
grid = float(self.grid)
|
||||
self._drain_tail &= set(self._settle) & set(live)
|
||||
if not self._drain_tail:
|
||||
self._drain_house = None
|
||||
return grid
|
||||
return grid - float(self.target) + sum(
|
||||
float(inv.batt) for inv in self.inverters if inv.name in live)
|
||||
aim = self.tuning.target_grid_w
|
||||
lo = hi = grid - aim
|
||||
for inv in self.inverters:
|
||||
if inv.name not in live:
|
||||
continue
|
||||
r = float(inv.batt)
|
||||
span = self._read_span.get(inv.name)
|
||||
if (span is None or span[2] != inv.batt_fresh.changed_mono
|
||||
or span[2] == self._now_mono):
|
||||
span = (r, r)
|
||||
# A battery that refuses one way (SAFETY-73) is not heading
|
||||
# that way, whatever it was asked: that side is its reading.
|
||||
if (inv.refused or 0) > 0:
|
||||
span = (min(span[0], r), r)
|
||||
elif (inv.refused or 0) < 0:
|
||||
span = (r, max(span[1], r))
|
||||
lo, hi = lo + span[0], hi + span[1]
|
||||
held = (float(self.target) if self._drain_house is None
|
||||
else self._drain_house)
|
||||
self._drain_house = min(hi, max(lo, held))
|
||||
return self._drain_house - float(self.target) + aim
|
||||
|
||||
def _track_reads(self) -> None:
|
||||
"""SAFETY-103, N>=2: per battery, the range its power can be in now:
|
||||
its last changed reading and every command on its wire since (a
|
||||
lagging battery moves from one toward the others, never beyond)."""
|
||||
if len(self.inverters) < 2:
|
||||
return
|
||||
for inv in self.inverters:
|
||||
if inv.batt is None:
|
||||
continue
|
||||
cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono
|
||||
lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None))
|
||||
if was is None or since != was:
|
||||
lo = hi = float(inv.batt)
|
||||
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
|
||||
|
||||
def _reseed_drop(self, live) -> None:
|
||||
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
|
||||
@@ -2658,6 +2803,43 @@ class Controller:
|
||||
out["sell"] = pm.sell
|
||||
return out
|
||||
|
||||
async def _maint_unit_action(self, what, unit, now) -> dict:
|
||||
"""MAINT-11: start or abort the maintenance cycle of ONE named battery.
|
||||
|
||||
Returns `{"error", "code", "unit"}` for a refusal and changes nothing.
|
||||
The site keeps its one maintenance slot: a start while any battery
|
||||
holds it is refused (no queue), see DECISIONS/MAINT-11. The phase
|
||||
machine is not touched beyond `force_start()` and `abort()`, the same
|
||||
two calls the bare button makes.
|
||||
"""
|
||||
inv = next((i for i in self.inverters if i.name == unit), None)
|
||||
if inv is None:
|
||||
return self._maint_refusal("unit")
|
||||
machine = self.maints[self.inverters.index(inv)]
|
||||
if what == "maint_start":
|
||||
holder = next((i for i, m in zip(self.inverters, self.maints)
|
||||
if m.phase in POWER_MOVING), None)
|
||||
if holder is not None:
|
||||
return self._maint_refusal("busy", holder.name)
|
||||
if self.modes_on and not self.pack_mode(inv).use:
|
||||
return self._maint_refusal("off", inv.name)
|
||||
for msg in machine.force_start(now):
|
||||
self.log_event(msg, hint=messages.hint(
|
||||
"maintenance", phase=machine.phase))
|
||||
return {"unit": inv.name, "phase": machine.phase}
|
||||
if machine.phase not in POWER_MOVING:
|
||||
return self._maint_refusal("idle", inv.name)
|
||||
for msg in machine.abort(now, "operator"):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=machine.phase))
|
||||
await self.command_site_zero()
|
||||
return {"unit": inv.name, "phase": machine.phase}
|
||||
|
||||
@staticmethod
|
||||
def _maint_refusal(code, unit="") -> dict:
|
||||
return {"error": messages.maint_refused(code, unit), "code": code,
|
||||
"unit": unit}
|
||||
|
||||
def mode_values(self) -> dict:
|
||||
"""OPT-11 P2: each battery's mode and switch, for their HA state
|
||||
topics. Read from the store, so they are right after a restart. Empty
|
||||
@@ -3179,18 +3361,31 @@ class Controller:
|
||||
async def _read_forecast(self) -> None:
|
||||
"""OPT-11 P4: the solar forecast sensors (`solarfc.read()`). A
|
||||
failed or stale read is no forecast, never a zero."""
|
||||
today = await self.hass.state(self.fc_entity)
|
||||
tomorrow = (await self.hass.state(self.fc_tomorrow_entity)
|
||||
if self.fc_tomorrow_entity else None)
|
||||
# OPT-17: every member of both days in one gather.
|
||||
ents = self.fc_entities + self.fc_tomorrow_entities
|
||||
states = await asyncio.gather(*(self.hass.state(e) for e in ents))
|
||||
n = len(self.fc_entities)
|
||||
now = datetime.now(timezone.utc)
|
||||
fc, why = solarfc.read(today, tomorrow, now)
|
||||
if (fc is None) != (self.solar_fc is None) or why != self.solar_fc_why:
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
list(zip(self.fc_entities, states[:n])),
|
||||
list(zip(self.fc_tomorrow_entities, states[n:])), now)
|
||||
# A member is named only in a sum of several; one entity reads as
|
||||
# before. Logged once per episode: on a change, never every read.
|
||||
gaps = tuple(g for g in gaps if len(
|
||||
self.fc_entities if g[0] == "today"
|
||||
else self.fc_tomorrow_entities) > 1)
|
||||
if (fc is None) != (self.solar_fc is None) \
|
||||
or why != self.solar_fc_why or gaps != self.solar_fc_gaps:
|
||||
self.log_event(
|
||||
"solar forecast: " + ("read" if fc is not None else
|
||||
f"none ({why})"),
|
||||
hint=messages.hint("forecast_read" if fc is not None
|
||||
else "forecast_none"))
|
||||
f"none ({why})")
|
||||
+ "".join(f", {d} {e} not usable ({w})"
|
||||
for d, _i, e, w in gaps),
|
||||
hint=messages.hint(
|
||||
"forecast_none" if fc is None else
|
||||
"forecast_no_tomorrow" if gaps else "forecast_read"))
|
||||
self.solar_fc, self.solar_fc_why = fc, why
|
||||
self.solar_fc_gaps = gaps
|
||||
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
@@ -3264,7 +3459,14 @@ class Controller:
|
||||
now_p = await self.hass.number(now_e)
|
||||
avg_p = await self.hass.number(avg_e)
|
||||
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
|
||||
self._price_gated = True
|
||||
self.tariff_words = "Prices from Home Assistant entities"
|
||||
elif uses_chain(o):
|
||||
# PRICE-01. A provider with fallbacks gives the maintenance gate
|
||||
# its own prices: the current 15-minute price against today's
|
||||
# average, as the Home Assistant pair did. Still no I/O here - the
|
||||
# series is the one the background refresh stored.
|
||||
self._provider_gate(datetime.now(timezone.utc))
|
||||
else:
|
||||
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
||||
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
|
||||
@@ -3287,8 +3489,9 @@ class Controller:
|
||||
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
|
||||
# alongside the two Home Assistant entities, because this `if` picks
|
||||
# one and the loser is silent.
|
||||
self.cheap, self.tariff_words = static_status(
|
||||
o, datetime.now(timezone.utc))
|
||||
_now = datetime.now(timezone.utc)
|
||||
self.cheap, self.tariff_words = static_status(o, _now)
|
||||
self._price_gated = self._static_gated(o, _now)
|
||||
|
||||
# ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND
|
||||
# THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the
|
||||
@@ -3827,6 +4030,61 @@ class Controller:
|
||||
inv.refusal_note())]
|
||||
if n]
|
||||
|
||||
def _provider_gate(self, now: datetime) -> None:
|
||||
"""PRICE-01: `cheap`, the gate and the gap from the standing plan.
|
||||
|
||||
The gate exists whenever the provider does: no price for now is a
|
||||
closed gate, so the charge waits and does not run unpriced. The gap
|
||||
flag stays False until the first refresh has finished, so a start-up
|
||||
is not a gap.
|
||||
"""
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
published = None if plan is None else plan.published
|
||||
if published is not None:
|
||||
self._last_published = published
|
||||
gate = gate_for(published, now)
|
||||
self.cheap, self._price_gated = gate.cheap, True
|
||||
self.tariff_words = gate.words
|
||||
# Owner, 2026-10-14: the words say 'maintenance waits', so they show
|
||||
# only while the maintenance charge is on.
|
||||
self.price_gap = ((not gate.covered) and self.tariff_at is not None
|
||||
and MaintConfig.from_options(self.o).enabled)
|
||||
last = self._last_published
|
||||
if gate.covered or last is None:
|
||||
self.price_gap_since = None
|
||||
elif last.covers(now):
|
||||
# The last series still covers now, but no refresh has made it
|
||||
# current for TARIFF_MAX_AGE_S: the refresh task has stopped. The
|
||||
# gap began when the plan was retired.
|
||||
self.price_gap_since = (
|
||||
None if self.tariff_at is None
|
||||
else self.tariff_at + timedelta(seconds=TARIFF_MAX_AGE_S))
|
||||
else:
|
||||
self.price_gap_since = last.last_end_before(now)
|
||||
|
||||
def _static_gated(self, o: dict, now: datetime) -> bool:
|
||||
"""`static_gated()`, worked out once an hour: it builds the same
|
||||
month-long plan as `static_status()` (about 20 ms with a profile), and
|
||||
whether a fixed tariff is set up does not change from tick to tick
|
||||
(options change only with a restart)."""
|
||||
key = (id(o), now.strftime("%Y-%m-%d %H"))
|
||||
if key != self._gated_key:
|
||||
self._gated_key, self._gated = key, static_gated(o, now)
|
||||
return self._gated
|
||||
|
||||
def _charge_gate_open(self, inv) -> bool:
|
||||
"""MAINT-13: may this pack charge now, for the maintenance timeout?
|
||||
|
||||
True when no price gate is set up (no price entities, no fixed tariff:
|
||||
nothing holds the charge back), in a cheap window, or while the pack is
|
||||
really taking charge (sun outside cheap hours). Closed only when a gate
|
||||
exists, it is shut, and the pack is not charging.
|
||||
"""
|
||||
if not self._price_gated or self.cheap:
|
||||
return True
|
||||
batt = inv.batt
|
||||
return batt is not None and float(batt) <= -MAINT_CHARGING_W
|
||||
|
||||
async def cycle(self) -> None:
|
||||
"""Gather claims, resolve precedence, write the winner.
|
||||
|
||||
@@ -3875,10 +4133,24 @@ class Controller:
|
||||
if blocked is None:
|
||||
maint_results.append(MaintResult(IDLE, False, None, False, []))
|
||||
continue
|
||||
res = machine.tick(now, inv.soc, blocked_by=blocked)
|
||||
# MAINT-13: the charge timeout counts only the minutes the pack was
|
||||
# allowed to charge (see `_charge_gate_open`).
|
||||
res = machine.tick(now, inv.soc, blocked_by=blocked,
|
||||
gate_open=self._charge_gate_open(inv))
|
||||
for msg in res.events:
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
phase=machine.phase))
|
||||
self.log_event(msg, hint=messages.hint(
|
||||
"maint_failed" if res.failed else "maintenance",
|
||||
phase=machine.phase))
|
||||
if res.failed:
|
||||
# Said once: only the tick that failed carries `res.failed`.
|
||||
multi = len(self.inverters) > 1
|
||||
slug = (unit_slugs(self.units)[self.inverters.index(inv)]
|
||||
if multi else "battery")
|
||||
await self.hass.call(
|
||||
"persistent_notification", "create",
|
||||
messages.maint_failed(
|
||||
res.failed, FAIL_BACKOFF_DAYS,
|
||||
unit=inv.name if multi else "", slug=slug).data())
|
||||
maint_results.append(res)
|
||||
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
|
||||
# deferral is edge-logged (it lasts as long as the other pack's cycle),
|
||||
@@ -4434,8 +4706,16 @@ class Controller:
|
||||
# is not measured on that path, and the honest report is NOT COVERED -
|
||||
# the same answer, for the same reason, that the status page already
|
||||
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
|
||||
# OPT-15: with battery modes on, the quarter `now` is in, even before
|
||||
# its first sample (DECISIONS/OPT-11-battery-modes.md).
|
||||
self._peak_proj = (None if not self.p1_enabled
|
||||
else self.peak.project(self.p1.averager, now))
|
||||
else self.peak.project(self.p1.averager, now,
|
||||
roll=self.modes_on))
|
||||
# OPT-15 x SAFETY-81: see `check_peak_saturated()`.
|
||||
self._peak_rolled = (self._peak_proj is not None
|
||||
and self._peak_proj.block_start is not None
|
||||
and self._peak_proj.block_start
|
||||
!= self.p1.averager.block_start)
|
||||
_reading = self.peak.peak(now) if self.p1_enabled else None
|
||||
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
|
||||
# guard goes on after the readings return.
|
||||
@@ -4878,17 +5158,25 @@ class Controller:
|
||||
# entering failsafe re-seeds the integrator to zero and the first
|
||||
# cycle after release starts from zero instead of dumping the whole
|
||||
# stale period as power.
|
||||
if self.loop_w is None or self.target != self.loop_w:
|
||||
moved = self.loop_w is None or self.target != self.loop_w
|
||||
if moved:
|
||||
self.i_w = self.target
|
||||
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop.
|
||||
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and
|
||||
# OPT-15 holds it there until the meter shows the step.
|
||||
if trade_was and not mode_trade:
|
||||
self._reseed_run_edge(claims)
|
||||
self._reseed_run_edge(claims, now)
|
||||
moved = False
|
||||
loop_grid = self._edge_grid(mode_trade or moved, now)
|
||||
# SAFETY-52: another writer overrode the loop, so a refusal learned
|
||||
# against the loop's own ask no longer describes the wire. A gap
|
||||
# against the loop's own ask may no longer describe the wire. A gap
|
||||
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
|
||||
# is what seeds it, and dropping it there restarts the ~70 s cycle.
|
||||
# SAFETY-107: it ends only if compute() no longer finds it against
|
||||
# that writer's ask (`_refusal_update()`). A held refusal makes the
|
||||
# loop ask 0 W, so any claim that binds differs from it every cycle.
|
||||
rejudged = 0
|
||||
if self.loop_w is not None and self.target != self.loop_w:
|
||||
self._refusal_end("another writer")
|
||||
rejudged = self.batt_refused
|
||||
# SAFETY-96: a battery that dropped out of the split leaves the
|
||||
# loop's start point (SAFETY-93's own if its charge ended too).
|
||||
self._reseed_drop(_live96)
|
||||
@@ -4897,7 +5185,8 @@ class Controller:
|
||||
# SAFETY-95: then a drain that ended, last (it reads the start
|
||||
# the others left).
|
||||
self._reseed_drain_end(maint_results, _live96)
|
||||
probe = self._refusal_probe()
|
||||
# No probe while it is judged afresh (the loop does not hold the wire).
|
||||
probe = False if rejudged else self._refusal_probe()
|
||||
if len(self.inverters) > 1:
|
||||
probe = any(_probing.values())
|
||||
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
|
||||
@@ -4914,6 +5203,9 @@ class Controller:
|
||||
# level: the two halves of §6.2's identity break in opposite
|
||||
# directions. See `site_batt_w` and test_alloc.py part 8.
|
||||
site_batt = self.site_batt_w
|
||||
# SAFETY-103: what each battery can be doing (its reading may be
|
||||
# older than the meter).
|
||||
self._track_reads()
|
||||
# SAFETY-96: a battery the loop cannot move counts at its command,
|
||||
# so its own power does not freeze the loop (the meter has it).
|
||||
site_batt += self._held_out_w(_live96)
|
||||
@@ -4927,8 +5219,10 @@ class Controller:
|
||||
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 self.grid),
|
||||
else loop_grid),
|
||||
actual_w=site_batt,
|
||||
tuning=self.tuning,
|
||||
sat_count=self.sat_count,
|
||||
@@ -4937,7 +5231,8 @@ class Controller:
|
||||
# limit already closes an empty battery's discharge end.
|
||||
# SAFETY-73: at N>1 the site law holds 0 W only while every
|
||||
# unit refuses the same way. Otherwise None, the old law.
|
||||
refused=self.batt_refused if len(self.inverters) == 1
|
||||
refused=(0 if rejudged else self.batt_refused)
|
||||
if len(self.inverters) == 1
|
||||
else self._site_refused(),
|
||||
probe=probe,
|
||||
# SAFETY-94: at N=1 the engage also needs the reading to have
|
||||
@@ -4963,7 +5258,8 @@ class Controller:
|
||||
self.loop_w = decision.target_w
|
||||
self._loop_decision = decision
|
||||
if len(self.inverters) == 1:
|
||||
self._refusal_update(decision, loop_prev, site_batt, probe)
|
||||
self._refusal_update(decision, loop_prev, site_batt, probe,
|
||||
rejudged)
|
||||
# --- TEL-10 ------------------------------------------------------
|
||||
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
|
||||
# THE CARD IS. `self.i_w` is carried between cycles and published
|
||||
@@ -5304,6 +5600,12 @@ class Controller:
|
||||
# is not, and test_freeze.py part 5 fails on exactly that.
|
||||
await self.check_meter_frozen()
|
||||
|
||||
# --- PRICE-01: no price for now, said once and taken down once --------
|
||||
# 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_fixed_note()
|
||||
|
||||
# --- SAFETY-31's alarm, and its boundary is the LATCH, not a number ---
|
||||
# Unconstrained where the two above are not: it grades
|
||||
# `self.circ_wd.tripped`, which was advanced near the top of this method
|
||||
@@ -5673,9 +5975,13 @@ class Controller:
|
||||
if prev is not None:
|
||||
batt = min(float(batt), float(prev))
|
||||
p = self._peak_proj
|
||||
# OPT-15 x SAFETY-81: until the new quarter's first sample the old
|
||||
# projection read "no seconds left" (not high), which restarted the
|
||||
# hold; a rolled one (battery modes on) is not graded then, so a
|
||||
# persistent overload is still notified once per quarter.
|
||||
high = bool(
|
||||
g is not None and g.saturated and p is not None
|
||||
and p.block_start is not None
|
||||
and p.block_start is not None and not self._peak_rolled
|
||||
and peakguard.load_too_high(
|
||||
p, batt_w=batt, ceiling_w=g.ceiling_w,
|
||||
achievable_w=self.site_rating_w))
|
||||
@@ -5916,6 +6222,50 @@ class Controller:
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.not_delivering(inv.name, slug).data())
|
||||
|
||||
async def check_price_gap(self) -> None:
|
||||
"""PRICE-01. One notification when no price covers now, gone when one does.
|
||||
|
||||
`price_gap` is worked out on the tick from the stored plan. A gap
|
||||
raises the notification once: the flag is set BEFORE the await, for
|
||||
`check_circulating()`'s reason (a Home Assistant call can raise or
|
||||
hang, and a retry every cycle would be a storm). When the gap ends the
|
||||
same notification is taken down. Control is not touched either way: the
|
||||
maintenance charge waits because its gate is shut (`_provider_gate`),
|
||||
and nothing here decides that.
|
||||
"""
|
||||
if self.price_gap and not self._price_gap_announced:
|
||||
self._price_gap_announced = True
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.no_prices(*self._gap_args()).data())
|
||||
elif not self.price_gap and self._price_gap_announced:
|
||||
self._price_gap_announced = False
|
||||
await self.hass.call("persistent_notification", "dismiss",
|
||||
messages.prices_back().data())
|
||||
|
||||
async def check_fixed_note(self) -> None:
|
||||
"""PRICE-01 G1. At the first cycle only: one INFO line when a
|
||||
`static_profile` is set beside `price_service` (it is the plan's last
|
||||
fallback, not a fault, so no notification), and a dismiss of the card an
|
||||
earlier build raised, which is harmless when absent."""
|
||||
if self._fixed_note_said:
|
||||
return
|
||||
self._fixed_note_said = True
|
||||
if fixed_tariff_beside_service(self.o):
|
||||
_LOG.info(messages.FIXED_TARIFF_LOG)
|
||||
try:
|
||||
await self.hass.call("persistent_notification", "dismiss",
|
||||
messages.fixed_tariff_card_gone().data())
|
||||
except Exception: # noqa: BLE001 - housekeeping never stops a cycle
|
||||
_LOG.debug("Could not take down the old fixed-tariff card.")
|
||||
|
||||
def _gap_args(self) -> tuple:
|
||||
"""`(since, now)` in Brussels time for the gap's words. `since` is None
|
||||
when no price was ever published."""
|
||||
now = brussels(datetime.now(timezone.utc))
|
||||
if self.price_gap_since is None:
|
||||
return None, now
|
||||
return brussels(self.price_gap_since), now
|
||||
|
||||
async def check_meter_frozen(self) -> None:
|
||||
"""SAFETY-18. Alarm on a meter that answers forever without changing.
|
||||
|
||||
@@ -6050,7 +6400,7 @@ class Controller:
|
||||
try:
|
||||
plan = await resolve_tariff(
|
||||
self.o, now, now + timedelta(hours=horizon), now=now,
|
||||
history=self.price_history)
|
||||
history=self.price_history, watch=self.price_watch)
|
||||
standing = self.fresh_tariff_plan(now)
|
||||
# Three terms, and each of them is the only false one in some
|
||||
# check: the feed answered (replace), nothing current stands
|
||||
@@ -6456,6 +6806,14 @@ class Controller:
|
||||
else:
|
||||
out.append({"ok": True, "warn": False, "id": "controller",
|
||||
"text": messages.row_controller_ran(_pass_age)})
|
||||
# SAFETY-112. The headline unless control has stalled: while the
|
||||
# Supervisor gives 502 (Home Assistant not yet RUNNING) no reading or
|
||||
# write gets through, and the rows below that need it follow from it.
|
||||
if OUTAGE.waiting():
|
||||
out.insert(1 if out[0]["ok"] is False else 0,
|
||||
{"ok": False, "warn": False, "id": "home_assistant",
|
||||
"banner": messages.banner_ha_waiting(),
|
||||
"text": messages.row_ha_waiting()})
|
||||
if self.p1_enabled:
|
||||
age = self.p1.published_age_s
|
||||
if (self.p1.stale and self.p1.age_s() is None
|
||||
@@ -6882,6 +7240,17 @@ class Controller:
|
||||
# is both is a check nobody reads.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_forecast",
|
||||
"text": messages.row_no_peak_forecast()})
|
||||
# OPT-17: a forecast summed from several entities names, by position,
|
||||
# each member with no usable reading. A caution: control goes on.
|
||||
gaps = getattr(self, "solar_fc_gaps", ())
|
||||
for day, ents in (("today", getattr(self, "fc_entities", ())),
|
||||
("tomorrow",
|
||||
getattr(self, "fc_tomorrow_entities", ()))):
|
||||
gone = [i for d, i, _e, _w in gaps if d == day]
|
||||
if gone:
|
||||
out.append({"ok": False, "warn": True, "id": "solar_forecast",
|
||||
"text": messages.row_forecast_gap(
|
||||
day, gone, len(ents))})
|
||||
# --- TEL-11 ----------------------------------------------------------
|
||||
# ⚠️ NO ROW AT ALL WHEN THE ROSTER IS EMPTY, AND THAT IS A DELIBERATE
|
||||
# DIFFERENCE FROM THE PEAK-FORECAST CAUTION DIRECTLY ABOVE. That one
|
||||
@@ -6954,7 +7323,7 @@ class Controller:
|
||||
# control goes on, so it neither reads "Not ready" nor holds the page.
|
||||
runs = [c for c in bad if c.get("runs")]
|
||||
bad = [c for c in bad if not c.get("runs")]
|
||||
running_alarm = False
|
||||
running_alarm = waiting = False
|
||||
# --- SAFETY-10, AND IT IS FIRST IN THE CASCADE -----------------------
|
||||
# "The battery is NOT being controlled" is the one sentence on this
|
||||
# page that may never be displaced by another condition. A banner that
|
||||
@@ -7005,6 +7374,10 @@ class Controller:
|
||||
# headline nobody reads. UX-08: without the key, the banner takes
|
||||
# the row's first sentence, so it stays a headline.
|
||||
level, banner = "bad", messages.banner_not_ready(bad[0])
|
||||
# SAFETY-112: nothing to do but wait, so Home says that instead
|
||||
# of "Needs you now". Only when it IS the headline: a safety stop
|
||||
# above outranks it and still needs a person.
|
||||
waiting = bad[0].get("id") == "home_assistant"
|
||||
elif not self.auto:
|
||||
level, banner = "warn", messages.banner_stopped()
|
||||
elif runs:
|
||||
@@ -7049,6 +7422,7 @@ class Controller:
|
||||
# date, unchanged.
|
||||
_now = datetime.now(timezone.utc)
|
||||
_dues = [m.next_due(_now) for m in self.maints]
|
||||
_waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u]
|
||||
next_due = None if any(d is None for d in _dues) else min(_dues)
|
||||
rows = [
|
||||
("Grid", w(self.grid)),
|
||||
@@ -7096,9 +7470,16 @@ class Controller:
|
||||
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
|
||||
for n, why in self._maint_deferred)),)
|
||||
if self._maint_deferred else ()),
|
||||
# MAINT-13: only while a failed cycle waits, so a site with nothing
|
||||
# failed carries exactly the rows it carried before.
|
||||
*((("Maintenance paused", messages.row_maint_waits(
|
||||
min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()),
|
||||
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||
("Cheap window", "yes" if self.cheap else "no"),
|
||||
("Tariff", self.tariff_words),
|
||||
# PRICE-01: only while no price covers now.
|
||||
*((("Prices", messages.row_no_prices(*self._gap_args())),)
|
||||
if self.price_gap else ()),
|
||||
# FCST-04. WHAT §8.3 HAS TO CARRY FORWARD, which is the difference
|
||||
# between a 48-hour plan and a 12-hour one on a weekday morning.
|
||||
# It is its own row rather than a clause of "Tariff" because it
|
||||
@@ -7146,7 +7527,7 @@ class Controller:
|
||||
# fields are served alone (the page then shows the installer details).
|
||||
try:
|
||||
out.update(homeview.build(self, level=level, checks=checks,
|
||||
lang=lang, held=held))
|
||||
lang=lang, held=held, waiting=waiting))
|
||||
except Exception: # noqa: BLE001
|
||||
# Once per process at ERROR: the page polls every 3 s.
|
||||
if not getattr(self, "_home_failed", False):
|
||||
@@ -7286,6 +7667,10 @@ class Controller:
|
||||
# would read as the stop button not working.
|
||||
await self.command_site_zero()
|
||||
return {"auto": self.auto}
|
||||
if what in ("maint_start", "maint_abort") and unit is not None:
|
||||
# MAINT-11: the operator named the battery. A request with no unit
|
||||
# falls through to the branches below, unchanged.
|
||||
return await self._maint_unit_action(what, unit, now)
|
||||
if what == "maint_start":
|
||||
# ⚠️ WHICH PACK, BECAUSE THERE ARE NOW SEVERAL AND THE BUTTON HAS
|
||||
# ONE LABEL. `_maint_button()` answers it: the pack that holds the
|
||||
@@ -7296,10 +7681,8 @@ class Controller:
|
||||
# picked on its own. At N=1 both branches are `maints[0]` and this
|
||||
# is the shipped call, byte for byte.
|
||||
#
|
||||
# A PER-PACK BUTTON IS A UI CARD, NOT THIS ONE. `web.py` posts a
|
||||
# bare `maint_start` with no unit, and inventing a unit parameter
|
||||
# here would ship half a surface: the page would still have one
|
||||
# button and the API would have two meanings.
|
||||
# This is the bare request (no unit). A request naming a battery
|
||||
# was handled above by `_maint_unit_action()` (MAINT-11).
|
||||
_m = self._maint_button(now)
|
||||
for msg in _m.force_start(now):
|
||||
self.log_event(msg, hint=messages.hint("maintenance",
|
||||
@@ -7317,6 +7700,10 @@ class Controller:
|
||||
return None
|
||||
|
||||
|
||||
# MAINT-13: a battery power reading at or below minus this many W is a pack
|
||||
# that is taking charge.
|
||||
MAINT_CHARGING_W = 50.0
|
||||
|
||||
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
|
||||
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
|
||||
EXIT_CONTROL_DIED = 70
|
||||
|
||||
@@ -65,10 +65,30 @@ POWER_MOVING = (DRAIN, CHARGE, HOLD)
|
||||
# 12 h means the battery is not following commands - a fault, not a slow day. The charge phase deliberately waits for sun and for cheap hours, so
|
||||
# it is expected to span a night; 36 h still bounds it.
|
||||
DRAIN_TIMEOUT_MIN = 720
|
||||
# MAINT-13: minutes the pack was ALLOWED to charge (the price gate open), not
|
||||
# wall-clock minutes since the charge began. See CHARGE_WALL_TIMEOUT_MIN.
|
||||
CHARGE_TIMEOUT_MIN = 2160
|
||||
# After an abort, do not immediately re-trigger in the same start hour.
|
||||
RESTART_COOLDOWN_MIN = 90
|
||||
|
||||
# MAINT-13 (DECISIONS/MAINT-13-full-pack-is-complete.md). Constants, not options.
|
||||
# A pack that reaches target - 3 and then takes no more charge for this many
|
||||
# minutes of allowed charging counts as full.
|
||||
CHARGE_NEAR_FULL_PCT = 3
|
||||
CHARGE_STALL_MIN = 60
|
||||
# Wall-clock backstop for a charge whose gate never opens (no cheap hours, no sun).
|
||||
CHARGE_WALL_TIMEOUT_MIN = 10080
|
||||
# A failed cycle (stuck drain or charge) is not retried before this many days.
|
||||
FAIL_BACKOFF_DAYS = 7
|
||||
# One tick counts at most this many minutes: a gap means the add-on was down.
|
||||
CHARGE_TICK_MAX_MIN = 5
|
||||
# The charge accounting is written to the state file at most this often.
|
||||
CHARGE_SAVE_MIN = 5
|
||||
# MAINT-14 (DECISIONS/MAINT-14-no-soc-in-maintenance.md): with no SoC reading
|
||||
# the timeouts still run, a drain holds at 0 W, and the log says once, after this
|
||||
# many minutes, that the level has been unknown for long.
|
||||
NOSOC_NOTE_MIN = 15
|
||||
|
||||
# ⚠️ CFG-03. THE ONE IN-CODE MIRROR OF config.yaml FOR EVERY `maintenance_*`
|
||||
# OPTION, and the only place in the add-on where one of these numbers is typed.
|
||||
#
|
||||
@@ -257,6 +277,9 @@ class MaintResult:
|
||||
# showing an idle machine and a due date in the past. Empty on every path
|
||||
# that is not deferring, so a roster of one never sees it.
|
||||
deferred: str = ""
|
||||
# MAINT-13: "drain" or "charge" on the one tick where a cycle FAILED, so
|
||||
# the Controller can tell the operator once. Empty on every other tick.
|
||||
failed: str = ""
|
||||
|
||||
|
||||
class UnitHistory:
|
||||
@@ -344,6 +367,14 @@ class Maintenance:
|
||||
# per cycle would flush the very context that explains it. Same pattern
|
||||
# as `Controller._arb_action` and `_maint_shape_suspended`.
|
||||
self._deferred = ""
|
||||
# MAINT-13: the charge accounting (see _charge_account), in memory and
|
||||
# written to the state file every CHARGE_SAVE_MIN minutes.
|
||||
self._trk = None
|
||||
self._trk_saved = None
|
||||
# MAINT-14: when the SoC reading went missing (None while it is there),
|
||||
# and whether the "unknown for long" line was said in this episode.
|
||||
self._nosoc_since = None
|
||||
self._nosoc_said = False
|
||||
|
||||
# -- state ---------------------------------------------------------------
|
||||
@property
|
||||
@@ -351,6 +382,11 @@ class Maintenance:
|
||||
return self.store.get("phase", IDLE)
|
||||
|
||||
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
|
||||
self._nosoc_since = None
|
||||
self._trk = None
|
||||
self._trk_saved = None
|
||||
if self.store.get("charge_track") is not None:
|
||||
self.store.set("charge_track", None)
|
||||
self.store.set("phase", phase)
|
||||
self.store.set_time("phase_started", now)
|
||||
_LOG.info("%s", msg)
|
||||
@@ -391,6 +427,21 @@ class Maintenance:
|
||||
return (last_try is None
|
||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
|
||||
|
||||
def backoff_until(self, now: datetime):
|
||||
"""MAINT-13: when a failed cycle may be tried again, or None.
|
||||
|
||||
None when nothing failed, when the back-off is over, or when a cycle
|
||||
completed after the failure (a forced start that worked ends it).
|
||||
"""
|
||||
failed = self.store.get_time("last_failed")
|
||||
if failed is None:
|
||||
return None
|
||||
done = self.store.get_time("last_completed")
|
||||
if done is not None and done >= failed:
|
||||
return None
|
||||
until = failed + timedelta(days=FAIL_BACKOFF_DAYS)
|
||||
return until if now < until else None
|
||||
|
||||
def wants_start(self, now: datetime) -> bool:
|
||||
"""Would this pack begin a cycle on this tick if the site let it?
|
||||
|
||||
@@ -402,7 +453,8 @@ class Maintenance:
|
||||
return (self.phase == IDLE and self.cfg.enabled
|
||||
and in_start_slot(now, self.cfg.start_hour, self.tz)
|
||||
and self.due(now)
|
||||
and self._cooled(now))
|
||||
and self._cooled(now)
|
||||
and self.backoff_until(now) is None)
|
||||
|
||||
# -- operations ----------------------------------------------------------
|
||||
def force_start(self, now: datetime, blocked_by: str = "") -> list:
|
||||
@@ -430,6 +482,140 @@ class Maintenance:
|
||||
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
||||
return events
|
||||
|
||||
def _charge_account(self, now: datetime, soc: float | None,
|
||||
gate_open: bool) -> dict:
|
||||
"""MAINT-13: what this charge has used so far, as minutes of ALLOWED time.
|
||||
|
||||
`open_min` counts only ticks with the price gate open; `flat_min` counts
|
||||
gate-open minutes since the SoC last beat its best; a closed gate pauses
|
||||
both. One tick adds at most CHARGE_TICK_MAX_MIN, so a gap (add-on down)
|
||||
counts for nothing. A state file from before MAINT-13, or from another
|
||||
charge, starts from the wall-clock minutes so far: the old behaviour.
|
||||
|
||||
MAINT-14: `soc` may be None (no reading). Time still counts, the stall
|
||||
wait pauses, and `last` keeps the last SoC seen (saved with the rest).
|
||||
"""
|
||||
since = self.store.get("phase_started")
|
||||
trk = self._trk
|
||||
if trk is None:
|
||||
raw = self.store.get("charge_track")
|
||||
if isinstance(raw, dict) and raw.get("since") == since:
|
||||
trk = dict(raw)
|
||||
else:
|
||||
trk = {"since": since, "open_min": self._elapsed_min(now),
|
||||
"flat_min": 0.0,
|
||||
"best": None if soc is None else float(soc),
|
||||
"last": None if soc is None else float(soc), "at": None}
|
||||
self._trk = trk
|
||||
at = trk.get("at")
|
||||
try:
|
||||
last = datetime.fromisoformat(at) if at else None
|
||||
except (TypeError, ValueError):
|
||||
last = None
|
||||
dt = 0.0 if last is None else min(
|
||||
CHARGE_TICK_MAX_MIN, max(0.0, (now - last).total_seconds() / 60.0))
|
||||
trk["at"] = now.isoformat()
|
||||
if soc is None:
|
||||
pass
|
||||
elif trk.get("best") is None or soc > trk["best"]:
|
||||
trk["best"], trk["flat_min"] = float(soc), 0.0
|
||||
elif gate_open:
|
||||
trk["flat_min"] += dt
|
||||
if soc is not None:
|
||||
trk["last"] = float(soc)
|
||||
if gate_open:
|
||||
trk["open_min"] += dt
|
||||
saved = self._trk_saved
|
||||
if saved is None or (now - saved).total_seconds() >= CHARGE_SAVE_MIN * 60:
|
||||
self.store.set("charge_track", dict(trk))
|
||||
self._trk_saved = now
|
||||
return trk
|
||||
|
||||
def _fail(self, now: datetime, kind: str, why: str) -> MaintResult:
|
||||
"""MAINT-13: stand down, remember the failure, and tell the Controller."""
|
||||
events = self.abort(now, why)
|
||||
self.store.set_time("last_failed", now)
|
||||
msg = (f"maintenance: the cycle did not finish. It waits "
|
||||
f"{FAIL_BACKOFF_DAYS} days before it tries again.")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
return MaintResult(IDLE, True, 0.0, False, events, failed=kind)
|
||||
|
||||
def _hold(self, now: datetime, events: list) -> MaintResult:
|
||||
"""HOLD for one tick, with or without a SoC reading (MAINT-15).
|
||||
|
||||
HOLD needs no level to end: it is reached only after the charge counted
|
||||
as done (MAINT-13/14), so its end is a time check and nothing else.
|
||||
"""
|
||||
elapsed = self._elapsed_min(now)
|
||||
if elapsed >= self.cfg.hold_min:
|
||||
self.store.set_time("last_completed", now)
|
||||
self._enter(IDLE, now, events,
|
||||
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop")
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
# 0 W, not "do nothing": commanding zero is what stops the loop
|
||||
# pulling the pack straight back down the moment it is full, which
|
||||
# is exactly when the BMS needs it parked at the top.
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
|
||||
def _no_soc(self, now: datetime, phase: str, gate_open: bool,
|
||||
events: list) -> MaintResult:
|
||||
"""MAINT-14: one tick of a pack in a moving phase with no SoC reading.
|
||||
|
||||
HOLD stays at 0 W and ends at its normal time (MAINT-15). DRAIN holds at 0 W, like HOLD, and its 12 h
|
||||
timeout still runs. CHARGE keeps its timeout and wall backstop: out of
|
||||
time is a failure, except when the last SoC seen was within
|
||||
CHARGE_NEAR_FULL_PCT of the target, which counts as done (MAINT-13).
|
||||
Nothing here may start a phase change that needs the level.
|
||||
"""
|
||||
if self._nosoc_since is None:
|
||||
self._nosoc_since = now
|
||||
self._nosoc_said = False
|
||||
self._trk_saved = None # save the last SoC seen, now
|
||||
if phase == DRAIN:
|
||||
msg = ("maintenance: the battery level is unknown, so the "
|
||||
"drain holds at 0 W until it is back.")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
if (not self._nosoc_said and (now - self._nosoc_since).total_seconds()
|
||||
>= NOSOC_NOTE_MIN * 60):
|
||||
self._nosoc_said = True
|
||||
tail = ("The hold still ends at its normal time."
|
||||
if phase == HOLD else
|
||||
f"The time limit of the {phase} still applies.")
|
||||
msg = (f"maintenance: no battery level for {NOSOC_NOTE_MIN} min. "
|
||||
f"{tail}")
|
||||
_LOG.warning("%s", msg)
|
||||
events.append(msg)
|
||||
if phase == HOLD:
|
||||
return self._hold(now, events)
|
||||
elapsed = self._elapsed_min(now)
|
||||
if phase == DRAIN:
|
||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||
return self._fail(now, "drain",
|
||||
f"drain stuck, battery level unknown, after "
|
||||
f"{elapsed / 60:.0f} h")
|
||||
return MaintResult(DRAIN, True, 0.0, False, events)
|
||||
trk = self._charge_account(now, None, gate_open)
|
||||
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||
last = trk.get("last")
|
||||
if last is not None and last >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: charge time is up and the last level "
|
||||
f"seen was {last:.0f} %, close enough to full, so the "
|
||||
f"charge counts as done -> holding "
|
||||
f"{self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if elapsed > CHARGE_WALL_TIMEOUT_MIN:
|
||||
lim = f"the {CHARGE_WALL_TIMEOUT_MIN // 1440}-day limit"
|
||||
else:
|
||||
lim = f"{trk['open_min'] / 60:.0f} h of charging time"
|
||||
return self._fail(now, "charge",
|
||||
f"charge stuck, battery level unknown, after {lim}")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
def abort(self, now: datetime, why: str) -> list:
|
||||
events = []
|
||||
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
||||
@@ -457,13 +643,16 @@ class Maintenance:
|
||||
|
||||
# -- the machine ---------------------------------------------------------
|
||||
def tick(self, now: datetime, soc: float | None,
|
||||
blocked_by: str = "") -> MaintResult:
|
||||
blocked_by: str = "", gate_open: bool = True) -> MaintResult:
|
||||
"""One cycle of this pack's state machine.
|
||||
|
||||
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
|
||||
site's one maintenance slot is available to it, otherwise the reason it
|
||||
is not. It gates two things and nothing else - a start, and a phase that
|
||||
is already moving power on a pack that does not hold the slot.
|
||||
|
||||
`gate_open` (MAINT-13) is whether the pack may charge now (the price
|
||||
gate); only those minutes count towards the charge timeout.
|
||||
"""
|
||||
events: list = []
|
||||
phase = self.phase
|
||||
@@ -514,13 +703,13 @@ class Maintenance:
|
||||
# transitions below mean anything, and a stale "100 %" would end the
|
||||
# charge phase having charged nothing. Hold the current phase's command
|
||||
# and wait; the phase timeout is the backstop.
|
||||
#
|
||||
# MAINT-14: "hold and wait" is not "wait for ever". The timeouts run
|
||||
# here too, and a drain holds at 0 W (an unknown level never allows
|
||||
# discharge, TEL-08). See `_no_soc`.
|
||||
if soc is None:
|
||||
_LOG.warning("maintenance: no SoC reading, holding phase %s", phase)
|
||||
if phase == DRAIN:
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
if phase == HOLD:
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
return self._no_soc(now, phase, gate_open, events)
|
||||
self._nosoc_since = None
|
||||
|
||||
elapsed = self._elapsed_min(now)
|
||||
|
||||
@@ -531,8 +720,8 @@ class Maintenance:
|
||||
f"after {elapsed:.0f} min -> charge")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||
events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
return self._fail(now, "drain", f"drain stuck at {soc:.0f} % "
|
||||
f"after {elapsed:.0f} min")
|
||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||
|
||||
if phase == CHARGE:
|
||||
@@ -541,22 +730,31 @@ class Maintenance:
|
||||
f"maintenance: charge complete at {soc:.0f} % after "
|
||||
f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if elapsed > CHARGE_TIMEOUT_MIN:
|
||||
events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min")
|
||||
return MaintResult(IDLE, True, 0.0, False, events)
|
||||
trk = self._charge_account(now, soc, gate_open)
|
||||
near = soc >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT
|
||||
# MAINT-13 rule 1: close to full and no longer taking charge.
|
||||
if near and trk["flat_min"] >= CHARGE_STALL_MIN:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: the battery takes no more charge at "
|
||||
f"{soc:.0f} %, so the charge counts as done -> holding "
|
||||
f"{self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||
# MAINT-13 rule 2: out of time close to full is complete.
|
||||
if near:
|
||||
self._enter(HOLD, now, events,
|
||||
f"maintenance: charge time is up at {soc:.0f} %, "
|
||||
f"close enough to full, so the charge counts as "
|
||||
f"done -> holding {self.cfg.hold_min} min to balance")
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return self._fail(now, "charge",
|
||||
f"charge stuck at {soc:.0f} % after "
|
||||
f"{trk['open_min']:.0f} min of charging time")
|
||||
return MaintResult(CHARGE, False, None, True, events)
|
||||
|
||||
if phase == HOLD:
|
||||
if elapsed >= self.cfg.hold_min:
|
||||
self.store.set_time("last_completed", now)
|
||||
self._enter(IDLE, now, events,
|
||||
f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop")
|
||||
return MaintResult(IDLE, False, None, False, events)
|
||||
# 0 W, not "do nothing": commanding zero is what stops the loop
|
||||
# pulling the pack straight back down the moment it is full, which
|
||||
# is exactly when the BMS needs it parked at the top.
|
||||
return MaintResult(HOLD, True, 0.0, False, events)
|
||||
return self._hold(now, events)
|
||||
|
||||
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
|
||||
events += self.abort(now, f"unknown phase {phase!r}")
|
||||
|
||||
@@ -102,6 +102,7 @@ class Notice:
|
||||
title: str
|
||||
body: str
|
||||
section: str = "" # the DOCS.md heading, when it is not the title itself
|
||||
body_nl: str = "" # MAINT-13: the same words in Dutch, shown under the English
|
||||
|
||||
@property
|
||||
def docs(self) -> str:
|
||||
@@ -110,7 +111,9 @@ class Notice:
|
||||
def data(self) -> dict:
|
||||
"""The `persistent_notification.create` payload."""
|
||||
return {"notification_id": self.id, "title": self.title,
|
||||
"message": f"{self.body}\n\n{details(self.docs)}"}
|
||||
"message": f"{self.body}\n\n"
|
||||
+ (f"{self.body_nl}\n\n" if self.body_nl else "")
|
||||
+ details(self.docs)}
|
||||
|
||||
|
||||
# --- the catalogue ----------------------------------------------------------
|
||||
@@ -124,6 +127,10 @@ T_CIRCULATING = "Inverters working against each other"
|
||||
T_NOT_DELIVERING = "{unit} is not delivering"
|
||||
S_NOT_DELIVERING = "An inverter is not delivering"
|
||||
T_METER_FROZEN = "Meter reading may be stuck"
|
||||
T_MAINT_FAILED = "Battery cycle did not finish"
|
||||
T_MAINT_FAILED_NL = "Batterijcyclus niet afgerond"
|
||||
T_NO_PRICES = "No prices"
|
||||
T_NO_PRICES_NL = "Prijzen ontbreken"
|
||||
|
||||
ID_LOOP_BOUND = "goodwe_integrator_bound"
|
||||
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
||||
@@ -132,6 +139,11 @@ ID_PEAK_RESERVE = "goodwe_peak_soc_floor"
|
||||
ID_CIRCULATING = "goodwe_circulating_power"
|
||||
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"
|
||||
# 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"
|
||||
|
||||
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||
# The setting is named by its label, "Inverter rating (W)".
|
||||
@@ -295,6 +307,95 @@ def meter_frozen(unchanged_s) -> Notice:
|
||||
f"stop control on the add-on page.")
|
||||
|
||||
|
||||
def maint_failed(kind: str, days: int, unit: str = "", slug: str = "") -> Notice:
|
||||
"""MAINT-13: a monthly cycle stopped before it was done. Said once."""
|
||||
full = kind == "charge"
|
||||
who = unit or "The battery"
|
||||
who_nl = unit or "De batterij"
|
||||
en = f"{who} did not {'fill up' if full else 'empty'}, so its cycle " \
|
||||
f"stopped and tries again in {days} days."
|
||||
nl = f"{who_nl} raakte niet {'vol' if full else 'leeg'}, dus de cyclus is " \
|
||||
f"gestopt en probeert het over {days} dagen opnieuw."
|
||||
return Notice(ID_MAINT_FAILED.format(slug=slug or "battery"), WARNING,
|
||||
T_MAINT_FAILED, en, body_nl=f"{T_MAINT_FAILED_NL}. {nl}")
|
||||
|
||||
|
||||
NL_MONTHS = ("jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep",
|
||||
"okt", "nov", "dec")
|
||||
|
||||
|
||||
def since_text(since, now, lang="en") -> str:
|
||||
"""`00:00` for a moment today, `13 Oct at 21:00` (`13 okt om 21:00` in
|
||||
Dutch) for an earlier day. Both arguments are already in the site's zone.
|
||||
Empty for no moment."""
|
||||
if since is None:
|
||||
return ""
|
||||
if since.date() == now.date():
|
||||
return f"{since:%H:%M}"
|
||||
if lang == "nl":
|
||||
return f"{since.day} {NL_MONTHS[since.month - 1]} om {since:%H:%M}"
|
||||
return when(since)
|
||||
|
||||
|
||||
def no_prices(since=None, now=None) -> Notice:
|
||||
"""PRICE-01: no source has a price for now. Said once, gone with the gap.
|
||||
`since` is the moment the last price ran out, or None."""
|
||||
if since is not None:
|
||||
en = (f"No prices since {since_text(since, now)}, so the monthly "
|
||||
f"battery cycle waits until they return.")
|
||||
nl = (f"Sinds {since_text(since, now, 'nl')} zijn er geen prijzen, dus "
|
||||
f"de maandelijkse batterijcyclus wacht tot ze terug zijn.")
|
||||
else:
|
||||
en = ("No prices have come in yet, so the monthly battery cycle waits "
|
||||
"until they do.")
|
||||
nl = ("Er zijn nog geen prijzen binnen, dus de maandelijkse "
|
||||
"batterijcyclus wacht tot ze er zijn.")
|
||||
return Notice(ID_NO_PRICES, WARNING, T_NO_PRICES, en,
|
||||
body_nl=f"{T_NO_PRICES_NL}. {nl}")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Dismiss:
|
||||
"""The payload that takes one notification down again."""
|
||||
id: str
|
||||
|
||||
def data(self) -> dict:
|
||||
return {"notification_id": self.id}
|
||||
|
||||
|
||||
def prices_back() -> Dismiss:
|
||||
"""PRICE-01: the 'No prices' notification, taken down when a price is back."""
|
||||
return Dismiss(ID_NO_PRICES)
|
||||
|
||||
|
||||
# PRICE-01: a fixed tariff beside the price service. One true sentence, for the
|
||||
# start-up log (English only, as every log line) and the settings page (EN, NL).
|
||||
FIXED_TARIFF_LOG = "Fixed tariff is only used if no prices are available."
|
||||
FIXED_TARIFF_NOTE = ("With the price service, the fixed tariff is only used if "
|
||||
"no prices are available.")
|
||||
FIXED_TARIFF_NOTE_NL = ("Met de prijsdienst wordt het vaste tarief alleen "
|
||||
"gebruikt als er geen prijzen zijn.")
|
||||
# Settings-page notes that have a Dutch twin (uipage.anchors).
|
||||
NOTE_NL = {FIXED_TARIFF_NOTE: FIXED_TARIFF_NOTE_NL}
|
||||
|
||||
|
||||
def fixed_tariff_card_gone() -> Dismiss:
|
||||
"""PRICE-01: takes down the card an earlier build raised."""
|
||||
return Dismiss(OLD_FIXED_CARD_ID)
|
||||
|
||||
|
||||
def row_no_prices(since=None, now=None) -> str:
|
||||
"""PRICE-01: the status row while no price covers now."""
|
||||
if since is not None:
|
||||
return f"No prices since {since_text(since, now)}, maintenance waits."
|
||||
return "No prices yet, maintenance waits."
|
||||
|
||||
|
||||
def row_maint_waits(until_text: str) -> str:
|
||||
"""MAINT-13: the status row while a failed cycle waits."""
|
||||
return f"Waits until {until_text} after a cycle that did not finish."
|
||||
|
||||
|
||||
# --- the status page (UX-08 phase 2b) ----------------------------------------
|
||||
# The rows of the Commissioning list and the banner at the top of the add-on
|
||||
# page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad);
|
||||
@@ -412,6 +513,17 @@ def banner_shadow(latched: bool) -> str:
|
||||
return "Shadow mode: the battery is not controlled. Inverters stay at 0 W."
|
||||
|
||||
|
||||
def banner_ha_waiting() -> str:
|
||||
"""SAFETY-112: the Supervisor passes nothing on until Home Assistant has
|
||||
finished starting, so the add-on can neither read nor write."""
|
||||
return "Waiting for Home Assistant to finish starting"
|
||||
|
||||
|
||||
def row_ha_waiting() -> str:
|
||||
return ("Home Assistant is not answering, perhaps it is still starting. "
|
||||
"Control resumes by itself when it answers.")
|
||||
|
||||
|
||||
# Rows: control and the meter. "Control" is what the add-on does, as in
|
||||
# "start control" (STYLE.md section 4).
|
||||
def row_controller_starting() -> str:
|
||||
@@ -730,6 +842,15 @@ def row_pv_partial(missing, count) -> str:
|
||||
f"reading")
|
||||
|
||||
|
||||
def row_forecast_gap(day, missing, count) -> str:
|
||||
"""OPT-17. `day` is "today" or "tomorrow"; `missing` are the 1-based
|
||||
positions of the forecast entities with no usable reading."""
|
||||
which = names([str(n) for n in missing])
|
||||
many = len(missing) > 1
|
||||
return (f"Solar forecast for {day}: not used, source{'s' if many else ''} "
|
||||
f"{which} of {count} {'have' if many else 'has'} no reading")
|
||||
|
||||
|
||||
def row_pv_single_ignored() -> str:
|
||||
return f"Solar production: clear {PV_ONE}, the per-inverter list replaces it"
|
||||
|
||||
@@ -929,6 +1050,7 @@ HINTS = {
|
||||
"trade_off": "Trading: waiting for a better price",
|
||||
"forecast_read": "Solar forecast read, the day plan uses it",
|
||||
"forecast_none": "No solar forecast, so Leftover solar takes solar only",
|
||||
"forecast_no_tomorrow": "Tomorrow's solar forecast is not used, a source has no reading",
|
||||
"split": "The power split between inverters changed",
|
||||
"clash": "Two limits clash, inverters set to 0 W",
|
||||
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
|
||||
@@ -947,6 +1069,7 @@ HINTS = {
|
||||
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
||||
"maintenance": "Battery maintenance: {phase}",
|
||||
"maint_paused": "Maintenance charging paused to stay under the peak limit",
|
||||
"maint_failed": "The battery cycle did not finish, so it waits before trying again",
|
||||
"pack_on": "{unit}: battery switched on",
|
||||
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
|
||||
"pack_mode": "{unit}: battery mode changed",
|
||||
@@ -976,6 +1099,21 @@ def pack_refused(why: str) -> str:
|
||||
return PACK_REFUSED[why]
|
||||
|
||||
|
||||
# MAINT-11: why a start or stop of ONE battery's cycle was refused. `{unit}` is
|
||||
# the battery the sentence is about (for "busy", the one that is cycling). The
|
||||
# page shows the same sentences in Dutch from app/ui_text, keyed by the code.
|
||||
MAINT_REFUSED = {
|
||||
"unit": "There is no battery with that name.",
|
||||
"busy": "Only one battery can cycle at a time, and {unit} is cycling now.",
|
||||
"off": "{unit} is switched off, so it cannot start a cycle.",
|
||||
"idle": "{unit} is not running a cycle.",
|
||||
}
|
||||
|
||||
|
||||
def maint_refused(why: str, unit: str = "") -> str:
|
||||
return MAINT_REFUSED[why].format(unit=unit)
|
||||
|
||||
|
||||
def hint(key: str, **kw) -> str:
|
||||
"""The plain words for one event. `w` is formatted as watts, `sw` as
|
||||
watts with their sign (positive gives power, negative charges)."""
|
||||
@@ -997,6 +1135,11 @@ def hint_samples() -> list:
|
||||
RED, AMBER, GREEN = "bad", "warn", "ok"
|
||||
|
||||
|
||||
# PRICE-01 samples: a gap that began today, and one that began the day before.
|
||||
_SINCE_SAMPLE = (datetime(2026, 10, 14, 0, 0), datetime(2026, 10, 14, 9, 0))
|
||||
_SINCE_OLDER = (datetime(2026, 10, 13, 21, 0), datetime(2026, 10, 14, 9, 0))
|
||||
|
||||
|
||||
def status_samples() -> list:
|
||||
"""`(function name, colour, text)` for every row and banner, for the test.
|
||||
|
||||
@@ -1020,6 +1163,10 @@ def status_samples() -> list:
|
||||
("banner_setup_only", None, banner_setup_only()),
|
||||
("row_setup_only", AMBER, row_setup_only()),
|
||||
("banner_shadow", None, banner_shadow(True)),
|
||||
("banner_not_ready", None, banner_not_ready(
|
||||
{"text": row_ha_waiting(), "banner": banner_ha_waiting()})),
|
||||
("banner_ha_waiting", None, banner_ha_waiting()),
|
||||
("row_ha_waiting", RED, row_ha_waiting()),
|
||||
("row_controller_starting", GREEN, row_controller_starting()),
|
||||
("row_controller_ran", GREEN, row_controller_ran(2)),
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
@@ -1089,6 +1236,8 @@ def status_samples() -> list:
|
||||
"entity - it cannot be read in watts")),
|
||||
("row_pv_partial", AMBER, row_pv_partial([2, 3], 3)),
|
||||
("row_pv_partial", AMBER, row_pv_partial([2], 2)),
|
||||
("row_forecast_gap", AMBER, row_forecast_gap("today", [2], 3)),
|
||||
("row_forecast_gap", AMBER, row_forecast_gap("tomorrow", [1, 2], 2)),
|
||||
("row_pv_single_ignored", AMBER, row_pv_single_ignored()),
|
||||
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
|
||||
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
||||
@@ -1118,6 +1267,10 @@ def status_samples() -> list:
|
||||
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
|
||||
True)),
|
||||
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
||||
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
|
||||
("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()),
|
||||
]
|
||||
|
||||
|
||||
@@ -1148,6 +1301,11 @@ def samples() -> list:
|
||||
not_delivering("inverter-b", "inverter_b"),
|
||||
meter_frozen(600),
|
||||
meter_frozen(90),
|
||||
maint_failed("charge", 7),
|
||||
maint_failed("drain", 7, "inverter-b", "inverter_b"),
|
||||
no_prices(*_SINCE_SAMPLE),
|
||||
no_prices(*_SINCE_OLDER),
|
||||
no_prices(),
|
||||
]
|
||||
|
||||
|
||||
@@ -1180,6 +1338,7 @@ LABELS = {
|
||||
"price_formula": "Your own tariff formula",
|
||||
"price_export_formula": "Your own injection formula",
|
||||
"price_csv_path": "Price CSV file",
|
||||
"price_service_token": "Price service token",
|
||||
"price_now_entity": "Current price entity",
|
||||
"price_avg_entity": "Average price entity",
|
||||
"arbitrage_enabled": "Trade on the price spread",
|
||||
@@ -1204,6 +1363,8 @@ LABELS = {
|
||||
"pv_entities": "Solar production entities, per inverter",
|
||||
"pv_forecast_entity": "Solar forecast, today",
|
||||
"pv_forecast_tomorrow_entity": "Solar forecast, tomorrow",
|
||||
"pv_forecast_entities": "Several solar forecasts, today",
|
||||
"pv_forecast_tomorrow_entities": "Several solar forecasts, tomorrow",
|
||||
"solar_trust_pct": "Trust in the forecast",
|
||||
"p1_phase_import_entities": "Import entities per phase",
|
||||
"p1_phase_export_entities": "Export entities per phase",
|
||||
|
||||
@@ -21,7 +21,7 @@ Roles (the page's Why line names them):
|
||||
grid the others cannot give power either; the allocator never
|
||||
splits a target both ways).
|
||||
|
||||
Rules that keep this safe (tests: test_modes_p3.py):
|
||||
Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
* every range holds 0 W (a mode may forbid a direction, never force one);
|
||||
* a Solar first battery never charges from the grid;
|
||||
* P4: with a day plan (`targets`), a Leftover solar battery charges from
|
||||
@@ -31,11 +31,11 @@ Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* grid charging needs a measured peak guard that is clear and a measured
|
||||
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
|
||||
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
|
||||
more, and once the guard or the headroom stops it, it stays stopped for
|
||||
the rest of that 15-minute period (`blocked`): a charge 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 a 3.5 kW house);
|
||||
more, and once the guard or a measured headroom stops it, it stays
|
||||
stopped for the rest of that 15-minute period (`blocked`): a charge
|
||||
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);
|
||||
* 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);
|
||||
@@ -43,13 +43,21 @@ Rules that keep this safe (tests: test_modes_p3.py):
|
||||
* a battery out of a run (full, or at the floor) stays out until the run
|
||||
ends, so the ceiling and the floor do not re-arm within one run;
|
||||
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
|
||||
run in that direction: the refusal wins over the run;
|
||||
run in that direction: the refusal wins over the run. The one exception
|
||||
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
|
||||
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
|
||||
card's "tries again every 15 minutes" holds in the run too;
|
||||
* OPT-20: a probe asks PROBE_W for the probing battery and nothing more.
|
||||
The rest of the ask is what the other chargers would have had without
|
||||
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.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .control import PROBE_W
|
||||
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
|
||||
PLAN_CHARGE_MODES, SOLAR_FIRST)
|
||||
|
||||
@@ -75,6 +83,7 @@ class Pack:
|
||||
live: bool = True
|
||||
idle: bool = True # not in a maintenance phase
|
||||
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
|
||||
probing: bool = False # OPT-15: its SAFETY-73 probe window is open
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -84,6 +93,7 @@ class Tick:
|
||||
action: str | None # "charge" | "sell" | None
|
||||
ask_w: float # signed site ask before sizing (0 if none)
|
||||
why: str | None # why no trade when a run is on, else None
|
||||
probe_w: float = 0.0 # OPT-20: the part of `ask_w` that is a probe
|
||||
|
||||
|
||||
def _room_kwh(p: Pack, pct: float) -> float:
|
||||
@@ -112,15 +122,18 @@ def charge_to(p: Pack, ceiling_pct, targets=None):
|
||||
return None if t is None else min(float(ceiling_pct), float(t))
|
||||
|
||||
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None) -> list:
|
||||
def chargers(packs, ceiling_pct, done=(), targets=None, probe=False) -> list:
|
||||
"""Batteries that may charge from the grid in a cheap run: below the
|
||||
level `charge_to()` allows them (P4: the day plan's)."""
|
||||
level `charge_to()` allows them (P4: the day plan's). With `probe`, also
|
||||
one refusing to charge while its SAFETY-73 probe is open: its own P_SAFETY
|
||||
pin keeps it at PROBE_W (OPT-15)."""
|
||||
out = []
|
||||
for p in packs:
|
||||
to = charge_to(p, ceiling_pct, targets)
|
||||
if (to is not None and p.use and p.idle and p.live
|
||||
and p.soc is not None and p.soc < to
|
||||
and p.refused >= 0 and p.name not in done):
|
||||
and (p.refused >= 0 or (probe and p.probing))
|
||||
and p.name not in done):
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
@@ -172,8 +185,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
|
||||
|
||||
why = None
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \
|
||||
else []
|
||||
ch = chargers(packs, ceiling_pct, done_charge, targets, probe=True) \
|
||||
if in_cheap else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
charging = False
|
||||
@@ -225,8 +238,12 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
ranges[p.name] = (lo, hi)
|
||||
|
||||
if charging:
|
||||
ask = -sum(abs(p.rating_w) for p in ch)
|
||||
return Tick(ranges, roles, CHARGE, ask, None)
|
||||
# OPT-20: a probing refuser is asked PROBE_W (its own pin holds it
|
||||
# there); only the batteries that charge without a probe are asked
|
||||
# for their rating.
|
||||
probe_w = PROBE_W * sum(1 for p in ch if p.refused < 0)
|
||||
ask = -sum(abs(p.rating_w) for p in ch if p.refused >= 0) - probe_w
|
||||
return Tick(ranges, roles, CHARGE, ask, None, probe_w)
|
||||
if selling:
|
||||
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
|
||||
return Tick(ranges, roles, SELL, ask, None)
|
||||
@@ -243,8 +260,20 @@ def size(tick: Tick, bound, headroom_w) -> float:
|
||||
return 0.0
|
||||
lo, hi = bound
|
||||
if tick.action == CHARGE:
|
||||
cap = min(-float(tick.ask_w), -float(lo),
|
||||
float(headroom_w) if headroom_w is not None else 0.0)
|
||||
room = float(headroom_w) if headroom_w is not None else 0.0
|
||||
pw = float(tick.probe_w)
|
||||
if pw > 0.0:
|
||||
# OPT-20: the others' share is sized as without the probe; the
|
||||
# probe's PROBE_W is added on top (the grid rises by PROBE_W at
|
||||
# most), and alone it is sized by the bound and the headroom.
|
||||
rest = max(0.0, min(-float(tick.ask_w) - pw, -float(lo) - pw,
|
||||
room))
|
||||
if rest > 0.0:
|
||||
return -(rest + pw)
|
||||
# PROBE_W exactly or no trade: less would void the pin and hand
|
||||
# the remainder to whichever battery the allocator can reach.
|
||||
return -pw if min(-float(lo), room) >= pw else 0.0
|
||||
cap = min(-float(tick.ask_w), -float(lo), room)
|
||||
return -max(0.0, cap)
|
||||
return max(0.0, min(float(tick.ask_w), float(hi)))
|
||||
|
||||
|
||||
@@ -312,6 +312,12 @@ class QuarterAverager:
|
||||
"""
|
||||
return self._last_net
|
||||
|
||||
@property
|
||||
def held_t(self) -> float | None:
|
||||
"""Epoch seconds of the held sample. OPT-15: `PeakTracker.project()`
|
||||
with `roll=True` credits it into the next block as `add()` will."""
|
||||
return self._last_t
|
||||
|
||||
@property
|
||||
def held_since(self) -> datetime | None:
|
||||
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
|
||||
|
||||
@@ -750,6 +750,18 @@ class _Series(PriceRead):
|
||||
except Absence:
|
||||
return False
|
||||
|
||||
def last_end_before(self, instant: datetime):
|
||||
"""When the last price that has run out by `instant` stopped covering,
|
||||
or None if no price has (PRICE-01: "no prices since 00:00").
|
||||
|
||||
Each point's end is the one `_cover_table()` worked out, so a hole in
|
||||
the series is reported from where the hole starts and not from the end
|
||||
of the day.
|
||||
"""
|
||||
u = instant.astimezone(timezone.utc)
|
||||
ends = [end for _start, end, _p in self._cover if end <= u]
|
||||
return max(ends) if ends else None
|
||||
|
||||
def window(self, since: datetime, until: datetime) -> tuple:
|
||||
"""Points whose START falls in [since, until). May legitimately be empty.
|
||||
|
||||
@@ -906,6 +918,10 @@ PRICE_OPTION_DEFAULTS = {
|
||||
"export_price_eur_kwh": 0.04, # §14 tariffs.export_price_eur_kwh (AC4)
|
||||
"price_csv_path": "/share/goodwe_prices.csv",
|
||||
"price_feed_url": "",
|
||||
# PRICE-01. The bearer token of the owner's price server. A secret: it is
|
||||
# in SECRET_OPTIONS below, so no text built from an error reaches a log,
|
||||
# the status page or a notification with it in.
|
||||
"price_service_token": "",
|
||||
"price_horizon_h": 48,
|
||||
"price_timeout_s": 10,
|
||||
# TARIFF-03. §14 `tariffs.static_profile: null`. A JSON object describing a
|
||||
@@ -992,6 +1008,26 @@ FRAMEWORK_KEYS = frozenset({
|
||||
})
|
||||
|
||||
|
||||
# PRICE-01. Options whose VALUE must never be printed. `scrub()` removes them
|
||||
# from any text the price code builds out of an error.
|
||||
SECRET_OPTIONS = frozenset({"price_service_token"})
|
||||
|
||||
|
||||
def scrub(text, opts) -> str:
|
||||
"""`text` with every secret option's value replaced by `***`.
|
||||
|
||||
The price code builds reasons out of library errors, and a library may put
|
||||
a request header into its error text. Taking the value out here is the
|
||||
last line of defence; the plugin also never puts it in a message itself.
|
||||
"""
|
||||
out = str(text)
|
||||
for key in SECRET_OPTIONS:
|
||||
secret = str((opts or {}).get(key) or "").strip()
|
||||
if len(secret) >= 4:
|
||||
out = out.replace(secret, "***")
|
||||
return out
|
||||
|
||||
|
||||
class PluginOptions:
|
||||
"""The options dict, minus the keys only the framework may read.
|
||||
|
||||
@@ -1216,6 +1252,13 @@ class PriceProvider(ABC):
|
||||
|
||||
id = "?"
|
||||
|
||||
# PRICE-01. The ids to try, in order, when this source cannot answer, and
|
||||
# whether the last answer is kept for a day without any source. Empty and
|
||||
# False for every plugin that existed before, which is what keeps them
|
||||
# exactly as they were: `price_chain.py` is not used for them at all.
|
||||
FALLBACKS: tuple = ()
|
||||
KEEPS_LAST_GOOD: bool = False
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def capabilities(cls) -> ProviderCapabilities:
|
||||
@@ -1533,19 +1576,58 @@ def assemble(caps: ProviderCapabilities, raw, markup: float,
|
||||
# Transport - one place, HTTPS only, host checked against the declaration
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
async def https_get(url: str, timeout_s: float) -> bytes:
|
||||
async def https_get(url: str, timeout_s: float, headers=None) -> bytes:
|
||||
"""The default transport. §16: outbound is allow-listed and TLS'd.
|
||||
|
||||
Imported lazily so `manual_csv` - the offline fallback - never needs aiohttp
|
||||
to be importable at all. AC3 says it works fully offline; that includes a
|
||||
machine where the HTTP stack is missing.
|
||||
|
||||
PRICE-01. `headers` is None for every plugin but `price_service`, and with
|
||||
None this is the code it has always been, byte for byte. With headers it
|
||||
answers in the SAFE words of `status_words()` and never lets a library
|
||||
error through: an error from a request that carried a token is the one
|
||||
place a token could be printed by somebody else's code.
|
||||
"""
|
||||
import aiohttp
|
||||
t = aiohttp.ClientTimeout(total=float(timeout_s))
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
if headers is None:
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
async with s.get(url) as r:
|
||||
r.raise_for_status()
|
||||
return await r.read()
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=t) as s:
|
||||
# No redirects: the host is fixed in code, and a redirect would
|
||||
# take the request (and the token) somewhere the allow-list never
|
||||
# saw. A 3xx is reported like any other status.
|
||||
async with s.get(url, headers=headers,
|
||||
allow_redirects=False) as r:
|
||||
if r.status != 200:
|
||||
raise SourceUnavailable(status_words(r.status))
|
||||
return await r.read()
|
||||
except SourceUnavailable:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SourceUnavailable(
|
||||
f"the price service did not answer ({type(e).__name__})")
|
||||
|
||||
|
||||
# PRICE-01. The one phrase a refused token is reported with. The fallback chain
|
||||
# reads it to tell "the owner must act" from "the server is down", so it is a
|
||||
# constant both sides import.
|
||||
AUTH_REFUSED = "the price service refused the token"
|
||||
|
||||
|
||||
def status_words(status: int) -> str:
|
||||
"""What an HTTP status from the price service is called. No header text."""
|
||||
if status in (401, 403):
|
||||
return f"{AUTH_REFUSED} ({status})"
|
||||
if status == 429:
|
||||
return "the price service says too many requests (429)"
|
||||
if status >= 500:
|
||||
return f"the price service has an error ({status})"
|
||||
return f"the price service answered {status}"
|
||||
|
||||
|
||||
def check_egress(url: str, caps: ProviderCapabilities) -> str:
|
||||
@@ -1870,6 +1952,91 @@ class GenericDayAhead(PriceProvider):
|
||||
provisional_default=False)
|
||||
|
||||
|
||||
@register
|
||||
class PriceService(PriceProvider):
|
||||
"""The owner's own price server (PRICE-01), COLUMNAR, in EUR/MWh.
|
||||
|
||||
SOURCES: energy
|
||||
The framework ADDS: markup, taxes
|
||||
|
||||
The server answers the energy-charts layout (`unix_seconds`, `price`,
|
||||
`unit`) plus `covers`, the complete Brussels days it holds. The price is
|
||||
the wholesale energy price and nothing else, so markup and taxes are added
|
||||
by the framework exactly as for `generic_dayahead`.
|
||||
|
||||
The host is fixed here, HTTPS only, and a bearer token (the option
|
||||
`price_service_token`) goes in the `Authorization` header. The token is not
|
||||
put into any message this class builds.
|
||||
|
||||
`FALLBACKS` names the next source down. `app/price_chain.py` walks it and
|
||||
then the stored prices; this class only fetches.
|
||||
"""
|
||||
|
||||
id = "price_service"
|
||||
|
||||
HOST = "prices.kammenstraatha.duckdns.org"
|
||||
URL = "https://prices.kammenstraatha.duckdns.org/v1/dayahead/BE"
|
||||
FALLBACKS = ("generic_dayahead",)
|
||||
KEEPS_LAST_GOOD = True
|
||||
|
||||
@classmethod
|
||||
def capabilities(cls):
|
||||
return ProviderCapabilities(
|
||||
product=PriceProduct.DAY_AHEAD,
|
||||
sources=frozenset({Component.ENERGY}),
|
||||
egress_hosts=(cls.HOST,))
|
||||
|
||||
async def fetch(self, opts, since, until, transport):
|
||||
token = str(opts.get("price_service_token") or "").strip()
|
||||
if not token:
|
||||
raise SourceUnavailable("no token is set for the price service")
|
||||
if any(c.isspace() or not c.isprintable() for c in token):
|
||||
raise SourceUnavailable(
|
||||
"the price service token has a space or a line break in it")
|
||||
check_egress(self.URL, self.capabilities())
|
||||
try:
|
||||
body = await transport(
|
||||
self.URL, float(opts.get("price_timeout_s")),
|
||||
headers={"Authorization": "Bearer " + token})
|
||||
except SourceUnavailable:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise SourceUnavailable(
|
||||
f"the price service did not answer ({type(e).__name__})")
|
||||
points = _points_from_columnar_json(body, expect_unit="EUR / MWh",
|
||||
scale=0.001,
|
||||
provisional_default=False)
|
||||
covers = _covered_days(body)
|
||||
if covers is None:
|
||||
return points
|
||||
whole = [p for p in points
|
||||
if brussels(p.start).date().isoformat() in covers]
|
||||
if not whole:
|
||||
raise SourceUnavailable(
|
||||
"the price service sent no complete day")
|
||||
return whole
|
||||
|
||||
|
||||
def _covered_days(body):
|
||||
"""The `covers` list of a price service answer as a set, or None if absent.
|
||||
|
||||
A day the server does not list as complete is not used: an hour missing
|
||||
inside a day is a hole, and a hole is not a free hour.
|
||||
"""
|
||||
try:
|
||||
text = body.decode("utf-8") if isinstance(body, (bytes, bytearray)) \
|
||||
else str(body)
|
||||
covers = json.loads(text).get("covers")
|
||||
except (ValueError, UnicodeDecodeError, AttributeError):
|
||||
return None
|
||||
if covers is None:
|
||||
return None
|
||||
if not isinstance(covers, list) or \
|
||||
not all(isinstance(d, str) for d in covers):
|
||||
raise SourceUnavailable("the price service sent a bad list of days")
|
||||
return set(covers)
|
||||
|
||||
|
||||
@register
|
||||
class ManualCsv(PriceProvider):
|
||||
"""A file on disk. AC3: the documented fallback, fully offline.
|
||||
@@ -2169,9 +2336,10 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
except FrameworkOption:
|
||||
raise # a plugin bug, not a site condition
|
||||
except SourceUnavailable as e:
|
||||
return PriceRead.no_data(f"{pid}: {e}")
|
||||
return PriceRead.no_data(scrub(f"{pid}: {e}", opts))
|
||||
except Exception as e: # noqa: BLE001 - see the docstring
|
||||
return PriceRead.no_data(f"{pid}: {type(e).__name__}: {e}")
|
||||
return PriceRead.no_data(
|
||||
scrub(f"{pid}: {type(e).__name__}: {e}", opts))
|
||||
|
||||
_emit_startup_preview(plugin, pid, points)
|
||||
return PriceRead.series(points, caps.product, pid,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""PRICE-01 - where the prices come from when the first source does not answer.
|
||||
|
||||
A provider that names `FALLBACKS` (app/price.py) is read through this module
|
||||
instead of straight from `load_prices()`. The order is fixed:
|
||||
|
||||
1. the provider itself (the owner's price server)
|
||||
2. each id in its `FALLBACKS` (energy-charts.info)
|
||||
3. the prices the history store kept from the last fetch that worked
|
||||
|
||||
A step counts only if its answer holds a price for NOW. A server that answers
|
||||
200 with yesterday's prices is as useless to a battery as one that is down, so
|
||||
it is skipped, with the same log line.
|
||||
|
||||
Every other provider never comes here (`uses_chain()` is False for it), so
|
||||
`generic_dayahead`, `manual_csv`, `custom_formula` and `off` take the road they
|
||||
always took.
|
||||
|
||||
THE GATE. `gate_for()` is the maintenance charge rule for a site on such a
|
||||
provider: the current 15-minute price is at or below today's average. It is the
|
||||
rule the Home Assistant pair (`price_now_entity`, `price_avg_entity`) gave, read
|
||||
from the provider's own prices, so moving a site from the pair to the provider
|
||||
does not change WHEN the charge runs. DECISIONS/PRICE-01-own-price-server.md
|
||||
says how equal, and where it is not.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from .price import (AUTH_REFUSED, PROVIDERS, PriceRead, brussels,
|
||||
price_option, provider_id, scrub)
|
||||
|
||||
_LOG = logging.getLogger("goodwe.price.chain")
|
||||
|
||||
# The four places a plan can come from, best first. A higher number is further
|
||||
# down the chain.
|
||||
STEP_OWN, STEP_PUBLIC, STEP_STORED, STEP_NONE = 0, 1, 2, 3
|
||||
|
||||
# What a step is called in a status line.
|
||||
SOURCE_WORDS = {
|
||||
"price_service": "the price service",
|
||||
"generic_dayahead": "energy-charts",
|
||||
"price_history": "saved prices",
|
||||
}
|
||||
|
||||
|
||||
def uses_chain(opts: dict) -> bool:
|
||||
"""Does the selected provider ask for the fallback chain?"""
|
||||
cls = PROVIDERS.get(provider_id(opts))
|
||||
return bool(cls is not None and (cls.FALLBACKS or cls.KEEPS_LAST_GOOD))
|
||||
|
||||
|
||||
def fixed_tariff_beside_service(opts: dict) -> bool:
|
||||
"""PRICE-01: a fixed tariff is set beside `price_service`. Not a fault: it
|
||||
stays the plan's last fallback (`resolve_tariff`), and only the cheap window
|
||||
and the gate ignore it. A blank or whitespace-only value is not set."""
|
||||
return (provider_id(opts) == "price_service"
|
||||
and bool(str(price_option(opts, "static_profile")).strip()))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Resolved:
|
||||
"""What the chain found. `published` is the series BEFORE the yesterday
|
||||
fill and the window, which is what the day's average has to be read from:
|
||||
the plan itself starts at the current hour."""
|
||||
|
||||
read: PriceRead
|
||||
step: int
|
||||
reasons: tuple = ()
|
||||
|
||||
@property
|
||||
def published(self):
|
||||
return self.read.match(series=lambda r: r, no_data=lambda _r: None)
|
||||
|
||||
|
||||
class PriceWatch:
|
||||
"""Which step the prices come from, and one log line per change of it.
|
||||
|
||||
The line is written when the step CHANGES and not before. Nothing repeats
|
||||
while a source stays down, and nothing is written for a refresh that
|
||||
changes nothing. The reasons (a status code, a timeout) go on a DEBUG line
|
||||
beside it, with the token taken out.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.step = None
|
||||
self.auth_refused = False
|
||||
|
||||
def note(self, step: int, reasons=(), opts=None) -> None:
|
||||
old, self.step = self.step, step
|
||||
self.auth_refused = any(AUTH_REFUSED in r for r in reasons)
|
||||
if old == step:
|
||||
return
|
||||
if reasons:
|
||||
_LOG.debug("Price sources said: %s.",
|
||||
scrub("; ".join(reasons), opts))
|
||||
if step == STEP_OWN:
|
||||
if old is None:
|
||||
_LOG.info("Prices come from the price service.")
|
||||
else:
|
||||
_LOG.info("The price service answers again.")
|
||||
elif step == STEP_PUBLIC:
|
||||
if old is not None and old > step:
|
||||
_LOG.info("Prices come from energy-charts again.")
|
||||
elif self.auth_refused:
|
||||
_LOG.warning("The price service refused the token, so prices "
|
||||
"come from energy-charts.")
|
||||
else:
|
||||
_LOG.warning("The price service does not answer, so prices "
|
||||
"come from energy-charts.")
|
||||
elif step == STEP_STORED:
|
||||
if old is not None and old > step:
|
||||
_LOG.info("The add-on uses the prices it saved again.")
|
||||
else:
|
||||
_LOG.warning("No price source answers, so the add-on uses "
|
||||
"the prices it saved.")
|
||||
else:
|
||||
_LOG.warning("No price source answers, and no saved price covers "
|
||||
"now.")
|
||||
|
||||
|
||||
async def resolve(opts: dict, since: datetime, until: datetime, transport,
|
||||
history, stamp: datetime, watch: PriceWatch,
|
||||
load) -> Resolved:
|
||||
"""Walk the chain and return the first step that has a price for `stamp`.
|
||||
|
||||
`load` is the caller's `load_prices`, passed in so that `static_tariff.py`
|
||||
stays the one module that fetches prices. Never raises
|
||||
for a source condition: a read that gets nowhere is a `no_data` whose
|
||||
reason names every step, so the §11.4 row applies exactly as for a single
|
||||
provider that failed.
|
||||
"""
|
||||
pid = provider_id(opts)
|
||||
cls = PROVIDERS[pid]
|
||||
reasons = []
|
||||
for step, sid in enumerate((pid,) + tuple(cls.FALLBACKS)):
|
||||
# A fallback is asked for ITS OWN feed. The operator's feed override is
|
||||
# for the first provider and would send energy-charts' request to a
|
||||
# URL it was never meant for.
|
||||
o = opts if step == 0 else dict(opts, price_provider=sid,
|
||||
price_feed_url="")
|
||||
read = await load(o, since, until, transport)
|
||||
if read.match(series=lambda r: r.covers(stamp), no_data=lambda _r: False):
|
||||
# One fallback is shipped. Any further one would also be "public".
|
||||
where = STEP_OWN if step == 0 else STEP_PUBLIC
|
||||
watch.note(where, reasons, opts)
|
||||
return Resolved(read, where, tuple(reasons))
|
||||
reasons.append(read.match(
|
||||
series=lambda r: f"{sid}: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
if cls.KEEPS_LAST_GOOD:
|
||||
kept = history.stored(stamp)
|
||||
if kept.match(series=lambda r: r.covers(stamp),
|
||||
no_data=lambda _r: False):
|
||||
watch.note(STEP_STORED, reasons, opts)
|
||||
return Resolved(kept, STEP_STORED, tuple(reasons))
|
||||
reasons.append(kept.match(
|
||||
series=lambda r: "price_history: it has no price for now",
|
||||
no_data=lambda r: r.reason))
|
||||
watch.note(STEP_NONE, reasons, opts)
|
||||
# The reason is short on purpose: it is printed on the status page and in
|
||||
# a WARNING every refresh. What each step said is on the DEBUG line.
|
||||
return Resolved(PriceRead.no_data(
|
||||
"no price source has a price for now"), STEP_NONE,
|
||||
tuple(scrub(r, opts) for r in reasons))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The maintenance charge gate, read from the provider's prices
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Gate:
|
||||
"""What the 30 s tick takes from the standing plan.
|
||||
|
||||
`covered` is whether a published price covers now. When it is False,
|
||||
`cheap` is False and the charge waits: no price is not a cheap price.
|
||||
`since` is when the cover ended, for the status line, or None when nothing
|
||||
was ever published.
|
||||
"""
|
||||
|
||||
cheap: bool
|
||||
covered: bool
|
||||
since: datetime | None
|
||||
words: str
|
||||
|
||||
|
||||
NO_PLAN = Gate(False, False, None, "No prices yet")
|
||||
|
||||
|
||||
def _round5(value: float) -> float:
|
||||
# The Home Assistant template rounded the average to 5 decimals, and a
|
||||
# price to 5 as well. Both sides are compared as it did, so a quarter that
|
||||
# sits within 0.00001 EUR/kWh of the average gets the same answer.
|
||||
return round(float(value), 5)
|
||||
|
||||
|
||||
def gate_for(published, now: datetime) -> Gate:
|
||||
"""The charge gate for `now`, from the series the chain published.
|
||||
|
||||
THE RULE (owner's decision, PRICE-01): the current 15-minute price is at or
|
||||
below the average of today's prices, today being the Brussels day. This is
|
||||
what `now_p <= avg_p` was on the Home Assistant pair. It is NOT
|
||||
`TariffPlan.cheap_now()`, which is strictly below the mean of the firm
|
||||
hours of the whole plan, and it is not meant to be.
|
||||
|
||||
`published` is a series or None. With no price for now there is no gate
|
||||
answer: the charge waits.
|
||||
"""
|
||||
if published is None:
|
||||
return NO_PLAN
|
||||
if not published.covers(now):
|
||||
return Gate(False, False, published.last_end_before(now),
|
||||
"No price for now")
|
||||
today = brussels(now).date()
|
||||
todays = [p.eur_per_kwh for p in published.points
|
||||
if not p.provisional and brussels(p.start).date() == today]
|
||||
here = published.at(now)
|
||||
if here.provisional or not todays:
|
||||
return Gate(False, False, published.last_end_before(now),
|
||||
"No price for now")
|
||||
avg = sum(todays) / len(todays)
|
||||
cheap = _round5(here.eur_per_kwh) <= _round5(avg)
|
||||
source = SOURCE_WORDS.get(published.provider, published.provider)
|
||||
return Gate(cheap, True, None,
|
||||
f"Prices from {source}, {here.eur_per_kwh:.4f} EUR/kWh now, "
|
||||
f"{'cheap' if cheap else 'not cheap'} against today's average "
|
||||
f"of {avg:.4f}")
|
||||
@@ -581,7 +581,7 @@ class PriceHistory:
|
||||
|
||||
# -- the one public entry point -----------------------------------------
|
||||
def carry(self, read: PriceRead, *, since: datetime, until: datetime,
|
||||
stamp: datetime) -> PriceRead:
|
||||
stamp: datetime, remember: bool = True) -> PriceRead:
|
||||
"""Remember what this read published, then §8.3-fill the missing hours.
|
||||
|
||||
THE ORDER IS DELIBERATE AND IS THE POINT OF THE METHOD. The read is
|
||||
@@ -599,7 +599,11 @@ class PriceHistory:
|
||||
source can price is left out (§11.4 for that hour), and `no_data` comes
|
||||
back when that is every hour.
|
||||
"""
|
||||
self._remember(read, stamp)
|
||||
if remember:
|
||||
# PRICE-01: False only for a read that CAME FROM this store
|
||||
# (`stored()`), which is already filed; filing it again would
|
||||
# rewrite the same file for nothing, and nothing else may pass it.
|
||||
self._remember(read, stamp)
|
||||
return fill_from_yesterday(read, yesterday=self.yesterday(stamp),
|
||||
since=since, until=until, stamp=stamp)
|
||||
|
||||
@@ -798,6 +802,37 @@ class PriceHistory:
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
"""PRICE-01: every price this store holds, as one series, or no_data.
|
||||
|
||||
This is the LAST step of the price server's fallback (price_chain.py):
|
||||
the answer of the last fetch that worked, kept on disk, so a site can
|
||||
plan for as long as the days it holds last. It is a read of what was
|
||||
PUBLISHED and filed, in the order they happened, and it is never the
|
||||
carried fill (rule 1 holds: `carry(..., remember=False)` is how this
|
||||
read is filled, and it is not filed again).
|
||||
|
||||
The prices are the all-in ones stored when they were fetched. They are
|
||||
not worked out again, so a change of markup or taxes shows from the
|
||||
next fetch that works, not in these.
|
||||
"""
|
||||
if stamp.tzinfo is None or stamp.utcoffset() is None:
|
||||
raise ValueError(f"stored({stamp!r}): a naive datetime is not an "
|
||||
f"instant")
|
||||
points = []
|
||||
for day in sorted(self._days):
|
||||
rec = self._days[day]
|
||||
got, _bad = _parse_points(rec.get("points") if isinstance(
|
||||
rec, dict) else None)
|
||||
points.extend(got)
|
||||
if not points:
|
||||
why = self.last_error or "it holds no prices"
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} gives nothing: {why}")
|
||||
points.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return PriceRead.series(points, PriceProduct.DAY_AHEAD,
|
||||
"price_history", stamp)
|
||||
|
||||
# -- observability -------------------------------------------------------
|
||||
@property
|
||||
def words(self) -> str:
|
||||
@@ -873,9 +908,14 @@ class NoHistory:
|
||||
last_error = "no history is kept by this caller"
|
||||
words = "not kept"
|
||||
|
||||
def carry(self, read: PriceRead, *, since, until, stamp) -> PriceRead:
|
||||
def carry(self, read: PriceRead, *, since, until, stamp,
|
||||
remember: bool = True) -> PriceRead:
|
||||
return read
|
||||
|
||||
def stored(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data("this caller keeps no price history, so "
|
||||
"there are no stored prices")
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data(
|
||||
"this caller keeps no price history, so there are no prices from "
|
||||
|
||||
@@ -27,6 +27,11 @@ Without an hourly split, the day's energy is spread over a fixed bell from
|
||||
A reading older than `FRESH_S`, or from before local midnight, is stale,
|
||||
and a stale or missing forecast is no forecast: the day plan is off and
|
||||
every mode acts as in P3 option A.
|
||||
|
||||
OPT-17: each day may instead name a list (`pv_forecast_entities`,
|
||||
`pv_forecast_tomorrow_entities`), summed by `read_sum()`. One member that is
|
||||
not usable makes that day's sum unusable, never a partial sum.
|
||||
DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -49,6 +54,12 @@ BELL_FROM_H, BELL_TO_H = 6.0, 20.0
|
||||
NOT_SET, MISSING, UNIT, STALE, BAD = (
|
||||
"not_set", "missing", "unit", "stale", "bad")
|
||||
|
||||
# OPT-17: (single key, list key) per day. A set list replaces the single key;
|
||||
# the validator refuses both set for the same day.
|
||||
TODAY_KEYS = ("pv_forecast_entity", "pv_forecast_entities")
|
||||
TOMORROW_KEYS = ("pv_forecast_tomorrow_entity",
|
||||
"pv_forecast_tomorrow_entities")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Day:
|
||||
@@ -223,6 +234,52 @@ def _reported(state):
|
||||
return max(ts) if ts else None
|
||||
|
||||
|
||||
def entities(opts: dict, *, tomorrow=False) -> tuple:
|
||||
"""The forecast entities one day names, in configured order: the list
|
||||
if it has any entry, else the single key, else (). A repeated id is
|
||||
dropped, as `pv.entities()` does: it would count one forecast twice."""
|
||||
one, many = TOMORROW_KEYS if tomorrow else TODAY_KEYS
|
||||
listed = [e for e in (str(x or "").strip()
|
||||
for x in (opts.get(many) or [])) if e]
|
||||
if listed:
|
||||
return tuple(dict.fromkeys(listed))
|
||||
e = str(opts.get(one) or "").strip()
|
||||
return (e,) if e else ()
|
||||
|
||||
|
||||
def _bounds(now):
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
return d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _today(state, day0, now):
|
||||
"""`(Day, at, None)` or `(None, None, why)` for one today sensor."""
|
||||
today, why = _day(state, day0, now, today=True)
|
||||
if today is None:
|
||||
return None, None, why
|
||||
at = _reported(state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, None, STALE
|
||||
return today, at, None
|
||||
|
||||
|
||||
def _tomorrow(state, day0, day1, now):
|
||||
"""`(Day, None)` or `(None, why)` for one tomorrow sensor."""
|
||||
t, why = _day(state, day1, now, today=False)
|
||||
if t is None:
|
||||
return None, why
|
||||
t_at = _reported(state)
|
||||
if t_at is None or t_at < day0 or (now - t_at).total_seconds() > FRESH_S:
|
||||
return None, STALE
|
||||
return t, None
|
||||
|
||||
|
||||
def read(today_state, tomorrow_state, now: datetime, *,
|
||||
configured=True):
|
||||
"""`(Forecast, None)` or `(None, why)`. `tomorrow_state` may be None
|
||||
@@ -230,25 +287,64 @@ def read(today_state, tomorrow_state, now: datetime, *,
|
||||
covers today only."""
|
||||
if not configured:
|
||||
return None, NOT_SET
|
||||
here = now.astimezone(BILLING_TZ)
|
||||
d0 = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
|
||||
nd = d0.date() + timedelta(days=1)
|
||||
d1 = datetime(nd.year, nd.month, nd.day, tzinfo=BILLING_TZ)
|
||||
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
|
||||
today, why = _day(today_state, day0, now, today=True)
|
||||
day0, day1 = _bounds(now)
|
||||
today, at, why = _today(today_state, day0, now)
|
||||
if today is None:
|
||||
return None, why
|
||||
at = _reported(today_state)
|
||||
# A reading from before local midnight is yesterday's "left today" (0
|
||||
# after sunset) until the integration refreshes: stale, not a dark day.
|
||||
if at is None or at < day0 or (now - at).total_seconds() > FRESH_S \
|
||||
or (at - now).total_seconds() > 300:
|
||||
return None, STALE
|
||||
tomorrow = None
|
||||
if tomorrow_state is not None:
|
||||
t, _w = _day(tomorrow_state, day1, now, today=False)
|
||||
t_at = _reported(tomorrow_state)
|
||||
if t is not None and t_at is not None and t_at >= day0 \
|
||||
and (now - t_at).total_seconds() <= FRESH_S:
|
||||
tomorrow = t
|
||||
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
|
||||
return Forecast(today, tomorrow, at), None
|
||||
|
||||
|
||||
def _sum(days):
|
||||
"""One Day from several members' Days. The hourly split is kept only if
|
||||
every member has one: the splits are then joined (`_day_between()`
|
||||
adds overlapping periods). Else the total goes on the bell."""
|
||||
if len(days) == 1:
|
||||
return days[0]
|
||||
periods = ()
|
||||
if all(d.periods for d in days):
|
||||
periods = tuple(sorted(p for d in days for p in d.periods))
|
||||
return Day(days[0].day0, sum(d.kwh for d in days), periods)
|
||||
|
||||
|
||||
def read_sum(today, tomorrow, now: datetime):
|
||||
"""OPT-17: `(Forecast or None, why, gaps)` over lists of
|
||||
`(entity, state)`, one per configured member, in configured order.
|
||||
|
||||
Each member is read exactly as `read()` reads one sensor, in its own
|
||||
unit. A member that is missing, unavailable, stale, not in an energy
|
||||
unit or bad makes that DAY unusable, as one such sensor does today: no
|
||||
today means no forecast, no tomorrow means today only. A partial sum
|
||||
would under-count the sun and mislead the day plan.
|
||||
|
||||
`gaps` is `((day, position, entity, why), ...)` for every unusable
|
||||
member (`day` "today" or "tomorrow", `position` 1-based), so the page
|
||||
and the log can name it. With one member per day the forecast is what
|
||||
`read()` gives (test_opt17.py proves it)."""
|
||||
if not today:
|
||||
return None, NOT_SET, ()
|
||||
day0, day1 = _bounds(now)
|
||||
gaps, days, ats, why0 = [], [], [], None
|
||||
for i, (ent, st) in enumerate(today, 1):
|
||||
d, at, why = _today(st, day0, now)
|
||||
if d is None:
|
||||
gaps.append(("today", i, ent, why))
|
||||
why0 = why0 or why
|
||||
else:
|
||||
days.append(d)
|
||||
ats.append(at)
|
||||
tdays = []
|
||||
for i, (ent, st) in enumerate(tomorrow, 1):
|
||||
t, why = _tomorrow(st, day0, day1, now)
|
||||
if t is None:
|
||||
gaps.append(("tomorrow", i, ent, why))
|
||||
else:
|
||||
tdays.append(t)
|
||||
gaps = tuple(gaps)
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats)), None, gaps
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
function renderPill() {
|
||||
const st = S.state || null;
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
|
||||
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad", waiting: " warn" }[st.kind] ?? " warn")
|
||||
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
|
||||
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.setup_only === true ? t("setup_only.name") : S.banner;
|
||||
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
|
||||
@@ -336,6 +336,39 @@
|
||||
finally { packBusy = false; }
|
||||
await refresh(true);
|
||||
}
|
||||
// MAINT-11: start or stop ONE battery's cycle. Two or more batteries only;
|
||||
// the add-on refuses a second cycle and says which battery is cycling.
|
||||
async function cycleAct(what, b) {
|
||||
if (packBusy) return;
|
||||
packBusy = true;
|
||||
try {
|
||||
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: what, unit: b.name }) });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
const own = j.code && t("batteries.cycle." + j.code);
|
||||
if (!r.ok) toast(own ? say(own.description, { name: j.unit || b.name }) : (j.error || t("toast.failed")));
|
||||
else toast(say(t("batteries.cycle.toast." + (what === "maint_start" ? "started" : "stopped")), { name: b.name }));
|
||||
} catch (e) { toast(t("toast.failed")); }
|
||||
finally { packBusy = false; }
|
||||
await refresh(true);
|
||||
}
|
||||
// A button that cannot work is disabled (the add-on still refuses it): Stop
|
||||
// needs this battery cycling; Start needs no battery cycling and this one on.
|
||||
function cycleState(b) {
|
||||
const cycling = (x) => x.maintenance !== "idle";
|
||||
return { start: !S.batteries.some(cycling) && b.use !== false, abort: cycling(b) };
|
||||
}
|
||||
function cycleButtons(b, i) {
|
||||
const can = cycleState(b);
|
||||
const ask1 = (key, what) => () => {
|
||||
const w = t("batteries.cycle." + key);
|
||||
ask({ name: say(w.name, { name: b.name }), description: say(w.description, { name: b.name }), yes: w.yes },
|
||||
() => cycleAct(what, b));
|
||||
};
|
||||
return h("div", { class: "cyc", style: "display:flex;gap:8px;flex-wrap:wrap;margin-top:10px" },
|
||||
h("button", { type: "button", class: "btn", id: "bcs-" + i, disabled: !can.start, onclick: ask1("start_dialog", "maint_start") }, t("batteries.cycle.start")),
|
||||
h("button", { type: "button", class: "btn danger", id: "bca-" + i, disabled: !can.abort, onclick: ask1("abort_dialog", "maint_abort") }, t("batteries.cycle.abort")));
|
||||
}
|
||||
function onSwitch(b, i, where, lid) {
|
||||
const sw = h("button", { type: "button", class: "switch", role: "switch", id: "bsw-" + where + i,
|
||||
"aria-checked": String(b.use), "aria-labelledby": lid,
|
||||
@@ -370,7 +403,8 @@
|
||||
h("dt", {}, t("batteries.level")), h("dd", {}, lvl + stored),
|
||||
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
|
||||
h("dt", {}, t("batteries.today")), h("dd", {}, todayLines(b)),
|
||||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")));
|
||||
h("dt", {}, t("batteries.why")), h("dd", { class: "muted" }, b.why || "–")),
|
||||
S.batteries.length > 1 ? cycleButtons(b, i) : null);
|
||||
}
|
||||
function simpleRow(b, i) {
|
||||
const lid = "bsname-" + i;
|
||||
@@ -399,7 +433,7 @@
|
||||
$("#batt-how").hidden = !modes;
|
||||
const docs = $("#batt-docs");
|
||||
if (S.control && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
|
||||
if (S.state && ["shadow", "stopped", "alarm"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||||
if (S.state && ["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)) banner($("#batt-banner"), S.level, S.state.pill, S.state.sub, S.level === "bad" ? "alert" : null);
|
||||
else $("#batt-banner").replaceChildren();
|
||||
keepFocus(() => {
|
||||
$("#bcards").replaceChildren(...S.batteries.map(batteryCard));
|
||||
|
||||
@@ -204,12 +204,13 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from . import messages, price_chain
|
||||
from .price import (
|
||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||
provider_configured, required_spread, uncertainty_of, widen_factor,
|
||||
)
|
||||
from .price_chain import uses_chain
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff.static")
|
||||
|
||||
@@ -793,6 +794,13 @@ class TariffPlan:
|
||||
self.read = read
|
||||
self.profile = profile
|
||||
self.origin = origin
|
||||
# PRICE-01. The series the price chain found, BEFORE the yesterday fill
|
||||
# cut it to the hours ahead: the maintenance gate needs the whole of
|
||||
# today to know its average. None for every plan that did not come
|
||||
# through the chain, which is every plan of every other provider.
|
||||
self.published = None
|
||||
self.step = None
|
||||
self.reasons = ()
|
||||
|
||||
# -- derived facts -------------------------------------------------------
|
||||
@property
|
||||
@@ -1232,6 +1240,16 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
|
||||
profile, "fallback" if provisional else "static")
|
||||
|
||||
|
||||
def static_gated(opts: dict, now: datetime) -> bool:
|
||||
"""MAINT-13: is a fixed tariff set up, so that a cheap window can hold a
|
||||
charge back? False for no profile or one that cannot be used."""
|
||||
try:
|
||||
return bool(static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
|
||||
now=now).has_prices)
|
||||
except ProfileInvalid:
|
||||
return False
|
||||
|
||||
|
||||
def static_status(opts: dict, now: datetime):
|
||||
"""(cheap, words) for the status page and for main.py's cheap-window flag.
|
||||
|
||||
@@ -1264,7 +1282,8 @@ def static_status(opts: dict, now: datetime):
|
||||
|
||||
|
||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
transport=None, now=None, *, history) -> TariffPlan:
|
||||
transport=None, now=None, *, history,
|
||||
watch=None) -> TariffPlan:
|
||||
"""The full §9.2 + §11.4 resolution. MAY PERFORM I/O - see `static_plan`.
|
||||
|
||||
⚠️ NOT ON THE 30 s TICK, and that is a deliberate boundary rather than an
|
||||
@@ -1320,13 +1339,30 @@ async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
||||
if not provider_configured(opts):
|
||||
return static_plan(opts, since, until, provisional=False, now=stamp)
|
||||
|
||||
if uses_chain(opts):
|
||||
# PRICE-01. The provider names fallbacks, so its prices come from the
|
||||
# first step of the chain that has one for now. Nothing else does.
|
||||
found = await price_chain.resolve(
|
||||
opts, since, until, transport, history, stamp,
|
||||
watch if watch is not None else price_chain.PriceWatch(),
|
||||
load=load_prices)
|
||||
plan = found.read.match(
|
||||
series=lambda r: _provider_plan(
|
||||
opts, r, since, until, stamp, history,
|
||||
remember=found.step != price_chain.STEP_STORED),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
plan.published, plan.step = found.published, found.step
|
||||
plan.reasons = found.reasons
|
||||
return plan
|
||||
|
||||
read = await load_prices(opts, since, until, transport)
|
||||
return read.match(
|
||||
series=lambda r: _provider_plan(opts, r, since, until, stamp, history),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
|
||||
def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
||||
def _provider_plan(opts, read, since, until, stamp, history, *,
|
||||
remember=True) -> TariffPlan:
|
||||
"""§8.3: record what the feed published, carry yesterday into the gaps.
|
||||
|
||||
The fill can itself return `no_data` - a feed whose points all fall outside
|
||||
@@ -1334,7 +1370,12 @@ def _provider_plan(opts, read, since, until, stamp, history) -> TariffPlan:
|
||||
§11.4 row arrived at one step later, so it degrades exactly as a failed
|
||||
fetch does rather than being special-cased here.
|
||||
"""
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||
if remember:
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp)
|
||||
else:
|
||||
# PRICE-01: a read that came out of the store is not filed again.
|
||||
filled = history.carry(read, since=since, until=until, stamp=stamp,
|
||||
remember=False)
|
||||
return filled.match(
|
||||
series=lambda r: TariffPlan(r, _quiet_profile(opts), "provider"),
|
||||
no_data=lambda r: _degrade(opts, r, since, until, stamp))
|
||||
|
||||
@@ -94,6 +94,7 @@ DEFAULTS = {
|
||||
"phase_started": None,
|
||||
"last_completed": None,
|
||||
"last_start_attempt": None,
|
||||
"last_failed": None,
|
||||
"sign_tripped": False,
|
||||
"sign_reason": "",
|
||||
"circ_tripped": False,
|
||||
@@ -171,7 +172,7 @@ MAINT_SCHEMA_KEY = "maint_schema_version"
|
||||
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
||||
# SAFETY-02 pair.
|
||||
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
||||
"last_start_attempt")
|
||||
"last_start_attempt", "last_failed")
|
||||
|
||||
|
||||
def unit_key(unit: str, key: str) -> str:
|
||||
|
||||
@@ -776,9 +776,14 @@ class PeakTracker:
|
||||
return r.peak_w / self.cap_w
|
||||
|
||||
@staticmethod
|
||||
def project(averager, now: datetime) -> Projection:
|
||||
def project(averager, now: datetime, roll: bool = False) -> Projection:
|
||||
"""End-of-quarter average if the last sample held for the rest of it.
|
||||
|
||||
`roll` (OPT-15, battery modes only): when `now` is past the open block
|
||||
because no sample has come since the boundary, project the quarter
|
||||
`now` is in, crediting the held sample as `add()` will (`max_hold_s`).
|
||||
Without it that tick reads "no seconds left" and measures nothing.
|
||||
|
||||
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
|
||||
block. `elapsed_w` divides by the seconds actually covered (what the
|
||||
meter has shown so far); `projected_w` divides by the full 900 s and
|
||||
@@ -798,6 +803,14 @@ class PeakTracker:
|
||||
elapsed_wall = max(0.0, (now - start).total_seconds())
|
||||
elapsed = averager.elapsed_s
|
||||
acc = averager.partial_ws
|
||||
if roll and elapsed_wall >= QUARTER_S:
|
||||
t = now.timestamp()
|
||||
block = int(t // QUARTER_S) * QUARTER_S
|
||||
start = datetime.fromtimestamp(block, timezone.utc)
|
||||
held_end = float(averager.held_t) + float(averager.max_hold_s)
|
||||
elapsed = max(0.0, min(t, held_end) - block)
|
||||
elapsed_wall = t - block
|
||||
acc = max(averager.held_net_w, 0.0) * elapsed
|
||||
remaining = max(0.0, QUARTER_S - elapsed_wall)
|
||||
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
|
||||
# was `getattr(averager, "_last_net", 0.0)`: a private name with a
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"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"},
|
||||
"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_provider": {"generic_dayahead": "Day-ahead market price", "custom_formula": "Your own price formula", "manual_csv": "A price file", "price_service": "Your own price service"},
|
||||
"log_level": {"trace": "Everything, for support", "debug": "Detailed, to find a problem", "info": "Normal", "warning": "Warnings and errors", "error": "Errors only"},
|
||||
"period_days": {"mon-sun": "Every day", "mon-fri": "Monday to Friday", "sat,sun": "Saturday and Sunday", "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday", "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday"}
|
||||
},
|
||||
@@ -92,6 +92,7 @@
|
||||
"off": {"name": "Off", "description": "Rests, and helps only when the peak limit needs it."}
|
||||
},
|
||||
"today": "Today", "why": "Why",
|
||||
"cycle": {"start": "Start cycle", "abort": "Stop cycle", "start_dialog": {"name": "Start a cycle on {name}?", "description": "{name} empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"}, "abort_dialog": {"name": "Stop the cycle on {name}?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}, "toast": {"started": "Cycle started on {name}.", "stopped": "Cycle stopped on {name}."}, "unit": {"description": "There is no battery with that name."}, "busy": {"description": "Only one battery can cycle at a time, and {name} is cycling now."}, "off": {"description": "{name} is switched off, so it cannot start a cycle."}, "idle": {"description": "{name} is not running a cycle."}},
|
||||
"modes_off": {"name": "Battery modes are off"},
|
||||
"how": {"name": "How the modes work", "description": "Which battery gets solar first, and when each gives power.",
|
||||
"solar_first": {"description": "Solar first gets spare solar before the other batteries, and covers the house at any hour."},
|
||||
@@ -267,6 +268,7 @@
|
||||
"shadow": "Shadow mode",
|
||||
"stopped": "Stopped",
|
||||
"alarm": "Needs you now",
|
||||
"waiting": "Waiting for Home Assistant",
|
||||
"warning": "Running, with a warning"
|
||||
},
|
||||
"banner_sub": {
|
||||
@@ -274,6 +276,7 @@
|
||||
"shadow": "The plans below say what the add-on would do.",
|
||||
"stopped": "Start control below when you are ready.",
|
||||
"alarm": "See Needs attention below for what to do.",
|
||||
"waiting": "Control resumes by itself when Home Assistant answers.",
|
||||
"warning": "See Needs attention below."
|
||||
},
|
||||
"tile": {
|
||||
@@ -407,6 +410,9 @@
|
||||
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
||||
},
|
||||
"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.",
|
||||
"no_prices_since_day": "No prices since {day} {month} at {at}, maintenance waits.",
|
||||
"step_in": "The batteries step in above {kw}.",
|
||||
"step_in_would": "The batteries would step in above {kw}.",
|
||||
"period": "This 15-minute period is heading for {kw}.",
|
||||
@@ -431,6 +437,7 @@
|
||||
"stopped": "Control is stopped. Every inverter is at 0 W."
|
||||
},
|
||||
"attn": {
|
||||
"home_assistant": "Home Assistant is not answering",
|
||||
"controller": "Control is not running",
|
||||
"meter": "Meter reading",
|
||||
"meter_frozen": "Meter reading may be stuck",
|
||||
@@ -449,6 +456,7 @@
|
||||
"delivery": "An inverter is not delivering",
|
||||
"estop": "Emergency stop",
|
||||
"pv": "Solar reading",
|
||||
"solar_forecast": "Solar forecast",
|
||||
"none": "Inverter connection"
|
||||
},
|
||||
"months": ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"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"},
|
||||
"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_provider": {"generic_dayahead": "Day-aheadprijs van de markt", "custom_formula": "Uw eigen prijsformule", "manual_csv": "Een prijsbestand", "price_service": "Uw eigen prijsdienst"},
|
||||
"log_level": {"trace": "Alles, voor support", "debug": "Uitgebreid, om een probleem te vinden", "info": "Normaal", "warning": "Waarschuwingen en fouten", "error": "Alleen fouten"},
|
||||
"period_days": {"mon-sun": "Elke dag", "mon-fri": "Maandag tot vrijdag", "sat,sun": "Zaterdag en zondag", "mon": "maandag", "tue": "dinsdag", "wed": "woensdag", "thu": "donderdag", "fri": "vrijdag", "sat": "zaterdag", "sun": "zondag"}
|
||||
},
|
||||
@@ -89,6 +89,7 @@
|
||||
"off": {"name": "Uit", "description": "Rust, en helpt alleen als de piekgrens haar nodig heeft."}
|
||||
},
|
||||
"today": "Vandaag", "why": "Waarom",
|
||||
"cycle": {"start": "Cyclus starten", "abort": "Cyclus stoppen", "start_dialog": {"name": "Cyclus starten op {name}?", "description": "{name} loopt leeg, laadt weer op en blijft vol, wat enkele uren duurt.", "yes": "Cyclus starten"}, "abort_dialog": {"name": "Cyclus stoppen op {name}?", "description": "Elke omvormer gaat even naar 0 W, daarna gaat de regeling verder.", "yes": "Cyclus stoppen"}, "toast": {"started": "Cyclus gestart op {name}.", "stopped": "Cyclus gestopt op {name}."}, "unit": {"description": "Er is geen batterij met die naam."}, "busy": {"description": "Er kan maar één batterij tegelijk een cyclus doen, en {name} is nu bezig."}, "off": {"description": "{name} staat uit en kan dus geen cyclus starten."}, "idle": {"description": "{name} doet nu geen cyclus."}},
|
||||
"modes_off": {"name": "Batterijmodi staan uit"},
|
||||
"how": {"name": "Zo werken de modi", "description": "Welke batterij eerst zonne-energie krijgt, en wanneer elke batterij levert.",
|
||||
"solar_first": {"description": "Eerst zonne-energie krijgt het overschot vóór de andere batterijen, en dekt het huis op elk uur."},
|
||||
@@ -230,8 +231,8 @@
|
||||
"zero_held": "Op 0 W gehouden",
|
||||
"zero_going": "Gaat naar 0 W",
|
||||
"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", "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.", "warning": "Zie Vraagt aandacht hieronder."},
|
||||
"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."},
|
||||
"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.",
|
||||
@@ -344,6 +345,9 @@
|
||||
},
|
||||
"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_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.",
|
||||
"no_prices_since_day": "Geen prijzen sinds {day} {month} om {at}, onderhoud wacht.",
|
||||
"step_in": "De batterijen springen bij boven {kw}.",
|
||||
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
||||
"period": "Dit kwartier gaat richting {kw}.",
|
||||
@@ -354,6 +358,7 @@
|
||||
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
|
||||
"control": {"shadow": "Schaduwmodus: elke omvormer blijft op 0 W.", "running": "De regeling is actief.", "stopped": "De regeling is gestopt. Elke omvormer staat op 0 W."},
|
||||
"attn": {
|
||||
"home_assistant": "Home Assistant antwoordt niet",
|
||||
"controller": "Regeling is niet actief",
|
||||
"meter": "Meting van de meter",
|
||||
"meter_frozen": "Meter staat mogelijk vast",
|
||||
@@ -372,6 +377,7 @@
|
||||
"delivery": "Een omvormer levert niet",
|
||||
"estop": "Noodstop",
|
||||
"pv": "Meting van de zonnepanelen",
|
||||
"solar_forecast": "Zonneprognose",
|
||||
"none": "Verbinding met de omvormer"
|
||||
},
|
||||
"months": ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"]
|
||||
|
||||
@@ -135,6 +135,10 @@ GROUPS = (
|
||||
Field("pv_forecast_entity", ENTITY, kind="energy", simple=True,
|
||||
note="forecast"),
|
||||
Field("pv_forecast_tomorrow_entity", ENTITY, kind="energy"),
|
||||
# OPT-17: several forecast sensors per day, summed.
|
||||
Field("pv_forecast_entities", ENTITY_LIST, kind="energy"),
|
||||
Field("pv_forecast_tomorrow_entities", ENTITY_LIST,
|
||||
kind="energy"),
|
||||
Field("solar_trust_pct", SLIDER, "%", step=5),
|
||||
)),
|
||||
)),
|
||||
@@ -293,7 +297,9 @@ DERIVED = {
|
||||
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
|
||||
}
|
||||
# Option keys the page never shows and never writes: passed through as stored.
|
||||
HIDDEN: dict = {}
|
||||
# PRICE-01: the price service token is a secret. It is set in the Configuration
|
||||
# tab (a password field there) and this page never draws it.
|
||||
HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"}
|
||||
|
||||
|
||||
def fields():
|
||||
|
||||
@@ -12,14 +12,17 @@ import copy
|
||||
import os
|
||||
import re
|
||||
|
||||
from . import messages
|
||||
from . import uilayout as L
|
||||
from . import uitext
|
||||
from . import uimodel as U
|
||||
from .configui import help_of, label_of, problems_by_field, split_group
|
||||
from .validate import power_request_entities
|
||||
|
||||
# A choice the page cannot make, only keep. None since P5's period editor.
|
||||
ONLY_IF_HELD = ()
|
||||
# A choice the page cannot make, only keep. `price_service` needs a token, and
|
||||
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
|
||||
# the Configuration tab and the page only keeps it (PRICE-01).
|
||||
ONLY_IF_HELD = ("price_service",)
|
||||
_TIERS = ("bad", "warn", "info")
|
||||
|
||||
|
||||
@@ -245,7 +248,7 @@ def derive_messages(errors, problems, notes, model, keys, lang="en"):
|
||||
kept = [p for p in problems if p in kept]
|
||||
notes = [n for n in notes if not set(n.keys or ()) & failed]
|
||||
return ([derive_message(err, lang) for err in errors]
|
||||
+ anchors(kept, notes, model, keys))
|
||||
+ anchors(kept, notes, model, keys, lang))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -290,9 +293,10 @@ def targets(key: str, text: str, model: dict) -> list:
|
||||
return [(None, None)]
|
||||
|
||||
|
||||
def anchors(problems, notes, model, keys) -> list:
|
||||
def anchors(problems, notes, model, keys, lang="en") -> list:
|
||||
"""[{field, card, tier, text}] for the refusals and notes. `field` is
|
||||
None for a sentence that names no setting on the page."""
|
||||
None for a sentence that names no setting on the page. A note with a Dutch
|
||||
twin in `messages.NOTE_NL` is shown in Dutch when `lang` is "nl"."""
|
||||
out, seen = [], set()
|
||||
|
||||
def add(field, card, tier, text):
|
||||
@@ -310,10 +314,11 @@ def anchors(problems, notes, model, keys) -> list:
|
||||
add(None, None, "bad", text)
|
||||
for note in notes:
|
||||
tier = note.tier if note.tier in _TIERS else "info"
|
||||
text = messages.NOTE_NL.get(note.text, note.text) if lang == "nl" else note.text
|
||||
for key in note.keys or (None,):
|
||||
for field, card in (targets(key, note.text, model) if key
|
||||
else [(None, None)]):
|
||||
add(field, card, tier, note.text)
|
||||
add(field, card, tier, text)
|
||||
# A sentence placed on one inverter's field is not also put on the list.
|
||||
roster = _roster()
|
||||
placed = {m["text"] for m in out
|
||||
|
||||
@@ -1659,6 +1659,12 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
f"{S('static_profile')} is empty, so the site has no tariff "
|
||||
"at all while the price feed is down.")
|
||||
|
||||
# PRICE-01: not a fault, so an info note and no log line here (the add-on
|
||||
# logs it once at start). With `price_service` the fixed tariff is only the
|
||||
# plan's last fallback; the cheap window and the gate do not read it.
|
||||
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
|
||||
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE)
|
||||
|
||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
||||
# rather than logged, because a strategy that is armed in the UI and cannot
|
||||
# fire is indistinguishable from one that fires and finds no opportunity -
|
||||
@@ -1706,6 +1712,26 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
|
||||
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
|
||||
"choose 'off'.")
|
||||
|
||||
# PRICE-01. The price service answers only to its token. Without one every
|
||||
# fetch is refused and the add-on lives on its fallbacks, which the
|
||||
# operator would not see: a source they chose, overridden by a layer they
|
||||
# cannot see. A token the server cannot read as a header is the same case.
|
||||
if pid == "price_service":
|
||||
token = str(opts.get("price_service_token", "") or "").strip()
|
||||
if not token:
|
||||
problems.append(
|
||||
f"{S('price_provider')} is 'price_service', but "
|
||||
f"{S('price_service_token')} is empty. Set the token, or "
|
||||
"choose another source.")
|
||||
elif any(c.isspace() or not c.isprintable() for c in token):
|
||||
problems.append(
|
||||
f"{S('price_service_token')} has a space or a line break in "
|
||||
"it. Paste the token again, with nothing around it.")
|
||||
elif len(token) < PRICE_TOKEN_MIN:
|
||||
problems.append(
|
||||
f"{S('price_service_token')} is too short. Paste the whole "
|
||||
"token.")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
@@ -1762,6 +1788,11 @@ def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
|
||||
return out
|
||||
|
||||
|
||||
# PRICE-01 F5. A shorter value is not a token; it also could not be scrubbed
|
||||
# from a log (price.scrub skips values under 4 characters).
|
||||
PRICE_TOKEN_MIN = 16
|
||||
|
||||
|
||||
def config_notes(opts: dict, *, entities: dict | None = None,
|
||||
month_peak_w=None) -> list[Note]:
|
||||
"""UX-13: warnings that need live data, for the settings page. Never a
|
||||
@@ -1802,12 +1833,13 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
|
||||
_advise(notes, "warn", (key,),
|
||||
f"{entity} does not read in kWh, so pick a solar "
|
||||
"forecast sensor in kWh.")
|
||||
if any(opts.get(k) for k in FORECAST_KEYS) and \
|
||||
today = solarfc.entities(opts)
|
||||
tomorrow = solarfc.entities(opts, tomorrow=True)
|
||||
if (today or tomorrow) and \
|
||||
opts.get("battery_modes_enabled") is not True:
|
||||
_advise(notes, "info", ("pv_forecast_entity",),
|
||||
"The solar forecast is used only with battery modes on.")
|
||||
if opts.get("pv_forecast_tomorrow_entity") and \
|
||||
not opts.get("pv_forecast_entity"):
|
||||
if tomorrow and not today:
|
||||
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
|
||||
"Tomorrow's forecast is used only with today's, so set "
|
||||
f"{S('pv_forecast_entity')} too.")
|
||||
@@ -2069,12 +2101,13 @@ def _margin_problems(opts: dict) -> list[str]:
|
||||
TRUST_RANGE_PCT = (30, 100)
|
||||
# A Home Assistant sensor id; it goes into the /api/states/<id> path.
|
||||
_SENSOR_ID = re.compile(r"sensor\.[a-z0-9_]+")
|
||||
FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
|
||||
FORECAST_KEYS = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS
|
||||
|
||||
|
||||
def _forecast_problems(opts: dict) -> list[str]:
|
||||
"""Rule 13 (OPT-11 P4): the trust is a whole percentage in range, and
|
||||
each forecast entity set is a sensor."""
|
||||
each forecast entity set is a sensor. OPT-17: a day names its forecast
|
||||
in the single key or in the list, not both."""
|
||||
out = []
|
||||
raw = opts.get("solar_trust_pct")
|
||||
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
|
||||
@@ -2082,14 +2115,30 @@ def _forecast_problems(opts: dict) -> list[str]:
|
||||
if trust is None or trust != int(trust) or not lo <= trust <= hi:
|
||||
out.append(f"{S('solar_trust_pct')} must be a whole number from {lo} "
|
||||
f"to {hi} %. It is {messages.shown(raw)}.")
|
||||
for key in FORECAST_KEYS:
|
||||
e = opts.get(key)
|
||||
if e is None or e == "":
|
||||
continue
|
||||
if not isinstance(e, str) or not _SENSOR_ID.fullmatch(e):
|
||||
out.append(f"{S(key)} must be a sensor, like "
|
||||
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
|
||||
e = opts.get(one)
|
||||
if e is not None and e != "" and (
|
||||
not isinstance(e, str) or not _SENSOR_ID.fullmatch(e)):
|
||||
out.append(f"{S(one)} must be a sensor, like "
|
||||
f"sensor.energy_production_today_remaining. It is "
|
||||
f"{messages.shown(e)}.")
|
||||
raw = opts.get(many)
|
||||
if raw is None:
|
||||
raw = []
|
||||
if not isinstance(raw, list):
|
||||
out.append(f"{S(many)} must be a list of sensors. It is "
|
||||
f"{messages.shown(raw)}.")
|
||||
continue
|
||||
listed = [x for x in raw if x is not None
|
||||
and not (isinstance(x, str) and not x.strip())]
|
||||
for x in listed:
|
||||
if not isinstance(x, str) or not _SENSOR_ID.fullmatch(x):
|
||||
out.append(f"{S(many)} must list only sensors, like "
|
||||
f"sensor.energy_production_today_remaining. "
|
||||
f"It has {messages.shown(x)}.")
|
||||
if e and listed:
|
||||
out.append(f"Set {S(one)} or {S(many)}, not both. Keep the "
|
||||
f"list and clear the single one.")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -48,18 +48,21 @@ from datetime import datetime, timezone
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import messages, selfconfig, uipage, uitext
|
||||
from . import messages, price, selfconfig, uipage, uitext
|
||||
from .haentities import EntityCache
|
||||
from .homeview import region_of
|
||||
from .validate import config_notes, setup_checks
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
|
||||
problems_by_field, secret_keys)
|
||||
from .uimodel import DeriveError, options_to_ui
|
||||
|
||||
_LOG = logging.getLogger("goodwe.web")
|
||||
|
||||
# OPT-11: the per-battery actions, which name a battery and a value.
|
||||
PACK_ACTIONS = ("pack_use", "pack_mode", "pack_sell")
|
||||
# MAINT-11: start or stop one battery's maintenance cycle (the unit is optional).
|
||||
MAINT_ACTIONS = ("maint_start", "maint_abort")
|
||||
|
||||
# UX-13 P1: the page lives in app/static/ as three files - index.html,
|
||||
# app.css, app.js - plus the page words from app/ui_text/<lang>.json (English
|
||||
@@ -163,6 +166,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
return web.json_response({"ok": False,
|
||||
"error": result["error"]},
|
||||
status=400)
|
||||
elif what in MAINT_ACTIONS and body.get("unit") is not None:
|
||||
# MAINT-11: one battery's cycle. No unit = the old bare call below.
|
||||
result = await controller.handle_action(what, unit=body["unit"])
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": result["error"],
|
||||
"code": result["code"], "unit": result["unit"]},
|
||||
status=409 if result["code"] != "unit" else 400)
|
||||
else:
|
||||
result = await controller.handle_action(what)
|
||||
if result is None:
|
||||
@@ -199,6 +210,11 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
raise web.HTTPBadRequest(text="expected an options object")
|
||||
return body, options, None
|
||||
|
||||
def _secrets(info):
|
||||
# PRICE-01 F1: every `password` option of the schema, and the price
|
||||
# token by name in case a schema is missing.
|
||||
return secret_keys((info or {}).get("schema"), price.SECRET_OPTIONS)
|
||||
|
||||
async def config_model(request):
|
||||
"""Everything the page needs to draw itself, from ONE Supervisor call.
|
||||
|
||||
@@ -217,13 +233,14 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
_LOG.error("cannot read the add-on's own options: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
options = info.get("options") or {}
|
||||
secrets = _secrets(info)
|
||||
lang = str(request.query.get("lang", "en") or "en")[:8]
|
||||
model = field_model(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang)
|
||||
model.update({
|
||||
"options": options,
|
||||
"options": hide_secrets(options, secrets),
|
||||
"fingerprint": hand_out(options),
|
||||
"running": controller.o,
|
||||
"running": hide_secrets(controller.o, secrets),
|
||||
"running_differs": changed_keys(controller.o, options),
|
||||
"lang": lang,
|
||||
# UX-13 P1: the levies preset and its year, for the P2 Settings
|
||||
@@ -232,7 +249,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
# UX-13 P2: the Installer view draws from these two.
|
||||
"layout": uipage.layout(info.get("schema") or [],
|
||||
info.get("translations") or {}, lang),
|
||||
"ui": options_to_ui(options),
|
||||
"ui": hide_secrets(options_to_ui(options), secrets),
|
||||
# UX-13 P4: the setup wizard, and why it opens by itself.
|
||||
"setup": uipage.setup_spec(),
|
||||
"setup_needed": uipage.setup_needed(
|
||||
@@ -271,6 +288,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
model, options = None, _body.get("options")
|
||||
if not isinstance(options, dict):
|
||||
raise web.HTTPBadRequest(text="expected an options object")
|
||||
try:
|
||||
info_now = await selfcfg.info()
|
||||
options = keep_secrets(options, info_now.get("options"),
|
||||
_secrets(info_now))
|
||||
except Exception: # noqa: BLE001 - a check never fails for this
|
||||
pass
|
||||
notes = []
|
||||
problems, unread = await selfconfig.problems_and_ranges(
|
||||
options, controller.hass, notes=notes)
|
||||
@@ -289,7 +312,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
_keys_of(options),
|
||||
uitext.lang_of(_body.get("lang")))
|
||||
if errors
|
||||
else uipage.anchors(problems, notes, model, _keys_of(options)))
|
||||
else uipage.anchors(problems, notes, model, _keys_of(options),
|
||||
uitext.lang_of(_body.get("lang"))))
|
||||
payload["steps"] = uipage.step_problems(payload["messages"], model)
|
||||
if _body.get("setup"):
|
||||
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
|
||||
@@ -329,9 +353,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="expected JSON")
|
||||
stored_now = None
|
||||
if isinstance(posted, dict) and isinstance(posted.get("model"), dict):
|
||||
secrets = frozenset(price.SECRET_OPTIONS)
|
||||
if isinstance(posted, dict):
|
||||
try:
|
||||
stored_now = (await selfcfg.info()).get("options") or {}
|
||||
info_now = await selfcfg.info()
|
||||
stored_now = info_now.get("options") or {}
|
||||
secrets = _secrets(info_now)
|
||||
except selfconfig.SupervisorUnavailable as err:
|
||||
_LOG.error("saving options failed: %s", err)
|
||||
return web.json_response({"error": str(err)}, status=502)
|
||||
@@ -347,6 +374,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"unanchored": [], "messages": messages_,
|
||||
"steps": uipage.step_problems(messages_, model)},
|
||||
status=422)
|
||||
# PRICE-01 F1: a secret the page did not change is the stored one.
|
||||
options = keep_secrets(options, stored_now, secrets)
|
||||
expect = str(body.get("fingerprint") or "")
|
||||
try:
|
||||
result = await selfconfig.save(selfcfg, options, expect,
|
||||
@@ -364,7 +393,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
||||
"moved": refused.moved,
|
||||
"changed_fields": (uipage.fields_of(moved, model)
|
||||
if model is not None else []),
|
||||
"stored": stored,
|
||||
"stored": hide_secrets(stored, secrets),
|
||||
# The page needs the CURRENT token to be able to save
|
||||
# again after the operator has read the difference. That
|
||||
# is not a force door: it takes a second, deliberate press
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.15.0"
|
||||
version: "0.18.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
@@ -157,6 +157,11 @@ options:
|
||||
# integrations whose sensors fit.
|
||||
pv_forecast_entity: ""
|
||||
pv_forecast_tomorrow_entity: ""
|
||||
# OPT-17: several forecast sensors per day (one per inverter or string),
|
||||
# summed. Use the list OR the single key above, not both. `[]` is
|
||||
# load-bearing, as for `pv_entities`.
|
||||
pv_forecast_entities: []
|
||||
pv_forecast_tomorrow_entities: []
|
||||
solar_trust_pct: 70
|
||||
|
||||
# TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT
|
||||
@@ -551,6 +556,10 @@ options:
|
||||
# naming that consequence on every fetch that uses an override; clearing this
|
||||
# option is what restores the full horizon.
|
||||
price_feed_url: ""
|
||||
# PRICE-01. The bearer token for the owner's price server
|
||||
# (`price_provider: price_service`). A password field. Empty with any other
|
||||
# source. Never printed: not in the log, the status page or a notification.
|
||||
price_service_token: ""
|
||||
price_horizon_h: 48
|
||||
price_timeout_s: 10
|
||||
|
||||
@@ -961,6 +970,13 @@ schema:
|
||||
battery_modes_enabled: bool
|
||||
pv_forecast_entity: str?
|
||||
pv_forecast_tomorrow_entity: str?
|
||||
# OPT-17: new list keys beside the single ones. A list schema on the old
|
||||
# keys refuses the string an install stores (tools/supervisor_shape_probe.py
|
||||
# section J); DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
pv_forecast_entities:
|
||||
- str
|
||||
pv_forecast_tomorrow_entities:
|
||||
- str
|
||||
solar_trust_pct: int(30,100)
|
||||
# TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike
|
||||
# `price_now_entity`/`price_avg_entity`, which are legal only while
|
||||
@@ -1073,7 +1089,7 @@ schema:
|
||||
# this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not
|
||||
# typed - and register() refuses to re-register a retired id, so the two
|
||||
# cannot both claim one.
|
||||
price_provider: list(off|generic_dayahead|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_provider: list(off|generic_dayahead|price_service|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
|
||||
price_markup_eur_kwh: float(0,1)
|
||||
price_taxes_eur_kwh: float(0,1)
|
||||
|
||||
@@ -1107,6 +1123,7 @@ schema:
|
||||
price_formula: str?
|
||||
price_csv_path: str?
|
||||
price_feed_url: str?
|
||||
price_service_token: password?
|
||||
price_horizon_h: int(1,72)
|
||||
price_timeout_s: int(2,60)
|
||||
price_now_entity: str?
|
||||
|
||||
@@ -81,6 +81,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from app import arbitrage as _arb
|
||||
from named_absence import first
|
||||
from app import main as _main
|
||||
from app.price_chain import PriceWatch
|
||||
from app import peakguard as _pg
|
||||
from app.arbiter import (
|
||||
P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY, Claim,
|
||||
@@ -2311,6 +2312,7 @@ class _T12Controller:
|
||||
self.tariff_plan = None
|
||||
self.tariff_at = None
|
||||
self.price_history = NoHistory()
|
||||
self.price_watch = PriceWatch() # PRICE-01: the refresh passes it on
|
||||
self.stop_event = asyncio.Event()
|
||||
self.stop_event.set() # so the inter-refresh wait returns
|
||||
self._ticks = 0
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"""MAINT-11: start or stop the maintenance cycle of ONE battery.
|
||||
`python3 test_maint11.py`
|
||||
|
||||
Graded here, at two and at three batteries: a start on a chosen battery with the
|
||||
slot free (and it is that battery, not the one the bare button would pick); a
|
||||
start with the slot held, in each moving phase, refused by name; an unknown
|
||||
unit; a battery switched off; a battery that refuses to give power; a stop on a
|
||||
battery that is not cycling; a stop on the cycling one; a request with no unit
|
||||
doing what it always did; the web layer's codes; the words in both languages;
|
||||
and a roster of one, whose requests, log and writes are pinned.
|
||||
See DECISIONS/MAINT-11-cycle-per-battery.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web as aioweb
|
||||
|
||||
from app import messages, packmode, web
|
||||
from app.main import Controller
|
||||
from app.maintenance import CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING
|
||||
from app.store import DEFAULTS
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class MemStore:
|
||||
def __init__(self):
|
||||
self.data = dict(DEFAULTS)
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, datetime):
|
||||
return raw
|
||||
parsed = datetime.fromisoformat(raw)
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(timezone.utc).isoformat() if when else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
def __init__(self, n):
|
||||
self.numbers = {"sensor.grid": 5000.0}
|
||||
self.limits_by = {}
|
||||
for k in range(n):
|
||||
self.numbers[f"sensor.soc_{k}"] = 80.0
|
||||
self.numbers[f"sensor.batt_{k}"] = 0.0
|
||||
self.limits_by[f"number.sp_{k}"] = (-5000.0, 5000.0, 1.0)
|
||||
self.writes = []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, value))
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
return True
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
NAMES = ["inverter-a", "inverter-b", "inverter-c"]
|
||||
OPTS = dict(meter_source="off", meter_entity="sensor.grid", gain=0.6,
|
||||
slew_w=6000, deadband_w=15, target_grid_w=0, step_w=10,
|
||||
heartbeat_s=10, stale_input_s=15, maintenance_discharge_w=2000,
|
||||
maintenance_charge_w=2000)
|
||||
|
||||
|
||||
def controller(n, modes=False):
|
||||
opts = dict(OPTS)
|
||||
opts["inverters"] = [
|
||||
dict(name=NAMES[k], setpoint_entity=f"number.sp_{k}",
|
||||
soc_entity=f"sensor.soc_{k}", batt_entity=f"sensor.batt_{k}",
|
||||
max_w=5000.0) for k in range(n)]
|
||||
if n == 1:
|
||||
del opts["inverters"]
|
||||
opts.update(setpoint_entity="number.sp_0", soc_entity="sensor.soc_0",
|
||||
batt_entity="sensor.batt_0", max_w=5000.0)
|
||||
if modes:
|
||||
opts["battery_modes_enabled"] = True
|
||||
hass = Hass(n)
|
||||
c = Controller(opts, hass, MemStore(), Pub(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
def act(c, what, unit=None):
|
||||
if unit is None:
|
||||
return asyncio.run(c.handle_action(what))
|
||||
return asyncio.run(c.handle_action(what, unit=unit))
|
||||
|
||||
|
||||
def phases(c):
|
||||
return [m.phase for m in c.maints]
|
||||
|
||||
|
||||
def snapshot(c, hass):
|
||||
return (phases(c), list(c.events), list(hass.writes),
|
||||
json.dumps(c.store.data, sort_keys=True, default=str))
|
||||
|
||||
|
||||
def settle(c):
|
||||
"""Events carry a clock stamp; compare them without it."""
|
||||
return [e.split(" ", 1)[1] for e in c.events]
|
||||
|
||||
|
||||
for N in (2, 3):
|
||||
print(f"\n{N} batteries")
|
||||
names = NAMES[:N]
|
||||
last = N - 1
|
||||
|
||||
# --- start with the slot free -------------------------------------------
|
||||
c, hass = controller(N)
|
||||
bare_pick = c._maint_button(datetime.now(timezone.utc))
|
||||
check("with every battery never cycled the bare button picks the first "
|
||||
"(this is the card's case)", bare_pick is c.maints[0])
|
||||
r = act(c, "maint_start", names[last])
|
||||
check(f"start on {names[last]}: its cycle starts, nobody else's",
|
||||
r == {"unit": names[last], "phase": DRAIN}
|
||||
and phases(c) == [IDLE] * last + [DRAIN])
|
||||
check("...and the log says a forced start",
|
||||
any("forced start" in e for e in c.events))
|
||||
check("...and the machine holding the slot is the named one",
|
||||
c.maint_index == last)
|
||||
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_start", names[0])
|
||||
check("start on the first battery works the same way",
|
||||
r["unit"] == names[0] and phases(c)[0] == DRAIN
|
||||
and phases(c)[1:] == [IDLE] * last)
|
||||
|
||||
# --- start with the slot held --------------------------------------------
|
||||
for phase in POWER_MOVING:
|
||||
for holder in range(N):
|
||||
c, hass = controller(N)
|
||||
c.maints[holder].store.set("phase", phase)
|
||||
before = snapshot(c, hass)
|
||||
bad = []
|
||||
for k in range(N):
|
||||
r = act(c, "maint_start", names[k])
|
||||
bad.append(not (r.get("code") == "busy"
|
||||
and r["unit"] == names[holder]
|
||||
and names[holder] in r["error"]
|
||||
and "error" in r))
|
||||
check(f"slot held by {names[holder]} in {phase}: a start on any "
|
||||
f"battery (itself too) is refused, naming {names[holder]}, "
|
||||
f"and nothing changes", not any(bad)
|
||||
and snapshot(c, hass) == before)
|
||||
|
||||
# --- unknown unit --------------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
before = snapshot(c, hass)
|
||||
rs = [act(c, w, u) for w in ("maint_start", "maint_abort")
|
||||
for u in ("inverter-z", "", "Inverter-A", 7, ["inverter-a"])]
|
||||
check("an unknown unit (wrong name, empty, wrong case, not a string) is "
|
||||
"refused for start and for stop, and nothing changes",
|
||||
all(r.get("code") == "unit" and r["error"]
|
||||
== messages.maint_refused("unit") for r in rs)
|
||||
and snapshot(c, hass) == before)
|
||||
|
||||
# --- a battery switched off ---------------------------------------------
|
||||
c, hass = controller(N, modes=True)
|
||||
c.inverters[last].store_set(packmode.USE_KEY, False)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_start", names[last])
|
||||
check("a battery switched off (battery modes on) is refused by name and "
|
||||
"nothing changes", r.get("code") == "off" and r["unit"] == names[last]
|
||||
and names[last] in r["error"] and snapshot(c, hass) == before)
|
||||
r = act(c, "maint_start", names[0])
|
||||
check("...while a battery that is on still starts", r.get("phase") == DRAIN
|
||||
and phases(c)[0] == DRAIN)
|
||||
c, hass = controller(N)
|
||||
c.inverters[last].store_set(packmode.USE_KEY, False)
|
||||
check("with battery modes off the stored switch is not consulted (it is "
|
||||
"not shown, so it cannot refuse)",
|
||||
act(c, "maint_start", names[last]).get("phase") == DRAIN)
|
||||
|
||||
# --- a battery that refuses to give power ---------------------------------
|
||||
c, hass = controller(N)
|
||||
c.inverters[last].refused = 1
|
||||
r = act(c, "maint_start", names[last])
|
||||
check("a battery that refuses to give power starts like the bare button "
|
||||
"would start it (its drain then waits, as before)",
|
||||
r == {"unit": names[last], "phase": DRAIN})
|
||||
|
||||
# --- stop ---------------------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_abort", names[0])
|
||||
check("a stop on a battery that is not cycling is refused and nothing "
|
||||
"changes (no 0 W written either)", r.get("code") == "idle"
|
||||
and names[0] in r["error"] and snapshot(c, hass) == before)
|
||||
c.maints[last].store.set("phase", CHARGE)
|
||||
before = snapshot(c, hass)
|
||||
r = act(c, "maint_abort", names[0])
|
||||
check(f"a stop on {names[0]} while {names[last]} cycles is refused, and "
|
||||
f"{names[last]}'s cycle is not touched", r.get("code") == "idle"
|
||||
and phases(c)[last] == CHARGE and snapshot(c, hass) == before)
|
||||
|
||||
for phase in POWER_MOVING:
|
||||
c, hass = controller(N)
|
||||
c.maints[last].store.set("phase", phase)
|
||||
r = act(c, "maint_abort", names[last])
|
||||
check(f"a stop on the cycling battery ({phase}) ends its cycle, "
|
||||
f"logs the abort and sets every inverter to 0 W",
|
||||
r == {"unit": names[last], "phase": IDLE}
|
||||
and phases(c) == [IDLE] * N
|
||||
and any("ABORT (operator)" in e for e in c.events)
|
||||
and {w for w in hass.writes}
|
||||
== {(f"number.sp_{k}", 0.0) for k in range(N)}
|
||||
and all(i.target == 0.0 for i in c.inverters))
|
||||
|
||||
# --- the old request ---------------------------------------------------
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_start")
|
||||
check("a start with no unit is the old call: the bare button's pick, and "
|
||||
"the old answer", r == {"phase": DRAIN} and phases(c)[0] == DRAIN
|
||||
and any("forced start" in e for e in c.events))
|
||||
r = act(c, "maint_start")
|
||||
check("...a second bare start re-enters the drain of the cycling battery, "
|
||||
"as it always did", r == {"phase": DRAIN} and phases(c)[0] == DRAIN)
|
||||
c.maints[last].store.set("phase", IDLE)
|
||||
r = act(c, "maint_abort")
|
||||
check("...and a bare stop stops the cycling battery and answers as before",
|
||||
r == {"phase": IDLE} and phases(c) == [IDLE] * N
|
||||
and any("ABORT (operator)" in e for e in c.events))
|
||||
c, hass = controller(N)
|
||||
r = act(c, "maint_abort")
|
||||
check("...a bare stop with nothing cycling is still accepted, as before",
|
||||
r == {"phase": IDLE} and hass.writes
|
||||
and {w[1] for w in hass.writes} == {0.0})
|
||||
|
||||
# --- the web layer ----------------------------------------------------------
|
||||
print("\nthe web layer")
|
||||
|
||||
|
||||
async def serve(c, calls):
|
||||
runner = aioweb.AppRunner(web.build_app(c, object()))
|
||||
await runner.setup()
|
||||
sock = socket.socket()
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
sock.close()
|
||||
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
|
||||
out = []
|
||||
try:
|
||||
async with aiohttp.ClientSession() as s:
|
||||
for body in calls:
|
||||
async with s.post(f"http://127.0.0.1:{port}/action",
|
||||
json=body) as r:
|
||||
out.append((r.status, await r.json(content_type=None)))
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
c, hass = controller(2)
|
||||
rs = asyncio.run(serve(c, [
|
||||
{"action": "maint_start", "unit": "inverter-b"},
|
||||
{"action": "maint_start", "unit": "inverter-a"},
|
||||
{"action": "maint_abort", "unit": "inverter-a"},
|
||||
{"action": "maint_start", "unit": "nope"},
|
||||
{"action": "maint_abort", "unit": "inverter-b"},
|
||||
{"action": "maint_start", "unit": None},
|
||||
{"action": "maint_abort"},
|
||||
{"action": "maint_start", "unit": ""},
|
||||
]))
|
||||
check("start with a unit: 200 and the unit's answer",
|
||||
rs[0] == (200, {"ok": True, "result": {"unit": "inverter-b",
|
||||
"phase": DRAIN}}))
|
||||
check("start while the slot is held: 409, a code, the cycling battery, the "
|
||||
"plain sentence", rs[1][0] == 409 and rs[1][1] == {
|
||||
"ok": False, "code": "busy", "unit": "inverter-b",
|
||||
"error": messages.maint_refused("busy", "inverter-b")})
|
||||
check("stop on a battery that is not cycling: 409 with the sentence",
|
||||
rs[2][0] == 409 and rs[2][1]["code"] == "idle"
|
||||
and rs[2][1]["error"] == messages.maint_refused("idle", "inverter-a"))
|
||||
check("unknown unit: 400 with the sentence", rs[3][0] == 400
|
||||
and rs[3][1]["code"] == "unit"
|
||||
and rs[3][1]["error"] == messages.maint_refused("unit"))
|
||||
check("stop on the cycling battery: 200", rs[4] == (
|
||||
200, {"ok": True, "result": {"unit": "inverter-b", "phase": IDLE}}))
|
||||
check("a null unit is the bare call: 200 and the old answer",
|
||||
rs[5] == (200, {"ok": True, "result": {"phase": DRAIN}}))
|
||||
check("a request with no unit: 200 and the old answer", rs[6] == (
|
||||
200, {"ok": True, "result": {"phase": IDLE}}))
|
||||
check("an empty unit is refused (400), not taken as the bare call",
|
||||
rs[7][0] == 400 and rs[7][1]["code"] == "unit"
|
||||
and phases(c) == [IDLE, IDLE])
|
||||
|
||||
# --- the words --------------------------------------------------------------
|
||||
print("\nthe words")
|
||||
words = {}
|
||||
for lang in ("en", "nl"):
|
||||
with open(os.path.join(HERE, "app", "ui_text", f"{lang}.json"),
|
||||
encoding="utf-8") as f:
|
||||
words[lang] = json.load(f)["home"]["batteries"]["cycle"]
|
||||
for code, sentence in messages.MAINT_REFUSED.items():
|
||||
en = words["en"][code]["description"]
|
||||
check(f"'{code}': the page's English is the API's sentence",
|
||||
en == sentence.replace("{unit}", "{name}"))
|
||||
nl = words["nl"][code]["description"]
|
||||
check(f"'{code}': the Dutch has the same placeholders",
|
||||
sorted(re.findall(r"\{\w+\}", nl))
|
||||
== sorted(re.findall(r"\{\w+\}", en)) and nl != en)
|
||||
check("both languages carry the same keys",
|
||||
json.dumps(words["en"], sort_keys=True).count('":')
|
||||
== json.dumps(words["nl"], sort_keys=True).count('":')
|
||||
and set(words["en"]) == set(words["nl"]))
|
||||
check("every code the server can answer has a page sentence",
|
||||
set(messages.MAINT_REFUSED) <= set(words["en"]))
|
||||
|
||||
# --- the page ----------------------------------------------------------------
|
||||
print("\nthe page")
|
||||
JS = open(os.path.join(HERE, "app", "static", "app.js"),
|
||||
encoding="utf-8").read()
|
||||
check("the buttons are drawn only with two or more batteries",
|
||||
"S.batteries.length > 1 ? cycleButtons(b, i) : null" in JS)
|
||||
check("both buttons post the battery's name and ask first",
|
||||
'JSON.stringify({ action: what, unit: b.name }) });' in JS and 'ask1("start_dialog", "maint_start")' in JS
|
||||
and 'ask1("abort_dialog", "maint_abort")' in JS)
|
||||
check("a refusal is shown in the page's language, by its code",
|
||||
'const own = j.code && t("batteries.cycle." + j.code);' in JS
|
||||
and 'toast(own ? say(own.description' in JS)
|
||||
check("Stop is disabled on a battery that is not cycling",
|
||||
'abort: cycling(b) };' in JS and "disabled: !can.abort" in JS)
|
||||
check("Start is disabled while any battery cycles",
|
||||
"start: !S.batteries.some(cycling) && " in JS
|
||||
and "disabled: !can.start" in JS)
|
||||
check("Start is disabled on a switched-off battery (use false, modes on)",
|
||||
"&& b.use !== false, abort" in JS)
|
||||
check("each button carries its own flag, and only that flag",
|
||||
'id: "bcs-" + i, disabled: !can.start, onclick: ask1("start_dialog"' in JS
|
||||
and 'id: "bca-" + i, disabled: !can.abort, onclick: ask1("abort_dialog"' in JS)
|
||||
check("a battery counts as cycling when its phase is not idle",
|
||||
'cycling = (x) => x.maintenance !== "idle"' in JS)
|
||||
check("the old header buttons still post a bare request",
|
||||
'ask("maint_start", () => simple("maint_start"))' in JS
|
||||
and "body: JSON.stringify({ action: what }) });" in JS)
|
||||
|
||||
# --- a roster of one -----------------------------------------------------------
|
||||
print("\none battery: the requests, the log and the wire stay as they were")
|
||||
c, hass = controller(1)
|
||||
r1 = act(c, "maint_start")
|
||||
r2 = act(c, "maint_start")
|
||||
r3 = act(c, "maint_abort")
|
||||
r4 = act(c, "maint_abort")
|
||||
check("answers", [r1, r2, r3, r4] == [{"phase": DRAIN}, {"phase": DRAIN},
|
||||
{"phase": IDLE}, {"phase": IDLE}])
|
||||
check("log lines (stamp removed), newest first", settle(c) == [
|
||||
"maintenance: ABORT (operator)", "maintenance: ABORT (operator)",
|
||||
"maintenance: forced start -> drain",
|
||||
"maintenance: forced start -> drain"][:len(settle(c))]
|
||||
and len(settle(c)) == 4)
|
||||
check("wire: two stops, each 0 W", hass.writes == [("number.sp_0", 0.0)] * 2)
|
||||
c, hass = controller(1)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,574 @@
|
||||
"""MAINT-13: a full pack that stops at 98-99 % is not a failed charge.
|
||||
`python3 test_maint13.py`
|
||||
|
||||
Graded here: the stall rule, the near-full timeout rule, the 7 day back-off
|
||||
after a failed cycle (and what ends it), the timeout counting only the minutes
|
||||
the price gate was open, restart and old-state-file behaviour, the notification
|
||||
(once), and the Controller wiring at one and two packs.
|
||||
See DECISIONS/MAINT-13-full-pack-is-complete.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from app import messages
|
||||
from app.maintenance import (
|
||||
CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_STALL_MIN, CHARGE_TIMEOUT_MIN,
|
||||
CHARGE_WALL_TIMEOUT_MIN, DRAIN, FAIL_BACKOFF_DAYS, HOLD, IDLE,
|
||||
MaintConfig, Maintenance, arbitrate,
|
||||
)
|
||||
from app.main import Controller
|
||||
from app.store import (DEFAULTS, MAINT_UNIT_KEYS, Store, migrate_maint_keys,
|
||||
unit_key)
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
# The live site: target 100, shipped hold. Start hour 19 UTC for the schedule.
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC) # B's drain ended (live)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
"""Store's surface, no disk; times kept as the real Store keeps them."""
|
||||
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_charge(store=None, cfg=CFG, at=T0):
|
||||
"""A pack that has just finished its drain and is charging at `at`."""
|
||||
m = Maintenance(cfg, store or Mem())
|
||||
m.force_start(at)
|
||||
m.tick(at, 11.0) # drain complete -> charge
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` minutes for `minutes`; `soc` is a number or f(minute).
|
||||
Returns (the last result, the time after the run, the first non-CHARGE result)."""
|
||||
t, res, out = start, None, None
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
if out is None and res.phase != CHARGE:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out
|
||||
|
||||
|
||||
print("1. the stall rule: near full and no longer taking charge")
|
||||
# Live record: 99 % at 04:00, back to 98 % at 04:15, never 100.
|
||||
m = in_charge()
|
||||
res, end, out = run(m, T0 + 60 * MIN, 400, lambda i: 99.0 if i < 15 else 98.0)
|
||||
check("a pack that reaches 99 and falls back to 98 completes (HOLD) after "
|
||||
f"{CHARGE_STALL_MIN} min without a new high",
|
||||
out is not None and out[0].phase == HOLD)
|
||||
check("...at 60 min after the last new high (the 99), not before",
|
||||
out is not None and out[1] == T0 + 120 * MIN)
|
||||
check("...with one plain log line",
|
||||
out is not None and len(out[0].events) == 1
|
||||
and "takes no more charge at 98 %" in out[0].events[0])
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 59, 98.0)
|
||||
check("59 minutes flat is not enough", out is None and res.phase == CHARGE)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 61, lambda i: 97.0)
|
||||
check("target - 3 (97 of 100) counts as near full", out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 300, lambda i: 96.0)
|
||||
check("target - 4 (96 of 100) does not complete however long it is flat",
|
||||
out is None)
|
||||
cfg99 = MaintConfig(enabled=True, start_hour=19, soc_target=99, hold_min=120)
|
||||
m = in_charge(cfg=cfg99)
|
||||
res, _, out = run(m, T0, 61, lambda i: 96.0)
|
||||
check("the shipped target 99 works the same way (96 is near full)",
|
||||
out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 400, lambda i: min(99.0, 94.0 + i // 30))
|
||||
check("a pack still rising 1 point per 30 min is not stalled: it completes 60 "
|
||||
"min after the 99 at minute 150", out and out[1] == T0 + 210 * MIN)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 130, lambda i: 97.0 if i < 50 else (98.0 if i < 100 else 98.0))
|
||||
check("a new high resets the wait: 97 flat for 50 min, 98 at 50 min -> "
|
||||
"completes at 110, not 60", out and out[1] == T0 + 110 * MIN)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, 300, lambda i: 97.0, gate=False)
|
||||
check("minutes with the gate closed do not count as 'no more charge'",
|
||||
out is None)
|
||||
m = in_charge()
|
||||
_, t, _ = run(m, T0, 30, 98.0)
|
||||
_, t, _ = run(m, t, 120, 98.0, gate=False)
|
||||
res, _, out = run(m, t, 31, 98.0)
|
||||
check("30 open + 120 closed + 31 open minutes complete (the closed ones "
|
||||
"pause the count)", out and out[0].phase == HOLD)
|
||||
|
||||
print("2. SoC at the target completes at once")
|
||||
m = in_charge()
|
||||
r = m.tick(T0 + MIN, 100.0)
|
||||
check("100 of 100 -> HOLD on that tick", r.phase == HOLD)
|
||||
check("...with the old log line", "charge complete at 100 %" in r.events[0])
|
||||
m = in_charge(cfg=cfg99)
|
||||
check("99 of 99 -> HOLD", m.tick(T0 + MIN, 99.0).phase == HOLD)
|
||||
|
||||
print("3. a charge that runs out of time")
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, CHARGE_TIMEOUT_MIN + 10,
|
||||
lambda i: 60.0 + i / 55.0, step=1)
|
||||
check("still rising 1 point per 55 min (never 60 flat) and near full when the "
|
||||
"time is up -> counts as complete", out and out[0].phase == HOLD)
|
||||
check("...with one plain line saying so",
|
||||
out and len(out[0].events) == 1 and "charge time is up" in out[0].events[0]
|
||||
and "counts as done" in out[0].events[0])
|
||||
check("...and no failure is recorded", m.store.get_time("last_failed") is None)
|
||||
m = in_charge()
|
||||
res, _, out = run(m, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
check("a pack stuck at 70 % fails when the time is up",
|
||||
out and out[0].phase == IDLE and out[0].failed == "charge")
|
||||
check("...once: the failure is on one tick, and the pack is idle after",
|
||||
m.phase == IDLE and m.tick(out[1] + MIN, 70.0).failed == "")
|
||||
check("...with the old technical line and one plain line",
|
||||
len(out[0].events) == 2 and "ABORT" in out[0].events[0]
|
||||
and "did not finish" in out[0].events[1]
|
||||
and f"{FAIL_BACKOFF_DAYS} days" in out[0].events[1])
|
||||
check("...and last_failed is set, last_completed is not",
|
||||
m.store.get_time("last_failed") == out[1]
|
||||
and m.store.get_time("last_completed") is None)
|
||||
|
||||
print("4. the back-off after a failed cycle")
|
||||
fail_at = out[1]
|
||||
day = lambda n: fail_at.replace(hour=19, minute=0) + timedelta(days=n)
|
||||
check("the next day at the start hour: no cycle (it would begin with a drain)",
|
||||
not m.wants_start(day(1)) and m.tick(day(1), 70.0).phase == IDLE)
|
||||
check("every day for 6 days: still no cycle",
|
||||
all(not m.wants_start(day(n)) for n in range(1, 7)))
|
||||
r = [m.tick(day(n), 70.0) for n in range(1, 7)]
|
||||
check("...and tick() keeps the pack idle and silent",
|
||||
all(x.phase == IDLE and not x.events and x.failed == "" for x in r))
|
||||
until = m.backoff_until(day(1))
|
||||
check("backoff_until() names the date, 7 days after the failure",
|
||||
until == fail_at + timedelta(days=FAIL_BACKOFF_DAYS))
|
||||
check("...it holds one minute before and is over at that minute",
|
||||
m.backoff_until(until - MIN) == until and m.backoff_until(until) is None)
|
||||
check("at 7 days, in the start hour, the pack wants to start again",
|
||||
m.backoff_until(until) is None
|
||||
and m.wants_start(until.replace(hour=19, minute=5)))
|
||||
check("a forced start ignores the back-off",
|
||||
m.force_start(day(1)) and m.phase == DRAIN)
|
||||
m2 = in_charge()
|
||||
run(m2, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
m2.force_start(day(2))
|
||||
m2.tick(day(2) + MIN, 11.0)
|
||||
res, _, out2 = run(m2, day(2) + 2 * MIN, 400, 100.0)
|
||||
check("a forced cycle that completes ends the back-off",
|
||||
out2 and out2[0].phase == HOLD)
|
||||
m2.tick(out2[1] + 130 * MIN, 100.0)
|
||||
check("...(the hold completes, last_completed is set)",
|
||||
m2.store.get_time("last_completed") is not None
|
||||
and m2.backoff_until(day(3)) is None)
|
||||
m3 = in_charge()
|
||||
m3.store.set_time("last_failed", T0)
|
||||
m3.abort(T0 + MIN, "operator")
|
||||
check("an operator abort is not a failure and starts no back-off",
|
||||
m3.store.get_time("last_failed") == T0
|
||||
and in_charge().store.get_time("last_failed") is None)
|
||||
m4 = Maintenance(CFG, Mem())
|
||||
m4.force_start(T0)
|
||||
r = m4.tick(T0 + timedelta(minutes=721), 40.0)
|
||||
check("a drain that times out is a failure too, with the same back-off",
|
||||
r.failed == "drain" and m4.store.get_time("last_failed") is not None
|
||||
and not m4.wants_start(day(1)))
|
||||
|
||||
print("5. only the minutes the pack was allowed to charge count")
|
||||
m = in_charge()
|
||||
# 5 min steps: one tick counts at most 5 min, so this is the same count as
|
||||
# 1 min steps in a fifth of the ticks.
|
||||
res, t, out = run(m, T0, CHARGE_TIMEOUT_MIN * 2, 70.0, gate=False, step=5)
|
||||
check("72 h with the gate closed: not timed out", out is None and m.phase == CHARGE)
|
||||
res, t, out = run(m, t, CHARGE_TIMEOUT_MIN - 5, 70.0, step=5)
|
||||
check("...and 35 h 55 min open on top: still charging", out is None)
|
||||
res, t, out = run(m, t, 10, 70.0)
|
||||
check("...36 h of open time in all: the charge fails", out and out[0].failed == "charge")
|
||||
m = in_charge()
|
||||
WALL = T0 + CHARGE_WALL_TIMEOUT_MIN * MIN
|
||||
res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 70.0, gate=False, step=5)
|
||||
check("a gate that never opens still ends in a failure after 7 days (not before)",
|
||||
out and out[0].failed == "charge" and WALL < out[1] <= WALL + 5 * MIN)
|
||||
m = in_charge()
|
||||
res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 98.0, gate=False, step=5)
|
||||
check("...or in a complete charge when the pack is near full",
|
||||
out and out[0].phase == HOLD and WALL < out[1] <= WALL + 5 * MIN)
|
||||
m = in_charge()
|
||||
res, t, out = run(m, T0, 10, 70.0)
|
||||
before = m._trk["open_min"]
|
||||
res = m.tick(t + 2 * timedelta(hours=1), 70.0)
|
||||
check("a gap in the ticks (add-on down) counts for exactly 5 minutes (the cap)",
|
||||
abs(m._trk["open_min"] - before - 5.0) < 1e-9)
|
||||
m.tick(t + 2 * timedelta(hours=1) + 2 * MIN, 70.0)
|
||||
check("...and a 2 minute step counts 2", abs(m._trk["open_min"] - before - 7.0) < 1e-9)
|
||||
_t = t + 2 * timedelta(hours=1) + 2 * MIN
|
||||
m.tick(_t - 10 * MIN, 70.0) # the clock steps back 10 min
|
||||
check("...and a step back in time counts nothing (not minus)",
|
||||
abs(m._trk["open_min"] - before - 7.0) < 1e-9)
|
||||
|
||||
print("6. restarts and old state files")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
st = Store(path)
|
||||
m = Maintenance(CFG, st)
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
_, t, out = run(m, T0, 60, 70.0, gate=False) # 60 min waiting
|
||||
_, t, out = run(m, t, 40, 70.0) # 40 min charging
|
||||
open_before = m._trk["open_min"]
|
||||
# a fresh process on the same file
|
||||
m_b = Maintenance(CFG, Store(path))
|
||||
r = m_b.tick(t + MIN, 70.0)
|
||||
check("a restart mid-charge keeps the open minutes (40 of 100 min; within "
|
||||
"the 5 min save interval), not the 100 wall-clock minutes",
|
||||
m_b.phase == CHARGE
|
||||
and open_before - 6 <= m_b._trk["open_min"] <= open_before + 2
|
||||
and m_b._trk["open_min"] < 60)
|
||||
_, t2, out = run(m_b, t + 2 * MIN, CHARGE_TIMEOUT_MIN - 30, 70.0, step=5)
|
||||
check("...so the timeout comes after 36 h in all, not 36 h from the restart",
|
||||
out is not None and out[0].failed == "charge")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.store.set_time("last_failed", T0)
|
||||
m_c = Maintenance(CFG, Store(path))
|
||||
check("a restart mid back-off keeps it (last_failed is in the file)",
|
||||
m_c.backoff_until(T0 + timedelta(days=3)) == T0 + timedelta(days=7)
|
||||
and not m_c.wants_start((T0 + timedelta(days=3)).replace(hour=19)))
|
||||
check("...and the state file is plain JSON",
|
||||
isinstance(json.load(open(path)).get("last_failed"), str))
|
||||
old = {"phase": "charge",
|
||||
"phase_started": (T0 - timedelta(hours=35)).isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None,
|
||||
"auto": True}
|
||||
path2 = os.path.join(d, "old.json")
|
||||
json.dump(old, open(path2, "w"))
|
||||
mo = Maintenance(CFG, Store(path2))
|
||||
check("a state file from before this change loads with no failure and no "
|
||||
"charge record", mo.backoff_until(T0) is None
|
||||
and mo.store.get("charge_track") is None and mo.phase == CHARGE)
|
||||
r = mo.tick(T0, 70.0)
|
||||
check("...a charge in progress counts the hours it already ran (35 h)",
|
||||
r.phase == CHARGE and 34 * 60 < mo._trk["open_min"] < 36 * 60)
|
||||
res, _, out = run(mo, T0 + MIN, 120, 70.0)
|
||||
check("...so it fails when the 36 h are up, as before",
|
||||
out and out[0].failed == "charge")
|
||||
# A record left by another charge (an add-on rolled back and forward
|
||||
# again does not clear it) must not be read as this charge's.
|
||||
stale = {"phase": "charge",
|
||||
"phase_started": (T0 - timedelta(hours=2)).isoformat(),
|
||||
"charge_track": {"since": (T0 - timedelta(days=30)).isoformat(),
|
||||
"open_min": 2000.0, "flat_min": 59.0,
|
||||
"best": 99.0, "at": None}}
|
||||
ms = Maintenance(CFG, Mem(stale))
|
||||
r = ms.tick(T0, 98.0)
|
||||
check("a charge record from another charge is ignored: this charge counts "
|
||||
"its own 2 h and its own best",
|
||||
r.phase == CHARGE and 119 < ms._trk["open_min"] < 121
|
||||
and ms._trk["flat_min"] == 0.0 and ms._trk["best"] == 98.0)
|
||||
mi = Maintenance(CFG, Store(os.path.join(d, "none.json")))
|
||||
check("no state file: due, no back-off", mi.due(T0) and mi.backoff_until(T0) is None)
|
||||
|
||||
print("7. two packs (N=2)")
|
||||
a, b = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"), \
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b")
|
||||
b.force_start(T0)
|
||||
b.tick(T0, 11.0)
|
||||
_, t, out = run(b, T0, CHARGE_TIMEOUT_MIN + 10, 70.0)
|
||||
slot = datetime(2026, 10, 7, 19, 5, tzinfo=UTC)
|
||||
check("B failed: B is backed off, A (due) is not",
|
||||
out and out[0].failed == "charge" and not b.wants_start(slot)
|
||||
and a.wants_start(slot))
|
||||
check("...so A takes the slot, and B is not named as waiting for anyone",
|
||||
arbitrate([a, b], slot) == ["", ""])
|
||||
a.force_start(slot)
|
||||
check("...and while A cycles, B is held as before",
|
||||
"inverter-a" in arbitrate([a, b], slot + 3 * MIN)[1])
|
||||
check("B's failure is under B's own keys, not A's",
|
||||
b.store.get_time("last_failed") is not None
|
||||
and a.store.get_time("last_failed") is None)
|
||||
mst = Mem({"last_failed": T0.isoformat(), "phase": "idle"})
|
||||
migrate_maint_keys(mst, "inverter-a", ["inverter-a", "inverter-b"])
|
||||
check("going from one pack to two moves the back-off to the first pack",
|
||||
mst.data.get(unit_key("inverter-a", "last_failed")) == T0.isoformat()
|
||||
and "last_failed" in MAINT_UNIT_KEYS)
|
||||
|
||||
|
||||
print("8. the Controller: gate, notification, status row")
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, numbers, limits):
|
||||
self.numbers, self.limits_by, self.calls = dict(numbers), dict(limits), []
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
v = self.numbers.get(entity)
|
||||
return None if v is None else (-v if invert else v)
|
||||
|
||||
async def limits(self, entity):
|
||||
return self.limits_by.get(entity, (None, None, None))
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
return True
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
self.calls.append((domain, service, data))
|
||||
return True
|
||||
|
||||
|
||||
class Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *a, **k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
OPTS1 = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
|
||||
batt_entity="sensor.batt_a", meter_source="off",
|
||||
meter_entity="sensor.grid", max_w=5000.0, gain=0.6, slew_w=6000,
|
||||
deadband_w=15, target_grid_w=0, step_w=10, heartbeat_s=10,
|
||||
stale_input_s=15, maintenance_discharge_w=2000,
|
||||
maintenance_charge_w=2000, maintenance_enabled=True,
|
||||
maintenance_soc_target=100,
|
||||
price_now_entity="sensor.p_now", price_avg_entity="sensor.p_avg")
|
||||
OPTS2 = {k: v for k, v in OPTS1.items()
|
||||
if k not in ("setpoint_entity", "soc_entity", "batt_entity")}
|
||||
OPTS2["inverters"] = [
|
||||
dict(name="inverter-a", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a", max_w=5000.0),
|
||||
dict(name="inverter-b", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b", max_w=3000.0)]
|
||||
|
||||
|
||||
def controller(opts, cheap, soc_a=70.0, soc_b=70.0):
|
||||
p_now, p_avg = (0.05, 0.10) if cheap else (0.20, 0.10)
|
||||
hass = Hass({"sensor.grid": 800.0, "sensor.soc_a": soc_a,
|
||||
"sensor.batt_a": 0.0, "sensor.soc_b": soc_b,
|
||||
"sensor.batt_b": 0.0, "sensor.p_now": p_now,
|
||||
"sensor.p_avg": p_avg},
|
||||
{"number.sp_a": (-5000.0, 5000.0, 1.0),
|
||||
"number.sp_b": (-3000.0, 3000.0, 1.0)})
|
||||
c = Controller(dict(opts), hass, Mem(), Pub(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
|
||||
c.auto = True
|
||||
return c, hass
|
||||
|
||||
|
||||
def one_cycle(c):
|
||||
async def go():
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def put_in_charge(c, which, hours_ago, open_min):
|
||||
now = datetime.now(UTC)
|
||||
m = c.maints[which]
|
||||
m.store.set("phase", CHARGE)
|
||||
m.store.set_time("phase_started", now - timedelta(hours=hours_ago))
|
||||
m.store.set("charge_track", {
|
||||
"since": m.store.get("phase_started"), "open_min": open_min,
|
||||
"flat_min": 0.0, "best": 70.0,
|
||||
"at": (now - timedelta(minutes=3)).isoformat()})
|
||||
return m
|
||||
|
||||
|
||||
def notes(h):
|
||||
# Not the dismiss of PRICE-01's old fixed-tariff card, which every start sends.
|
||||
# Only that dismiss: a `create` with the old id would still be counted.
|
||||
return [x for x in h.calls if x[0] == "persistent_notification"
|
||||
and not (x[1] == "dismiss"
|
||||
and x[2].get("notification_id") == "goodwe_fixed_tariff_unused")]
|
||||
|
||||
|
||||
for cheap in (True, False):
|
||||
c, h = controller(OPTS1, cheap)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check(f"N=1, price gate {'open' if cheap else 'closed'}: the Controller "
|
||||
f"{'adds' if cheap else 'does not add'} the 3 minutes since the last "
|
||||
f"tick to the charging time (+{got:.1f})",
|
||||
(2.5 < got < 3.5) if cheap else abs(got) < 0.01)
|
||||
|
||||
c, h = controller(OPTS1, True)
|
||||
m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
n = notes(h)
|
||||
check("N=1: a charge past its time at 70 % ends in IDLE with ONE notification",
|
||||
m.phase == IDLE and len(n) == 1
|
||||
and n[0][2]["notification_id"] == "goodwe_maint_failed_battery"
|
||||
and n[0][2]["title"] == "Battery cycle did not finish")
|
||||
one_cycle(c)
|
||||
one_cycle(c)
|
||||
check("...and later cycles send no second one, and start no drain",
|
||||
len(notes(h)) == 1 and m.phase == IDLE)
|
||||
check("...the status page says when it waits until",
|
||||
any(k == "Maintenance paused" and "Waits until" in v
|
||||
for k, v in c.status_payload()["rows"]))
|
||||
c0, _h0 = controller(OPTS1, True)
|
||||
check("...and has no such row when nothing failed",
|
||||
not any(k == "Maintenance paused" for k, v in c0.status_payload()["rows"]))
|
||||
|
||||
c, h = controller(OPTS2, True)
|
||||
m = put_in_charge(c, 1, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
n = notes(h)
|
||||
check("N=2: pack B fails, B alone is told, by name, under its own id",
|
||||
c.maints[1].phase == IDLE and c.maints[0].phase == IDLE and len(n) == 1
|
||||
and n[0][2]["notification_id"] == "goodwe_maint_failed_inverter_b"
|
||||
and "inverter-b" in n[0][2]["message"])
|
||||
check("...and its failure is on B's keys",
|
||||
c.maints[1].store.get_time("last_failed") is not None
|
||||
and c.maints[0].store.get_time("last_failed") is None)
|
||||
|
||||
# a site with no price entities and no fixed tariff: nothing holds the charge back
|
||||
NOTARIFF = {k: v for k, v in OPTS1.items()
|
||||
if k not in ("price_now_entity", "price_avg_entity")}
|
||||
c, h = controller(NOTARIFF, False)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check("N=1, no tariff and no price entities: the gate counts as open, so the "
|
||||
f"3 minutes count (+{got:.1f}) although `cheap` is false",
|
||||
not c.cheap and 2.5 < got < 3.5)
|
||||
c, h = controller(NOTARIFF, False)
|
||||
m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1)
|
||||
one_cycle(c)
|
||||
check("...and a pack past its charging time there fails at once (not 7 days)",
|
||||
m.phase == IDLE and len(notes(h)) == 1)
|
||||
# a site with a price provider (TARIFF-11) and no fixed tariff: the provider's
|
||||
# plan reaches only the Tariff row, never `cheap`, so no cheap window ever
|
||||
# opens a grid top-up there. Like the site above, nothing gate-like holds the
|
||||
# charge back, and the time counts.
|
||||
PROVIDER = dict(NOTARIFF, price_provider="generic_dayahead", static_profile="")
|
||||
c, h = controller(PROVIDER, False)
|
||||
m = put_in_charge(c, 0, 5, 100.0)
|
||||
one_cycle(c)
|
||||
got = m._trk["open_min"] - 100.0
|
||||
check("N=1, price provider and no fixed tariff: not a gate (`cheap` is never "
|
||||
f"set from the provider), so the 3 minutes count (+{got:.1f})",
|
||||
c._price_gated is False and not c.cheap
|
||||
and c._charge_gate_open(c.inverters[0]) is True and 2.5 < got < 3.5)
|
||||
def read(c):
|
||||
asyncio.run(c.read_inputs())
|
||||
|
||||
|
||||
c, h = controller(OPTS1, False)
|
||||
read(c)
|
||||
c.inverters[0].batt = -800.0
|
||||
check("price gate closed but the pack is taking 800 W (sun): gate open",
|
||||
c._charge_gate_open(c.inverters[0]) is True)
|
||||
c.inverters[0].batt = -10.0
|
||||
check("...closed and about idle: gate closed",
|
||||
c._charge_gate_open(c.inverters[0]) is False)
|
||||
c.inverters[0].batt = None
|
||||
check("...no battery reading: gate closed", c._charge_gate_open(c.inverters[0]) is False)
|
||||
c, h = controller(OPTS1, True)
|
||||
read(c)
|
||||
check("price gate open: gate open", c._charge_gate_open(c.inverters[0]) is True)
|
||||
PROFILE = json.dumps({"periods": [
|
||||
{"name": "day", "from": "07:00", "to": "22:00", "days": "all",
|
||||
"eur_per_kwh": 0.29},
|
||||
{"name": "night", "from": "22:00", "to": "07:00", "days": "all",
|
||||
"eur_per_kwh": 0.19}]})
|
||||
c, h = controller(dict(NOTARIFF, price_provider="off", static_profile=PROFILE),
|
||||
False)
|
||||
read(c)
|
||||
check("a fixed tariff set up counts as a gate: outside its cheap hours a pack "
|
||||
"that is not charging is closed, inside them it is open",
|
||||
c._price_gated is True
|
||||
and c._charge_gate_open(c.inverters[0]) is c.cheap)
|
||||
import app.main as main_mod # noqa: E402
|
||||
_real_gated, _calls = main_mod.static_gated, []
|
||||
main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
|
||||
try:
|
||||
c._gated_key = None
|
||||
read(c)
|
||||
read(c)
|
||||
read(c)
|
||||
c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1))
|
||||
finally:
|
||||
main_mod.static_gated = _real_gated
|
||||
check("...worked out once an hour, not on every tick (3 reads in one hour = 1 "
|
||||
f"build; a new hour = 1 more): {len(_calls)} builds",
|
||||
len(_calls) == 2 and c._price_gated is True)
|
||||
|
||||
# the save timer is reset on a phase change
|
||||
mm = in_charge()
|
||||
mm.tick(T0 + MIN, 70.0)
|
||||
saved_before = mm._trk_saved
|
||||
mm.abort(T0 + 2 * MIN, "operator")
|
||||
check("leaving CHARGE resets the save timer along with the record",
|
||||
saved_before is not None and mm._trk is None and mm._trk_saved is None)
|
||||
|
||||
print("9. the words")
|
||||
en, nl = messages.maint_failed("charge", 7), messages.maint_failed("drain", 7)
|
||||
for n_ in (en, nl):
|
||||
print(" " + n_.title + " | " + n_.body + " | " + n_.body_nl)
|
||||
check("EN charge text", en.body == "The battery did not fill up, so its cycle "
|
||||
"stopped and tries again in 7 days.")
|
||||
check("EN drain text", nl.body == "The battery did not empty, so its cycle "
|
||||
"stopped and tries again in 7 days.")
|
||||
check("NL texts", en.body_nl == "Batterijcyclus niet afgerond. De batterij "
|
||||
"raakte niet vol, dus de cyclus is gestopt en probeert het over 7 dagen "
|
||||
"opnieuw." and nl.body_nl.endswith("raakte niet leeg, dus de cyclus is "
|
||||
"gestopt en probeert het over 7 dagen opnieuw."))
|
||||
check("the notification carries EN, NL and the Details line, in that order",
|
||||
re.fullmatch(r"The battery .*\n\nBatterijcyclus .*\n\nDetails: .*",
|
||||
en.data()["message"], re.S) is not None)
|
||||
check("the DOCS section and table row exist",
|
||||
"### Battery cycle did not finish" in (HERE / "DOCS.md").read_text()
|
||||
and "goodwe_maint_failed_<name>" in (HERE / "DOCS.md").read_text())
|
||||
check("no new option: the numbers are constants",
|
||||
(CHARGE_NEAR_FULL_PCT, CHARGE_STALL_MIN, FAIL_BACKOFF_DAYS)
|
||||
== (3, 60, 7))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,403 @@
|
||||
"""MAINT-14: maintenance with no SoC reading.
|
||||
`python3 test_maint14.py`
|
||||
|
||||
Graded here: charge and drain with SoC None at one and two packs - the charge
|
||||
timeout and 7 day backstop still run (failure, or done when the last SoC was
|
||||
>= target - 3), the drain holds at 0 W and times out, SoC coming back resumes
|
||||
normally with no back-off, the log lines (once per episode), a restart while the
|
||||
SoC is missing, and one pack's missing SoC leaving the other pack alone.
|
||||
See DECISIONS/MAINT-14-no-soc-in-maintenance.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.maintenance import (
|
||||
CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_TIMEOUT_MIN, CHARGE_WALL_TIMEOUT_MIN,
|
||||
DRAIN, DRAIN_TIMEOUT_MIN, FAIL_BACKOFF_DAYS, HOLD, IDLE, NOSOC_NOTE_MIN,
|
||||
MaintConfig, Maintenance, arbitrate,
|
||||
)
|
||||
from app.store import DEFAULTS, Store
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_drain(store=None, unit=None, at=T0):
|
||||
m = Maintenance(CFG, store or Mem(), unit=unit, name=unit or "")
|
||||
m.force_start(at)
|
||||
assert m.phase == DRAIN
|
||||
return m
|
||||
|
||||
|
||||
def in_charge(store=None, unit=None, at=T0):
|
||||
m = in_drain(store, unit, at)
|
||||
m.tick(at, 11.0)
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` min; returns (last result, end time, first result that
|
||||
is not the starting phase with its time, all events)."""
|
||||
ph, t, res, out, ev = m.phase, start, None, None, []
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
ev += res.events
|
||||
if out is None and res.phase != ph:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out, ev
|
||||
|
||||
|
||||
print("1. charge with no SoC")
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, 600, None)
|
||||
check("it keeps charging: the loop owns the setpoint, no start of a new phase",
|
||||
out is None and res.phase == CHARGE and res.charge_only
|
||||
and not res.owns_setpoint)
|
||||
|
||||
m = in_charge()
|
||||
run(m, T0, 60, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 60 * MIN, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
check("the charge timeout still runs: it fails once the time is up (last SoC 60)",
|
||||
out and out[0].failed == "charge" and out[0].phase == IDLE)
|
||||
check("...with the MAINT-13 back-off and its one plain line",
|
||||
m.store.get_time("last_failed") is not None
|
||||
and sum("did not finish" in e for e in ev) == 1
|
||||
and f"{FAIL_BACKOFF_DAYS} days" in ev[-1])
|
||||
check("...it took 36 h of allowed time from the charge start, not more",
|
||||
out and out[1] <= T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 10))
|
||||
check("...and the pack does not want a start in the back-off",
|
||||
not m.wants_start((T0 + timedelta(days=3)).replace(hour=19)))
|
||||
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
check("never saw a SoC: the timeout is a failure, not 'done'",
|
||||
out and out[0].failed == "charge")
|
||||
|
||||
for last in (97.0, 99.0):
|
||||
m = in_charge()
|
||||
run(m, T0, 10, last)
|
||||
res, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check(f"last SoC {last:.0f} (>= target - {CHARGE_NEAR_FULL_PCT}): time up counts as done",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and m.store.get_time("last_failed") is None)
|
||||
check("...one plain line says so",
|
||||
sum("counts as done" in e for e in ev) == 1
|
||||
and f"{last:.0f} %" in " ".join(ev))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 96.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check("last SoC 96 (below target - 3): time up is a failure",
|
||||
out and out[0].failed == "charge")
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 99.0)
|
||||
run(m, T0 + 10 * MIN, 10, 50.0)
|
||||
res, _, out, _ = run(m, T0 + 20 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
check("'last seen' is the last reading, not the best (99 then 50: failure)",
|
||||
out and out[0].failed == "charge")
|
||||
|
||||
print("2. the 7 day backstop, with the gate shut")
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
check("gate never opens, last SoC 70: failure at 7 days",
|
||||
out and out[0].failed == "charge"
|
||||
and out[1] >= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN)
|
||||
and out[1] <= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN + 10))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 98.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
check("gate never opens, last SoC 98: done at 7 days",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and out[1] >= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
res, _, out, _ = run(m, T0 + 10 * MIN, 3000, None, gate=False, step=5)
|
||||
check("gate shut, no SoC: the open-time clock does not run (no early end)",
|
||||
out is None)
|
||||
|
||||
print("3. SoC comes back mid-charge")
|
||||
m = in_charge()
|
||||
run(m, T0, 30, 80.0)
|
||||
run(m, T0 + 30 * MIN, 600, None)
|
||||
res, _, out, _ = run(m, T0 + 630 * MIN, 5, 80.0)
|
||||
check("normal behaviour resumes: still charging, nothing recorded",
|
||||
out is None and res.phase == CHARGE
|
||||
and m.store.get_time("last_failed") is None)
|
||||
res, _, out, _ = run(m, T0 + 640 * MIN, 10, 100.0)
|
||||
check("...and it completes at the target as usual",
|
||||
out and out[0].phase == HOLD and m.store.get_time("last_failed") is None)
|
||||
m = in_charge()
|
||||
run(m, T0, 30, 98.0)
|
||||
run(m, T0 + 30 * MIN, 600, None)
|
||||
res, _, out, _ = run(m, T0 + 630 * MIN, 30, 98.0)
|
||||
check("the stall wait paused while the SoC was missing (30 min flat after the "
|
||||
"gap is not 60)", out is None)
|
||||
res, _, out, _ = run(m, T0 + 660 * MIN, 40, 98.0)
|
||||
check("...and completes once 60 min flat are in", out and out[0].phase == HOLD)
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN - 100, None)
|
||||
res, _, out, _ = run(m, T0 + (CHARGE_TIMEOUT_MIN - 90) * MIN, 5, 70.0)
|
||||
check("a gap just before the limit then a reading: no failure, no back-off",
|
||||
out is None and m.store.get_time("last_failed") is None)
|
||||
|
||||
print("4. drain with no SoC")
|
||||
m = in_drain()
|
||||
run(m, T0, 30, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 30 * MIN, 60, None)
|
||||
check("DRAIN holds at 0 W, owned by maintenance, the same as HOLD",
|
||||
out is None and res.phase == DRAIN and res.owns_setpoint
|
||||
and res.setpoint_w == 0.0 and not res.charge_only)
|
||||
hold = in_charge()
|
||||
hold.tick(T0 + MIN, 100.0)
|
||||
hr = hold.tick(T0 + 2 * MIN, None)
|
||||
check("...the same answer as HOLD gives",
|
||||
(hr.owns_setpoint, hr.setpoint_w) == (res.owns_setpoint, res.setpoint_w))
|
||||
check("never above 0 W on any missing tick",
|
||||
all(m.tick(T0 + (100 + i) * MIN, None).setpoint_w == 0.0 for i in range(30)))
|
||||
check("one plain line that the drain holds, once, not per tick",
|
||||
sum("drain holds at 0 W" in e for e in ev) == 1)
|
||||
check("one plain line after the threshold, once",
|
||||
sum("no battery level for" in e for e in ev) == 1
|
||||
and f"{NOSOC_NOTE_MIN} min" in " ".join(ev))
|
||||
m = in_drain()
|
||||
res, _, out, ev = run(m, T0, NOSOC_NOTE_MIN - 1, None)
|
||||
check("under the threshold the 'no level for long' line is not said",
|
||||
not any("no battery level for" in e for e in ev))
|
||||
m = in_drain()
|
||||
res, _, out, ev = run(m, T0, DRAIN_TIMEOUT_MIN + 10, None)
|
||||
check("the 12 h drain timeout still runs: failure with back-off",
|
||||
out and out[0].failed == "drain" and out[0].phase == IDLE
|
||||
and out[1] > T0 + timedelta(minutes=DRAIN_TIMEOUT_MIN)
|
||||
and m.store.get_time("last_failed") is not None)
|
||||
m = in_drain()
|
||||
run(m, T0, 60, None)
|
||||
res, _, out, _ = run(m, T0 + 60 * MIN, 5, 60.0)
|
||||
check("SoC back: the drain continues at the drain rate",
|
||||
out is None and res.phase == DRAIN and res.owns_setpoint
|
||||
and res.setpoint_w == CFG.discharge_w)
|
||||
res, _, out, _ = run(m, T0 + 70 * MIN, 5, 10.0)
|
||||
check("...and ends at the floor as usual",
|
||||
out and out[0].phase == CHARGE and m.store.get_time("last_failed") is None)
|
||||
m = in_drain()
|
||||
run(m, T0, 60, None)
|
||||
run(m, T0 + 60 * MIN, 5, 60.0)
|
||||
res, _, out, ev = run(m, T0 + 70 * MIN, 5, None)
|
||||
check("a second episode says its lines again",
|
||||
any("drain holds at 0 W" in e for e in ev))
|
||||
|
||||
print("5. the log is once per episode")
|
||||
m = in_charge()
|
||||
res, _, out, ev = run(m, T0, 300, None)
|
||||
check("a 5 h charge episode: exactly one 'no level for long' line, none per tick",
|
||||
len(ev) == 1 and "no battery level for" in ev[0])
|
||||
run(m, T0 + 300 * MIN, 3, 70.0)
|
||||
res, _, out, ev = run(m, T0 + 303 * MIN, 60, None)
|
||||
check("...and one more for a new episode", len(ev) == 1)
|
||||
m = in_charge()
|
||||
res, _, out, ev = run(m, T0, 5, None)
|
||||
check("a 5 min gap says nothing", ev == [])
|
||||
|
||||
print("5b. the words per phase")
|
||||
m = in_charge()
|
||||
m.tick(T0 + MIN, 100.0)
|
||||
_, _, _, ev = run(m, T0 + 2 * MIN, 30, None)
|
||||
note = [e for e in ev if "no battery level for" in e]
|
||||
check("HOLD: the line exists and says the hold ends at its normal time (MAINT-15)",
|
||||
len(note) == 1 and "normal time" in note[0] and "waits" not in note[0])
|
||||
m = in_charge()
|
||||
_, _, _, ev = run(m, T0, 30, None)
|
||||
check("charge keeps its wording", any("time limit of the charge" in e for e in ev))
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 70.0)
|
||||
_, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
||||
gate=False, step=5)
|
||||
fail = [e for e in ev if "ABORT" in e]
|
||||
check("7-day backstop: the reason names the 7-day limit, not '0 h'",
|
||||
len(fail) == 1 and "7-day limit" in fail[0] and "0 h" not in fail[0])
|
||||
m = in_charge()
|
||||
_, _, out, ev = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None)
|
||||
fail = [e for e in ev if "ABORT" in e]
|
||||
check("charge-time limit: the reason gives the hours of charging time",
|
||||
len(fail) == 1 and "36 h of charging time" in fail[0])
|
||||
m = in_charge()
|
||||
run(m, T0, 10, 98.0)
|
||||
_, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
||||
_, _, _, ev = run(m, out[1] + MIN, 30, None)
|
||||
check("charge done with no SoC, then HOLD with no SoC: a new episode, so the "
|
||||
"HOLD line is said", out[0].phase == HOLD
|
||||
and sum("normal time" in e for e in ev) == 1)
|
||||
|
||||
print("6. restart while the SoC is missing")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
run(m, T0, 97, lambda i: 90.0 if i < 96 else 98.0) # 98 seen 1 min ago
|
||||
run(m, T0 + 97 * MIN, 3, None) # SoC lost; state saved at once
|
||||
t = T0 + 100 * MIN # restart 3 min after the loss, before the next 5 min save
|
||||
m2 = Maintenance(CFG, Store(path))
|
||||
r = m2.tick(t, None)
|
||||
check("a fresh process reads charge, and still charges", m2.phase == CHARGE
|
||||
and r.phase == CHARGE and r.charge_only)
|
||||
check("...with the open minutes and the last SoC seen from the file",
|
||||
m2._trk is not None and m2._trk["last"] == 98.0
|
||||
and 92 <= m2._trk["open_min"] <= 105)
|
||||
res, _, out, _ = run(m2, t + MIN, CHARGE_TIMEOUT_MIN, None, step=5)
|
||||
check("...so the timeout counts from the first charge minute and, with the "
|
||||
"last SoC 98, completes (no failure)",
|
||||
out and out[0].phase == HOLD and out[0].failed == ""
|
||||
and out[1] < T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 60))
|
||||
# drain: restart keeps it at 0 W and keeps the clock
|
||||
path2 = os.path.join(d, "s2.json")
|
||||
md = Maintenance(CFG, Store(path2))
|
||||
md.force_start(T0)
|
||||
md.tick(T0 + 100 * MIN, None)
|
||||
md2 = Maintenance(CFG, Store(path2))
|
||||
r = md2.tick(T0 + 101 * MIN, None)
|
||||
check("a drain restarted with no SoC holds at 0 W", md2.phase == DRAIN
|
||||
and r.setpoint_w == 0.0 and r.owns_setpoint)
|
||||
res, _, out, _ = run(md2, T0 + 102 * MIN, DRAIN_TIMEOUT_MIN, None, step=5)
|
||||
check("...and the 12 h clock is from the drain start, not the restart",
|
||||
out and out[0].failed == "drain"
|
||||
and out[1] <= T0 + timedelta(minutes=DRAIN_TIMEOUT_MIN + 10))
|
||||
# a charge restarted with no record of any SoC (old state file)
|
||||
old = {"phase": "charge", "phase_started": (T0 - timedelta(hours=35)).isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None, "auto": True}
|
||||
import json
|
||||
path3 = os.path.join(d, "s3.json")
|
||||
json.dump(old, open(path3, "w"))
|
||||
mo = Maintenance(CFG, Store(path3))
|
||||
res, _, out, _ = run(mo, T0, 120, None)
|
||||
check("an old state file with no SoC seen: the charge fails at 36 h",
|
||||
out and out[0].failed == "charge")
|
||||
# a MAINT-13 state file: charge_track with a best but no `last`
|
||||
since = (T0 - timedelta(hours=35)).isoformat()
|
||||
m13 = dict(old, phase_started=since, charge_track={
|
||||
"since": since, "open_min": 2100.0, "flat_min": 0.0, "best": 99.0,
|
||||
"at": (T0 - MIN).isoformat()})
|
||||
path4 = os.path.join(d, "s4.json")
|
||||
json.dump(m13, open(path4, "w"))
|
||||
mo = Maintenance(CFG, Store(path4))
|
||||
res, _, out, _ = run(mo, T0, 120, None)
|
||||
check("a MAINT-13 state file (no last SoC): no crash, the charge fails at "
|
||||
"36 h (best is not last)", out and out[0].failed == "charge")
|
||||
|
||||
print("7. two packs")
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a.force_start(T0)
|
||||
# the twin site: the same two packs, A healthy (SoC 60, still above the floor)
|
||||
a_ok, b_alone = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a_ok.force_start(T0)
|
||||
same = True
|
||||
for i in range(0, 120, 10):
|
||||
t = T0 + i * MIN
|
||||
plan = arbitrate([a, b], t)
|
||||
plan0 = arbitrate([a_ok, b_alone], t)
|
||||
same = same and plan[1] == plan0[1]
|
||||
ra = a.tick(t, None, blocked_by=plan[0])
|
||||
rb = b.tick(t, 55.0, blocked_by=plan[1])
|
||||
a_ok.tick(t, 60.0, blocked_by=plan0[0])
|
||||
rb0 = b_alone.tick(t, 55.0, blocked_by=plan0[1])
|
||||
same = same and (rb.phase, rb.setpoint_w, rb.deferred) == \
|
||||
(rb0.phase, rb0.setpoint_w, rb0.deferred)
|
||||
check("A drains with no SoC at 0 W while B (SoC 55, idle) is as it is when A "
|
||||
"is healthy", ra.setpoint_w == 0.0 and ra.owns_setpoint and rb.phase == IDLE
|
||||
and rb.setpoint_w is None and not rb.owns_setpoint)
|
||||
check("...B's answers are unchanged tick by tick", same)
|
||||
res = None
|
||||
for i in range(0, DRAIN_TIMEOUT_MIN + 30, 10):
|
||||
t = T0 + 120 * MIN + i * MIN
|
||||
plan = arbitrate([a, b], t)
|
||||
res = a.tick(t, None, blocked_by=plan[0])
|
||||
b.tick(t, 55.0, blocked_by=plan[1])
|
||||
if res.failed:
|
||||
break
|
||||
check("A's drain times out and A backs off (A's keys only)",
|
||||
res.failed == "drain" and a.store.get_time("last_failed") is not None
|
||||
and b.store.get_time("last_failed") is None)
|
||||
slot = (T0 + timedelta(days=1)).replace(hour=19, minute=5)
|
||||
check("...then B, with its SoC, takes the slot as usual",
|
||||
arbitrate([a, b], slot) == ["", ""] and b.wants_start(slot)
|
||||
and not a.wants_start(slot))
|
||||
b.force_start(slot)
|
||||
rb = b.tick(slot + 5 * MIN, 50.0)
|
||||
check("...and B's drain runs at the drain rate",
|
||||
rb.phase == DRAIN and rb.setpoint_w == CFG.discharge_w)
|
||||
# the other way: B charges with no SoC, A has a SoC
|
||||
a = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a")
|
||||
b = Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b")
|
||||
b.force_start(T0)
|
||||
b.tick(T0, 11.0)
|
||||
run(b, T0, 5, 97.0)
|
||||
a_alone = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a")
|
||||
same, t = True, T0 + 10 * MIN
|
||||
while t < T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 30):
|
||||
plan = arbitrate([a, b], t)
|
||||
rb = b.tick(t, None, blocked_by=plan[1])
|
||||
ra = a.tick(t, 60.0, blocked_by=plan[0])
|
||||
ra0 = a_alone.tick(t, 60.0, blocked_by=plan[0])
|
||||
same = same and (ra.phase, ra.setpoint_w, ra.deferred) == \
|
||||
(ra0.phase, ra0.setpoint_w, ra0.deferred)
|
||||
if rb.phase != CHARGE:
|
||||
break
|
||||
t += 5 * MIN
|
||||
check("B charges with no SoC (last 97): A (SoC 60) is unchanged tick by tick",
|
||||
same)
|
||||
check("...B ends as done (HOLD), A untouched, no failure on either",
|
||||
rb.phase == HOLD and rb.failed == ""
|
||||
and b.store.get_time("last_failed") is None
|
||||
and a.store.get_time("last_failed") is None and a.phase == IDLE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all passed")
|
||||
@@ -0,0 +1,299 @@
|
||||
"""MAINT-15: the HOLD ends on time with no SoC reading.
|
||||
`python3 test_maint15.py`
|
||||
|
||||
Graded here: a HOLD that loses its SoC (at the start, mid-hold, just before
|
||||
hold_min) ends on time, writes last_completed and says the COMPLETE line; SoC
|
||||
back mid-hold changes nothing; HOLD stays 0 W; one pack's loss leaves the other
|
||||
pack alone; a restart in HOLD with no SoC and an old state file.
|
||||
See DECISIONS/MAINT-15-hold-ends-without-soc.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.maintenance import (
|
||||
CHARGE, DRAIN, HOLD, IDLE, NOSOC_NOTE_MIN, MaintConfig, Maintenance,
|
||||
)
|
||||
from app.store import DEFAULTS, Store
|
||||
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=120)
|
||||
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC)
|
||||
MIN = timedelta(minutes=1)
|
||||
|
||||
|
||||
class Mem:
|
||||
def __init__(self, data=None):
|
||||
self.data = dict(DEFAULTS, **(data or {}))
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
||||
|
||||
def get_time(self, k):
|
||||
raw = self.data.get(k)
|
||||
return datetime.fromisoformat(raw) if raw else None
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def in_drain(store=None, unit=None, at=T0):
|
||||
m = Maintenance(CFG, store or Mem(), unit=unit, name=unit or "")
|
||||
m.force_start(at)
|
||||
assert m.phase == DRAIN
|
||||
return m
|
||||
|
||||
|
||||
def in_charge(store=None, unit=None, at=T0):
|
||||
m = in_drain(store, unit, at)
|
||||
m.tick(at, 11.0)
|
||||
assert m.phase == CHARGE
|
||||
return m
|
||||
|
||||
|
||||
def run(m, start, minutes, soc, gate=True, step=1):
|
||||
"""Tick every `step` min; returns (last result, end time, first result that
|
||||
is not the starting phase with its time, all events)."""
|
||||
ph, t, res, out, ev = m.phase, start, None, None, []
|
||||
for i in range(0, minutes, step):
|
||||
t = start + i * MIN
|
||||
s = soc(i) if callable(soc) else soc
|
||||
res = m.tick(t, s, gate_open=gate)
|
||||
ev += res.events
|
||||
if out is None and res.phase != ph:
|
||||
out = (res, t)
|
||||
break
|
||||
return res, start + minutes * MIN, out, ev
|
||||
|
||||
|
||||
|
||||
HOLD_MIN = CFG.hold_min
|
||||
|
||||
|
||||
def in_hold(store=None, unit=None, at=T0):
|
||||
"""A pack in HOLD that started at `at` + 1 min (charge counted as done)."""
|
||||
m = in_charge(store, unit, at)
|
||||
m.tick(at + MIN, 100.0)
|
||||
assert m.phase == HOLD
|
||||
return m
|
||||
|
||||
|
||||
H0 = T0 + MIN # the hold's start
|
||||
print("1. SoC lost in HOLD: the hold ends on time")
|
||||
for label, lost_at in (("at the start", 0), ("mid-hold", 60),
|
||||
("just before hold_min", HOLD_MIN - 1)):
|
||||
m = in_hold()
|
||||
run(m, H0, lost_at, 100.0) if lost_at else None
|
||||
res, _, out, ev = run(m, H0 + lost_at * MIN, HOLD_MIN + 5 - lost_at, None)
|
||||
check(f"lost {label}: ends at hold_min, to IDLE, not failed",
|
||||
out is not None and out[0].phase == IDLE and out[0].failed == ""
|
||||
and out[1] == H0 + HOLD_MIN * MIN)
|
||||
check("...last_completed is written at that time",
|
||||
m.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
check("...the same COMPLETE line, once, and no last_failed",
|
||||
sum("cycle COMPLETE (120 min hold)" in e for e in ev) == 1
|
||||
and m.store.get_time("last_failed") is None)
|
||||
check("...the pack is idle and hands the setpoint back",
|
||||
m.phase == IDLE and not out[0].owns_setpoint)
|
||||
|
||||
m = in_hold()
|
||||
res, _, out, _ = run(m, H0, HOLD_MIN - 1, None)
|
||||
check("before hold_min: still HOLD at 0 W, owned, nothing written",
|
||||
out is None and res.phase == HOLD and res.owns_setpoint
|
||||
and res.setpoint_w == 0.0 and m.store.get_time("last_completed") is None)
|
||||
check("...the pack is not due again (next due date moved after the end)",
|
||||
(run(m, H0 + (HOLD_MIN - 1) * MIN, 3, None), m.due(H0 + 130 * MIN))[1] is False)
|
||||
|
||||
print("2. the text")
|
||||
m = in_hold()
|
||||
_, _, out, ev = run(m, H0, HOLD_MIN + 5, None)
|
||||
note = [e for e in ev if "no battery level for" in e]
|
||||
check("one 15 min line, saying the hold ends at its normal time",
|
||||
len(note) == 1 and "normal time" in note[0] and "waits" not in note[0]
|
||||
and f"{NOSOC_NOTE_MIN} min" in note[0])
|
||||
check("...and the other events are the one COMPLETE line only", len(ev) == 2)
|
||||
m = in_hold()
|
||||
_, _, _, ev = run(m, H0, NOSOC_NOTE_MIN - 2, None)
|
||||
check("under the threshold the line is not said", ev == [])
|
||||
|
||||
print("3. SoC comes back mid-hold")
|
||||
ends = {}
|
||||
for label, soc in (("no SoC", lambda i: None if i < 40 else 100.0),
|
||||
("SoC the whole time", 100.0),
|
||||
("a low SoC after the gap", lambda i: None if i < 40 else 55.0)):
|
||||
m = in_hold()
|
||||
res, _, out, ev = run(m, H0, HOLD_MIN + 5, soc)
|
||||
ends[label] = (out[1], m.store.get_time("last_completed")) if out else None
|
||||
check(f"{label}: ends at hold_min", out is not None
|
||||
and out[1] == H0 + HOLD_MIN * MIN)
|
||||
check("...the end time is the same with or without the gap",
|
||||
len(set(ends.values())) == 1)
|
||||
m = in_hold()
|
||||
run(m, H0, 40, None)
|
||||
res, _, out, _ = run(m, H0 + 40 * MIN, 30, 100.0)
|
||||
check("back mid-hold: still HOLD at 0 W until the time is up",
|
||||
out is None and res.phase == HOLD and res.setpoint_w == 0.0)
|
||||
|
||||
print("4. restart in HOLD with no SoC")
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
path = os.path.join(d, "state.json")
|
||||
m = Maintenance(CFG, Store(path))
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
m.tick(H0, 100.0)
|
||||
run(m, H0, 50, None)
|
||||
m2 = Maintenance(CFG, Store(path))
|
||||
r = m2.tick(H0 + 51 * MIN, None)
|
||||
check("a fresh process reads HOLD and holds 0 W", m2.phase == HOLD
|
||||
and r.phase == HOLD and r.owns_setpoint and r.setpoint_w == 0.0)
|
||||
res, _, out, _ = run(m2, H0 + 52 * MIN, HOLD_MIN, None)
|
||||
check("...and the hold ends at hold_min from the hold start, not the restart",
|
||||
out and out[0].phase == IDLE and out[1] == H0 + HOLD_MIN * MIN
|
||||
and m2.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
import json
|
||||
old = {"phase": "hold", "phase_started": H0.isoformat(),
|
||||
"last_completed": None, "last_start_attempt": None, "auto": True}
|
||||
p2 = os.path.join(d, "old.json")
|
||||
json.dump(old, open(p2, "w"))
|
||||
mo = Maintenance(CFG, Store(p2))
|
||||
r = mo.tick(H0 + 10 * MIN, None)
|
||||
check("an old state file in HOLD loads and holds 0 W", mo.phase == HOLD
|
||||
and r.setpoint_w == 0.0)
|
||||
res, _, out, _ = run(mo, H0 + 11 * MIN, HOLD_MIN, None)
|
||||
check("...and ends on time, writing last_completed",
|
||||
out and out[1] == H0 + HOLD_MIN * MIN
|
||||
and mo.store.get_time("last_completed") == H0 + HOLD_MIN * MIN)
|
||||
mr = Maintenance(CFG, Store(p2))
|
||||
check("...the written file reloads as a finished cycle",
|
||||
mr.phase == IDLE and mr.store.get_time("last_completed") is not None)
|
||||
|
||||
print("5. two packs")
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a_ok, b_ok = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
for x in (a, a_ok):
|
||||
x.force_start(T0)
|
||||
x.tick(T0, 11.0)
|
||||
x.tick(H0, 100.0)
|
||||
# B is idle; A's hold loses its SoC (twin site: A keeps it)
|
||||
same, a_end = True, None
|
||||
for i in range(0, HOLD_MIN + 10):
|
||||
t = H0 + i * MIN
|
||||
ra = a.tick(t, None)
|
||||
rb = b.tick(t, 60.0)
|
||||
ra2 = a_ok.tick(t, 100.0)
|
||||
rb2 = b_ok.tick(t, 60.0)
|
||||
same = same and (rb.phase, rb.owns_setpoint, rb.setpoint_w) == \
|
||||
(rb2.phase, rb2.owns_setpoint, rb2.setpoint_w) and b.phase == b_ok.phase
|
||||
if a_end is None and a.phase == IDLE:
|
||||
a_end = t
|
||||
check("A loses SoC in HOLD: A ends on time (as the twin with a reading)",
|
||||
a_end == H0 + HOLD_MIN * MIN and a_ok.phase == IDLE
|
||||
and a.store.get_time("last_completed") == a_ok.store.get_time("last_completed"))
|
||||
check("...B is the same as in the twin site where A kept its SoC", same)
|
||||
# B in its own HOLD with SoC, A in HOLD without
|
||||
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
||||
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
||||
a.force_start(T0); a.tick(T0, 11.0); a.tick(H0, 100.0)
|
||||
b_start = H0 + 30 * MIN
|
||||
b.force_start(b_start - MIN); b.tick(b_start - MIN, 11.0); b.tick(b_start, 100.0)
|
||||
b_end = None
|
||||
for i in range(0, HOLD_MIN + 40):
|
||||
t = H0 + i * MIN
|
||||
a.tick(t, None)
|
||||
if t >= b_start:
|
||||
b.tick(t, 100.0)
|
||||
if b_end is None and b.phase == IDLE:
|
||||
b_end = t
|
||||
check("B (SoC present) ends at its own hold_min, A's loss changes nothing",
|
||||
b_end == b_start + HOLD_MIN * MIN and a.phase == IDLE
|
||||
and b.store.get_time("last_completed") == b_start + HOLD_MIN * MIN)
|
||||
check("...A's end is not delayed by B", a.store.get_time("last_completed")
|
||||
== H0 + HOLD_MIN * MIN)
|
||||
|
||||
print("6. other phases unchanged")
|
||||
m = in_drain()
|
||||
res, _, out, _ = run(m, T0, 60, None)
|
||||
check("DRAIN with no SoC still holds 0 W and does not end", out is None
|
||||
and res.phase == DRAIN and res.setpoint_w == 0.0)
|
||||
m = in_charge()
|
||||
res, _, out, _ = run(m, T0, 600, None)
|
||||
check("CHARGE with no SoC still keeps charging", out is None
|
||||
and res.phase == CHARGE and res.charge_only)
|
||||
|
||||
|
||||
def old_hold_branch(phase_started, last_completed, now, hold_min):
|
||||
"""FROZEN copy of release/1.0 (db9e231) HOLD branch of step(), with a
|
||||
reading: returns (phase, owns_setpoint, setpoint_w, events,
|
||||
last_completed). Kept as a reference for the differential check below."""
|
||||
elapsed = (now - phase_started).total_seconds() / 60.0
|
||||
if elapsed >= hold_min:
|
||||
return (IDLE, False, None,
|
||||
[f"maintenance: cycle COMPLETE ({elapsed:.0f} min hold) "
|
||||
"- handing back to the loop"], now)
|
||||
return (HOLD, True, 0.0, [], last_completed)
|
||||
|
||||
|
||||
print("7. with a reading: the new HOLD against the frozen release/1.0 branch")
|
||||
for hm in (0, 5, 60, 120):
|
||||
cfg = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
||||
discharge_w=2000, charge_w=2000, soc_floor=11,
|
||||
soc_target=100, hold_min=hm)
|
||||
m = Maintenance(cfg, Mem())
|
||||
m.force_start(T0)
|
||||
m.tick(T0, 11.0)
|
||||
m.tick(H0, 100.0)
|
||||
started, last, same, end = H0, None, True, None
|
||||
for i in range(0, hm + 15):
|
||||
t = H0 + i * MIN
|
||||
if m.phase != HOLD:
|
||||
break
|
||||
want = old_hold_branch(started, last, t, hm)
|
||||
r = m.tick(t, 100.0 - (i % 3))
|
||||
got = (r.phase, r.owns_setpoint, r.setpoint_w, r.events,
|
||||
m.store.get_time("last_completed"))
|
||||
same = same and got == want
|
||||
last = want[4]
|
||||
if r.phase == IDLE:
|
||||
end = t
|
||||
check(f"hold_min {hm}: every tick identical (phase, owns, W, events, "
|
||||
"last_completed)", same)
|
||||
check("...ends at hold_min, last_completed = that tick, one COMPLETE line",
|
||||
end == H0 + hm * MIN and m.store.get_time("last_completed") == end
|
||||
and m.phase == IDLE)
|
||||
|
||||
m = in_hold()
|
||||
res, _, out, ev = run(m, H0, HOLD_MIN + 5, 100.0)
|
||||
check("HOLD with a reading: 0 W until the end, then IDLE at hold_min",
|
||||
out is not None and out[1] == H0 + HOLD_MIN * MIN and out[0].phase == IDLE)
|
||||
check("...last_completed is that tick and the COMPLETE line is said once",
|
||||
m.store.get_time("last_completed") == H0 + HOLD_MIN * MIN
|
||||
and ev == ["maintenance: cycle COMPLETE (120 min hold) "
|
||||
"- handing back to the loop"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all ok")
|
||||
@@ -122,7 +122,8 @@ m = Maintenance(cfg, FakeStore())
|
||||
m.force_start(t0)
|
||||
r = m.tick(t0 + timedelta(minutes=5), soc=None)
|
||||
check("no SoC: holds the phase rather than guessing", r.phase == DRAIN)
|
||||
check("no SoC: still commands the drain rate", r.setpoint_w == 2500)
|
||||
check("no SoC: MAINT-14 the drain holds at 0 W, never discharges",
|
||||
r.owns_setpoint and r.setpoint_w == 0.0)
|
||||
|
||||
m = Maintenance(cfg, FakeStore())
|
||||
m.store.set("phase", "banana")
|
||||
@@ -622,6 +623,8 @@ def flat_store(**over):
|
||||
"phase_started": "2026-09-03T22:00:00+00:00",
|
||||
"last_completed": "2026-08-07T12:00:00+00:00",
|
||||
"last_start_attempt": "2026-09-03T10:00:00+00:00",
|
||||
# MAINT-13: a failed cycle on record, so that key's move is graded too.
|
||||
"last_failed": "2026-09-01T10:00:00+00:00",
|
||||
}, **over))
|
||||
|
||||
|
||||
@@ -929,10 +932,11 @@ print(f" ok={_rep8.ok} lost={len(_rep8.lost)} kept={len(_rep8.kept)} "
|
||||
f"misdirected={len(_rep8.misdirected)}; marker "
|
||||
f"{_m8.data.get(MAINT_SCHEMA_KEY, '<<absent>>')!r}")
|
||||
check("PRECONDITION: this fixture reaches the marker through the `lost` guard "
|
||||
"and ONLY that one - two keys in `kept`, two in `lost`, `misdirected` "
|
||||
"and ONLY that one - two keys in `kept`, three in `lost` (the three the "
|
||||
"fixture records; MAINT-13 added `last_failed`), `misdirected` "
|
||||
"empty. If it ever stops having a non-empty `kept` the mutation below "
|
||||
"goes invisible again, so the shape is asserted rather than assumed",
|
||||
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 2
|
||||
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 3
|
||||
and not _rep8.misdirected and not _rep8.ok)
|
||||
check("⚠️ THE MARKER IS NOT STAMPED ON A MIGRATION THAT LOST A MONTH OF "
|
||||
"HISTORY, and the two keys that DID land do not buy it one. `rep.ok` is "
|
||||
@@ -1010,10 +1014,14 @@ check("...and nothing about a deferral, a slot or another pack appears in any "
|
||||
# pressure.
|
||||
_solo2 = Maintenance(cfg, MemStore())
|
||||
_solo2.force_start(t0)
|
||||
# MAINT-13: a drain that times out is a failed cycle, so one plain line follows
|
||||
# the abort line (the abort line itself is unchanged).
|
||||
check("...and so is the abort, which is the string an operator reads under "
|
||||
"pressure",
|
||||
_solo2.tick(t0 + timedelta(hours=13), 60.0).events
|
||||
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)"])
|
||||
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)",
|
||||
"maintenance: the cycle did not finish. It waits 7 days before it "
|
||||
"tries again."])
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1469,8 +1469,11 @@ _info = [r.getMessage() for r in _out if r.levelno == logging.INFO]
|
||||
check(f"a Home Assistant restart is one plain WARNING line, and one INFO line "
|
||||
f"when both connections are back ({_warn}, {_info})",
|
||||
_warn[:1] == ["Home Assistant is not answering (error 502), perhaps it "
|
||||
"is restarting. The add-on keeps trying."]
|
||||
and _info == ["Home Assistant answers again."] and len(_warn) == 2)
|
||||
"is still starting. Control waits and resumes by itself."]
|
||||
and len(_info) == 1
|
||||
and re.fullmatch(r"Home Assistant answers again after \d+ seconds\.",
|
||||
_info[0]) is not None and len(_warn) == 2)
|
||||
# SAFETY-112's reminder and its timing are graded in test_safety112.py.
|
||||
check("...and the library's own text is kept, at DEBUG",
|
||||
any("Received message 8:1000" in r.getMessage() for r in _out
|
||||
if r.levelno == logging.DEBUG)
|
||||
|
||||
@@ -74,7 +74,7 @@ AT_7A00F12 = {
|
||||
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
_rows = {k: ident.run(**v)[0] for k, v in ident.SCENARIOS.items()}
|
||||
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
|
||||
_got = {k: ident.digest(v) for k, v in _rows.items()}
|
||||
for k in AT_7A00F12:
|
||||
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
|
||||
@@ -482,10 +482,16 @@ _ref = [r for r in ROWS_R if present(_first)
|
||||
print(f" B refuses to charge; after 3 min: trades "
|
||||
f"{sorted({str(r['action']) for r in _ref})}, B roles "
|
||||
f"{sorted({r['roles'].get(B) for r in _ref})}")
|
||||
# OPT-15: B's SAFETY-73 probe (every 15 min, 34 s) is the one grid trade
|
||||
# left: PROBE_W on B alone, A resting.
|
||||
_prb = [r for r in _ref if r["action"] is not None]
|
||||
check("B refusing to charge: no grid trade is filed after the refusal is "
|
||||
"learned, B's role is no longer 'charge', and A covers the house",
|
||||
_ref and all(r["action"] is None for r in _ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE for r in _ref)
|
||||
"learned but B's probe (PROBE_W on B, A resting), B charges only then, "
|
||||
"and A covers the house",
|
||||
_ref and all(r["shares"].get(B) == -200 and r["shares"].get(A, 0) == 0
|
||||
for r in _prb) and len(_prb) <= 0.1 * len(_ref)
|
||||
and all(r["roles"].get(B) != modetrade.CHARGE
|
||||
for r in _ref if r["action"] is None)
|
||||
and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2)
|
||||
|
||||
# ============================================================================ #
|
||||
@@ -516,7 +522,7 @@ print("\n7. a run edge reseeds the loop")
|
||||
|
||||
|
||||
def _no_reseed(c):
|
||||
c._reseed_run_edge = lambda claims: None
|
||||
c._reseed_run_edge = lambda claims, now: None
|
||||
|
||||
|
||||
def edge(rows, kind):
|
||||
|
||||
@@ -60,37 +60,42 @@ UTC0 = D0.astimezone(timezone.utc)
|
||||
print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
||||
# Printed by tools/opt11p4_identity.py on release/1.0 5a4cdc2 (2026-09-29),
|
||||
# goodwe-addon-checks:1. 1080 cycles each, battery modes on.
|
||||
# OPT-15: the six scenarios with a grid charge (`_ch`) were re-pinned on the
|
||||
# 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.
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
|
||||
"n2_ls_ls":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch":
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
|
||||
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
|
||||
"n2_ls_ls_fc_down":
|
||||
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
|
||||
"n2_sf_ch_fc_down":
|
||||
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
|
||||
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
|
||||
}
|
||||
import opt11p4_identity as ident # noqa: E402
|
||||
|
||||
got = ident.digests()
|
||||
for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: same digest as 5a4cdc2", got.get(k) == v)
|
||||
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))
|
||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||
@@ -113,19 +118,21 @@ check("the harness bites: Leftover solar grid-charging with no plan moves "
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES",
|
||||
(packmode.LEFTOVER_SOLAR, packmode.CHEAP_HOURS)),
|
||||
lambda: setattr(modetrade, "GRID_CHARGE_MODES", _gcm)))
|
||||
_read = solarfc.read
|
||||
# OPT-17: the controller reads through `read_sum()`, so that is the seam.
|
||||
_read = solarfc.read_sum
|
||||
|
||||
|
||||
def _as_dark(today, tomorrow, now, **kw):
|
||||
return _read({"state": "0", "attributes": {"unit_of_measurement":
|
||||
"kWh"},
|
||||
"last_updated": now.isoformat()}, None, now)
|
||||
return _read([("sensor.f", {"state": "0", "attributes": {
|
||||
"unit_of_measurement": "kWh"}, "last_updated": now.isoformat()})],
|
||||
[], now)
|
||||
|
||||
|
||||
check("...and an unavailable forecast read as a dark one moves "
|
||||
"n2_sf_ls_fc_down (Leftover solar would buy its shortfall)",
|
||||
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
|
||||
lambda: setattr(solarfc, "read", _read)))
|
||||
_moved("n2_sf_ls_fc_down",
|
||||
lambda: setattr(solarfc, "read_sum", _as_dark),
|
||||
lambda: setattr(solarfc, "read_sum", _read)))
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. the forecast sensors")
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
"""Runnable check for OPT-17, the solar forecast summed from several entities.
|
||||
`python3 test_opt17.py`
|
||||
|
||||
1. `solarfc.read_sum()`: sums in mixed units, refusals, a member down or
|
||||
stale makes that day unusable (never a partial sum), hourly splits, today
|
||||
and tomorrow, an empty list, and one member equal to `read()`.
|
||||
2. `solarfc.entities()`: which key a day uses.
|
||||
3. Options: the validator, the settings notes and config.yaml.
|
||||
4. The REAL controller (tools/opt11p3_replay.py): a one-member list runs
|
||||
exactly like the single key, two halves like one whole, a member down
|
||||
like a forecast down; the status row and the action log name the member
|
||||
once; every member is read in one gather; modes off reads nothing.
|
||||
|
||||
DECISIONS/OPT-17-several-forecast-entities.md has the rules. Mutations
|
||||
(MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
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 (dayplan, homeview, messages, solarfc, uitext, # noqa: E402
|
||||
validate)
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
UTC0 = datetime(2026, 9, 21, tzinfo=L).astimezone(timezone.utc)
|
||||
TOM = UTC0 + timedelta(days=1)
|
||||
NOW = datetime(2026, 9, 21, 9, 0, tzinfo=L).astimezone(timezone.utc)
|
||||
END = UTC0 + timedelta(days=1)
|
||||
OLD = NOW - timedelta(seconds=solarfc.FRESH_S + 1)
|
||||
|
||||
|
||||
def st(state, unit="kWh", at=NOW, **attrs):
|
||||
a = dict(attrs)
|
||||
if unit is not None:
|
||||
a["unit_of_measurement"] = unit
|
||||
return {"state": state, "attributes": a,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
def members(*states):
|
||||
return [(f"sensor.fc_{i}", s) for i, s in enumerate(states, 1)]
|
||||
|
||||
|
||||
def near(a, b, eps=1e-6):
|
||||
return a is not None and b is not None and abs(a - b) < eps
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. read_sum")
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("7500", "Wh")), [], NOW)
|
||||
check("two members, kWh + Wh: 5 + 7.5 = 12.5 kWh",
|
||||
fc is not None and near(fc.today.kwh, 12.5) and why is None
|
||||
and gaps == ())
|
||||
check("...and the rest of the day adds up to the sum",
|
||||
near(fc.between(NOW, END), 12.5))
|
||||
fc, why, gaps = solarfc.read_sum(
|
||||
members(st("5"), st("2500", "Wh"), st("0.005", "MWh")), [], NOW)
|
||||
check("three members, kWh + Wh + MWh: 5 + 2.5 + 5 = 12.5 kWh",
|
||||
fc is not None and near(fc.today.kwh, 12.5))
|
||||
for unit in ("W", None, "kW", "%"):
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("5", unit)), [],
|
||||
NOW)
|
||||
check(f"a member in {unit or 'no unit'} makes today unusable (unit), "
|
||||
f"and is named",
|
||||
fc is None and why == solarfc.UNIT
|
||||
and gaps == (("today", 2, "sensor.fc_2", solarfc.UNIT),))
|
||||
for bad, w in (("unavailable", solarfc.MISSING), ("unknown", solarfc.MISSING),
|
||||
(None, solarfc.MISSING), ("-1", solarfc.BAD),
|
||||
("lots", solarfc.BAD)):
|
||||
s2 = None if bad is None else st(bad)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), s2, st("4")), [], NOW)
|
||||
check(f"a member that reads {bad!r}: no forecast at all ({w}), not "
|
||||
f"the 9 kWh of the others",
|
||||
fc is None and why == w
|
||||
and gaps == (("today", 2, "sensor.fc_2", w),))
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4", at=OLD)), [], NOW)
|
||||
check("a stale member (older than 3 h): no forecast (stale), named",
|
||||
fc is None and why == solarfc.STALE
|
||||
and gaps == (("today", 2, "sensor.fc_2", solarfc.STALE),))
|
||||
EVE = datetime(2026, 9, 20, 23, 30, tzinfo=L).astimezone(timezone.utc)
|
||||
MID = datetime(2026, 9, 21, 0, 10, tzinfo=L).astimezone(timezone.utc)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5", at=MID), st("0", at=EVE)),
|
||||
[], MID)
|
||||
check("a member from before local midnight is stale, not a dark member",
|
||||
fc is None and why == solarfc.STALE)
|
||||
fc, why, gaps = solarfc.read_sum(members(st("unavailable"), st("4", at=OLD)),
|
||||
[], NOW)
|
||||
check("two members down: both named, the first reason is the day's",
|
||||
fc is None and why == solarfc.MISSING and [g[1] for g in gaps] == [1, 2])
|
||||
check("an empty list is not set", solarfc.read_sum([], [], NOW)
|
||||
== (None, solarfc.NOT_SET, ())
|
||||
and solarfc.read_sum([], members(st("5")), NOW)[1] == solarfc.NOT_SET)
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5", at=NOW - timedelta(hours=2)),
|
||||
st("4")), [], NOW)
|
||||
check("the forecast's time is the oldest member's report",
|
||||
fc.at == NOW - timedelta(hours=2))
|
||||
|
||||
# Tomorrow.
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
|
||||
members(st("12"), st("8000", "Wh")), NOW)
|
||||
check("tomorrow: 12 + 8 = 20 kWh, and today is 9",
|
||||
fc is not None and near(fc.tomorrow.kwh, 20.0) and near(fc.today.kwh,
|
||||
9.0)
|
||||
and near(fc.between(TOM, TOM + timedelta(days=1)), 20.0))
|
||||
for name, t2, w in (("unavailable", st("unavailable"), solarfc.MISSING),
|
||||
("stale", st("8", at=OLD), solarfc.STALE),
|
||||
("in W", st("8", "W"), solarfc.UNIT)):
|
||||
fc, why, gaps = solarfc.read_sum(members(st("5"), st("4")),
|
||||
members(st("12"), t2), NOW)
|
||||
check(f"a tomorrow member {name}: tomorrow is not used (not 12 kWh), "
|
||||
f"today is kept, the member is named",
|
||||
fc is not None and fc.tomorrow is None and near(fc.today.kwh, 9.0)
|
||||
and why is None
|
||||
and gaps == (("tomorrow", 2, "sensor.fc_2", w),)
|
||||
and fc.between(NOW, TOM + timedelta(hours=9)) is None)
|
||||
fc, _w, gaps = solarfc.read_sum(members(st("5"), st("4")), [], NOW)
|
||||
check("no tomorrow list: today only, nothing named",
|
||||
fc.tomorrow is None and gaps == ())
|
||||
|
||||
# Hourly splits.
|
||||
sol = [{"period_start": (UTC0 + timedelta(minutes=30 * i)).isoformat(),
|
||||
"pv_estimate": 2.0 if 20 <= i < 36 else 0.0} for i in range(48)]
|
||||
wh = {(UTC0 + timedelta(hours=h)).isoformat(): (1000.0 if 8 <= h < 16
|
||||
else 0.0)
|
||||
for h in range(24)}
|
||||
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
|
||||
st("7.0", wh_period=wh)), [], NOW)
|
||||
one_s = solarfc.read(st("8.0", detailedForecast=sol), None, NOW)[0]
|
||||
one_w = solarfc.read(st("7.0", wh_period=wh), None, NOW)[0]
|
||||
spans = [(NOW, END)] + [
|
||||
(datetime(2026, 9, 21, h, tzinfo=L), datetime(2026, 9, 21, h + 1,
|
||||
tzinfo=L))
|
||||
for h in range(9, 20)]
|
||||
check("every member has a split: the splits are merged (hour by hour, the "
|
||||
"sum of each member's own split)",
|
||||
fc.today.periods and near(fc.today.kwh, 15.0)
|
||||
and all(near(fc.between(a, b), one_s.between(a, b)
|
||||
+ one_w.between(a, b)) for a, b in spans))
|
||||
check("...so 10:00-12:00 is 2 (Solcast) + 2 (Open-Meteo) = 4 kWh",
|
||||
near(fc.between(datetime(2026, 9, 21, 10, tzinfo=L),
|
||||
datetime(2026, 9, 21, 12, tzinfo=L)), 4.0))
|
||||
fc, _w, _g = solarfc.read_sum(members(st("8.0", detailedForecast=sol),
|
||||
st("7.0")), [], NOW)
|
||||
bell = solarfc.read(st("15.0"), None, NOW)[0]
|
||||
check("one member without a split: the sum goes on the bell, as one "
|
||||
"state-only sensor of 15 kWh would",
|
||||
fc.today.periods == () and near(fc.today.kwh, 15.0)
|
||||
and all(near(fc.between(a, b), bell.between(a, b)) for a, b in spans))
|
||||
wht = {(TOM + timedelta(hours=h)).isoformat(): (500.0 if 8 <= h < 16 else 0)
|
||||
for h in range(24)}
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5")),
|
||||
members(st("4", wh_period=wht),
|
||||
st("4", wh_period=wht)), NOW)
|
||||
check("tomorrow's splits are merged too (8 x 0.5 kWh each, 8 in all)",
|
||||
fc.tomorrow.periods and near(fc.between(TOM, TOM + timedelta(days=1)),
|
||||
8.0)
|
||||
and near(fc.between(TOM + timedelta(hours=8),
|
||||
TOM + timedelta(hours=10)), 2.0))
|
||||
fc, _w, _g = solarfc.read_sum(members(st("5")),
|
||||
members(st("4", wh_period=wht), st("4")), NOW)
|
||||
check("...and fall back to the bell when one has none",
|
||||
fc.tomorrow.periods == () and near(fc.tomorrow.kwh, 8.0))
|
||||
|
||||
# One member is read().
|
||||
TODAYS = [st("12.5"), st("12500", "Wh"), st("0.0125", "MWh"), st("12.5", "W"),
|
||||
st("12.5", None), st("unavailable"), st("unknown"), None, st("-1"),
|
||||
st("lots"), st("12.5", at=OLD), st("12.5", at=NOW + timedelta(
|
||||
hours=1)), st("8.0", detailedForecast=sol),
|
||||
st("7.0", wh_period=wh), st("0")]
|
||||
TOMS = [None, st("20"), st("20", at=OLD), st("unavailable"), st("20", "W"),
|
||||
st("4", wh_period=wht)]
|
||||
WHENS = [NOW, MID, datetime(2026, 9, 21, 19, 30, tzinfo=L)]
|
||||
same, n = True, 0
|
||||
for now in WHENS:
|
||||
for a in TODAYS:
|
||||
for b in TOMS:
|
||||
n += 1
|
||||
one = solarfc.read(a, b, now)
|
||||
got = solarfc.read_sum([("sensor.f", a)],
|
||||
[("sensor.t", b)] if b is not None
|
||||
else [], now)
|
||||
same = same and got[:2] == one
|
||||
check(f"one member per day gives exactly what read() gives ({n} cases: "
|
||||
f"units, refusals, stale, splits, tomorrow)", same and n == 270)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. entities")
|
||||
E = solarfc.entities
|
||||
check("the single key alone", E({"pv_forecast_entity": "sensor.a"})
|
||||
== ("sensor.a",))
|
||||
check("the list alone, in order", E({"pv_forecast_entities": [
|
||||
"sensor.b", "sensor.a"]}) == ("sensor.b", "sensor.a"))
|
||||
check("a list with an entry replaces the single key",
|
||||
E({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["sensor.b"]}) == ("sensor.b",))
|
||||
check("an empty list or blank rows use the single key",
|
||||
E({"pv_forecast_entity": "sensor.a", "pv_forecast_entities": []})
|
||||
== ("sensor.a",)
|
||||
and E({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["", " "]}) == ("sensor.a",))
|
||||
check("a repeated id counts once (it would double that forecast)",
|
||||
E({"pv_forecast_entities": ["sensor.a", "sensor.b", "sensor.a"]})
|
||||
== ("sensor.a", "sensor.b"))
|
||||
check("tomorrow reads its own keys",
|
||||
E({"pv_forecast_entities": ["sensor.a"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.t1", "sensor.t2"]},
|
||||
tomorrow=True) == ("sensor.t1", "sensor.t2")
|
||||
and E({"pv_forecast_tomorrow_entity": "sensor.t"}, tomorrow=True)
|
||||
== ("sensor.t",))
|
||||
check("nothing set: ()", E({}) == () and E({}, tomorrow=True) == ())
|
||||
|
||||
# ============================================================================ #
|
||||
print("3. options")
|
||||
F = validate._forecast_problems
|
||||
for one, many in (solarfc.TODAY_KEYS, solarfc.TOMORROW_KEYS):
|
||||
p = F({one: "sensor.a", many: ["sensor.b", "sensor.c"]})
|
||||
check(f"{one} and {many} both set: refused, one plain message that "
|
||||
f"says which to keep",
|
||||
len(p) == 1 and "not both" in p[0] and "Keep the list" in p[0]
|
||||
and one in p[0] and many in p[0] and len(p[0].split()) <= 30)
|
||||
check(f"...{many} alone is fine, and so is {one} alone",
|
||||
not F({many: ["sensor.b", "sensor.c"]}) and not F({one: "sensor.a"})
|
||||
and not F({one: "sensor.a", many: []})
|
||||
and not F({one: "", many: ["sensor.b"]}))
|
||||
check(f"...blank rows in {many} are ignored, alone or beside {one}",
|
||||
not F({many: ["", None, "sensor.b"]})
|
||||
and not F({one: "sensor.a", many: ["", None]}))
|
||||
p = F({many: ["sensor.b", "number.x"]})
|
||||
check(f"...an entry in {many} that is not a sensor is refused",
|
||||
len(p) == 1 and "only sensors" in p[0] and "number.x" in p[0])
|
||||
check(f"...and {many} that is not a list is refused",
|
||||
F({many: "sensor.b"}) and "list of sensors" in F({many: "x"})[0])
|
||||
with open(os.path.join(HERE, "translations", "en.yaml"),
|
||||
encoding="utf-8") as fh:
|
||||
EN = fh.read()
|
||||
for k in solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS:
|
||||
name = re.search(rf"^ {k}:\n name: \"Hardware · ([^\"(]+?)"
|
||||
rf"(?: \(kWh\))?\"$", EN, re.M)
|
||||
check(f"the refusal names {k} by its label on the tab",
|
||||
name is not None
|
||||
and f"“{name.group(1)}” ({k})" in validate.S(k))
|
||||
print(" " + F({"pv_forecast_entity": "sensor.a",
|
||||
"pv_forecast_entities": ["sensor.b"]})[0])
|
||||
VBASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "maintenance_charge_w": 2000,
|
||||
"maintenance_discharge_w": 2000, "battery_modes_enabled": True}
|
||||
check("the start-up check refuses both keys set for a day",
|
||||
any("not both" in p for p in validate.validate_startup_config(
|
||||
dict(VBASE, pv_forecast_tomorrow_entity="sensor.a",
|
||||
pv_forecast_tomorrow_entities=["sensor.b"])))
|
||||
and validate.validate_startup_config(
|
||||
dict(VBASE, pv_forecast_entities=["sensor.a", "sensor.b"],
|
||||
pv_forecast_tomorrow_entities=["sensor.c", "sensor.d"]))
|
||||
== [])
|
||||
notes = []
|
||||
validate._entity_notes(notes, dict(VBASE, pv_forecast_entities=[
|
||||
"sensor.a", "sensor.b", "sensor.gone"]),
|
||||
{"sensor.a": {"unit": "kWh"}, "sensor.b": {"unit": "W"}})
|
||||
check("settings notes: a listed sensor not in kWh, and one Home Assistant "
|
||||
"does not have, are each named",
|
||||
any("sensor.b" in n.text and "kWh" in n.text for n in notes)
|
||||
and any("sensor.gone" in n.text for n in notes)
|
||||
and not any("sensor.a " in n.text for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, dict(VBASE, pv_forecast_tomorrow_entities=[
|
||||
"sensor.t"]), {"sensor.t": {"unit": "kWh"}})
|
||||
check("a tomorrow list without today's forecast gets the same warning as "
|
||||
"the single key", any("used only with today's" in n.text
|
||||
for n in notes))
|
||||
notes = []
|
||||
validate._entity_notes(notes, {"pv_forecast_entities": ["sensor.a"]},
|
||||
{"sensor.a": {"unit": "kWh"}})
|
||||
check("a list with battery modes off gets the 'unused' note",
|
||||
any("battery modes on" in n.text for n in notes))
|
||||
with open(os.path.join(HERE, "config.yaml"), encoding="utf-8") as fh:
|
||||
CFG = fh.read()
|
||||
for k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
|
||||
check(f"config.yaml: {k} defaults to [] (load-bearing) and is a list "
|
||||
f"of str",
|
||||
re.search(rf"^ {k}: \[\]$", CFG, re.M) is not None
|
||||
and re.search(rf"^ {k}:\n - str$", CFG, re.M) is not None)
|
||||
for k in solarfc.TODAY_KEYS[:1] + solarfc.TOMORROW_KEYS[:1]:
|
||||
check(f"config.yaml: {k} is unchanged (str?, default \"\")",
|
||||
re.search(rf"^ {k}: \"\"$", CFG, re.M) is not None
|
||||
and re.search(rf"^ {k}: str\?$", CFG, re.M) is not None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("4. the real controller")
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
# Prices as in test_modes_p4: cheap 01:00-03:00, dear 19:00-20:00 local.
|
||||
DAYW = [0.20] * 96 * 2
|
||||
for i in list(range(4, 12)) + list(range(100, 108)):
|
||||
DAYW[i] = 0.02
|
||||
for i in list(range(76, 80)) + list(range(172, 176)):
|
||||
DAYW[i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(DAYW)]
|
||||
SF_LS = {"a": (True, "solar_first", False),
|
||||
"b": (True, "leftover_solar", False)}
|
||||
# Each fake sensor: (share of the day's sun, has a split, status).
|
||||
SENSORS = {}
|
||||
FC_KWH = [1.0]
|
||||
START = [UTC0]
|
||||
CALLS = []
|
||||
|
||||
|
||||
async def _sensor(self, e):
|
||||
CALLS.append(("start", e))
|
||||
await asyncio.sleep(0)
|
||||
CALLS.append(("end", e))
|
||||
now = START[0] + timedelta(seconds=self.t)
|
||||
share, split, status = SENSORS[e]
|
||||
if status == "down":
|
||||
return {"state": "unavailable", "attributes": {},
|
||||
"last_updated": now.isoformat()}
|
||||
at = now - timedelta(hours=4) if status == "stale" else now
|
||||
loc = now.astimezone(L)
|
||||
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
if e.startswith("sensor.tom"):
|
||||
d0 += timedelta(days=1)
|
||||
wh = {(d0 + timedelta(hours=h)).astimezone(timezone.utc).isoformat():
|
||||
(FC_KWH[0] * 100.0 * share if 8 <= h < 18 else 0.0)
|
||||
for h in range(24)}
|
||||
left = sum(v for k, v in wh.items()
|
||||
if datetime.fromisoformat(k) >= now) / 1000.0
|
||||
attrs = {"unit_of_measurement": "kWh"}
|
||||
if split:
|
||||
attrs["wh_period"] = wh
|
||||
return {"state": f"{left:.6f}", "attributes": attrs,
|
||||
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
|
||||
|
||||
|
||||
rp.Hass.state = _sensor
|
||||
|
||||
|
||||
def seed(c):
|
||||
if c.house_profile is not None:
|
||||
hp = dayplan.HouseProfile()
|
||||
t = UTC0 - timedelta(days=1)
|
||||
while t < UTC0:
|
||||
h = t.astimezone(L).hour
|
||||
hp.add(t, 3000.0 if 17 <= h < 22 else 600.0)
|
||||
t += timedelta(seconds=5)
|
||||
hp.add(t, None)
|
||||
c.house_profile = hp
|
||||
|
||||
|
||||
def run(opts, hours=4.0, modes=True):
|
||||
start = UTC0 + timedelta(hours=0.75)
|
||||
START[0] = start
|
||||
rows = []
|
||||
|
||||
def cb(c, h, now, pr):
|
||||
sh = dict(c.allocation.shares) if c.allocation is not None and \
|
||||
c.allocation.shares else {}
|
||||
rows.append((now, round(h.grid(), 6), tuple(sorted(sh.items())),
|
||||
getattr(c.arb, "action", None),
|
||||
tuple(sorted(c.mode_tick.roles.items()))
|
||||
if c.mode_tick else (),
|
||||
tuple(sorted((u, round(p.soc, 6))
|
||||
for u, p in h.plants.items())),
|
||||
tuple(sorted((c.plan_targets() or {}).items())),
|
||||
None if c.day_plan is None else c.day_plan.verdict))
|
||||
_st, c = rp.run("p3_ls" if modes else "modes_off", [(UTC0.timestamp()
|
||||
- 10, 800.0)],
|
||||
WPTS, start, 3600 * hours, check=cb,
|
||||
soc0={"a": 60.0, "b": 30.0}, choice=SF_LS,
|
||||
extra=dict({"cycle_cost_threshold_eur": 0.20}, **opts),
|
||||
patch=seed)
|
||||
return rows, c
|
||||
|
||||
|
||||
def fc_rows(c):
|
||||
return [r for r in c.checks() if r["id"] == "solar_forecast"]
|
||||
|
||||
|
||||
def fc_events(c):
|
||||
return [e for e in c.events if "solar forecast" in e]
|
||||
|
||||
|
||||
SENSORS.update({
|
||||
"sensor.all": (1.0, True, "ok"), "sensor.h1": (0.5, True, "ok"),
|
||||
"sensor.h2": (0.5, True, "ok"), "sensor.h2_flat": (0.5, False, "ok"),
|
||||
"sensor.down": (0.5, True, "down"), "sensor.stale": (0.5, True, "stale"),
|
||||
"sensor.tom_all": (1.0, True, "ok"), "sensor.tom_h1": (0.5, True, "ok"),
|
||||
"sensor.tom_down": (0.5, True, "down"), "sensor.none": (1.0, True, "down"),
|
||||
})
|
||||
single, c_single = run({"pv_forecast_entity": "sensor.all"})
|
||||
listed, c_listed = run({"pv_forecast_entities": ["sensor.all"]})
|
||||
plans = [r for r in single if r[6]]
|
||||
check(f"the run makes a day plan with a level ({len(plans)} of "
|
||||
f"{len(single)} cycles), so the forecast steers something",
|
||||
plans and any(r[7] is not None for r in single))
|
||||
check("a one-member list runs exactly like the single key: every grid "
|
||||
"figure, share, action, role, level and plan, cycle by cycle",
|
||||
single == listed and len(single) > 2000)
|
||||
check("...and says the same on the status page and in the action log",
|
||||
fc_rows(c_single) == fc_rows(c_listed) == []
|
||||
and fc_events(c_single) == fc_events(c_listed))
|
||||
halves, c_halves = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]})
|
||||
check("two members of half the sun each run like one sensor of all of it",
|
||||
[r[:6] + (r[7],) for r in halves] == [r[:6] + (r[7],) for r in single]
|
||||
and all(len(a[6]) == len(b[6]) and all(
|
||||
k1 == k2 and abs(v1 - v2) < 1e-6 for (k1, v1), (k2, v2)
|
||||
in zip(a[6], b[6])) for a, b in zip(halves, single)))
|
||||
none_, c_none = run({"pv_forecast_entity": "sensor.none"})
|
||||
check("(the comparison bites: no forecast runs differently)",
|
||||
[r[:6] for r in none_] != [r[:6] for r in single])
|
||||
for name, mem in (("unavailable", "sensor.down"), ("stale", "sensor.stale")):
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", mem]})
|
||||
check(f"a member {name}: the controller runs exactly as with the single "
|
||||
f"forecast unavailable (no partial sum)", rows == none_)
|
||||
check(f"...the status page names it by place, in one short line",
|
||||
[r["text"] for r in fc_rows(c)] == [
|
||||
"Solar forecast for today: not used, source 2 of 2 has no "
|
||||
"reading"] and fc_rows(c)[0]["warn"])
|
||||
ev = fc_events(c)
|
||||
check(f"...and the action log names it once in {len(single)} cycles",
|
||||
len(ev) == 1 and mem in ev[0] and "today" in ev[0])
|
||||
one_down, c_one_down = run({"pv_forecast_entities": ["sensor.none"]})
|
||||
check("a one-member list that is down runs, shows and logs exactly like the "
|
||||
"single key down", one_down == none_
|
||||
and fc_rows(c_one_down) == fc_rows(c_none)
|
||||
and fc_events(c_one_down) == fc_events(c_none))
|
||||
# A forecast missing from the start logs nothing (the start state is
|
||||
# already "missing"); that is release/1.0's behaviour, kept.
|
||||
check("the single key down: no new row and no new log line",
|
||||
fc_rows(c_none) == [] and fc_events(c_none) == [])
|
||||
CALLS.clear()
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.tom_h1",
|
||||
"sensor.tom_down"]},
|
||||
hours=0.2)
|
||||
check("a tomorrow member down: today is used, tomorrow is not, and the row "
|
||||
"says so",
|
||||
c.solar_fc is not None and c.solar_fc.tomorrow is None
|
||||
and [r["text"] for r in fc_rows(c)] == [
|
||||
"Solar forecast for tomorrow: not used, source 2 of 2 has no "
|
||||
"reading"]
|
||||
and len(fc_events(c)) == 1 and "sensor.tom_down" in fc_events(c)[0])
|
||||
check("...and the page hint says tomorrow's forecast is not used",
|
||||
(c.hint or "").endswith(messages.hint("forecast_no_tomorrow")))
|
||||
check("...and Home titles the row as the solar forecast, in EN and NL",
|
||||
[a["title"] for a in homeview.attention(fc_rows(c))]
|
||||
== ["Solar forecast"]
|
||||
and uitext.words("nl")["home_words"]["attn"]["solar_forecast"]
|
||||
== "Zonneprognose")
|
||||
first = CALLS[:8]
|
||||
check("every member of both days is read in one gather (all four start "
|
||||
"before any ends)",
|
||||
[k for k, _e in first] == ["start"] * 4 + ["end"] * 4
|
||||
and sorted(e for _k, e in first[:4]) == sorted(
|
||||
["sensor.h1", "sensor.h2", "sensor.tom_h1", "sensor.tom_down"]))
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1"],
|
||||
"pv_forecast_tomorrow_entities": ["sensor.tom_all"]},
|
||||
hours=0.2)
|
||||
check("tomorrow: a one-member list is read and counted",
|
||||
c.solar_fc is not None and c.solar_fc.tomorrow is not None)
|
||||
rows, c = run({"pv_forecast_entities": ["sensor.h1", "sensor.h2_flat"]},
|
||||
hours=0.2)
|
||||
check("a member without a split: the controller's forecast is on the bell",
|
||||
c.solar_fc is not None and c.solar_fc.today.periods == ())
|
||||
CALLS.clear()
|
||||
run({"pv_forecast_entities": ["sensor.h1", "sensor.h2"]}, hours=0.2,
|
||||
modes=False)
|
||||
check("battery modes off: no forecast member is read", CALLS == [])
|
||||
check("the status row is a STYLE status row (<= 15 words, no entity id)",
|
||||
all(len(t.split()) <= 15 and "sensor." not in t for t in (
|
||||
messages.row_forecast_gap("today", [2, 3], 3),
|
||||
messages.row_forecast_gap("tomorrow", [1], 2))))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"FAILED: {len(fails)}")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,267 @@
|
||||
"""Runnable check for OPT-20. `python3 test_opt20.py`
|
||||
|
||||
A refusal probe (OPT-15 F3, SAFETY-73) asks the probing battery PROBE_W and
|
||||
nothing else. On TEST-23 (OPT-15 g7) the probe made the trade ask 2500 W: B
|
||||
was pinned at 200 W and the allocator put the other 2300 W on A, which sat in
|
||||
its maintenance charge phase at 0 W, with the grid at 3100 W for 34 s.
|
||||
|
||||
1. modetrade: the ask carries PROBE_W for the probing battery, the other
|
||||
chargers' ratings as without the probe, and `size()` adds the probe on top
|
||||
of their share (or asks PROBE_W exactly, or nothing).
|
||||
2. main: the ask carries PROBE_W for exactly the pins filed this tick, so the
|
||||
first and the last cycle of a window do not move another battery's share.
|
||||
3. The real Controller in the lagged replay, with B refusing and probed while
|
||||
A is (a) in its maintenance charge phase (also with the charge shaped to
|
||||
0 W), (b) idle, (c) a normal charger in the same run, and the OPT-15 rig's
|
||||
g7 sequence: A's command is the same through the window as just before it,
|
||||
B gets PROBE_W, and the commanded charge rises by PROBE_W at most.
|
||||
4. Battery modes on with no probe: byte-identical to release/1.0 bf9019e.
|
||||
|
||||
Out of scope, and unchanged: a normal (non-probing) trade placed on a battery
|
||||
in maintenance charge (OPT-11 P4 F4) and the action-log wording (UX-38).
|
||||
Mutations: MUTATION.md says how to run them (purged copies); the 12 of
|
||||
OPT-20 are listed in the pull request.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
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 opt20_scen as sc # noqa: E402
|
||||
from app import arbitrage, modetrade, packmode # noqa: E402
|
||||
from app import control as ctl # noqa: E402
|
||||
from app import main # noqa: E402
|
||||
from app.arbiter import Claim, P_SAFETY # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
PW = ctl.PROBE_W
|
||||
A, B = sc.A, sc.B
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. modetrade: the ask and the size")
|
||||
_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)
|
||||
|
||||
|
||||
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
|
||||
refused=-1, probing=True)
|
||||
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0)
|
||||
_pm = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST, idle=False)
|
||||
_pi = dataclasses.replace(_pa, mode=packmode.SOLAR_FIRST)
|
||||
|
||||
_t_alone = _dec([_pb, _pm])
|
||||
check("a probe beside a battery in maintenance asks PROBE_W and no more",
|
||||
_t_alone.action == modetrade.CHARGE and _t_alone.ask_w == -PW
|
||||
and _t_alone.probe_w == PW)
|
||||
check("...beside an idle battery that cannot charge from the grid too",
|
||||
_dec([_pb, _pi]).ask_w == -PW and _dec([_pb, _pi]).probe_w == PW)
|
||||
_t_two = _dec([_pb, _pa])
|
||||
check("...beside a normal charger the ask is that charger's rating plus "
|
||||
"PROBE_W", _t_two.ask_w == -2500.0 - PW and _t_two.probe_w == PW)
|
||||
_t_np = _dec([dataclasses.replace(_pb, probing=False), _pa])
|
||||
check("no probe: the ask is the chargers' ratings, probe_w is 0 (as before)",
|
||||
_t_np.ask_w == -2500.0 and _t_np.probe_w == 0.0
|
||||
and _dec([_pa, _pm]).ask_w == -2500.0
|
||||
and modetrade.Tick({}, {}, None, 0.0, None).probe_w == 0.0)
|
||||
|
||||
|
||||
def sz(ask, probe_w, lo, room, action=modetrade.CHARGE):
|
||||
t = modetrade.Tick({}, {}, action, ask, None, probe_w)
|
||||
return modetrade.size(t, (lo, 2500.0), room)
|
||||
|
||||
|
||||
check("size, a probe alone: PROBE_W, whatever the headroom above it",
|
||||
sz(-PW, PW, -(PW), 3000.0) == -PW and sz(-PW, PW, -2700.0, 9000.0) == -PW)
|
||||
check("...exactly PROBE_W or nothing: a headroom or a bound under it files "
|
||||
"no trade (a smaller ask voids the pin and the allocator would place "
|
||||
"it elsewhere)",
|
||||
sz(-PW, PW, -2700.0, PW - 1) == 0.0 and sz(-PW, PW, -(PW - 1), 3000.0)
|
||||
== 0.0 and sz(-PW, PW, -2700.0, 0.0) == 0.0
|
||||
and sz(-PW, PW, -2700.0, PW) == -PW)
|
||||
check("size with another charger: its share is sized as without the probe, "
|
||||
"the probe's PROBE_W is added on top",
|
||||
sz(-2700.0, PW, -2700.0, 3000.0) == -2700.0
|
||||
and sz(-2700.0, PW, -2700.0, 1000.0) == -1200.0
|
||||
and sz(-2700.0, PW, -1700.0, 3000.0) == -1700.0
|
||||
and sz(-2700.0, PW, -2700.0, 0.0) == 0.0)
|
||||
check("size with no probe is what it was (ask, bound, headroom)",
|
||||
sz(-2500.0, 0.0, -2700.0, 3000.0) == -2500.0
|
||||
and sz(-2500.0, 0.0, -2700.0, 1000.0) == -1000.0
|
||||
and sz(-2500.0, 0.0, -1800.0, 3000.0) == -1800.0
|
||||
and sz(-2500.0, 0.0, -2700.0, None) == 0.0
|
||||
and modetrade.size(modetrade.Tick({}, {}, None, 0.0, None),
|
||||
(-5.0, 5.0), 3000.0) == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. main: the ask carries the pins filed this tick")
|
||||
|
||||
|
||||
def trade(tick, pins, bound=(-2700.0, 2500.0), room=3000.0):
|
||||
"""The trade `_mode_trade()` files: (claims, size or None)."""
|
||||
claims = [Claim.set("refused", P_SAFETY, -PW * 1.0, ctl.PROBING,
|
||||
actuator="x") for _ in range(pins)]
|
||||
me = SimpleNamespace(
|
||||
mode_tick=tick, loop_bound=lambda cl: bound, arb_headroom_w=room,
|
||||
mode_runs=SimpleNamespace(
|
||||
cheap=SimpleNamespace(start=sc.UTC0, end=sc.UTC0,
|
||||
mean_eur_kwh=0.1),
|
||||
sell=None, level_eur_kwh=0.1),
|
||||
arb=None, _arb_action=None, log_event=lambda *a, **k: None)
|
||||
filed = main.Controller._mode_trade(me, claims, sc.UTC0)
|
||||
t = [c for c in claims if c.source == arbitrage.SOURCE]
|
||||
return filed, (t[0].value if t else None), me.mode_tick
|
||||
|
||||
|
||||
_t0 = modetrade.Tick({}, {A: modetrade.CHARGE}, modetrade.CHARGE, -2500.0,
|
||||
None, 0.0)
|
||||
check("the pin is filed but decide() ran before it (the window's first "
|
||||
"cycle): the charger keeps its whole ask and the pin is added",
|
||||
trade(_t0, 1)[1] == -2700.0 and trade(_t0, 1)[2].probe_w == PW)
|
||||
check("the window closed after decide() (its last cycle): the probe's PROBE_W "
|
||||
"leaves the ask, so no battery is handed it",
|
||||
trade(_t_two, 0)[1] == -2500.0
|
||||
and trade(_t_alone, 0) == (False, None, trade(_t_alone, 0)[2])
|
||||
and trade(_t_alone, 0)[2].action is None)
|
||||
check("pin and ask agree: nothing changes",
|
||||
trade(_t_two, 1)[1] == -2700.0 and trade(_t_alone, 1)[1] == -PW
|
||||
and trade(_t0, 0)[1] == -2500.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the real Controller: B refuses and is probed")
|
||||
|
||||
|
||||
def charge(r):
|
||||
return -sum(min(0.0, v) for v in r["shares"].values())
|
||||
|
||||
|
||||
def window_checks(rows, label):
|
||||
"""Per probe window: A as just before, B at PROBE_W (or 0 when nothing is
|
||||
traded), the charge up by at most PROBE_W."""
|
||||
win = sc.windows(rows)
|
||||
ok_a = ok_b = ok_sum = ok_grid = True
|
||||
for i, j in win:
|
||||
pre = rows[i - 1]
|
||||
lo, hi = max(0, i - 3), min(len(rows) - 1, j + 3)
|
||||
ext = rows[lo:hi + 1]
|
||||
base_sum = sum(min(0.0, v) for v in pre["shares"].values())
|
||||
ok_a &= all(abs(r["shares"].get(A, 0.0) - pre["shares"].get(A, 0.0))
|
||||
< 1e-6 for r in ext)
|
||||
bs = [r["shares"].get(B, 0.0) for r in ext]
|
||||
if any(r["action"] for r in rows[i:j + 1]):
|
||||
ok_b &= set(bs) <= {0.0, -PW} and bs.count(-PW) >= 20
|
||||
else: # a drain comes first: the window trades nothing
|
||||
ok_b &= set(bs) == {0.0}
|
||||
ok_sum &= all(charge(r) <= charge(pre) + PW + 1e-6 for r in ext)
|
||||
ok_grid &= max(r["grid"] for r in ext) <= pre["grid"] + PW + 50.0
|
||||
n_ch = sum(1 for r in rows if r["probing"] and r["action"])
|
||||
print(f" {label}: {len(win)} window(s), {n_ch} probing cycles with a "
|
||||
f"trade, highest grid "
|
||||
f"{max(r['grid'] for r in rows):.0f} W")
|
||||
return win, ok_a, ok_b, ok_sum, ok_grid
|
||||
|
||||
|
||||
SCEN = {
|
||||
"(a) A in its maintenance charge phase": (
|
||||
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE), "same"),
|
||||
"(a') ...with the charge shaped to 0 W": (
|
||||
sc.day(sc.SF_CH, a_maint=sc.MAINT_CHARGE, shaped=True), "same"),
|
||||
"(b) A idle at 0 W (Solar first, the sun covers the house)": (
|
||||
sc.day(sc.SF_CH, house_w=-10.0), "same"),
|
||||
"(c) A a normal charger in the same run": (sc.day(sc.CH_CH), "same"),
|
||||
"(g7) the OPT-15 rig: drain, then maintenance charge at 0 W": (
|
||||
sc.g7(), "same"),
|
||||
# Not the fix's: A gives the house, and a charge ask means no other
|
||||
# battery gives (OPT-11 owner answer 3), so A rests, as on bf9019e.
|
||||
"(b2) A gives 800 W to the house": (sc.day(sc.SF_CH), "rests"),
|
||||
}
|
||||
for _name, (_rows, _kind) in SCEN.items():
|
||||
win, ok_a, ok_b, ok_sum, ok_grid = window_checks(_rows, _name)
|
||||
check(f"{_name}: a probe happens (non-vacuous)", len(win) >= 1)
|
||||
if _kind == "same":
|
||||
check(f"{_name}: A's command is the same through each window, first "
|
||||
"and last cycle included", ok_a)
|
||||
check(f"{_name}: the grid rises by PROBE_W at most (plus 50 W of "
|
||||
"noise)", ok_grid)
|
||||
else:
|
||||
check(f"{_name}: A stops giving for the window (the one-sign rule, "
|
||||
"as on bf9019e), charges nothing and gives back after it",
|
||||
all(r["shares"].get(A, 0.0) == 0.0 for i, j in win
|
||||
for r in _rows[i + 1:j]) and all(
|
||||
_rows[j + 4]["shares"].get(A, 0.0) > 0.0 for i, j in win))
|
||||
check(f"{_name}: B gets PROBE_W and nothing else", ok_b)
|
||||
check(f"{_name}: the commanded charge rises by PROBE_W at most", ok_sum)
|
||||
|
||||
# The g7 sequence in detail: the probe after the drain.
|
||||
_g7 = SCEN["(g7) the OPT-15 rig: drain, then maintenance charge at 0 W"][0]
|
||||
_w7 = sc.windows(_g7)
|
||||
_after = [w for w in _w7 if _g7[w[0]]["now"] > sc.at(18.3, sc.GUTC0)]
|
||||
_peak7 = max(r["grid"] for r in _g7 if r["now"] > sc.at(18.3, sc.GUTC0))
|
||||
print(f" g7: probes after the drain at "
|
||||
f"{[_g7[w[0]]['now'].astimezone(sc.L).strftime('%H:%M:%S') for w in _after]}"
|
||||
f", grid after the drain at most {_peak7:.0f} W (TEST-23: 3100 W)")
|
||||
check("g7: the probe after A's drain asks B for 200 W with A at 0 W "
|
||||
"(TEST-23: -2300 W on A, the grid at 3100 W)",
|
||||
_after and all(
|
||||
abs(r["shares"].get(A, 0.0)) < 1e-6 for i, j in _after
|
||||
for r in _g7[i:j + 4]) and _peak7 <= 800.0 + PW + 50.0)
|
||||
check("g7: during the drain no probe trades (a drain comes first)",
|
||||
all(r["action"] is None for r in _g7
|
||||
if sc.at(18.0, sc.GUTC0) <= r["now"] < sc.at(18.28, sc.GUTC0)))
|
||||
_acts = [r["reason"] for i, j in _after for r in _g7[i:j + 1]
|
||||
if r["action"]]
|
||||
check("g7: the action log's trade line names the probe's watts, 200 W",
|
||||
_acts and all(f"{PW:.0f} W on {B}" in a for a in _acts))
|
||||
check("no opposite-direction split on any of these cycles",
|
||||
not [r for _r, _ in SCEN.values() for r in _r
|
||||
if any(v > 0 for k, v in r["shares"].items() if r["action"])
|
||||
and any(v < 0 for v in r["shares"].values())])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
|
||||
# Printed by tools/opt20_scen.py on release/1.0 bf9019e (2026-10-15),
|
||||
# goodwe-addon-checks:1.
|
||||
AT_BASE = {
|
||||
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
|
||||
"ch_ch": "15fc25dd34186e56d34b752d37c9e8613295ba737938c92454acca08c83080fb",
|
||||
"ch_ch_maint":
|
||||
"e46e3c666dd63ab33b56e83c0a560c07cec7774a9a70560f55c62a6ae6f55920",
|
||||
}
|
||||
_got = sc.no_probe_digests()
|
||||
for _k in AT_BASE:
|
||||
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
||||
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)", _got == AT_BASE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -534,11 +534,15 @@ FIXTURES = {
|
||||
[v / 1000 for v in EPEX_EUR_MWH]),
|
||||
"custom_formula": (columnar_feed(EPEX_EUR_MWH), FORMULA_EUR_KWH),
|
||||
"manual_csv": (None, [0.1834, -0.0210, 0.3115]),
|
||||
# PRICE-01: the owner's price server answers the same columnar layout.
|
||||
"price_service": (columnar_feed(EPEX_EUR_MWH),
|
||||
[v / 1000 for v in EPEX_EUR_MWH]),
|
||||
}
|
||||
|
||||
check(f"the plugins that ship after TARIFF-07 and TARIFF-15 are registered "
|
||||
f"{sorted(PROVIDERS)}",
|
||||
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv"})
|
||||
set(PROVIDERS) == {"generic_dayahead", "custom_formula", "manual_csv",
|
||||
"price_service"})
|
||||
check(f"...and the three withdrawn ids resolve to NOTHING "
|
||||
f"{sorted(RETIRED_PROVIDERS)}",
|
||||
set(RETIRED_PROVIDERS) == {"engie_dynamic", "totalenergies_dynamic",
|
||||
@@ -571,7 +575,7 @@ def canned(body):
|
||||
"""A transport that serves recorded bytes and records that it was used."""
|
||||
calls = []
|
||||
|
||||
async def t(url, timeout_s):
|
||||
async def t(url, timeout_s, headers=None):
|
||||
calls.append((url, timeout_s))
|
||||
return body
|
||||
t.calls = calls
|
||||
@@ -603,6 +607,9 @@ def load_any(pid):
|
||||
# is exactly what section 3 grades.
|
||||
return load(pid, {"price_formula": json.dumps(ENECO_CARD)},
|
||||
transport=canned(body))
|
||||
if pid == "price_service":
|
||||
return load(pid, {"price_service_token": "test-token-1234"},
|
||||
transport=canned(body))
|
||||
return load(pid, transport=canned(body))
|
||||
|
||||
|
||||
@@ -898,7 +905,16 @@ _networked = sorted(pid for pid, cls in PROVIDERS.items()
|
||||
# SAME CAPTURED BYTES (section 13c), because it reads the same endpoint with
|
||||
# the same parser. A networked plugin with no capture is what this line exists
|
||||
# to forbid.
|
||||
_replayed = ["custom_formula", "generic_dayahead"]
|
||||
#
|
||||
# ⚠️ PRICE-01 PUT A THIRD ON IT, AND ITS CAPTURE IS A RESHAPE, SAID PLAINLY. The
|
||||
# price server answers only to a token this session does not hold, so no real
|
||||
# answer of its own exists to replay. Its answer is the energy-charts layout
|
||||
# (design, API section), so test_price_service.py replays the REAL energy-charts
|
||||
# two-day capture with the three extra keys the design lists (`covers`,
|
||||
# `source`, `fetched_at`). The prices and instants are real; the wrapper is the
|
||||
# design's. The first answer captured from the live server on the rig test
|
||||
# replaces it - that is the open item, and it is in the PRICE-01 report.
|
||||
_replayed = ["custom_formula", "generic_dayahead", "price_service"]
|
||||
check(f"the networked plugins are {_networked}, and EVERY one of them is "
|
||||
f"replayed from a captured real body ({_replayed}) - no shipped plugin "
|
||||
f"reaches a network this file has never seen answer (TARIFF-07)",
|
||||
@@ -1252,7 +1268,7 @@ check(f"a negative day-ahead price passes through, all-in {_neg}",
|
||||
# (TARIFF-15): it reads the SAME columnar document, which has no per-point
|
||||
# flag, and applies arithmetic to it - arithmetic cannot invent a provenance
|
||||
# the document never carried.
|
||||
_SETTLED_FEED = {"generic_dayahead", "custom_formula"}
|
||||
_SETTLED_FEED = {"generic_dayahead", "custom_formula", "price_service"}
|
||||
for pid in sorted(set(READS) - _SETTLED_FEED):
|
||||
read = READS[pid]
|
||||
flags = derived(lambda r=read: [p.provisional for p in r.points])
|
||||
|
||||
@@ -1133,6 +1133,11 @@ _T17_KEYS = ["price_taxes_profile"]
|
||||
# `export_price_eur_kwh` is untouched, every site on the scalar is unaffected,
|
||||
# and it lives in this dict and in config.yaml and nowhere else.
|
||||
_T16_KEYS = ["price_export_formula"]
|
||||
# PRICE-01 added the eighth: `price_service_token`, the bearer token of the
|
||||
# owner's price server. An ADDITION, like the three above: nothing was renamed,
|
||||
# every other provider ignores it, and it lives in this dict and in config.yaml
|
||||
# (as a password field) and nowhere else.
|
||||
_P01_KEYS = ["price_service_token"]
|
||||
check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
|
||||
f"TARIFF-15's, TARIFF-17's and TARIFF-16's keys - FCST-03 still added "
|
||||
f"none of its own {_defaults}",
|
||||
@@ -1142,7 +1147,7 @@ check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, "
|
||||
"price_horizon_h", "price_timeout_s",
|
||||
"static_profile"]
|
||||
+ _OPT04_KEYS + _T15_KEYS + _T17_KEYS
|
||||
+ _T16_KEYS))
|
||||
+ _T16_KEYS + _P01_KEYS))
|
||||
# ...and the later keys are genuinely NEW, not a rename of something that was
|
||||
# already there, or the line above would pass while the mirror silently lost a
|
||||
# key.
|
||||
@@ -1151,7 +1156,7 @@ check("...and OPT-04's four, TARIFF-15's one, TARIFF-17's one and TARIFF-16's "
|
||||
"are all still present",
|
||||
isinstance(_defaults, list)
|
||||
and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)
|
||||
- set(_T17_KEYS) - set(_T16_KEYS)) == 9)
|
||||
- set(_T17_KEYS) - set(_T16_KEYS) - set(_P01_KEYS)) == 9)
|
||||
# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME.
|
||||
# The line above counts nine, and a rename of `price_taxes_eur_kwh` to
|
||||
# `price_taxes_profile` would also count nine. The scalar path being unchanged
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
"""Runnable check for OPT-15, the P3 price-run fixes. `python3 test_price_run_fixes.py`
|
||||
|
||||
The OPT-11 P3 post-merge test on TEST-23 (5a4cdc2) found three defects. This
|
||||
file drives the pure modules and the REAL Controller (tools/opt11p3_replay.py)
|
||||
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.
|
||||
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
|
||||
the model and in the lagged variants (TEST-23 measured -1120 W).
|
||||
3. F3: a charge refusal inside a cheap run is probed every 15 minutes.
|
||||
4. The P3 and SAFETY-79 invariants on the lagged runs.
|
||||
5. Modes off is byte-identical to release/1.0 e3e4ba6, also with a late P1
|
||||
sample on every quarter boundary, and the harness sees a change there.
|
||||
|
||||
DECISIONS/OPT-11-battery-modes.md has the record. Mutations (MUTATION.md,
|
||||
purged copies, graded over this file and test_modes_p3.py) are listed in the
|
||||
pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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_identity as ident # noqa: E402
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import arbitrage, modetrade, packmode, peakguard # noqa: E402
|
||||
from app import tariff # noqa: E402
|
||||
from app import control as ctl # noqa: E402
|
||||
from app import main # noqa: E402
|
||||
from app.p1 import QuarterAverager, make_sample # noqa: E402
|
||||
from app.tariff import PeakTracker # 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=2))
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
# Cheap 02:00-04:00 local, dear 19:00-20:00, flat otherwise (two days).
|
||||
PRICES = [0.30] * 192
|
||||
for _i in range(8, 16):
|
||||
PRICES[_i] = 0.10
|
||||
for _i in range(76, 80):
|
||||
PRICES[_i] = 0.40
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(PRICES)]
|
||||
RATING = rp.RATING
|
||||
BOUND = 0.15 * RATING
|
||||
AIM = rp.PEAK_CAP - 50
|
||||
CH_SF = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.SOLAR_FIRST, False)}
|
||||
|
||||
|
||||
def at(h):
|
||||
return UTC0 + timedelta(hours=h)
|
||||
|
||||
|
||||
def house(steps, base=800.0):
|
||||
"""[(t, W)] for the replay: `base`, then each (local hour, W) from then."""
|
||||
out = [(UTC0.timestamp() - 10, base)]
|
||||
out += [(at(h).timestamp(), float(w)) for h, w in steps]
|
||||
return out
|
||||
|
||||
|
||||
def run(start_h, hours, *, lag=None, soc_a=20.0, steps=(), choice=None,
|
||||
refuse=(), patch=None, check_cb=None, n=2):
|
||||
rows = []
|
||||
|
||||
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 {},
|
||||
refused=c.inverters[0].refused,
|
||||
probing=c.inverters[0].probe_from is not None,
|
||||
headroom=c.arb_headroom_w,
|
||||
p={u: p.p for u, p in h.plants.items()}))
|
||||
if check_cb:
|
||||
check_cb(c, h, now, pr)
|
||||
st, c = rp.run("p3_ch_sell", house(steps), WPTS, at(start_h),
|
||||
3600 * hours, check=cb, lag=lag, n=n,
|
||||
soc0={"a": soc_a, "b": 60.0},
|
||||
choice=CH_SF if choice is None else choice,
|
||||
refuse=refuse, patch=patch,
|
||||
extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows, c
|
||||
|
||||
|
||||
def stops(rows):
|
||||
"""[(row index, row)] where a grid charge stopped."""
|
||||
return [(i, rows[i]) for i in range(1, len(rows))
|
||||
if rows[i - 1]["action"] == arbitrage.CHARGE
|
||||
and rows[i]["action"] != arbitrage.CHARGE]
|
||||
|
||||
|
||||
def q(when):
|
||||
return int(when.timestamp() // 900)
|
||||
|
||||
|
||||
def banked(c, rows):
|
||||
"""The add-on's own P1 blocks for the quarters that had a grid charge."""
|
||||
chq = {q(r["now"]) for r in rows if r["action"] == arbitrage.CHARGE}
|
||||
return [b.offtake_avg_w for b in c.p1.blocks if q(b.start) in chq]
|
||||
|
||||
|
||||
def mixed(rows):
|
||||
return [r for r in rows if any(v > 0 for v in r["shares"].values())
|
||||
and any(v < 0 for v in r["shares"].values())]
|
||||
|
||||
|
||||
# Close to TEST-23's plant: 1 s cycles, readings 1 s old, a battery that
|
||||
# follows the previous second's command.
|
||||
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. F1: a quarter edge while charging")
|
||||
# 1a. The projection on the edge tick, before the new block's first sample.
|
||||
_avg = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
_edge = datetime(2026, 9, 9, 12, 0, tzinfo=timezone.utc)
|
||||
for _s in range(-900, 1, 1):
|
||||
_t = _edge + timedelta(seconds=_s - 0.35)
|
||||
_avg.add(make_sample("ha_signed", 3300.0, 0.0, phases=1, ingest_ts=_t,
|
||||
ingest_mono=0.0))
|
||||
_now = _edge + timedelta(seconds=0.6)
|
||||
_old = PeakTracker.project(_avg, _now)
|
||||
_new = PeakTracker.project(_avg, _now, roll=True)
|
||||
|
||||
|
||||
def _decide(pr):
|
||||
return peakguard.decide(pr, batt_w=-2500.0, cap_w=3500.0, margin_w=50.0,
|
||||
reading=None, achievable_w=5000.0, soc=50.0,
|
||||
soc_floor=20.0, max_hold_s=30.0)
|
||||
|
||||
|
||||
print(f" edge tick: old block {_old.explain()}; rolled {_new.explain()}")
|
||||
check("on the edge tick the open block is still the old one: the guard reads "
|
||||
"'no seconds left' and the headroom is unmeasured (the F1 cause)",
|
||||
_old.elapsed_s >= 900 and peakguard.charge_headroom_w(
|
||||
_decide(_old)) is None)
|
||||
_later = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
for _s in range(-900, 1, 1):
|
||||
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
|
||||
ingest_mono=0.0))
|
||||
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=0.6),
|
||||
ingest_mono=0.0))
|
||||
_real = PeakTracker.project(_later, _now)
|
||||
_hr = peakguard.charge_headroom_w(_decide(_new))
|
||||
check("rolled, it is the new quarter, measured, and equal to what the "
|
||||
"averager projects once the new block's first sample is in; the "
|
||||
"headroom is the house and the aim (3450 - 800 W)",
|
||||
_new.block_start == _edge == _real.block_start and _new.complete
|
||||
and abs(_new.projected_w - _real.projected_w) < 0.01
|
||||
and abs(_new.elapsed_s - 0.6) < 1e-6
|
||||
and _hr is not None and abs(_hr - 2650.0) < 1.0)
|
||||
_gap = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
_gap.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
|
||||
ingest_ts=_edge - timedelta(seconds=100), ingest_mono=0))
|
||||
_gp = PeakTracker.project(_gap, _edge + timedelta(seconds=40), roll=True)
|
||||
check("...and a sample older than the hold is not credited: past the hold "
|
||||
"the rolled quarter is blind, as the averager would make it",
|
||||
_gp.elapsed_s == 40.0 and _gp.covered_frac == 0.0
|
||||
and peakguard.blindness(_gp, 30.0, False) is not None)
|
||||
check("without `roll` nothing changes (the modes-off path)",
|
||||
PeakTracker.project(_avg, _now, roll=False) == _old)
|
||||
# Review of #157 (X1): a held export is credited as 0 W, as `add()` banks it,
|
||||
# never as a negative that would pull the rolled quarter under the meter.
|
||||
_exp = QuarterAverager(phases=1, max_hold_s=30.0)
|
||||
for _s in range(-900, 1, 1):
|
||||
_exp.add(make_sample("ha_signed", 0.0, 1500.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
|
||||
ingest_mono=0.0))
|
||||
_ep = PeakTracker.project(_exp, _now, roll=True)
|
||||
_exp.add(make_sample("ha_signed", 0.0, 1500.0, phases=1,
|
||||
ingest_ts=_edge + timedelta(seconds=0.6),
|
||||
ingest_mono=0.0))
|
||||
_er = PeakTracker.project(_exp, _now)
|
||||
check("...and a held export rolls in as 0 W, equal to what the averager "
|
||||
"banks for those seconds",
|
||||
_ep.block_start == _er.block_start and _ep.projected_w == 0.0
|
||||
and _ep.elapsed_w == 0.0 and _er.projected_w == 0.0)
|
||||
|
||||
# 1b. The lagged replay: 02:00-03:30 local, a Cheap hours battery charging
|
||||
# under an 800 W house through five quarter edges.
|
||||
ROWS1, C1 = run(1.9, 1.6, lag=LAG, choice={
|
||||
"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.LEFTOVER_SOLAR, False)})
|
||||
_ch1 = [r for r in ROWS1 if r["action"] == arbitrage.CHARGE]
|
||||
_edges1 = sorted({q(r["now"]) for r in _ch1})
|
||||
_st1 = stops(ROWS1)
|
||||
print(f" {len(_ch1)} charge cycles over {len(_edges1)} quarters; stops "
|
||||
f"{[(r['now'].astimezone(L).strftime('%H:%M:%S'), r['reason'][:40]) for _, r in _st1]}")
|
||||
check("no stop at any quarter edge crossed while charging (two inverters; "
|
||||
"see below for the old projection)",
|
||||
len(_edges1) >= 5 and not _st1)
|
||||
|
||||
|
||||
def _no_roll(c):
|
||||
real = c.peak.project
|
||||
c.peak.project = lambda a, now, roll=False: real(a, now)
|
||||
|
||||
|
||||
_CH1 = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.LEFTOVER_SOLAR, False)}
|
||||
# One inverter: SAFETY-91's `next_quarter()` is not applied there, so this is
|
||||
# where the old projection stops the charge at every edge.
|
||||
ROWS1N, C1N = run(1.9, 1.6, lag=LAG, n=1, choice=_CH1)
|
||||
ROWS1M, _ = run(1.9, 0.6, lag=LAG, n=1, patch=_no_roll, choice=_CH1)
|
||||
_m1 = stops(ROWS1M)
|
||||
check("one inverter: no stop at any edge crossed while charging",
|
||||
len({q(r["now"]) for r in ROWS1N if r["action"] == arbitrage.CHARGE})
|
||||
>= 5 and not stops(ROWS1N))
|
||||
check("non-vacuous: with the old projection one inverter stops at every edge "
|
||||
"for want of a measured headroom (5a4cdc2 then latched the quarter; "
|
||||
"the latch fix alone keeps it to that tick)",
|
||||
len(_m1) >= 2 and all("headroom" in r["reason"] and
|
||||
r["now"].astimezone(L).minute % 15 == 0 and
|
||||
r["now"].astimezone(L).second == 0
|
||||
for _, r in _m1))
|
||||
# Two inverters: since SAFETY-91 the guard grades the next quarter past a
|
||||
# block's end (`tariff.next_quarter()`), so the old projection no longer
|
||||
# stops there; the rolled one gives the same quarters.
|
||||
ROWS1O, C1O = run(1.9, 1.6, lag=LAG, patch=_no_roll, choice=_CH1)
|
||||
print(f" two inverters, old projection: {len(stops(ROWS1O))} stops; banked "
|
||||
f"{[round(x) for x in banked(C1O, ROWS1O)]} against "
|
||||
f"{[round(x) for x in banked(C1, ROWS1)]} rolled")
|
||||
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.
|
||||
# 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=[
|
||||
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
|
||||
(2 + 40 / 60, 2850)])
|
||||
_st2 = stops(ROWS2)
|
||||
_first = _st2[0][1] if _st2 else None
|
||||
_q2 = q(_first["now"]) if _first else None
|
||||
_after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
|
||||
and q(r["now"]) == _q2]
|
||||
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",
|
||||
_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))
|
||||
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
|
||||
if a["action"] != arbitrage.CHARGE
|
||||
and b["action"] == arbitrage.CHARGE]
|
||||
_low = [r for r in ROWS2 if r["headroom"] is not None
|
||||
and 0 < r["headroom"] <= modetrade.START_W and r["action"] is None]
|
||||
print(f" {len(_starts)} charge starts, lowest headroom at a start "
|
||||
f"{min(r['headroom'] for r in _starts):.0f} W; {len(_low)} cycles "
|
||||
f"with 0-500 W of headroom and no charge")
|
||||
check("the 500 W minimum stays: every charge starts with more than 500 W of "
|
||||
"headroom, and a house at 3 kW (450 W) holds it off",
|
||||
_starts and _low and all(r["headroom"] > modetrade.START_W
|
||||
for r in _starts))
|
||||
|
||||
# 1d. An unmeasured headroom stops one tick and does not latch.
|
||||
_c1d = run(1.9, 0.2, soc_a=20.0)[1]
|
||||
_n1d = UTC0 + timedelta(hours=2, minutes=5)
|
||||
_ok1d = False
|
||||
if _c1d.mode_tick is not None and _c1d.mode_tick.action == modetrade.CHARGE:
|
||||
_c1d.arb_headroom_w = None
|
||||
_t1 = _c1d._mode_roles(_n1d, None)
|
||||
_c1d.arb_headroom_w = 3000.0
|
||||
_t2 = _c1d._mode_roles(_n1d + timedelta(seconds=1), None)
|
||||
_c1d.arb_headroom_w = 0.0
|
||||
_t3 = _c1d._mode_roles(_n1d + timedelta(seconds=2), None)
|
||||
_c1d.arb_headroom_w = 3000.0
|
||||
_t4 = _c1d._mode_roles(_n1d + timedelta(seconds=3), None)
|
||||
_ok1d = (_t1.action is None and _t2.action == modetrade.CHARGE
|
||||
and _t3.action is None and _t4.action is None
|
||||
and _c1d._charge_blocked_q == q(_n1d))
|
||||
check("an unmeasured headroom (None) stops the charge that tick only; a "
|
||||
"measured 0 W stops it for the rest of the quarter", _ok1d)
|
||||
|
||||
|
||||
# 1e. Review of #157: gaps cannot flip the charge. A headroom forced to None
|
||||
# every other cycle flipped it 1799 times in 30 min before this; the second
|
||||
# unmeasured stop in a quarter now latches it.
|
||||
def _gaps(c):
|
||||
real, k = c._mode_roles, [0]
|
||||
|
||||
def mr(now, maint):
|
||||
k[0] += 1
|
||||
if k[0] % 2 == 0:
|
||||
c.arb_headroom_w = None
|
||||
return real(now, maint)
|
||||
c._mode_roles = mr
|
||||
|
||||
|
||||
_rows1e = run(2.0, 0.5, lag=LAG, patch=_gaps)[0]
|
||||
_flips = {}
|
||||
_on1e = next((i for i, r in enumerate(_rows1e)
|
||||
if r["action"] == arbitrage.CHARGE), len(_rows1e))
|
||||
for _a, _b in zip(_rows1e[_on1e:], _rows1e[_on1e + 1:]):
|
||||
if (_a["action"] == arbitrage.CHARGE) != (_b["action"] == arbitrage.CHARGE):
|
||||
_flips[q(_b["now"])] = _flips.get(q(_b["now"]), 0) + 1
|
||||
print(f" None every other cycle: charge on/off changes per quarter "
|
||||
f"{sorted(_flips.values())}")
|
||||
check("a headroom unmeasured every other cycle changes the charge at most "
|
||||
"4 times a quarter (restart, stop, restart, stop and latch)",
|
||||
_flips and max(_flips.values()) <= 4)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. F2: the export overshoot at a charge end")
|
||||
# A fills to its 95 % ceiling at about 04:03 under an 800 W house, and
|
||||
# Solar first B takes the house over.
|
||||
VARIANTS = {
|
||||
"model (5 s, instant readings)": None,
|
||||
"model, lagged (1 s cycles)": rp.Lag(),
|
||||
"TEST-23 plant, meter 1 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9),
|
||||
"TEST-23 plant, meter 2 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
|
||||
meter_s=2.0),
|
||||
"meter 2 s, battery reading fresh": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
|
||||
meter_s=2.0, batt_s=0.0),
|
||||
"2 s cycles, meter 2 s": rp.Lag(tick=2.0, meter_s=2.0),
|
||||
"5 s meter": rp.Lag(tick=5.0, meter_s=5.0, batt_s=2.0),
|
||||
"slow battery (8 s, 2 s dead), 5 s meter": rp.Lag(
|
||||
tau_s=8.0, dead_s=2.0, tick=5.0, meter_s=5.0, batt_s=2.0),
|
||||
}
|
||||
|
||||
|
||||
def charge_end(rows, lag):
|
||||
"""(min grid, max grid, pre-edge grid, settle s) after the first charge
|
||||
end, over 120 s; settle: from then on within 40 W of the target."""
|
||||
st = stops(rows)
|
||||
if not st:
|
||||
return None
|
||||
i = st[0][0]
|
||||
tick = rp.TICK if lag is None else lag.tick
|
||||
w = [r["grid"] for r in rows[i:i + int(120 / tick)]]
|
||||
settle = next((j * tick for j in range(len(w))
|
||||
if all(abs(x + 10) < 40 for x in w[j:])), None)
|
||||
return min(w), max(w), rows[i - 1]["grid"], settle
|
||||
|
||||
|
||||
F2 = {}
|
||||
for _k, _lag in VARIANTS.items():
|
||||
F2[_k] = charge_end(run(3.9, 0.5, lag=_lag, soc_a=86.0)[0], _lag)
|
||||
print(f" {_k}: {F2[_k] and tuple(round(x) if x is not None else x for x in F2[_k])}")
|
||||
check(f"every variant: the charge ends, the export overshoot stays within "
|
||||
f"0.15 x rating ({BOUND:.0f} W), no import above the charge's own, and "
|
||||
f"the grid settles within 45 s (90 s for the slow battery)",
|
||||
all(v is not None and v[0] >= -BOUND and v[1] <= v[2] + 50
|
||||
and v[3] is not None and v[3] <= (90 if k.startswith("slow")
|
||||
else 45)
|
||||
for k, v in F2.items()))
|
||||
|
||||
|
||||
def _no_hold(c):
|
||||
c._edge_grid = lambda end, now: c.grid
|
||||
|
||||
|
||||
_pre = charge_end(run(3.9, 0.5, lag=VARIANTS["TEST-23 plant, meter 2 s"],
|
||||
soc_a=86.0, patch=_no_hold)[0],
|
||||
VARIANTS["TEST-23 plant, meter 2 s"])
|
||||
print(f" without the hold (5a4cdc2's reseed), meter 2 s: "
|
||||
f"{_pre and tuple(round(x) if x is not None else x for x in _pre)}")
|
||||
check("non-vacuous: without the hold the lagged model shows TEST-23's "
|
||||
"overshoot (over 0.4 x rating), so it cannot hide again",
|
||||
_pre is not None and _pre[0] < -0.4 * RATING)
|
||||
|
||||
# The hold must not hide a real load change at the edge.
|
||||
_down = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[])[0]
|
||||
_i_end = stops(_down)[0][0] if stops(_down) else None
|
||||
_edge_t = _down[_i_end]["now"] if _i_end else None
|
||||
|
||||
|
||||
def _edge_step(w):
|
||||
"""The same charge end with the house stepping to `w` at the edge:
|
||||
(seconds until the loop moves B off E, lowest grid, highest grid after
|
||||
the first 3 s)."""
|
||||
if _edge_t is None:
|
||||
return None
|
||||
h = (_edge_t - UTC0).total_seconds() / 3600
|
||||
rows = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[(h, w)])[0]
|
||||
after = [r for r in rows if r["now"] >= _edge_t]
|
||||
first = nth(after, 0, "the edge cycle")
|
||||
if not present(first):
|
||||
return None
|
||||
e = first["shares"].get(B)
|
||||
moved = next(((r["now"] - _edge_t).total_seconds() for r in after
|
||||
if r["shares"].get(B) != e), None)
|
||||
return moved, min(r["grid"] for r in after[:60]), max(
|
||||
r["grid"] for r in after[3:60]), [r["grid"] for r in after[:60]]
|
||||
|
||||
|
||||
_fall, _rise = _edge_step(200.0), _edge_step(1800.0)
|
||||
print(f" house 800 -> 200 W at the edge: loop moves after {_fall[0]} s, "
|
||||
f"lowest {_fall[1]:.0f} W; 800 -> 1800 W: after {_rise[0]} s, highest "
|
||||
f"{_rise[2]:.0f} W")
|
||||
check("the hold does not hide a load change: a house that falls at the edge "
|
||||
"ends it within 5 s, with no export beyond the fall and the bound; one "
|
||||
"that rises is followed by EDGE_HOLD_S, with no import beyond the rise",
|
||||
_fall and _rise and _fall[0] is not None and _fall[0] <= 5
|
||||
and _fall[1] >= -600 - BOUND
|
||||
and _rise[0] is not None and _rise[0] <= main.EDGE_HOLD_S + 2
|
||||
and _rise[2] <= 1000 + 50)
|
||||
_peak = _edge_step(4500.0)
|
||||
_g10 = nth(_peak[3], 10, "10 s after the edge") if _peak else None
|
||||
print(f" 800 -> 4500 W at the edge: grid 10 s later {_g10} W")
|
||||
check("...and when the peak floor takes the command during the hold, the "
|
||||
"hold ends: 10 s after the edge B gives its rating (grid 4500 - 2500 W), "
|
||||
"not the aim the floor holds", present(_g10) and _g10 <= 2100)
|
||||
|
||||
# Review of #157: the hold's own rules, on `_reseed_run_edge()` and
|
||||
# `_edge_grid()` directly (target -10 W, deadband 15 W, EDGE_HOLD_S 18 s).
|
||||
_T0 = UTC0
|
||||
|
||||
|
||||
def _held(grid, batt, s, last_b=-2500.0, end=False):
|
||||
"""(what the loop reads, still held) for a charge end E=800 W at +s."""
|
||||
f = SimpleNamespace(
|
||||
_edge=(800.0, 1.0, _T0 + timedelta(seconds=main.EDGE_HOLD_S), last_b),
|
||||
tuning=ctl.Tuning(target_grid_w=-10.0), grid=grid,
|
||||
site_batt_w=batt, i_w=0.0)
|
||||
g = main.Controller._edge_grid(f, end, _T0 + timedelta(seconds=s))
|
||||
return g, f._edge is not None
|
||||
|
||||
|
||||
_hs = main.EDGE_HOLD_S
|
||||
check("the hold: the loop reads the target while the meter still shows the "
|
||||
"step; it ends on a new trade or claim, when the meter comes within the "
|
||||
"deadband of the target, and at EDGE_HOLD_S (later only while the "
|
||||
"battery still moves to E, and never past twice that)",
|
||||
_held(3300.0, -2500.0, 1) == (-10.0, True)
|
||||
and _held(3300.0, -2500.0, 1, end=True) == (3300.0, False)
|
||||
and _held(5.0, -2500.0, 1) == (-10.0, True)
|
||||
and _held(4.0, -2500.0, 1) == (4.0, False)
|
||||
and _held(3300.0, -2500.0, _hs + 1) == (3300.0, False)
|
||||
and _held(3300.0, -2000.0, _hs + 1) == (-10.0, True)
|
||||
and _held(3300.0, -2000.0, 2 * _hs + 1) == (3300.0, False))
|
||||
# Review of #157 (X4): a reading that has passed E is not "still moving".
|
||||
check("...and a battery reading already past E does not extend the hold",
|
||||
_held(3300.0, 900.0, _hs + 1, last_b=700.0) == (3300.0, False))
|
||||
_f = SimpleNamespace(site_batt_w=500.0, grid=90.0, _edge=None,
|
||||
tuning=ctl.Tuning(target_grid_w=-10.0), site_max_w=5000.0,
|
||||
loop_bound=lambda claims: None, target=None, i_w=None)
|
||||
main.Controller._reseed_run_edge(_f, [], _T0)
|
||||
check("...and a small step is held too (E 600 W from 500 W)",
|
||||
_f.target == 600.0 and _f._edge is not None and _f._edge[:2] == (
|
||||
600.0, 1.0))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. F3: a charge refusal inside a cheap run is probed")
|
||||
# A refuses to charge from its first charge; the plant accepts again at
|
||||
# 02:40. The cheap run lasts to 04:45.
|
||||
|
||||
|
||||
def _accept(c, h, now, pr):
|
||||
if now >= at(2 + 40 / 60):
|
||||
h.plants["a"].refuse_charge = False
|
||||
|
||||
|
||||
ROWS3, C3 = run(1.9, 1.2, soc_a=30.0, refuse=("a",), check_cb=_accept)
|
||||
_ref3 = [r for r in ROWS3 if r["refused"]]
|
||||
_pr3 = [r for r in ROWS3 if r["probing"] and r["action"] == arbitrage.CHARGE]
|
||||
_probe_q = sorted({r["now"].astimezone(L).strftime("%H:%M") for r in _pr3})
|
||||
_end3 = next((r for r in ROWS3 if _ref3 and r["now"] > _ref3[-1]["now"]),
|
||||
None)
|
||||
print(f" refused {len(_ref3)} cycles; charge probes at {_probe_q}; "
|
||||
f"after: {_end3 and _end3['now'].astimezone(L).strftime('%H:%M:%S')}"
|
||||
f" {_end3 and _end3['action']}")
|
||||
check("while A refuses, it is probed every 15 minutes: at PROBE_W exactly, "
|
||||
"with B resting (no share on B, one sign)",
|
||||
len(_probe_q) >= 2 and all(
|
||||
r["shares"].get(A) == -ctl.PROBE_W and r["shares"].get(B, 0) == 0
|
||||
for r in _pr3))
|
||||
check("...outside a probe the refusal wins: no grid trade and A never "
|
||||
"charges",
|
||||
all(r["action"] is None and r["shares"].get(A, 0) >= 0
|
||||
for r in _ref3 if not r["probing"]))
|
||||
check("...and the probe after the plant accepts again ends the refusal and "
|
||||
"the charge resumes in the same run",
|
||||
_end3 is not None and _end3["now"] < at(3)
|
||||
and any(r["action"] == arbitrage.CHARGE and r["shares"].get(A, 0)
|
||||
< -1000 for r in ROWS3 if r["now"] > _end3["now"]))
|
||||
# Review of #157 (M15): outside its probe a refuser is no charger, so the
|
||||
# others are not made to rest. With solar to spare, Solar first B takes it.
|
||||
_rows3s = run(1.9, 0.5, soc_a=30.0, refuse=("a",), steps=[(2, -1500)])[0]
|
||||
_pt = [r["now"] for r in _rows3s if r["probing"]]
|
||||
_sun = [r for r in _rows3s if r["refused"] and not r["probing"]
|
||||
and r["now"] >= at(2 + 5 / 60) and not any(
|
||||
timedelta(0) < r["now"] - t <= timedelta(seconds=30) for t in _pt)]
|
||||
_bsun = sorted(r["shares"].get(B, 0) for r in _sun)
|
||||
print(f" solar 1500 W over the house, A refusing: B's share outside probes "
|
||||
f"{_bsun[0] if _bsun else None} .. {_bsun[-1] if _bsun else None} W")
|
||||
check("...outside a probe the others are not held at rest: Solar first B "
|
||||
"takes the spare solar (from 30 s after a probe)",
|
||||
_sun and all(r["shares"].get(B, 0) < -1000 for r in _sun))
|
||||
_pk = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0,
|
||||
10.0, refused=-1, probing=True)
|
||||
_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, UTC0, packs, **args)
|
||||
|
||||
|
||||
_pb = modetrade.Pack(B, True, packmode.SOLAR_FIRST, False, 2500.0, 60.0,
|
||||
10.0)
|
||||
check("modetrade: a probing charge refuser is a charger, the others rest; "
|
||||
"not while in maintenance, not on Leftover solar or Solar first, not "
|
||||
"latched, not under the 500 W minimum, not with the guard engaged, "
|
||||
"and never in energy() (the plan's sizing)",
|
||||
_dec([_pk, _pb]).action == modetrade.CHARGE
|
||||
and _dec([_pk, _pb]).roles == {A: modetrade.CHARGE, B: modetrade.REST}
|
||||
and _dec([replace(_pk, idle=False), _pb]).action is None
|
||||
and all(_dec([replace(_pk, mode=m), _pb]).action is None
|
||||
for m in (packmode.LEFTOVER_SOLAR, packmode.SOLAR_FIRST))
|
||||
and _dec([_pk, _pb], blocked=True).action is None
|
||||
and _dec([_pk, _pb], headroom_w=450.0).action is None
|
||||
and _dec([_pk, _pb], charge_ok=False, trade_why="peak").action is None
|
||||
and modetrade.energy([_pk], 20.0, 95.0)[1] == 0.0
|
||||
and _dec([replace(_pk, probing=False), _pb]).action is None)
|
||||
|
||||
# On the new base (release/1.0 e3e4ba6): SAFETY-78's drain comes before the
|
||||
# probe, and P4's day plan sets how far a probing Leftover solar battery goes.
|
||||
_ls = replace(_pk, mode=packmode.LEFTOVER_SOLAR)
|
||||
_dr = _dec([_pk, _pb], draining=True)
|
||||
check("a monthly drain comes before the probe: no grid charge while another "
|
||||
"battery drains, the probing refuser included (why 'cycle')",
|
||||
_dr.action is None and _dr.why == "cycle"
|
||||
and _dr.roles.get(A) != modetrade.CHARGE
|
||||
and _dec([_pk, _pb], draining=False).action == modetrade.CHARGE)
|
||||
check("with a day plan a probing Leftover solar battery is a charger up to "
|
||||
"its planned level and no further; without a level for it, it is not",
|
||||
_dec([_ls, _pb], targets={A: 60.0}).action == modetrade.CHARGE
|
||||
and _dec([_ls, _pb], targets={A: 25.0}).action is None
|
||||
and _dec([_ls, _pb], targets={B: 60.0}).action is None
|
||||
and _dec([_ls, _pb]).action is None
|
||||
and modetrade.energy([_ls], 20.0, 95.0, targets={A: 60.0})[1] == 0.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. invariants on these runs (and part 1c's house in the model)")
|
||||
_rows4, _c4 = run(1.9, 0.9, steps=[
|
||||
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
|
||||
(2 + 40 / 60, 2850)])
|
||||
_all = ROWS1 + ROWS2 + ROWS3 + _rows4
|
||||
check("no opposite-direction split on any cycle", not mixed(_all))
|
||||
_ceil = peakguard.quarter_ceiling_w(rp.PEAK_CAP, None)[0]
|
||||
_bk0 = banked(_c4, _rows4)
|
||||
_bk = banked(C1, ROWS1) + banked(C2, ROWS2)
|
||||
print(f" banked quarters with a grid charge: model {len(_bk0)}, highest "
|
||||
f"{max(_bk0):.2f} W; lagged {len(_bk)}, highest {max(_bk):.2f} W "
|
||||
f"(e3e4ba6: the same 3451.37 W); ceiling - margin {_ceil - 50:.0f} W")
|
||||
check("every banked 15-minute period with a grid charge is at most "
|
||||
"quarter_ceiling_w() - margin (SAFETY-79) in the model; lagged, the "
|
||||
"battery's lag may use 2 W of the 50 W margin, as on e3e4ba6",
|
||||
_bk0 and max(_bk0) <= _ceil - 50 + 0.5
|
||||
and _bk and max(_bk) <= _ceil - 50 + 2.0)
|
||||
check("Solar first and Leftover solar never charge from the grid: no charge "
|
||||
"role and no charge share on B while the site charges",
|
||||
all(r["roles"].get(B) != modetrade.CHARGE for r in _all)
|
||||
and all(r["shares"].get(B, 0) >= 0 for r in _all
|
||||
if r["action"] == arbitrage.CHARGE))
|
||||
check("selling is off: no sale on any cycle",
|
||||
packmode.SELLING_ENABLED is False
|
||||
and all(r["action"] != arbitrage.DISCHARGE for r in _all))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed by tools/opt11p3_identity.py on release/1.0 e3e4ba6 (2026-10-13),
|
||||
# goodwe-addon-checks:1, with this branch's harness (same API). n1 and n2 are
|
||||
# also 5a4cdc2's; the lag ones moved with SAFETY-90/91 (n2_lag now equals n2:
|
||||
# the next-quarter grading removed the edge tick's blindness at N=2).
|
||||
AT_BASE = {
|
||||
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
|
||||
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
"n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190",
|
||||
"n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
|
||||
}
|
||||
_got = {k: ident.digest(ident.run(**ident.SCENARIOS[k])[0])
|
||||
for k in AT_BASE}
|
||||
for _k in AT_BASE:
|
||||
print(f" {_k} {'same' if _got[_k] == AT_BASE[_k] else 'DIFFERS'} "
|
||||
f"{_got[_k][:16]}")
|
||||
check("with battery modes off every command, integrator, allocation and "
|
||||
"trade sentence equals e3e4ba6's, also when every quarter boundary's "
|
||||
"cycle still sees the old P1 block", _got == AT_BASE)
|
||||
|
||||
_real_project = tariff.PeakTracker.project
|
||||
tariff.PeakTracker.project = staticmethod(
|
||||
lambda a, now, roll=False: _real_project(a, now, roll=True))
|
||||
try:
|
||||
_moved = ident.digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
|
||||
!= AT_BASE["n1_lag"]
|
||||
finally:
|
||||
tariff.PeakTracker.project = _real_project
|
||||
check("non-vacuous: the rolled projection leaking into modes off moves the "
|
||||
"late-sample scenario", _moved)
|
||||
|
||||
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,412 @@
|
||||
"""PRICE-01 round 2 (F1): a `password` option never reaches a page.
|
||||
`python3 test_price_secret.py`
|
||||
|
||||
The real web app (`web.build_app`) runs over a fake Supervisor that holds a
|
||||
site on `price_service` with a token. The token string must be absent from
|
||||
`/config`, `/config/model` (`options`, `running`, `ui`), `/config/validate`,
|
||||
a good save's answer and every refused save's answer. A save that sends the
|
||||
placeholder, an empty field or no key keeps the stored token; a new value
|
||||
replaces it. The same holds for any option the schema calls a password, not
|
||||
only this one name.
|
||||
|
||||
⚠️ MUTATING A MODULE THIS FILE GRADES? Purge `__pycache__` first, or use
|
||||
`tools/mutation_run.sh`. See MUTATION.md.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
from aiohttp import ClientSession, web as aioweb # noqa: E402
|
||||
|
||||
import translations_lint as TL # noqa: E402
|
||||
import ux13_demo as D # noqa: E402
|
||||
from app import configui, price, web # noqa: E402
|
||||
from app.configui import SECRET_SHOWN # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
TOKEN = "tk-SECRET-0123456789-abcdef"
|
||||
NEW_TOKEN = "tk-REPLACED-9876543210-zyxw"
|
||||
SCHEMA = D.schema_ui(D.CONFIG)
|
||||
TRANSLATIONS = {"en": TL.parse_translation_file(
|
||||
os.path.join(HERE, "translations", "en.yaml"))}
|
||||
SITE = dict(D.defaults(D.CONFIG), setpoint_entity="number.goodwe_setpoint",
|
||||
soc_entity="sensor.soc", batt_entity="sensor.batt",
|
||||
price_provider="price_service", price_service_token=TOKEN)
|
||||
|
||||
|
||||
class Sup:
|
||||
def __init__(self, options, schema=SCHEMA):
|
||||
self.options, self.schema, self.writes = copy.deepcopy(options), schema, []
|
||||
|
||||
async def info(self):
|
||||
return {"options": copy.deepcopy(self.options), "schema": self.schema,
|
||||
"translations": TRANSLATIONS}
|
||||
|
||||
async def write_options(self, o):
|
||||
self.writes.append(copy.deepcopy(o))
|
||||
self.options = copy.deepcopy(o)
|
||||
|
||||
async def restart(self, c):
|
||||
raise AssertionError
|
||||
|
||||
|
||||
class Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
class Ctl:
|
||||
def __init__(self, o):
|
||||
self.o, self.hass, self.inverters = dict(o), Hass(), []
|
||||
|
||||
def log_event(self, *a, **k):
|
||||
pass
|
||||
|
||||
def status_payload(self):
|
||||
return {"banner": "", "level": "ok", "rows": [], "checks": []}
|
||||
|
||||
|
||||
async def serve(sup, ctl_options=None):
|
||||
runner = aioweb.AppRunner(web.build_app(Ctl(ctl_options or sup.options), sup))
|
||||
await runner.setup()
|
||||
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
||||
await site.start()
|
||||
port = site._server.sockets[0].getsockname()[1]
|
||||
return runner, f"http://127.0.0.1:{port}/"
|
||||
|
||||
|
||||
async def scenario():
|
||||
out = {}
|
||||
sup = Sup(SITE)
|
||||
runner, root = await serve(sup)
|
||||
seen = [] # every answer body, as text
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async def get(path):
|
||||
async with s.get(root + path) as r:
|
||||
t = await r.text()
|
||||
seen.append(t)
|
||||
return r.status, t
|
||||
|
||||
async def post(path, body):
|
||||
async with s.post(root + path, json=body) as r:
|
||||
t = await r.text()
|
||||
seen.append(t)
|
||||
return r.status, t
|
||||
|
||||
_, page = await get("config")
|
||||
out["page"] = page
|
||||
_, raw = await get("config/model?lang=en")
|
||||
m = json.loads(raw)
|
||||
out["model"] = m
|
||||
out["raw_model"] = raw
|
||||
out["field"] = [f for g in m["groups"] for f in g["fields"]
|
||||
if f["key"] == "price_service_token"]
|
||||
|
||||
# a model of a site with NO token: shown empty, not as 'set'
|
||||
sup0 = Sup(dict(SITE, price_service_token=""))
|
||||
r0, root0 = await serve(sup0)
|
||||
try:
|
||||
async with s.get(root0 + "config/model?lang=en") as r:
|
||||
out["model_unset"] = await r.json()
|
||||
finally:
|
||||
await r0.cleanup()
|
||||
|
||||
# --- classic page save: the page posts `options`, placeholder in
|
||||
opts = copy.deepcopy(m["options"])
|
||||
opts["peak_cap_w"] = 4100
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m["fingerprint"]})
|
||||
out["classic_placeholder"] = (st, sup.options["price_service_token"],
|
||||
sup.options["peak_cap_w"], body)
|
||||
# --- classic save with the field emptied
|
||||
_, raw2 = await get("config/model?lang=en")
|
||||
m2 = json.loads(raw2)
|
||||
opts = copy.deepcopy(m2["options"])
|
||||
opts["price_service_token"] = ""
|
||||
opts["peak_cap_w"] = 4200
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m2["fingerprint"]})
|
||||
out["classic_empty"] = (st, sup.options["price_service_token"],
|
||||
sup.options["peak_cap_w"])
|
||||
# --- classic save with the key left out
|
||||
_, raw2 = await get("config/model?lang=en")
|
||||
m2 = json.loads(raw2)
|
||||
opts = copy.deepcopy(m2["options"])
|
||||
del opts["price_service_token"]
|
||||
opts["peak_cap_w"] = 4250
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m2["fingerprint"]})
|
||||
out["classic_absent"] = (st, sup.options["price_service_token"])
|
||||
# --- the new settings page: model in, with and without base
|
||||
_, raw3 = await get("config/model?lang=en")
|
||||
m3 = json.loads(raw3)
|
||||
ui = copy.deepcopy(m3["ui"])
|
||||
ui["peak_cap_w"] = 4300
|
||||
st, body = await post("config/save", {
|
||||
"model": ui, "base": copy.deepcopy(m3["ui"]),
|
||||
"fingerprint": m3["fingerprint"]})
|
||||
out["ui_base"] = (st, sup.options["price_service_token"])
|
||||
_, raw3 = await get("config/model?lang=en")
|
||||
m3 = json.loads(raw3)
|
||||
ui = copy.deepcopy(m3["ui"])
|
||||
ui["peak_cap_w"] = 4400
|
||||
st, body = await post("config/save",
|
||||
{"model": ui, "fingerprint": m3["fingerprint"]})
|
||||
out["ui_nobase"] = (st, sup.options["price_service_token"])
|
||||
# --- validate with the placeholder in: no 'too short', no echo
|
||||
_, raw4 = await get("config/model?lang=en")
|
||||
m4 = json.loads(raw4)
|
||||
st, body = await post("config/validate", {"options": m4["options"]})
|
||||
out["validate_placeholder"] = (st, json.loads(body)["problems"])
|
||||
st, body = await post("config/validate", {"model": m4["ui"]})
|
||||
out["validate_ui_placeholder"] = (st, json.loads(body)["problems"])
|
||||
# --- a refused save (token too short) and a stale one
|
||||
opts = copy.deepcopy(m4["options"])
|
||||
opts["price_service_token"] = "short"
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m4["fingerprint"]})
|
||||
out["refused"] = (st, json.loads(body), sup.options["price_service_token"])
|
||||
opts = copy.deepcopy(m4["options"])
|
||||
opts["peak_cap_w"] = 4500
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": "not-current"})
|
||||
out["stale"] = (st, json.loads(body))
|
||||
# --- a refusal that is not about the token
|
||||
opts = copy.deepcopy(m4["options"])
|
||||
opts["max_w"] = -5
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m4["fingerprint"]})
|
||||
out["refused_other"] = (st, sup.options["price_service_token"],
|
||||
sup.options["max_w"])
|
||||
# --- a new value replaces
|
||||
_, raw5 = await get("config/model?lang=en")
|
||||
m5 = json.loads(raw5)
|
||||
opts = copy.deepcopy(m5["options"])
|
||||
opts["price_service_token"] = NEW_TOKEN
|
||||
st, body = await post("config/save",
|
||||
{"options": opts, "fingerprint": m5["fingerprint"]})
|
||||
out["replaced"] = (st, sup.options["price_service_token"], body)
|
||||
out["writes"] = len(sup.writes)
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
out["seen"] = seen
|
||||
return out
|
||||
|
||||
|
||||
S = asyncio.run(scenario())
|
||||
|
||||
print("1. the token is in no page and no JSON answer")
|
||||
check("GET /config (the classic page) has no token",
|
||||
TOKEN not in S["page"] and NEW_TOKEN not in S["page"])
|
||||
check("GET /config/model has no token anywhere in its JSON",
|
||||
TOKEN not in S["raw_model"])
|
||||
for key in ("options", "running", "ui"):
|
||||
check(f"...and {key} shows the placeholder, not the token",
|
||||
json.dumps(S["model"][key]).count(SECRET_SHOWN) >= 1
|
||||
and TOKEN not in json.dumps(S["model"][key]))
|
||||
check("...the placeholder stands in the token's own place",
|
||||
S["model"]["options"]["price_service_token"] == SECRET_SHOWN
|
||||
and S["model"]["running"]["price_service_token"] == SECRET_SHOWN
|
||||
and S["model"]["ui"]["price_service_token"] == SECRET_SHOWN)
|
||||
check("a site with no token shows it empty, so the page can say 'not set'",
|
||||
S["model_unset"]["options"]["price_service_token"] == ""
|
||||
and S["model_unset"]["ui"]["price_service_token"] == "")
|
||||
check("no answer the app gave in the whole run (page, model, validate, saves, "
|
||||
"refusals, the stale one) holds the stored token",
|
||||
not [t for t in S["seen"] if TOKEN in t])
|
||||
check("...nor the new token, after it was saved",
|
||||
not [t for t in S["seen"] if NEW_TOKEN in t])
|
||||
|
||||
print("\n2. the classic page draws it as a password field")
|
||||
check("the field model types it as a password",
|
||||
[f["type"] for f in S["field"]] == ["password"])
|
||||
check("the classic page's script makes a password input for that type",
|
||||
"'password'" in S["page"] and "input.type = 'password'" in S["page"])
|
||||
|
||||
print("\n3. saves keep the stored token unless given a new one")
|
||||
st, tok, cap, body = S["classic_placeholder"]
|
||||
check("a classic save with the placeholder keeps the token and saves the "
|
||||
"other edit", st == 200 and tok == TOKEN and cap == 4100)
|
||||
st, tok, cap = S["classic_empty"]
|
||||
check("a classic save with the field emptied keeps the token",
|
||||
st == 200 and tok == TOKEN and cap == 4200)
|
||||
check("a classic save without the key keeps the token",
|
||||
S["classic_absent"] == (200, TOKEN))
|
||||
check("a save from the new settings page keeps it (with a base)",
|
||||
S["ui_base"] == (200, TOKEN))
|
||||
check("...and without a base", S["ui_nobase"] == (200, TOKEN))
|
||||
st, tok, body = S["replaced"]
|
||||
check("a new value replaces the token", st == 200 and tok == NEW_TOKEN)
|
||||
check("...and the answer to that save does not echo it",
|
||||
NEW_TOKEN not in body)
|
||||
st, probs = S["validate_placeholder"]
|
||||
check("validate with the placeholder in sees the stored token: nothing about "
|
||||
"the token is refused", st == 200 and not [p for p in probs if "token" in p])
|
||||
st, probs = S["validate_ui_placeholder"]
|
||||
check("...also from the new page's model",
|
||||
st == 200 and not [p for p in probs if "token" in p])
|
||||
st, body, tok = S["refused"]
|
||||
check("a refused save (token too short) is 422, says why in short plain "
|
||||
"words, and writes nothing",
|
||||
st == 422 and tok == TOKEN
|
||||
and any("too short" in p for p in body["problems"]))
|
||||
st, body = S["stale"]
|
||||
check("a stale refusal is 409 and its `stored` shows the placeholder",
|
||||
st == 409 and body["stored"]["price_service_token"] == SECRET_SHOWN)
|
||||
check("a refusal about something else (422) writes nothing, so the token and "
|
||||
"the rating stay",
|
||||
S["refused_other"][0] == 422 and S["refused_other"][1] == TOKEN
|
||||
and S["refused_other"][2] != -5)
|
||||
|
||||
print("\n4. every password option, not only this name")
|
||||
other = [{"name": "mqtt_secret", "type": "string", "format": "password"},
|
||||
{"name": "other_pw", "type": "password"},
|
||||
{"name": "plain", "type": "string"}]
|
||||
keys = configui.secret_keys(other)
|
||||
check("a schema password is found in both spellings, a plain string is not",
|
||||
keys == {"mqtt_secret", "other_pw"})
|
||||
check("the price token is counted by name too, in case a schema is missing",
|
||||
configui.secret_keys([], price.SECRET_OPTIONS)
|
||||
== frozenset(price.SECRET_OPTIONS) and "price_service_token" in
|
||||
price.SECRET_OPTIONS)
|
||||
blob = {"mqtt_secret": "hunter2hunter2", "other_pw": "", "plain": "x",
|
||||
"nested": [{"mqtt_secret": "deep-secret-value"}]}
|
||||
shown = configui.hide_secrets(blob, keys)
|
||||
check("hide_secrets: set -> placeholder, empty -> empty, other keys alone, "
|
||||
"at any depth",
|
||||
shown == {"mqtt_secret": SECRET_SHOWN, "other_pw": "", "plain": "x",
|
||||
"nested": [{"mqtt_secret": SECRET_SHOWN}]}
|
||||
and blob["mqtt_secret"] == "hunter2hunter2")
|
||||
back = configui.keep_secrets({"mqtt_secret": SECRET_SHOWN, "other_pw": "new",
|
||||
"plain": "y"}, blob, keys)
|
||||
check("keep_secrets: the placeholder gives the stored value, a new value "
|
||||
"stays",
|
||||
back["mqtt_secret"] == "hunter2hunter2" and back["other_pw"] == "new"
|
||||
and back["plain"] == "y")
|
||||
check("keep_secrets: a placeholder with nothing stored is never saved as "
|
||||
"the placeholder",
|
||||
configui.keep_secrets({"mqtt_secret": SECRET_SHOWN}, {}, keys)
|
||||
["mqtt_secret"] == "")
|
||||
|
||||
|
||||
async def other_option_scenario():
|
||||
"""A second password option, found from the schema, is hidden and kept."""
|
||||
schema = SCHEMA + [{"name": "extra_secret", "type": "string",
|
||||
"format": "password", "required": False,
|
||||
"optional": True}]
|
||||
sup = Sup(dict(SITE, extra_secret="extra-secret-value-12345"), schema)
|
||||
runner, root = await serve(sup)
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(root + "config/model?lang=en") as r:
|
||||
raw = await r.text()
|
||||
m = json.loads(raw)
|
||||
opts = copy.deepcopy(m["options"])
|
||||
opts["peak_cap_w"] = 4100
|
||||
async with s.post(root + "config/save", json={
|
||||
"options": opts, "fingerprint": m["fingerprint"]}) as r:
|
||||
st = r.status
|
||||
return raw, st, sup.options.get("extra_secret")
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
_raw, _st, _kept = asyncio.run(other_option_scenario())
|
||||
check("a password option the add-on has no name for is hidden from the model",
|
||||
"extra-secret-value-12345" not in _raw)
|
||||
check("...and kept by a save that sends the placeholder",
|
||||
_st == 200 and _kept == "extra-secret-value-12345")
|
||||
|
||||
async def named_scenario():
|
||||
"""A schema that does not call the token a password (a Supervisor that
|
||||
spells it another way): the add-on still hides it, by name."""
|
||||
schema = [dict(n, format=None) if n["name"] == "price_service_token" else n
|
||||
for n in SCHEMA]
|
||||
sup = Sup(SITE, schema)
|
||||
runner, root = await serve(sup)
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
async with s.get(root + "config/model?lang=en") as r:
|
||||
return await r.text()
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
check("the price token is hidden by name even when the schema does not mark "
|
||||
"it as a password", TOKEN not in asyncio.run(named_scenario()))
|
||||
|
||||
print("\n5. PRICE-01 round 3: the fixed-tariff note on the real validate route")
|
||||
_PROFILE = json.dumps({"periods": [
|
||||
{"name": "all", "days": "all", "from": "00:00", "to": "24:00",
|
||||
"eur_per_kwh": 0.25}]})
|
||||
_NOTE = {"en": ("With the price service, the fixed tariff is only used if no "
|
||||
"prices are available."),
|
||||
"nl": ("Met de prijsdienst wordt het vaste tarief alleen gebruikt als "
|
||||
"er geen prijzen zijn.")}
|
||||
|
||||
|
||||
async def note_scenario(site):
|
||||
runner, root = await serve(Sup(site))
|
||||
out = {}
|
||||
try:
|
||||
async with ClientSession() as s:
|
||||
for lang in ("en", "nl"):
|
||||
async with s.get(root + f"config/model?lang={lang}") as r:
|
||||
m = await r.json()
|
||||
async with s.post(root + "config/validate",
|
||||
json={"model": m["ui"], "lang": lang}) as r:
|
||||
b = await r.json()
|
||||
out[lang] = [x for x in b.get("messages", [])
|
||||
if x["text"] in _NOTE.values()]
|
||||
finally:
|
||||
await runner.cleanup()
|
||||
return out
|
||||
|
||||
|
||||
_with = asyncio.run(note_scenario(dict(SITE, static_profile=_PROFILE)))
|
||||
check("an English page gets the note in English, as info, beside a field",
|
||||
[(x["text"], x["tier"]) for x in _with["en"]] == [(_NOTE["en"], "info")]
|
||||
and _with["en"][0]["field"])
|
||||
check("...and a Dutch page gets it in Dutch, beside the same field",
|
||||
[(x["text"], x["tier"]) for x in _with["nl"]] == [(_NOTE["nl"], "info")]
|
||||
and _with["nl"][0]["field"] == _with["en"][0]["field"])
|
||||
_blank = asyncio.run(note_scenario(dict(SITE, static_profile=" ")))
|
||||
check("a spaces-only fixed tariff: no note on either page",
|
||||
_blank == {"en": [], "nl": []})
|
||||
|
||||
print("\n6. the shipped config.yaml")
|
||||
cfg = open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
|
||||
schema_text = cfg[cfg.index("\nschema:"):]
|
||||
pw = {line.split(":")[0].strip() for line in schema_text.splitlines()
|
||||
if line.split(":", 1)[-1].strip().rstrip("?") == "password"}
|
||||
check(f"every password option of config.yaml is counted as a secret ({sorted(pw)})",
|
||||
pw and pw <= configui.secret_keys(SCHEMA, price.SECRET_OPTIONS))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,401 @@
|
||||
"""Runnable check for SAFETY-103: no swing on old battery readings after a
|
||||
drain ends. `python3 test_safety103.py`
|
||||
|
||||
While a battery settles from its ended drain (SAFETY-95), the loop works
|
||||
from the grid plus each battery's reading. A reading can be one register
|
||||
period (10 s) older than the meter: on the rig (SAFETY-95 test, check 5) A's
|
||||
reading still said 1238 W while A was at 616 W, and B went 860 -> 1650 ->
|
||||
870 W. Now the house point is held, and moved only as far as the meter
|
||||
proves (each battery between its last changed reading and its commands
|
||||
since). Drives the REAL Controller on tools/safety103_plant.py: a lagging
|
||||
battery, the meter every 1 s, registers every 10 s at the rig's phases, B
|
||||
idling at -100 W. DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
|
||||
|
||||
1. The rig case and every drain end at N=2 and N=3, 10 s registers: no
|
||||
swing beyond one slew step; release/1.0 swings (non-vacuous).
|
||||
2. SAFETY-95/96's gains stay: no jump at the end, no give into a surplus,
|
||||
one slew step a cycle, refusal and stale link still learned.
|
||||
3. A house step inside the window is still followed.
|
||||
4. With readings as fresh as the meter, every row is release/1.0's; N=1 is
|
||||
untouched.
|
||||
5. The rule's parts, one by one.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
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 safety103_plant as p # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import DRAIN # noqa: E402
|
||||
|
||||
fails = []
|
||||
SLEW = 400.0 # the rig's slew_w
|
||||
AIM = -10.0 # the rig's target_grid_w
|
||||
T = p.T
|
||||
A = p.A
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
fixed = Controller._drain_grid_w
|
||||
track = getattr(Controller, "_track_reads", None)
|
||||
|
||||
|
||||
def _release(self, live):
|
||||
"""release/1.0's `_drain_grid_w` (SAFETY-95 as merged): every battery at
|
||||
its reading."""
|
||||
grid = float(self.grid)
|
||||
self._drain_tail &= set(self._settle) & set(live)
|
||||
if not self._drain_tail:
|
||||
return grid
|
||||
return grid - float(self.target) + sum(
|
||||
float(inv.batt) for inv in self.inverters if inv.name in live)
|
||||
|
||||
|
||||
def without(fn):
|
||||
"""Run `fn` with SAFETY-103 taken out (release/1.0's rule)."""
|
||||
Controller._drain_grid_w = _release
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
Controller._drain_grid_w = fixed
|
||||
|
||||
|
||||
def soc_done(u, t):
|
||||
return 5.0 if (u == "a" and t >= T) else 50.0
|
||||
|
||||
|
||||
# A's drain ends at T: switched Off (the rig case), completed on SoC, its
|
||||
# battery refusing (gives nothing from T; SAFETY-73 learns it later), or its
|
||||
# link going stale (writes refused from T, its board to 0 W 25 s later).
|
||||
ENDS = {
|
||||
"Off": dict(),
|
||||
"complete": dict(before=None, soc=soc_done),
|
||||
"refusal": dict(before=None, dead=("a", T), dur=T + 150.0),
|
||||
"stale link": dict(before=None, reject={"a": T}, dead=("a", T + 25.0),
|
||||
dur=T + 150.0),
|
||||
}
|
||||
|
||||
|
||||
def end_of(rows):
|
||||
"""The first cycle A's drain is over (its command left 2000 W, or its
|
||||
phase left DRAIN); None if never."""
|
||||
return next((r["t"] for r in rows if r["t"] >= T and (
|
||||
r["cmd"][0] < 1990.0 or r["phase"][0] != DRAIN)), None)
|
||||
|
||||
|
||||
def measure(rows, n, house):
|
||||
"""From the end on: the largest swing of the others' summed command in
|
||||
the 23 s window (a move later undone; the slew step is the site's); their
|
||||
largest command on the end cycle; the site target's largest step after
|
||||
the end cycle; the others' largest command after the end (for a
|
||||
surplus); the grid's range in the window."""
|
||||
te = end_of(rows)
|
||||
if te is None:
|
||||
return dict(end=math.nan, swing=math.nan, first=math.nan,
|
||||
step=math.nan, give=math.nan, gmin=math.nan,
|
||||
gmax=math.nan, rows=rows)
|
||||
win = [r for r in rows if te <= r["t"] <= te + 23.0]
|
||||
sw = 0.0
|
||||
w = [sum(r["cmd"][1:]) for r in win][1:]
|
||||
for j in range(len(w)):
|
||||
sw = max(sw, min(w[j] - min(w[:j + 1]), w[j] - min(w[j:])),
|
||||
min(max(w[:j + 1]) - w[j], max(w[j:]) - w[j]))
|
||||
seg = [r["target"] for r in rows if r["t"] >= te]
|
||||
return dict(
|
||||
end=te, swing=sw,
|
||||
first=max(win[0]["cmd"][1:]) if win else math.nan,
|
||||
step=max(abs(b - a) for a, b in zip(seg, seg[1:])),
|
||||
give=max(max(r["cmd"][1:]) for r in rows if r["t"] >= te),
|
||||
gmin=min(r["grid"] for r in win), gmax=max(r["grid"] for r in win),
|
||||
rows=rows)
|
||||
|
||||
|
||||
def run(kind, n, house, **k):
|
||||
return p.run(n, house, **dict(ENDS[kind], **k))[0]
|
||||
|
||||
|
||||
TABLE = []
|
||||
# A settles from its drain: the tail this rule is about. A refusing battery
|
||||
# already reads ~0 W, and a dropped one is out of the split (the meter has
|
||||
# it), so their tails last at most a cycle.
|
||||
SETTLE = ("Off", "complete")
|
||||
|
||||
|
||||
def case(kind, n, house, **k):
|
||||
now = measure(run(kind, n, house, **k), n, house)
|
||||
old = measure(without(lambda: run(kind, n, house, **k)), n, house)
|
||||
fresh = measure(run(kind, n, house, reg_s=1.0, **k), n, house)
|
||||
TABLE.append((kind, n, house, now, old, fresh))
|
||||
return now, old, fresh
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. no swing on an old reading: every drain end, N=2 and N=3, 10 s "
|
||||
"registers")
|
||||
for kind in ENDS:
|
||||
for n in (2, 3):
|
||||
for house in (1200.0, 4000.0, -1500.0):
|
||||
now, old, fresh = case(kind, n, house)
|
||||
own = kind in SETTLE
|
||||
check(f"{'⭐' if kind == 'Off' else ' '} {kind}, N={n}, house "
|
||||
f"{house:.0f} W: old readings add at most one slew step "
|
||||
f"to the others' swing in the 23 s after the end"
|
||||
f"{', and it is at most one slew step' if own else ''} "
|
||||
f"({now['swing']:.0f} W; 1 s registers "
|
||||
f"{fresh['swing']:.0f}; release/1.0 {old['swing']:.0f})",
|
||||
now["swing"] - fresh["swing"] <= SLEW
|
||||
and (not own or now["swing"] <= SLEW))
|
||||
_by = {(k, n, h): (a, b) for k, n, h, a, b, f in TABLE}
|
||||
_now, _old = _by[("Off", 2, 1200.0)]
|
||||
_b = [r["cmd"][1] for r in _now["rows"] if 0 <= r["t"] - _now["end"] <= 23]
|
||||
_bo = [r["cmd"][1] for r in _old["rows"] if 0 <= r["t"] - _old["end"] <= 23]
|
||||
print(f" rig check 5 (N=2, 1200 W, A Off): B {_b}\n"
|
||||
f" release/1.0: B {_bo}")
|
||||
check("⭐ the rig case (SAFETY-95 check 5): release/1.0 swings B by more "
|
||||
"than a slew step on A's old reading; now B holds within 50 W",
|
||||
_old["swing"] > SLEW and max(_b) - min(_b) <= 50.0)
|
||||
for n in (2, 3):
|
||||
check(f"non-vacuous (N={n}): on release/1.0 old readings add more than "
|
||||
f"a slew step in at least one of these cases",
|
||||
any(b["swing"] - f["swing"] > SLEW
|
||||
for k, nn, h, a, b, f in TABLE if nn == n))
|
||||
_s5 = []
|
||||
for lag in (5.0, 20.0):
|
||||
for reg in (5.0, 10.0):
|
||||
for n in (2, 3):
|
||||
_s5.append(measure(run("Off", n, 1200.0, lag=lag, reg_s=reg), n,
|
||||
1200.0)["swing"])
|
||||
check(f"no plant constant is assumed: battery lag 5 s and 20 s, registers "
|
||||
f"every 5 s and 10 s, N=2 and N=3: largest swing {max(_s5):.0f} W",
|
||||
max(_s5) <= SLEW)
|
||||
_ph = [measure(run("Off", 2, 1200.0, phase={"a": a, "b": b}), 2, 1200.0)
|
||||
["swing"] for a, b in ((0.0, 0.5), (0.9, 0.1), (0.3, 0.3))]
|
||||
check(f"other register phases: largest swing {max(_ph):.0f} W",
|
||||
max(_ph) <= SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. SAFETY-95/96's gains stay")
|
||||
for kind in ENDS:
|
||||
for n in (2, 3):
|
||||
now, old = _by[(kind, n, 1200.0)]
|
||||
check(f"{kind}, N={n}, 1200 W: no jump on the end cycle (others "
|
||||
f"<= the house, {now['first']:.0f} W) and the site moves at "
|
||||
f"most one slew step a cycle after ({now['step']:.0f} W)",
|
||||
now["first"] <= 1200.0 - AIM + 50.0 and now["step"] <= SLEW)
|
||||
now, old = _by[(kind, n, -1500.0)]
|
||||
check(f"{kind}, N={n}, solar surplus: no battery gives into it "
|
||||
f"(largest command after the end {now['give']:.0f} W"
|
||||
+ (f"; a lost link as release/1.0, {old['give']:.0f} W)"
|
||||
if kind == "stale link" else ")"),
|
||||
now["give"] <= (max(50.0, old["give"]) if kind == "stale link"
|
||||
else 50.0))
|
||||
_now, _old = _by[("Off", 2, 1200.0)]
|
||||
check(f"the grid in the window stays inside release/1.0's range "
|
||||
f"([{_now['gmin']:.0f}, {_now['gmax']:.0f}] W, release/1.0 "
|
||||
f"[{_old['gmin']:.0f}, {_old['gmax']:.0f}])",
|
||||
_now["gmin"] >= _old["gmin"] - 50.0 and _now["gmax"] <= _old["gmax"])
|
||||
|
||||
|
||||
_ref = {}
|
||||
for name, f in (("now", lambda: p.run(2, 1200.0, **ENDS["refusal"])),
|
||||
("old", lambda: without(lambda: p.run(
|
||||
2, 1200.0, **ENDS["refusal"])))):
|
||||
rows, c = f()
|
||||
_ref[name] = (c.inverters[0].refused, c.inverters[0].refused_at)
|
||||
check(f"a refusal is still learned, at the same instant as release/1.0 "
|
||||
f"({_ref['now']})", _ref["now"] == _ref["old"]
|
||||
and _ref["now"][0] == 1)
|
||||
_st = {}
|
||||
for name, f in (("now", lambda: p.run(2, 1200.0, **ENDS["stale link"])),
|
||||
("old", lambda: without(lambda: p.run(
|
||||
2, 1200.0, **ENDS["stale link"])))):
|
||||
rows, c = f()
|
||||
_st[name] = sorted(c.live_units())
|
||||
check(f"a stale link is still dropped, as in release/1.0 "
|
||||
f"(live: {_st['now']})", _st["now"] == _st["old"]
|
||||
and len(_st["now"]) == 1)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. a house step inside the window is still followed")
|
||||
|
||||
|
||||
def step(sign):
|
||||
return lambda t: 1200.0 + (sign * 2000.0 if t >= T + 5.0 else 0.0)
|
||||
|
||||
|
||||
for sign, what in ((1, "kettle +2000 W"), (-1, "solar -2000 W")):
|
||||
for n in (2, 3):
|
||||
rows = p.run(n, step(sign))[0]
|
||||
rows_o = without(lambda: p.run(n, step(sign)))[0]
|
||||
te = end_of(rows)
|
||||
|
||||
def wh(rs):
|
||||
return sum(abs(r["grid"] - AIM) for r in rs
|
||||
if te + 5.0 <= r["t"] <= te + 35.0) / 3600.0
|
||||
sw = measure(rows, n, 1200.0)["swing"]
|
||||
check(f"{what} at E+5 s, N={n}: the grid's distance from its aim "
|
||||
f"over the next 30 s is within 1.5 Wh of release/1.0's "
|
||||
f"({wh(rows):.1f} Wh, release/1.0 {wh(rows_o):.1f}); swing "
|
||||
f"{sw:.0f} W", wh(rows) <= wh(rows_o) + 1.5 and sw <= SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. fresh readings and N=1: release/1.0's rows")
|
||||
|
||||
|
||||
def digest(rows):
|
||||
return hashlib.sha256(repr(rows).encode()).hexdigest()
|
||||
|
||||
|
||||
_fresh = [(n, h) for n in (2, 3) for h in (1200.0, 4000.0, -1500.0)]
|
||||
check("⭐ registers as fresh as the meter (1 s), N=2 and N=3, three houses: "
|
||||
"every row byte-identical with release/1.0 (a reading that changed "
|
||||
"this cycle is taken as it is)",
|
||||
[digest(p.run(n, h, reg_s=1.0)[0]) for n, h in _fresh]
|
||||
== [digest(without(lambda: p.run(n, h, reg_s=1.0))[0])
|
||||
for n, h in _fresh])
|
||||
_rf = [(n, h) for n in (2, 3) for h in (1200.0, -1500.0)]
|
||||
check("⭐ a refusal end, 10 s registers, N=2 and N=3, 1200 W and a surplus: "
|
||||
"every row byte-identical with release/1.0 (A reads ~0 W and will not "
|
||||
"give, so its side is its reading)",
|
||||
[digest(run("refusal", n, h)) for n, h in _rf]
|
||||
== [digest(without(lambda: run("refusal", n, h))) for n, h in _rf])
|
||||
_n1 = [dict(), dict(before=None, soc=soc_done)]
|
||||
check("⭐ N=1, A switched Off and A's drain completing, 10 s registers: "
|
||||
"every row byte-identical with release/1.0",
|
||||
[digest(p.run(1, 1200.0, **k)[0]) for k in _n1]
|
||||
== [digest(without(lambda: p.run(1, 1200.0, **k))[0]) for k in _n1])
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. the rule's parts")
|
||||
_c = p.run(2, 1200.0, dur=4.0)[1]
|
||||
AB = {"inverter-a", "inverter-b"}
|
||||
ia, ib = _c.inverters
|
||||
|
||||
|
||||
def at(now, batts, cmds, changed):
|
||||
"""Set the clock, each battery's reading, its standing command and the
|
||||
instant its reading last changed; then track."""
|
||||
_c._now_mono = now
|
||||
for inv, b, cmd, ch in zip(_c.inverters, batts, cmds, changed):
|
||||
inv.batt, inv.last_write = b, cmd
|
||||
inv.batt_fresh.changed_mono = ch
|
||||
_c._track_reads()
|
||||
|
||||
|
||||
_c._read_span = {}
|
||||
at(10.0, (2000.0, -100.0), (2000.0, 0.0), (10.0, 10.0))
|
||||
check("a reading that changed is the start of its range: (R, R) joined "
|
||||
"with the command on its wire",
|
||||
_c._read_span[A] == (2000.0, 2000.0, 10.0)
|
||||
and _c._read_span["inverter-b"] == (-100.0, 0.0, 10.0))
|
||||
at(11.0, (2000.0, -100.0), (0.0, 1140.0), (10.0, 10.0))
|
||||
at(12.0, (2000.0, -100.0), (0.0, 800.0), (10.0, 10.0))
|
||||
check("every command since the reading widens the range (a lagging "
|
||||
"battery moves toward each in turn): A [0, 2000], B [-100, 1140]",
|
||||
_c._read_span[A][:2] == (0.0, 2000.0)
|
||||
and _c._read_span["inverter-b"][:2] == (-100.0, 1140.0))
|
||||
at(13.0, (1241.0, -100.0), (0.0, 800.0), (13.0, 10.0))
|
||||
check("a new reading starts the range again: A [0, 1241]",
|
||||
_c._read_span[A][:2] == (0.0, 1241.0))
|
||||
_c._read_span = {}
|
||||
_c.inverters = _c.inverters[:1]
|
||||
at(14.0, (2000.0,), (2000.0,), (14.0,))
|
||||
_c.inverters = [ia, ib]
|
||||
check("N=1: nothing is tracked", _c._read_span == {})
|
||||
|
||||
|
||||
def tail(now, grid, target, held=None, live=AB):
|
||||
"""`_drain_grid_w` with A settling: the error it hands the loop (grid
|
||||
minus aim) and the house point it holds."""
|
||||
_c._now_mono, _c.grid, _c.target = now, grid, target
|
||||
_c._drain_tail, _c._settle = {A}, {A: 0.0}
|
||||
_c._drain_house = held
|
||||
g = _c._drain_grid_w(live)
|
||||
return round(g - AIM, 6), _c._drain_house
|
||||
|
||||
|
||||
# A: reading 1241 W from 13 s, range [0, 1241]; B: reading -100 W from 10 s,
|
||||
# range [-100, 1140]. Grid -330 W: the house is in
|
||||
# [-330 + 10 + 0 - 100, -330 + 10 + 1241 + 1140] = [-420, 2061].
|
||||
_c._read_span = {A: (0.0, 1241.0, 13.0), "inverter-b": (-100.0, 1140.0, 10.0)}
|
||||
ia.batt, ib.batt = 1241.0, -100.0
|
||||
ia.batt_fresh.changed_mono, ib.batt_fresh.changed_mono = 13.0, 10.0
|
||||
check("⭐ held: the first window cycle holds the loop's start (1140 W) while "
|
||||
"the meter allows it (error 0; release/1.0's would be -330 + 1241 - "
|
||||
"100 + 10 - 1140 = -319 W)", tail(14.0, -330.0, 1140.0) == (0.0, 1140.0)
|
||||
and round(_release(_c, AB) - AIM) == -319)
|
||||
check("⭐ held, then kept: a later cycle keeps the held point, not the "
|
||||
"loop's target", tail(15.0, -330.0, 1000.0, held=1140.0)
|
||||
== (140.0, 1140.0))
|
||||
check("⭐ moved as far as the meter proves: a grid of +2000 W puts the "
|
||||
"house at least at 2000 + 10 + 0 - 100 = 1910 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0) == (770.0, 1910.0))
|
||||
check("...and down: a grid of -2500 W puts it at most at -2500 + 10 + 1241 "
|
||||
"+ 1140 = -109 W", tail(15.0, -2500.0, 1140.0, held=1140.0)
|
||||
== (-1249.0, -109.0))
|
||||
ia.batt_fresh.changed_mono = 15.0
|
||||
check("⭐ a reading that changed this cycle is taken as it is: A at 1241 W, "
|
||||
"not [0, 1241], so a grid of +2000 W puts the house at least at "
|
||||
"2000 + 10 + 1241 - 100 = 3151 W", tail(15.0, 2000.0, 1140.0,
|
||||
held=1140.0) == (2011.0, 3151.0))
|
||||
ia.batt_fresh.changed_mono = 13.0
|
||||
# A refuses to give (SAFETY-73): it reads 8 W, though it was asked 2000 W
|
||||
# since that reading. Grid -1500 W: the house is at most -1500 + 10 + 8 +
|
||||
# 1140 = -342 W, not -1500 + 10 + 2000 + 1140 = 1650 W.
|
||||
_c._read_span[A] = (8.0, 2000.0, 13.0)
|
||||
ia.batt = 8.0
|
||||
ia.refused = 1
|
||||
_ref1 = tail(15.0, -1500.0, 1140.0, held=1140.0)
|
||||
ia.refused = -1
|
||||
_c._read_span[A] = (-2000.0, 8.0, 13.0)
|
||||
_ref2 = tail(15.0, 1000.0, -500.0, held=-500.0)
|
||||
ia.refused = 0
|
||||
check("⭐ a battery that refuses one way is taken at its reading on that "
|
||||
"side: will not give, asked 2000 W, reads 8 W -> the house at most "
|
||||
"-342 W; will not take, asked -2000 W -> at least 1000 + 10 + 8 - 100 "
|
||||
"= 918 W", _ref1 == (-1482.0, -342.0) and _ref2 == (1418.0, 918.0))
|
||||
_c._read_span[A], ia.batt = (0.0, 1241.0, 10.0), 1241.0
|
||||
check("a range from an older reading than the one in hand (a cycle the "
|
||||
"loop did not run) is not used: A at its reading, 1241 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0) == (2011.0, 3151.0))
|
||||
_c._read_span[A] = (0.0, 1241.0, 13.0)
|
||||
check("⭐ a battery out of the split is not counted (the meter has its "
|
||||
"power): B dropped, a grid of +2000 W puts the house at least at "
|
||||
"2000 + 10 + 0 = 2010 W, not 1910 W",
|
||||
tail(15.0, 2000.0, 1140.0, held=1140.0, live={A}) == (870.0, 2010.0))
|
||||
_c._drain_tail, _c._settle, _c._drain_house = {A}, {}, 1140.0
|
||||
_c.grid = -330.0
|
||||
check("⭐ the tail's end lets the hold go: the meter again, and the next "
|
||||
"tail starts from the loop's start",
|
||||
_c._drain_grid_w(AB) == -330.0 and _c._drain_house is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n drain end | N | house W | swing W now/release/1 s registers | "
|
||||
"end-cycle others W now/release | site step W now/release | grid in "
|
||||
"window W now | release")
|
||||
for k, n, h, a, b, f in TABLE:
|
||||
print(f" {k} | {n} | {h:.0f} | {a['swing']:.0f}/{b['swing']:.0f}/"
|
||||
f"{f['swing']:.0f} | "
|
||||
f"{a['first']:.0f}/{b['first']:.0f} | {a['step']:.0f}/"
|
||||
f"{b['step']:.0f} | [{a['gmin']:.0f}, {a['gmax']:.0f}] | "
|
||||
f"[{b['gmin']:.0f}, {b['gmax']:.0f}]")
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-103 checks passed")
|
||||
@@ -0,0 +1,307 @@
|
||||
"""Runnable check for SAFETY-107: a refusal held while another writer has the
|
||||
wire is learned once. `python3 test_safety107.py`
|
||||
|
||||
A full pack under a maintenance charge in a cheap window took no charge. At
|
||||
one inverter SAFETY-52's "another writer" end then fired every cycle, and
|
||||
compute() learned the refusal again in the same cycle: "BATTERY REFUSES" and
|
||||
"BATTERY REFUSES ENDED: another writer" each second on the rig (MAINT-13 F5).
|
||||
Now another writer ends a refusal only if compute(), judging it afresh
|
||||
against that writer's ask, no longer finds it. DECISIONS/SAFETY-107.
|
||||
|
||||
Drives the REAL Controller (read_inputs + cycle) on opt11_identity's rig.
|
||||
1. The card's case: 15 min of a full pack under a maintenance charge, N=1
|
||||
and N=2, at a 1, 2 and 5 s meter: at most 2 refusal lines, held throughout.
|
||||
2. Other writers: a trade and the peak guard over a battery that gives
|
||||
nothing. One episode each, no flap.
|
||||
3. A refusal still ends: a different writer really takes over, the battery
|
||||
delivers again, a writer asks the other way, stop. The probe clock.
|
||||
|
||||
4. Not covered, pinned: a perfectly flat reading under a writer.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in DECISIONS/SAFETY-107.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
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 opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import control # noqa: E402
|
||||
from app.arbiter import Claim, P_ARBITRAGE # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from named_absence import nth, present # noqa: E402
|
||||
|
||||
fails = []
|
||||
QUARTER = 900.0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def run(n=1, *, plant, house, dur=QUARTER, tick=5.0, maint=True,
|
||||
cheap=lambda c, t: True, trade=lambda t: None, p1=False, cap=2500,
|
||||
auto=lambda t: True):
|
||||
"""Rows (t, commands, site target, loop reason, refused, refused_at,
|
||||
unit refusals) and every action-log line as (t, line, hint).
|
||||
|
||||
`plant(u, t, cmd)` is battery u's reading. `cheap(c, t)` opens the cheap
|
||||
window. `trade(t)` is a SET claim at P_ARBITRAGE added to the claim list
|
||||
(a writer above the loop), or None."""
|
||||
log, rows, now = [], [], [0.0]
|
||||
real_power, real_tick = rig._Hass.power, rig.TICK
|
||||
real_log, real_resolve, real_c = (Controller.log_event, app_main.resolve,
|
||||
rig.Controller)
|
||||
|
||||
def power(self, u):
|
||||
return float(plant(u, self.t, self.cmd[u]))
|
||||
|
||||
def log_event(self, msg, hint=None):
|
||||
log.append((now[0], msg, hint))
|
||||
return real_log(self, msg, hint)
|
||||
|
||||
def resolve(claims, **kw):
|
||||
v = trade(now[0])
|
||||
if v is not None:
|
||||
claims.append(Claim.set("arbitrage", P_ARBITRAGE, v, "trade"))
|
||||
return real_resolve(claims, **kw)
|
||||
|
||||
def before(c, t):
|
||||
now[0] = t
|
||||
if not getattr(c.hass, "_s107", False):
|
||||
read = c.hass.number
|
||||
|
||||
async def number(e, invert=False):
|
||||
if e == "sensor.pnow":
|
||||
return 0.05 if cheap(c, c.hass.t) else 0.30
|
||||
if e == "sensor.pavg":
|
||||
return 0.20
|
||||
return await read(e, invert)
|
||||
c.hass.number, c.hass._s107 = number, True
|
||||
if p1:
|
||||
s79._feed(c, t)
|
||||
c.auto = auto(t)
|
||||
|
||||
def on_cycle(c, t):
|
||||
rows.append((t, tuple(c.hass.cmd[u] for u in "ab"[:n]), c.target,
|
||||
c.loop_reason, c.batt_refused, c.batt_refused_at,
|
||||
tuple(i.refused for i in c.inverters)))
|
||||
|
||||
extra = {"price_now_entity": "sensor.pnow",
|
||||
"price_avg_entity": "sensor.pavg"}
|
||||
if maint:
|
||||
extra.update({"maintenance_enabled": True,
|
||||
"maintenance_start_hour": 11,
|
||||
"maintenance_charge_w": 2000})
|
||||
if p1:
|
||||
extra.update(s79.P1, peak_cap_w=cap)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s107-"), "tariff.db")
|
||||
rig._Hass.power, rig.TICK = power, tick
|
||||
Controller.log_event, app_main.resolve = log_event, resolve
|
||||
rig.Controller = functools.partial(real_c, peak_db_path=db)
|
||||
try:
|
||||
_, c = rig.run(n, extra=extra, dur=dur, load=house,
|
||||
soc=lambda u, t: 50.0, cut=lambda u, t: False,
|
||||
before=before, on_cycle=on_cycle,
|
||||
store=s79.store_charging(n) if maint else rig._Store())
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK = real_power, real_tick
|
||||
Controller.log_event, app_main.resolve = real_log, real_resolve
|
||||
rig.Controller = real_c
|
||||
return rows, log, c
|
||||
|
||||
|
||||
def full_a(u, t, cmd):
|
||||
"""A takes no charge (a full pack), its reading jittering by a few watts
|
||||
as a real one does; B follows."""
|
||||
return -1.0 - int(t) % 3 if u == "a" and cmd < 0 else round(cmd)
|
||||
|
||||
|
||||
def flat_a(u, t, cmd):
|
||||
"""`full_a` with a perfectly flat reading (the stale-reading gate trips)."""
|
||||
return -1.0 if u == "a" and cmd < 0 else round(cmd)
|
||||
|
||||
|
||||
def empty_a(u, t, cmd):
|
||||
"""A gives nothing (an empty pack), its reading jittering by a few watts
|
||||
(SAFETY-73's jitter shape, so the stale-reading gate stays out of it);
|
||||
B follows."""
|
||||
return -4.0 - int(t) % 3 if u == "a" and cmd >= -4.0 else round(cmd)
|
||||
|
||||
|
||||
def lines(log, word="REFUSES"):
|
||||
return [x for x in log if word in x[1]]
|
||||
|
||||
|
||||
def ends(log):
|
||||
return [x for x in log if "REFUSES ENDED" in x[1]]
|
||||
|
||||
|
||||
def refused(r):
|
||||
"""Row r holds a refusal (site at N=1, any unit at N>1)."""
|
||||
return bool(r[4]) or any(r[6])
|
||||
|
||||
|
||||
def held_from(rows):
|
||||
"""True when, once learned, the refusal is held on every later row."""
|
||||
first = next((k for k, r in enumerate(rows) if refused(r)), None)
|
||||
return first is not None and all(refused(r) for r in rows[first:])
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
print("1. the card: a full pack under a maintenance charge, 15 min")
|
||||
# ==============================================================================
|
||||
for n, tick, house in ((n, k, h) for n in (1, 2) for k in (1.0, 2.0, 5.0)
|
||||
for h in (800.0, -1500.0)):
|
||||
rows, log, c = run(n, plant=full_a, house=lambda t: house, tick=tick)
|
||||
ref = lines(log)
|
||||
tag = f"N={n}, {tick:.0f} s meter, house {house:+.0f} W"
|
||||
print(f" {tag}: {len(ref)} refusal line(s): "
|
||||
+ " / ".join(x[1][:60] for x in ref))
|
||||
check(f"{tag}: at most 2 refusal lines in 15 min (rig: 548 + 548)",
|
||||
1 <= len(ref) <= 2)
|
||||
check(f"{tag}: no end line", not ends(log))
|
||||
check(f"{tag}: learned once and held to the end", held_from(rows))
|
||||
# N=1: the charge's ceiling stays on the wire. N=2: SAFETY-73 closes A
|
||||
# in the refused direction, as before.
|
||||
check(f"{tag}: still in its charge, the wire as before",
|
||||
c.maints[0].phase == "charge"
|
||||
and all(r[1][0] == (-2000.0 if n == 1 else 0.0)
|
||||
for r in rows[-10:]))
|
||||
# The hint under the buttons is set at most once for the refusal.
|
||||
check(f"{tag}: the refusal hint is set once",
|
||||
sum(1 for x in ref if x[2] is not None) == 1)
|
||||
|
||||
# The probe clock: while the charge holds the wire no probe is tried (the
|
||||
# loop's ask is not on the wire), so the action log stays quiet.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0, dur=3600.0)
|
||||
check("N=1, an hour: still one line, no probe tried under the charge",
|
||||
len(lines(log)) == 1
|
||||
and not any(r[3] == control.PROBING for r in rows))
|
||||
|
||||
# ==============================================================================
|
||||
print("\n2. other writers: a trade and the peak guard, no flap")
|
||||
# ==============================================================================
|
||||
# A trade (a SET above the loop) asks a full pack to charge for 15 min.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0, maint=False,
|
||||
trade=lambda t: -1500.0)
|
||||
print(f" trade into a full pack: {len(lines(log))} line(s)")
|
||||
check("a trade into a full pack: learned once, held, no end",
|
||||
len(lines(log)) == 1 and not ends(log) and held_from(rows))
|
||||
# The peak guard asks an empty pack to give while the house is over the limit.
|
||||
rows, log, c = run(1, plant=empty_a, house=lambda t: 4500.0, maint=False,
|
||||
p1=True)
|
||||
print(f" peak guard on an empty pack: {len(lines(log))} line(s), "
|
||||
f"guard floor {c.peak_guard.floor_w if c.peak_guard else None}")
|
||||
check("the peak guard on an empty pack: the guard holds the wire",
|
||||
c.peak_guard is not None and c.peak_guard.engaged)
|
||||
check("the peak guard on an empty pack: at most 2 lines, held to the end",
|
||||
1 <= len(lines(log)) <= 2 and held_from(rows))
|
||||
|
||||
# ==============================================================================
|
||||
print("\n3. a refusal still ends")
|
||||
# ==============================================================================
|
||||
# a. Learned by the loop on an empty pack; at 300 s a trade really takes the
|
||||
# wire and asks it to give 2000 W. The refusal was learned against the
|
||||
# loop's ask: it ends, then is learned against the trade, once.
|
||||
rows, log, _ = run(1, plant=empty_a, house=lambda t: 2000.0, maint=False,
|
||||
trade=lambda t: 2000.0 if 300.0 <= t < 900.0 else None,
|
||||
dur=1200.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("a different writer takes over: the refusal ends, 'another writer'",
|
||||
len(e) == 1 and "another writer" in e[0][1] and 300.0 < e[0][0] < 320.0)
|
||||
check("...and is learned again against the trade, once, and held",
|
||||
len(lines(log)) == 3 and held_from([r for r in rows if r[0] > 400.0]))
|
||||
check("...and the loop keeps it when the trade leaves (no line)",
|
||||
all(x[0] < 900.0 for x in lines(log)) and refused(nth(rows, -1)))
|
||||
# The writer asks the other way, which the empty pack takes.
|
||||
rows, log, _ = run(1, plant=empty_a, house=lambda t: 2000.0, maint=False,
|
||||
trade=lambda t: -1500.0 if t >= 300.0 else None)
|
||||
e = ends(log)
|
||||
check("a writer that asks the other way ends it ('another writer')",
|
||||
len(e) == 1 and "another writer" in e[0][1] and present(nth(rows, -1))
|
||||
and not refused(nth(rows, -1)))
|
||||
# b. The full pack starts taking charge again under the same writer.
|
||||
rows, log, _ = run(1, plant=lambda u, t, cmd: (
|
||||
round(cmd) if t >= 400.0 else full_a(u, t, cmd)), house=lambda t: 800.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("the battery takes charge again: the refusal ends once, on its reading",
|
||||
len(e) == 1 and "the battery gives power" in e[0][1]
|
||||
and 400.0 <= e[0][0] < 430.0 and present(nth(rows, -1))
|
||||
and not refused(nth(rows, -1)))
|
||||
# c. Stop and start still end it.
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: 800.0,
|
||||
auto=lambda t: not 500.0 <= t < 520.0)
|
||||
e = ends(log)
|
||||
check("stopping control still ends it", any("control stopped" in x[1]
|
||||
for x in e))
|
||||
# d. The probe clock. Learned under the charge; the cheap window closes when
|
||||
# the refusal is 910 s old, and the loop holds the wire (a surplus, so it
|
||||
# wants to charge). The first probe is a full PROBE_EVERY_S after the
|
||||
# window closed, as before: never at once.
|
||||
close = [None]
|
||||
|
||||
|
||||
def window(c, t):
|
||||
if close[0] is None and c.batt_refused:
|
||||
close[0] = t + 910.0
|
||||
return close[0] is None or t < close[0]
|
||||
|
||||
|
||||
rows, log, _ = run(1, plant=full_a, house=lambda t: -1500.0, cheap=window,
|
||||
dur=3000.0)
|
||||
probes = [r[0] for r in rows if r[3] == control.PROBING]
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
print(f" window closed at {close[0]} s, first probe at "
|
||||
f"{probes[0] if probes else None} s")
|
||||
check("the refusal carries over to the loop with no line",
|
||||
len(lines(log)) == 1 and held_from(rows))
|
||||
check("the first probe after the writer left comes PROBE_EVERY_S later",
|
||||
close[0] is not None and bool(probes)
|
||||
and probes[0] >= close[0] + control.PROBE_EVERY_S - 10.0)
|
||||
# e. A writer turns round on a dead pack with no gap. The evidence restarts
|
||||
# on the new sign (SAFETY-94), so the old refusal ends at once and the
|
||||
# other way is learned once, ~12 cycles later.
|
||||
rows, log, _ = run(1, plant=lambda u, t, cmd: (
|
||||
-2.0 - int(t) % 3 if u == "a" else round(cmd)), house=lambda t: 2000.0,
|
||||
maint=False, trade=lambda t: 2000.0 if t < 300.0 else -2000.0)
|
||||
e = ends(log)
|
||||
print(" " + " / ".join(f"{x[0]:.0f} s {x[1][:45]}" for x in lines(log)))
|
||||
check("a writer turns round on a dead pack: one end, then the other way",
|
||||
len(lines(log)) == 3 and len(e) == 1 and 300.0 < e[0][0] < 400.0
|
||||
and present(nth(rows, -1)) and nth(rows, -1)[4] == -1)
|
||||
|
||||
# ==============================================================================
|
||||
print("\n4. not covered: a perfectly flat reading under a writer")
|
||||
# ==============================================================================
|
||||
# The stale-reading gate trips on a flat reading while the command moves,
|
||||
# stops the site and seeds the refusal (SAFETY-52 decision 3). After the stop
|
||||
# the writer's ask comes back fresh, compute() has no saturation history
|
||||
# against it yet, so the refusal ends at the first cycle and the next trip
|
||||
# seeds it again: one pair per gate cycle, as on release/1.0 (the commands
|
||||
# are the same; DECISIONS/SAFETY-107 "Not covered"). Pinned so a change shows.
|
||||
rows, log, _ = run(1, plant=flat_a, house=lambda t: -1500.0)
|
||||
trips = len(lines(log, "STALE inverter"))
|
||||
print(f" flat reading, house -1500 W: {trips} gate trips, "
|
||||
f"{len(lines(log))} refusal lines in 15 min")
|
||||
check("flat reading: no per-cycle flap, at most one pair per gate trip",
|
||||
trips >= 1 and len(lines(log)) <= 2 * trips)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||
sys.exit(1)
|
||||
print("all SAFETY-107 checks passed")
|
||||
@@ -0,0 +1,440 @@
|
||||
"""SAFETY-112: waiting for Home Assistant to finish starting. `python3 -B test_safety112.py`
|
||||
|
||||
The Supervisor answers 502 to every add-on read, write and websocket until
|
||||
Home Assistant is RUNNING (DECISIONS/SAFETY-112-waiting-for-home-assistant.md),
|
||||
so the add-on cannot control then. This file grades what it says meanwhile:
|
||||
|
||||
1. The log: one WARNING when Home Assistant stops answering, one INFO line
|
||||
every 5 minutes while it stays down, one INFO line with the wait when it
|
||||
answers again. Only the API channel counts as "waiting": a 502 or a
|
||||
refused connection turns it on, the next good read off, a timeout never.
|
||||
2. The status page, N=1 and N=2: the waiting row is first and red, and is the
|
||||
banner. Home says "Waiting for Home Assistant" in English and Dutch, not
|
||||
"Needs you now", unless a safety stop outranks it. The page script has it.
|
||||
3. Control is unchanged: the same readings give the same writes with the
|
||||
waiting flag on or off, with 502 readings and with live ones.
|
||||
|
||||
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app import hass as _hass
|
||||
from app import messages
|
||||
from app.main import Controller
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class _Rec(logging.Handler):
|
||||
def __init__(self):
|
||||
super().__init__(logging.DEBUG)
|
||||
self.records = []
|
||||
|
||||
def emit(self, record):
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
def captured(fn):
|
||||
rec, lg = _Rec(), logging.getLogger("goodwe.hass")
|
||||
old = lg.level
|
||||
lg.addHandler(rec)
|
||||
lg.setLevel(logging.DEBUG)
|
||||
try:
|
||||
fn()
|
||||
finally:
|
||||
lg.removeHandler(rec)
|
||||
lg.setLevel(old)
|
||||
return [(r.levelno, r.getMessage()) for r in rec.records
|
||||
if r.levelno >= logging.INFO]
|
||||
|
||||
|
||||
class Clock:
|
||||
"""`hass.time` stand-in: only `monotonic` is read by Outage."""
|
||||
|
||||
def __init__(self):
|
||||
self.t = 1000.0
|
||||
|
||||
def monotonic(self):
|
||||
return self.t
|
||||
|
||||
|
||||
def with_clock(fn):
|
||||
clock, real = Clock(), _hass.time
|
||||
_hass.time = SimpleNamespace(monotonic=clock.monotonic)
|
||||
try:
|
||||
return fn(clock)
|
||||
finally:
|
||||
_hass.time = real
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("1. the log while Home Assistant does not answer")
|
||||
W, I = logging.WARNING, logging.INFO
|
||||
|
||||
|
||||
def outage(clock):
|
||||
o = _hass.Outage()
|
||||
o.down("api", "error 502", "read sensor.x: 502")
|
||||
for t in (1, 60, 299):
|
||||
clock.t = 1000 + t
|
||||
o.down("api", "error 502", "read sensor.x: 502")
|
||||
clock.t = 1300
|
||||
o.down("api", "error 502", "") # 5 minutes: the first reminder
|
||||
clock.t = 1301
|
||||
o.down("websocket", "the meter connection closed", "")
|
||||
clock.t = 1599
|
||||
o.down("api", "error 502", "") # 299 s after the reminder: none
|
||||
clock.t = 1600
|
||||
o.down("api", "error 502", "") # 10 minutes: the second
|
||||
clock.t = 1640
|
||||
o.up("api")
|
||||
o.up("websocket") # 640 s in all
|
||||
clock.t = 2000
|
||||
o.down("api", "no connection", "") # a new outage starts afresh
|
||||
clock.t = 2030
|
||||
o.up("api")
|
||||
|
||||
|
||||
lines = with_clock(lambda c: captured(lambda: outage(c)))
|
||||
check(f"one WARNING per outage, in plain words ({lines})",
|
||||
[m for lv, m in lines if lv == W] == [
|
||||
"Home Assistant is not answering (error 502), perhaps it is still "
|
||||
"starting. Control waits and resumes by itself.",
|
||||
"Home Assistant is not answering (no connection), perhaps it is "
|
||||
"still starting. Control waits and resumes by itself."])
|
||||
check("one 'still waiting' line per 5 minutes down, with the time so far",
|
||||
[m for lv, m in lines if lv == I and m.startswith("Still")] == [
|
||||
"Still waiting for Home Assistant to finish starting, 5 minutes "
|
||||
"so far.",
|
||||
"Still waiting for Home Assistant to finish starting, 10 minutes "
|
||||
"so far."])
|
||||
check("one INFO line when the last channel is back, with the wait, and a new "
|
||||
"outage counts from its own start",
|
||||
[m for lv, m in lines if lv == I and m.startswith("Home")] == [
|
||||
"Home Assistant answers again after 11 minutes.",
|
||||
"Home Assistant answers again after 30 seconds."])
|
||||
|
||||
|
||||
def channels(_clock):
|
||||
o = _hass.Outage()
|
||||
seen = [o.waiting()]
|
||||
o.down("websocket", "the meter connection closed", "")
|
||||
seen.append(o.waiting())
|
||||
o.down("api", "error 502", "")
|
||||
seen.append(o.waiting())
|
||||
o.up("websocket")
|
||||
seen.append(o.waiting())
|
||||
o.up("api")
|
||||
seen.append(o.waiting())
|
||||
return seen
|
||||
|
||||
|
||||
check("waiting() follows the API channel only, not the meter websocket",
|
||||
with_clock(channels) == [False, False, True, True, False])
|
||||
|
||||
|
||||
# The flag as `HomeAssistant.state()` sets it: a 502 or a refused connection
|
||||
# turns it on, the next good read turns it off, and a timeout (a slow Home
|
||||
# Assistant, not a starting one) leaves it alone.
|
||||
class _Answer:
|
||||
def __init__(self, what):
|
||||
self.what = what
|
||||
|
||||
async def __aenter__(self):
|
||||
if isinstance(self.what, BaseException):
|
||||
raise self.what
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_a):
|
||||
return False
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
return self.what
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def json(self):
|
||||
return {"state": "1"}
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, answers):
|
||||
self.answers = list(answers)
|
||||
|
||||
def get(self, *_a, **_k):
|
||||
return _Answer(self.answers.pop(0))
|
||||
|
||||
|
||||
def reads(answers):
|
||||
async def go():
|
||||
real, _hass.OUTAGE = _hass.OUTAGE, _hass.Outage()
|
||||
lg = logging.getLogger("goodwe.hass")
|
||||
lg.disabled = True
|
||||
try:
|
||||
h = _hass.HomeAssistant(_Session(answers), token="t")
|
||||
seen = []
|
||||
for _ in answers:
|
||||
await h.state("sensor.x")
|
||||
seen.append(_hass.OUTAGE.waiting())
|
||||
return seen
|
||||
finally:
|
||||
_hass.OUTAGE = real
|
||||
lg.disabled = False
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
check("a 502 read turns waiting on and the next good read turns it off",
|
||||
reads([502, 200, 503, 404]) == [True, False, True, False])
|
||||
check("a refused connection is waiting too",
|
||||
reads([_hass.aiohttp.ClientConnectionError(), 200]) == [True, False])
|
||||
check("a read timeout is not waiting",
|
||||
reads([TimeoutError(), TimeoutError()]) == [False, False])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("2. the status page, N=1 and N=2")
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
def __init__(self):
|
||||
self.writes = []
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None # what a 502 read gives
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.writes.append((e, float(v)))
|
||||
return False # what a 502 write gives
|
||||
|
||||
async def call(self, *_a):
|
||||
return False
|
||||
|
||||
async def states(self):
|
||||
return None
|
||||
|
||||
|
||||
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):
|
||||
return None
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, *_a, **_k):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
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": False, "stale_input_s": 15}
|
||||
TWO = dict(ONE, setpoint_entity="", soc_entity="", batt_entity="", inverters=[
|
||||
{"name": "inverter-a", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"bms_soc_floor": 10},
|
||||
{"name": "inverter-b", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"bms_soc_floor": 10}])
|
||||
|
||||
|
||||
def make(opts, hass=None):
|
||||
c = Controller(dict(opts), hass or _Hass(), _Store(), _Pub(),
|
||||
stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
return c
|
||||
|
||||
|
||||
def waiting_on():
|
||||
_hass.OUTAGE.down_on = {"api"}
|
||||
_hass.OUTAGE.since = _hass.OUTAGE._told = _hass.time.monotonic()
|
||||
|
||||
|
||||
def waiting_off():
|
||||
_hass.OUTAGE.down_on = set()
|
||||
|
||||
|
||||
BANNER = "Not ready — Waiting for Home Assistant to finish starting"
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
c = make(opts)
|
||||
waiting_off()
|
||||
plain = c.checks()
|
||||
pay_off = c.status_payload()
|
||||
waiting_on()
|
||||
rows = c.checks()
|
||||
pay = c.status_payload()
|
||||
pay_nl = c.status_payload(lang="nl")
|
||||
waiting_off()
|
||||
check(f"{label}: no waiting row while Home Assistant answers",
|
||||
not [r for r in plain if r.get("id") == "home_assistant"]
|
||||
and pay_off["state"]["kind"] != "waiting")
|
||||
check(f"{label}: while it does not, the waiting row is first and red",
|
||||
rows[0] == {"ok": False, "warn": False, "id": "home_assistant",
|
||||
"banner": messages.banner_ha_waiting(),
|
||||
"text": messages.row_ha_waiting()}
|
||||
and rows[1:] == plain)
|
||||
check(f"{label}: ...and it is the banner ({pay['banner']!r})",
|
||||
pay["banner"] == BANNER and pay["level"] == "bad")
|
||||
check(f"{label}: Home says waiting, not 'Needs you now' "
|
||||
f"({pay['state']['pill']!r}, {pay['state']['sub']!r})",
|
||||
pay["state"]["kind"] == "waiting"
|
||||
and pay["state"]["pill"] == "Waiting for Home Assistant"
|
||||
and pay["state"]["sub"] == "Control resumes by itself when Home "
|
||||
"Assistant answers.")
|
||||
check(f"{label}: ...and in Dutch ({pay_nl['state']['pill']!r})",
|
||||
pay_nl["state"]["pill"] == "Wacht op Home Assistant"
|
||||
and pay_nl["state"]["sub"] == "De regeling gaat vanzelf verder "
|
||||
"zodra Home Assistant antwoordt.")
|
||||
check(f"{label}: Needs attention lists it first, with its own title",
|
||||
pay["attention"][0]["id"] == "home_assistant"
|
||||
and pay["attention"][0]["title"] == "Home Assistant is not answering"
|
||||
and pay_nl["attention"][0]["title"] == "Home Assistant antwoordt "
|
||||
"niet")
|
||||
|
||||
# A safety stop outranks the wait: it still needs a person.
|
||||
c = make(TWO)
|
||||
c.inverters[1].sign_wd.tripped = True
|
||||
waiting_on()
|
||||
pay = c.status_payload()
|
||||
waiting_off()
|
||||
check(f"a safety stop keeps its banner and 'Needs you now' over the wait "
|
||||
f"({pay['banner']!r}, {pay['state']['kind']!r})",
|
||||
pay["banner"] == messages.banner_direction(True)
|
||||
and pay["state"]["kind"] == "alarm")
|
||||
# A stalled control loop does not resume by itself, so it stays the headline.
|
||||
c = make(ONE)
|
||||
c.loop_pass_mono = 0.0
|
||||
waiting_on()
|
||||
rows, pay = c.checks(), c.status_payload()
|
||||
waiting_off()
|
||||
check(f"a stalled control loop stays first and keeps 'Needs you now' "
|
||||
f"({pay['banner']!r})",
|
||||
[r["id"] for r in rows[:2]] == ["controller", "home_assistant"]
|
||||
and pay["state"]["kind"] == "alarm")
|
||||
c = make(dict(ONE, shadow_mode="on"))
|
||||
waiting_on()
|
||||
pay = c.status_payload()
|
||||
waiting_off()
|
||||
check("shadow mode keeps its own state over the wait",
|
||||
pay["state"]["kind"] == "shadow")
|
||||
|
||||
|
||||
# The page script, read as text (the image has no JS engine): an amber dot,
|
||||
# and the batteries tab carries the banner like the other held states.
|
||||
JS = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||
"static", "app.js"), encoding="utf-8").read()
|
||||
check("the page script gives 'waiting' an amber dot",
|
||||
re.search(r'alarm: " bad", waiting: " warn" \}\[st\.kind\]', JS)
|
||||
is not None)
|
||||
check("...and shows it as the batteries-tab banner",
|
||||
'["shadow", "stopped", "alarm", "waiting"].includes(S.state.kind)'
|
||||
in JS)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("3. control is unchanged by the waiting flag")
|
||||
|
||||
|
||||
def run(opts, flag):
|
||||
async def go():
|
||||
h = _Hass()
|
||||
c = make(opts, h)
|
||||
(waiting_on if flag else waiting_off)()
|
||||
targets = []
|
||||
for _ in range(4):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
|
||||
targets.append(c.target)
|
||||
waiting_off()
|
||||
return targets, h.writes
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
off, on = run(opts, False), run(opts, True)
|
||||
check(f"{label}: same targets and writes with the flag on or off "
|
||||
f"({on[0]}, {len(on[1])} writes)", off == on)
|
||||
check(f"{label}: ...and every write while Home Assistant does not answer "
|
||||
f"asks 0 W", on[1] and all(v == 0.0 for _e, v in on[1]))
|
||||
|
||||
|
||||
# With 502 readings every write is 0 W whatever the flag says, so the check
|
||||
# above cannot see a flag that forces 0 W. Live readings can: the flag alone
|
||||
# must not change a non-zero write.
|
||||
class _LiveHass(_Hass):
|
||||
async def number(self, e, invert=False):
|
||||
v = {"sensor.grid": 900.0}.get(e, 50.0 if "soc" in e else 0.0)
|
||||
return -v if invert else v
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.writes.append((e, float(v)))
|
||||
return True
|
||||
|
||||
|
||||
def run_live(opts, flag):
|
||||
async def go():
|
||||
h = _LiveHass()
|
||||
c = make(opts, h)
|
||||
(waiting_on if flag else waiting_off)()
|
||||
for _ in range(4):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await c.send_heartbeats(asyncio.get_running_loop().time(), 10.0)
|
||||
waiting_off()
|
||||
return h.writes
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
for label, opts in (("N=1", ONE), ("N=2", TWO)):
|
||||
off, on = run_live(opts, False), run_live(opts, True)
|
||||
check(f"{label}: with live readings the flag changes no write "
|
||||
f"({on[-2:]})", off == on and any(v != 0.0 for _e, v in on))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
raise SystemExit(1)
|
||||
print("all passed")
|
||||
@@ -57,6 +57,28 @@ BASE = {
|
||||
"twice7000": ([1001.0, 1502.0, 2699.0], "1cddda8cf35859efb2ad503f1683f744cb812a6325a390c4fa3df19d717af043"),
|
||||
"step6500_gap": ([1201.0, 1801.0], "95e5c90e4a6814526cb4f3bea76286063b449d86a420f888c014531fb6e913a3"),
|
||||
}
|
||||
# OPT-15: with battery modes ON the guard no longer reads "no seconds left" on
|
||||
# a quarter's edge tick (it projects the quarter it is in), so the 1 s 0 W
|
||||
# command at each edge is gone and every run differs from 5a4cdc2's. Printed by
|
||||
# `python3 tools/safety81_sim.py` on the OPT-15 head (BASE above stays as the
|
||||
# reference). Modes OFF stays byte-identical (section 1b).
|
||||
OPT15 = {
|
||||
"const4500": "ed2f4ef9a74172ada2f00707a09ad839743449642f61dc6583ace44bac33caf4",
|
||||
"const4500_m0": "63d4d62643b9d39697b16078603576d180d90090117a497122e77c33f3db2227",
|
||||
"sine4500": "a034af0a896d55552915f1e4816055e26c006d4df0a86d1c9d9c3a625faf747f",
|
||||
"edge4600": "74e03c7182469715c1517765c2f53f1d68d7342aed4a8ad388878eac24964020",
|
||||
"edge4600_m0": "93e4402b6b879a44da5ecc28fb3b51b16c0b0f701396e00a9703803273dbd18e",
|
||||
"burst_end": "8dc4001e06ad8fa562caea02d755227cdfa16396708fa986aef7a5704e22a691",
|
||||
"noise5000_m50": "71e29a970837211835e56636280c29f70d7c2d3f3062cfac2b940ea30fa66424",
|
||||
"noise5000_m0": "a47988b7bbee629a6f8722588f325a4c87d7dcb143bba549b6df452664b30251",
|
||||
"step6500": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8",
|
||||
"miss5560": "2b0afb600fe1cfd0bfe4b28fbfeeff73fc7e875e3d5f377ae7c2a489e20abf53",
|
||||
"ramp6500": "ae3838aa8f2af9458f73b540eeb76cb5fa25ace0f1ea06df2b4f6d9201907522",
|
||||
"step7500_reserve": "21eb9c07a4582f47f347084d1c75a28a5d767bc068bc3f74f32433d3239e9ccb",
|
||||
"sine4500_fast": "47ed9d02a4f7afbc50905f44519b14364f262ba793aae80f3cd077eb6f600b8b",
|
||||
"twice7000": "b89396022f589bbb90bc592ece121cf2a0bf3a4f04316da36891ac1a2b6ae18e",
|
||||
"step6500_gap": "a7356fe0f8c00ac4d16dd4f60b054af7336ceb2eedcd76911d30138df4b457b8",
|
||||
}
|
||||
OUT = {k: sim.run(**v) for k, v in sim.SCENARIOS.items()}
|
||||
RUN = {k: v[:2] for k, v in OUT.items()}
|
||||
ERR = {k: v[4] for k, v in OUT.items()}
|
||||
@@ -65,7 +87,23 @@ ERR = {k: v[4] for k, v in OUT.items()}
|
||||
print("1. every command byte-identical to release/1.0 5a4cdc2")
|
||||
check("the same scenarios as 5a4cdc2", set(RUN) == set(BASE))
|
||||
for k, (alarms, digest) in RUN.items():
|
||||
check(f"{k}: digest {digest[:12]}", digest == BASE[k][1])
|
||||
check(f"{k}: digest {digest[:12]} (OPT-15 pin; modes off in 1b)",
|
||||
digest == OPT15[k])
|
||||
|
||||
# ============================================================================ #
|
||||
print("1b. battery modes off: every command byte-identical to release/1.0 e3e4ba6")
|
||||
# Printed on e3e4ba6 (this sim copied in, `modes=False`), in the CI image.
|
||||
OFF = {
|
||||
"const4500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"edge4600": "4f8f943f5a4e9b218c2e8bc6a95d20934ec19f1bebfbdc5748ca52e753e5afa0",
|
||||
"step6500": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"burst_end": "4ca4ba895afe7dacfe3952743e13d21607e690dfec39029fd5c5745fe03af5d7",
|
||||
"twice7000": "719d8bfa450710d9c0c22282d17ea4ab9945b1cb53ce344084e5ff906ca027b3",
|
||||
"sine4500_fast": "3e41ad417880a4ebb63ae3d6826f413fb779c18dfb677207775b1c980133423f",
|
||||
}
|
||||
for k, want in OFF.items():
|
||||
digest = sim.run(modes=False, **sim.SCENARIOS[k])[1]
|
||||
check(f"{k} (modes off): digest {digest[:12]}", digest == want)
|
||||
|
||||
# ============================================================================ #
|
||||
print("2. no false alarm (5a4cdc2's are listed)")
|
||||
@@ -76,16 +114,17 @@ for k in ("edge4600", "edge4600_m0", "const4500", "const4500_m0", "sine4500",
|
||||
# ============================================================================ #
|
||||
print("3. real overloads still alarm, ALARM_HOLD_S after 5a4cdc2")
|
||||
# ALARM_HOLD_S (10 s) plus up to 2 s of late meter samples; at 1800 s the
|
||||
# quarter-edge tick restarts the hold.
|
||||
for k, want in (("step6500", [1211.0, 1813.0]),
|
||||
("miss5560", [1213.0, 1813.0])):
|
||||
# quarter-edge tick restarts the hold (OPT-15: with modes on, by not grading
|
||||
# a projection that rolled into the new quarter before its first sample).
|
||||
for k, want in (("step6500", [1211.0, 1811.0]),
|
||||
("miss5560", [1213.0, 1811.0])):
|
||||
print(f" {k}: load steps up at 1200 s; alarms at {RUN[k][0]}, "
|
||||
f"5a4cdc2 {BASE[k][0]}")
|
||||
check(f"{k}: after the hold, and again only at the next quarter",
|
||||
RUN[k][0] == want)
|
||||
check("step6500_gap: cycles stopped by a lost battery reading do not "
|
||||
"restart the hold",
|
||||
RUN["step6500_gap"][0] == [1215.0, 1813.0])
|
||||
RUN["step6500_gap"][0] == [1215.0, 1811.0])
|
||||
check("twice7000: notified once in the quarter (5a4cdc2: "
|
||||
f"{BASE['twice7000'][0]})", RUN["twice7000"][0] == [1011.0])
|
||||
check("twice7000: the ERROR line for both overloads",
|
||||
@@ -96,7 +135,7 @@ print(f" ramp6500: alarms at {RUN['ramp6500'][0]}, "
|
||||
f"5a4cdc2 {BASE['ramp6500'][0]}")
|
||||
check("ramp6500: alarms once the quarter will end over the real cap by "
|
||||
"more than ALARM_MISS_W",
|
||||
RUN["ramp6500"][0] == [1316.0, 1813.0])
|
||||
RUN["ramp6500"][0] == [1317.0, 1811.0])
|
||||
check("step7500 under the peak reserve: not this alarm (the guard is not "
|
||||
"allowed to discharge)", RUN["step7500_reserve"][0] == [])
|
||||
|
||||
@@ -150,6 +189,7 @@ stub = SimpleNamespace(
|
||||
reason="PEAK_GUARD: fixture"),
|
||||
site_rating_w=2000.0, shadow=False, events=[],
|
||||
_peak_batt_prev=None, _peak_alarm_block=None, _peak_high_since=None,
|
||||
_peak_rolled=False,
|
||||
_peak_saturated=False, hass=SimpleNamespace(call=_call),
|
||||
log_event=lambda msg, hint=None: None)
|
||||
first = None
|
||||
|
||||
@@ -37,7 +37,7 @@ import safety79_identity as s79 # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.maintenance import (CHARGE_TIMEOUT_MIN, HOLD, IDLE, # noqa: E402
|
||||
from app.maintenance import (CHARGE_TIMEOUT_MIN, DRAIN, HOLD, IDLE, # noqa: E402
|
||||
MaintResult)
|
||||
from app.store import unit_key # noqa: E402
|
||||
from named_absence import first, present # noqa: E402
|
||||
@@ -221,9 +221,16 @@ def switch_off(at):
|
||||
return before
|
||||
|
||||
|
||||
def timed_out_b_done(end):
|
||||
"""timed_out(), with B's monthly cycle finished so it starts none."""
|
||||
s = timed_out(end)
|
||||
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
ENDS = {
|
||||
"timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=timed_out(1780.0)),
|
||||
store=timed_out_b_done(1780.0)),
|
||||
"Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=b_done(), before=switch_off(1780.0),
|
||||
extra={"battery_modes_enabled": True}),
|
||||
@@ -246,6 +253,31 @@ for k, fn in ENDS.items():
|
||||
f"the grid", at(old, 1780.0, "b") == -1600.0
|
||||
and charging_hi(old, 1780, 1798, hi1) >= 1)
|
||||
|
||||
# MAINT-13: A's timed-out charge is a failed cycle and backs off, so A no longer
|
||||
# restarts at once; a due B takes the slot instead and starts ITS drain. That is
|
||||
# a drain start (a step to the drain rate by design), not a charge end.
|
||||
rows, c = run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||
store=timed_out(1780.0))
|
||||
print(f" timeout, B due: A {[cmd(r)['a'] for r in rows if 1776 <= r[0] <= 1790]}"
|
||||
f" B {[cmd(r)['b'] for r in rows if 1776 <= r[0] <= 1790]}")
|
||||
check("timeout with B due: the charge ends at 1780 s and A is not restarted",
|
||||
phase_at(rows, 1778.0) == "charge" and phase_at(rows, 1780.0) == IDLE
|
||||
and c.maints[0].phase == IDLE
|
||||
and c.maints[0].backoff_until(rig.BASE + timedelta(seconds=1800))
|
||||
is not None)
|
||||
# A command below 0 W is a charge, so B's commands must stay at or above 0;
|
||||
# the drain rate is the one this rig's controller was configured with.
|
||||
drain_w = c.maints[1].cfg.discharge_w
|
||||
print(f" B drain rate configured on the rig: {drain_w:.0f} W")
|
||||
check("⭐ timeout with B due: no battery charges from the grid after it",
|
||||
charging_hi(rows, 1780, 1798, hi1) == 0
|
||||
and min(cmd(r)["b"] for r in rows if r[0] >= 1780) >= 0.0)
|
||||
check("...B takes the slot and begins its own drain, which is the only large "
|
||||
"step, to the configured drain rate and no higher",
|
||||
c.maints[1].phase == DRAIN
|
||||
and max(cmd(r)["b"] for r in rows if r[0] >= 1780) <= drain_w
|
||||
and moves(rows, "a", 1780, 1798) <= SLEW)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. the charge ends while the guard has suspended it")
|
||||
# House 6000 W from 1500 s: the guard suspends the shaping, the loop has both
|
||||
|
||||
@@ -454,7 +454,11 @@ check("the saved blob passes the normal gate: no refusal, not setup-only",
|
||||
|
||||
print("\n after the restart, on the saved settings:")
|
||||
rc, ev, err, files = run_child("after", SAVED or {}, SAVED_STATE)
|
||||
services = [e for e in ev if e[0] == "SERVICE"]
|
||||
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
|
||||
# Only that dismiss: a `create` with the old id would still be counted.
|
||||
services = [e for e in ev if e[0] == "SERVICE"
|
||||
and not (e[1:3] == ["persistent_notification", "dismiss"]
|
||||
and e[3].get("notification_id") == "goodwe_fixed_tariff_unused")]
|
||||
writes = [e[3].get("value") for e in services
|
||||
if e[1:3] == ["number", "set_value"]]
|
||||
print(f" after: exit {rc}, {len(services)} service calls, set_value "
|
||||
|
||||
@@ -1111,8 +1111,17 @@ check("...carrying the reason with it, so the operator who has to decide "
|
||||
check("...and it is still commanding 0 W on the far side of the restart",
|
||||
_b.target == 0.0 and "circulation" in _b.reason)
|
||||
|
||||
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"]
|
||||
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"]
|
||||
# Not the dismiss of PRICE-01's old fixed-tariff card, sent at every start.
|
||||
# Only that dismiss: a `create` with the old id would still be counted.
|
||||
def _not_old_dismiss(c):
|
||||
return not (c[1] == "dismiss"
|
||||
and c[2].get("notification_id") == "goodwe_fixed_tariff_unused")
|
||||
|
||||
|
||||
_notes1 = [c for c in _h1.calls if c[0] == "persistent_notification"
|
||||
and _not_old_dismiss(c)]
|
||||
_notes2 = [c for c in _h2.calls if c[0] == "persistent_notification"
|
||||
and _not_old_dismiss(c)]
|
||||
print(f" notifications raised: {len(_notes1)} before the restart, "
|
||||
f"{len(_notes2)} after it")
|
||||
check("the alarm was raised in the first process", len(_notes1) >= 1)
|
||||
|
||||
@@ -19,12 +19,14 @@ import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety79_identity as s79 # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
|
||||
from app.static_tariff import TariffPlan # noqa: E402
|
||||
|
||||
@@ -53,6 +55,21 @@ def _feed(c, t):
|
||||
s79._feed(c, t)
|
||||
|
||||
|
||||
# OPT-15: the P1 sample of each cycle is built this long after the cycle
|
||||
# reads the grid (the HA source's debounce), so the cycle on a quarter
|
||||
# boundary still sees the old block, as on TEST-23.
|
||||
P1_LAG_S = 0.35
|
||||
|
||||
|
||||
def _feed_lag(c, t):
|
||||
c.tariff_plan, c.tariff_at = PLAN, rig.BASE + timedelta(seconds=t)
|
||||
imp, exp = split_signed(c.hass.grid())
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t - P1_LAG_S),
|
||||
ingest_mono=time.monotonic()))
|
||||
|
||||
|
||||
def load(t):
|
||||
"""Under the limit, over it, a surplus, a big evening load."""
|
||||
k = int(t // 600) % 5
|
||||
@@ -70,10 +87,13 @@ SCENARIOS = {
|
||||
# The option present and false: an install that saved it off.
|
||||
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
||||
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
|
||||
# OPT-15: every quarter boundary with the old block still open.
|
||||
"n1_lag": dict(n=1, lag=True),
|
||||
"n2_lag": dict(n=2, lag=True),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, extra=None, dur=DUR):
|
||||
def run(n, *, extra=None, dur=DUR, lag=False):
|
||||
opts = dict(s79.P1, **ARB, **(extra or {}))
|
||||
trade = []
|
||||
|
||||
@@ -85,7 +105,8 @@ def run(n, *, extra=None, dur=DUR):
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(n, extra=opts, dur=dur, load=load, soc=soc,
|
||||
cut=lambda u, t: False, before=_feed,
|
||||
cut=lambda u, t: False,
|
||||
before=_feed_lag if lag else _feed,
|
||||
on_cycle=on_cycle)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
|
||||
@@ -18,7 +18,11 @@ earlier (364 days), or the captured quarter-hourly energy-charts bodies for
|
||||
2026-09-09/10), the decision code.
|
||||
MODELLED: the batteries (a first-order lag, 3 s, 800 W/s ramp, SoC
|
||||
integrated, 95 % each way, a reading that jitters by a few watts, and
|
||||
optionally a device limit or a refusal to charge), the all-in price (the EPEX file's own stated
|
||||
optionally a device limit or a refusal to charge). By default the readings
|
||||
are instantaneous and the loop runs every 5 s, which hides what the loop
|
||||
does on readings a second or two old; `lag=Lag(...)` (OPT-15) adds a command
|
||||
dead time, a meter and battery reading age, the P1 sample's debounce and a
|
||||
1 s cycle, as on TEST-23. The all-in price (the EPEX file's own stated
|
||||
assumption: (wholesale + 0.02 + 0.10) x 1.06), export paid at the wholesale
|
||||
price, tomorrow's prices known from 13:00 local. The absolute money is
|
||||
therefore an estimate; the comparison between variants is the point.
|
||||
@@ -104,10 +108,28 @@ def qh_points():
|
||||
if p is not None]
|
||||
|
||||
|
||||
class Lag:
|
||||
"""OPT-15: how old what the add-on reads is, and how often it acts.
|
||||
`dead_s` a command takes effect that late; `meter_s` / `batt_s` the age
|
||||
of the grid and battery readings; `p1_s` a P1 sample is built that long
|
||||
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)."""
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
|
||||
class Plant:
|
||||
def __init__(self, soc, refuse_charge=False):
|
||||
self.cmd, self.p, self.soc = 0.0, 0.0, float(soc)
|
||||
self.refuse_charge = refuse_charge
|
||||
self.queue = [] # (t, command) not yet in effect
|
||||
self.tau, self.ramp = 3.0, 800.0
|
||||
|
||||
def step(self, dt, floor):
|
||||
c = self.cmd
|
||||
@@ -115,8 +137,8 @@ class Plant:
|
||||
c = 0.0
|
||||
if c < 0 and (self.soc >= 100.0 or self.refuse_charge):
|
||||
c = 0.0
|
||||
want = self.p + (c - self.p) * (1 - math.exp(-dt / 3.0))
|
||||
dp = max(-800.0 * dt, min(800.0 * dt, want - self.p))
|
||||
want = self.p + (c - self.p) * (1 - math.exp(-dt / self.tau))
|
||||
dp = max(-self.ramp * dt, min(self.ramp * dt, want - self.p))
|
||||
self.p += dp
|
||||
e = self.p * dt / 3600.0 / 1000.0 # kWh, + = out
|
||||
eff = 1 / 0.95 if e > 0 else 0.95
|
||||
@@ -125,17 +147,52 @@ class Plant:
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None):
|
||||
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None,
|
||||
lag=None):
|
||||
self.units = units
|
||||
self.lim = limits or {}
|
||||
self.plants = {u: Plant((soc0 or {}).get(u, 50.0), u in refuse)
|
||||
for u in units}
|
||||
self.house_at = house_at
|
||||
self.t = 0.0
|
||||
self.lag = lag
|
||||
self.hist = [] # (t, grid, {unit: p}), with `lag`
|
||||
for p in self.plants.values():
|
||||
if lag is not None:
|
||||
p.tau, p.ramp = lag.tau_s, lag.ramp_w_s
|
||||
|
||||
def grid(self):
|
||||
return self.house_at(self.t) - sum(p.p for p in self.plants.values())
|
||||
|
||||
def _past(self, age):
|
||||
"""(grid, {unit: p}) as they were `age` seconds ago."""
|
||||
want = self.t - age
|
||||
for t, g, ps in reversed(self.hist):
|
||||
if t <= want + 1e-9:
|
||||
return g, ps
|
||||
return self.hist[0][1:] if self.hist else (self.grid(), {
|
||||
u: p.p for u, p in self.plants.items()})
|
||||
|
||||
def advance(self, dt, floor):
|
||||
"""Step the plant `dt` seconds (with `lag`: in `sub` steps, commands
|
||||
applied after `dead_s`, history kept for the lagged readings)."""
|
||||
lag = self.lag
|
||||
if lag is None:
|
||||
for p in self.plants.values():
|
||||
p.step(dt, floor)
|
||||
return
|
||||
end = self.t + dt
|
||||
while self.t < end - 1e-9:
|
||||
h = min(lag.sub, end - self.t)
|
||||
for p in self.plants.values():
|
||||
while p.queue and p.queue[0][0] <= self.t + 1e-9:
|
||||
p.cmd = p.queue.pop(0)[1]
|
||||
p.step(h, floor)
|
||||
self.t += h
|
||||
self.hist.append((self.t, self.grid(),
|
||||
{u: p.p for u, p in self.plants.items()}))
|
||||
del self.hist[:-200]
|
||||
|
||||
async def limits(self, e):
|
||||
lo, hi = self.lim.get(e[-1], (-5000.0, 5000.0))
|
||||
return (lo, hi, 1.0)
|
||||
@@ -145,7 +202,8 @@ class Hass:
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
if e == "sensor.grid":
|
||||
return self.grid()
|
||||
return self.grid() if self.lag is None else \
|
||||
self._past(self.lag.meter_s)[0]
|
||||
u = e[-1]
|
||||
if e.startswith("sensor.soc"):
|
||||
return round(self.plants[u].soc, 1)
|
||||
@@ -153,11 +211,17 @@ class Hass:
|
||||
# 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.
|
||||
j = (ord(u) * 31 + int(self.t)) % 7 - 3
|
||||
return float(round(self.plants[u].p) + j)
|
||||
p = self.plants[u].p if self.lag is None else \
|
||||
self._past(self.lag.batt_s)[1][u]
|
||||
return float(round(p) + j)
|
||||
return None
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.plants[e[-1]].cmd = float(v)
|
||||
p = self.plants[e[-1]]
|
||||
if self.lag is None:
|
||||
p.cmd = float(v)
|
||||
else:
|
||||
p.queue.append((self.t + self.lag.dead_s, float(v)))
|
||||
return True
|
||||
|
||||
|
||||
@@ -239,7 +303,7 @@ def plan_at(now, pts):
|
||||
|
||||
def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
soc0=None, refuse=(), extra=None, choice=None, patch=None,
|
||||
limits=None):
|
||||
limits=None, lag=None):
|
||||
modes, arb, var_choice = VARIANTS[variant]
|
||||
choice = var_choice if choice is None else choice
|
||||
units = tuple("ab"[:n])
|
||||
@@ -272,7 +336,8 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
ticks=0, quarters={}, trade_ticks=0)
|
||||
|
||||
async def go():
|
||||
hass = Hass(units, house_at, soc0, refuse, limits)
|
||||
hass = Hass(units, house_at, soc0, refuse, limits, lag)
|
||||
tick = TICK if lag is None else lag.tick
|
||||
import tempfile
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="p3r-"), "tariff.db")
|
||||
c = Controller(dict(options(modes, arb, n), **(extra or {})), hass,
|
||||
@@ -299,6 +364,8 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
now = start + timedelta(seconds=t)
|
||||
hass.t = t
|
||||
clock[0] = now
|
||||
if lag is not None and not hass.hist:
|
||||
hass.advance(0.0, 10.0)
|
||||
if plan_hour != now.replace(minute=0, second=0,
|
||||
microsecond=0):
|
||||
plan_hour = now.replace(minute=0, second=0,
|
||||
@@ -306,17 +373,25 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
c.tariff_plan, c.tariff_at = plan_at(now, pts), now
|
||||
else:
|
||||
c.tariff_at = now
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
if lag is None:
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
elif t >= lag.p1_s:
|
||||
# The sample of the reading one debounce ago.
|
||||
imp, exp = split_signed(hass._past(
|
||||
lag.meter_s + lag.p1_s)[0])
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now - timedelta(seconds=lag.p1_s),
|
||||
ingest_mono=time.monotonic()))
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
for p in hass.plants.values():
|
||||
p.step(TICK, 10.0)
|
||||
hass.advance(tick, 10.0)
|
||||
g = hass.grid()
|
||||
pr = price_at(now)
|
||||
dt_h = TICK / 3600.0
|
||||
dt_h = tick / 3600.0
|
||||
if g > 0:
|
||||
stats["imp_kwh"] += g / 1000 * dt_h
|
||||
stats["cost"] += g / 1000 * dt_h * (all_in(pr) if pr
|
||||
@@ -339,7 +414,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
stats["ticks"] += 1
|
||||
if check is not None:
|
||||
check(c, hass, now, pr)
|
||||
t += TICK
|
||||
t += tick
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return c
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""OPT-20: the refusal-probe scenarios, on the REAL Controller in the lagged
|
||||
replay (tools/opt11p3_replay.py). Uses only the API bf9019e has, so the same
|
||||
file runs on both trees (test_opt20.py pins the digests printed on bf9019e).
|
||||
|
||||
Battery B is Cheap hours and refuses to charge; it is probed every 15 minutes
|
||||
inside the cheap run (OPT-15). A is, per scenario, in its maintenance charge
|
||||
phase, idle (Solar first), or a normal charger in the same run.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
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.dirname(HERE))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11p3_replay as rp # noqa: E402
|
||||
from app import packmode # noqa: E402
|
||||
from app.maintenance import CHARGE, DRAIN, MaintResult # noqa: E402
|
||||
|
||||
L = timezone(timedelta(hours=2))
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
|
||||
SF_CH = {"a": (True, packmode.SOLAR_FIRST, False),
|
||||
"b": (True, packmode.CHEAP_HOURS, False)}
|
||||
CH_CH = {"a": (True, packmode.CHEAP_HOURS, False),
|
||||
"b": (True, packmode.CHEAP_HOURS, False)}
|
||||
|
||||
# A day of the owner's kind: cheap 02:00-04:00 local, flat otherwise.
|
||||
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
|
||||
UTC0 = D0.astimezone(timezone.utc)
|
||||
_PR = [0.30] * 192
|
||||
for _i in range(8, 16):
|
||||
_PR[_i] = 0.10
|
||||
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p) for i, p in enumerate(_PR)]
|
||||
|
||||
# OPT-15 rig g7: prices 0 from 17:45 to 18:45 local, flat 0.06 otherwise.
|
||||
G0 = datetime(2026, 10, 13, 0, 0, tzinfo=L)
|
||||
GUTC0 = G0.astimezone(timezone.utc)
|
||||
_GP = [0.06] * 192
|
||||
for _i in range(71, 75):
|
||||
_GP[_i] = 0.0
|
||||
for _i in range(76, 78):
|
||||
_GP[_i] = 0.5
|
||||
G7PTS = [(GUTC0 + timedelta(minutes=15 * i), 900, p)
|
||||
for i, p in enumerate(_GP)]
|
||||
|
||||
|
||||
def at(h, base=UTC0):
|
||||
return base + timedelta(hours=h)
|
||||
|
||||
|
||||
def _rows(rows):
|
||||
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 "",
|
||||
probing=c.inverters[1].probe_from is not None,
|
||||
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
|
||||
p={u: p.p for u, p in h.plants.items()}))
|
||||
return cb
|
||||
|
||||
|
||||
def day(choice, *, a_maint=None, shaped=False, refuse=("b",), hours=0.5,
|
||||
house_w=800.0):
|
||||
"""02:00..: a cheap run, `house_w` house. `a_maint` is A's forced maintenance
|
||||
result from 5 minutes in (B's refusal stands by then); `shaped` closes the
|
||||
maintenance charge's own watts (charge-only shaping, hi = 0)."""
|
||||
rows = []
|
||||
|
||||
def patch(c):
|
||||
if shaped:
|
||||
c._charge_headroom = lambda floor, claims: 0.0
|
||||
if a_maint is None:
|
||||
return
|
||||
m, real = c.maints[0], c.maints[0].tick
|
||||
|
||||
def tick(now, soc, blocked_by=None, gate_open=True):
|
||||
if now >= at(1.9 + 5 / 60):
|
||||
return a_maint
|
||||
return real(now, soc, blocked_by=blocked_by, gate_open=gate_open)
|
||||
m.tick = tick
|
||||
rp.run("p3_ch_sell", [(UTC0.timestamp() - 10, house_w)], WPTS, at(1.9),
|
||||
3600 * hours, check=_rows(rows), lag=LAG, n=2,
|
||||
soc0={"a": 50.0, "b": 30.0}, choice=choice, refuse=refuse,
|
||||
patch=patch, extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows
|
||||
|
||||
|
||||
def g7():
|
||||
"""The OPT-15 rig's g7: B refuses from the start of the cheap run, A
|
||||
drains 18:00 to 18:17:04 and then sits in its maintenance charge phase
|
||||
(charge-only) at 0 W. The probe after the drain used to put the trade's
|
||||
2500 W on A."""
|
||||
rows = []
|
||||
t_end = at(18 + 17 / 60 + 4 / 3600, GUTC0)
|
||||
|
||||
def patch(c):
|
||||
m, real = c.maints[0], c.maints[0].tick
|
||||
|
||||
def tick(now, soc, blocked_by=None, gate_open=True):
|
||||
if at(18.0, GUTC0) <= now < t_end:
|
||||
return MaintResult(DRAIN, True, 2000.0, False, [])
|
||||
if now >= t_end:
|
||||
return MaintResult(CHARGE, False, None, True, [])
|
||||
return real(now, soc, blocked_by=blocked_by, gate_open=gate_open)
|
||||
m.tick = tick
|
||||
rp.run("p3_ch_sell", [(GUTC0.timestamp() - 10, 800.0)], G7PTS,
|
||||
at(17.6, GUTC0), 3600 * 1.1, check=_rows(rows), lag=LAG, n=2,
|
||||
soc0={"a": 17.0, "b": 73.0}, choice=SF_CH, refuse=("b",),
|
||||
patch=patch, extra={"cycle_cost_threshold_eur": 0.10})
|
||||
return rows
|
||||
|
||||
|
||||
MAINT_CHARGE = MaintResult(CHARGE, False, None, True, [])
|
||||
|
||||
|
||||
def windows(rows):
|
||||
"""[(first, last)] row indexes of each probe window."""
|
||||
out, i = [], 0
|
||||
while i < len(rows):
|
||||
if rows[i]["probing"]:
|
||||
j = i
|
||||
while j + 1 < len(rows) and rows[j + 1]["probing"]:
|
||||
j += 1
|
||||
out.append((i, j))
|
||||
i = j + 1
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def digest(rows) -> str:
|
||||
keep = [(r["now"].isoformat(), sorted(r["shares"].items()),
|
||||
round(r["grid"], 3), r["action"], r["reason"],
|
||||
sorted(r["roles"].items()), sorted(r["p"].items()))
|
||||
for r in rows]
|
||||
return hashlib.sha256(repr(keep).encode()).hexdigest()
|
||||
|
||||
|
||||
def no_probe_digests() -> dict:
|
||||
"""Battery modes on, no refusal, so no probe: a scripted cheap run."""
|
||||
return {
|
||||
"sf_ch": digest(day(SF_CH, refuse=(), hours=1.2)),
|
||||
"ch_ch": digest(day(CH_CH, refuse=(), hours=1.2)),
|
||||
"ch_ch_maint": digest(day(CH_CH, a_maint=MAINT_CHARGE, refuse=(),
|
||||
hours=1.2)),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for k, v in no_probe_digests().items():
|
||||
print(k, v)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""SAFETY-103's plant: test_safety95's lagging battery with the SAFETY-90
|
||||
register model, driven through the REAL Controller in simulated time.
|
||||
|
||||
Each battery ramps at 800 W/s, then follows a first-order lag (`lag` s). Its
|
||||
power register publishes every `reg_s` s at its own phase (the SAFETY-90/96
|
||||
rig: A at 0.6, B at 0.16 of the period), and reads `off` W from the truth
|
||||
(B idles at -100 W). The meter reads every cycle (`tick` s). A drain on A
|
||||
runs from the first cycle (the others finished theirs); `before(c, t)` ends
|
||||
it. `run()` has no side effects on import, so tests and probes share it.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
from app import main as app_main, packmode # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
|
||||
A = "inverter-a"
|
||||
T = 300.0 # A's drain ends here
|
||||
PHASE = {"a": 0.6, "b": 0.16, "c": 0.38}
|
||||
OFF = {"b": -100.0} # the rig's B: idle reading -100 W
|
||||
M = {"maintenance_enabled": True, "maintenance_start_hour": 11,
|
||||
"battery_modes_enabled": True}
|
||||
|
||||
|
||||
def options(n, extra=None):
|
||||
o = rig._options(n, dict(M, **(extra or {})))
|
||||
if n > 1:
|
||||
o["inverters"] = [
|
||||
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
|
||||
"max_w": 2500, "bms_soc_floor": 10} for u in "abc"[:n]]
|
||||
return o
|
||||
|
||||
|
||||
def store(n):
|
||||
"""A due for its monthly cycle (it drains from the first cycle)."""
|
||||
s = rig._Store()
|
||||
for u in "bc"[:n - 1]:
|
||||
s.data[unit_key(f"inverter-{u}", "last_completed")] = \
|
||||
rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def off(c, t):
|
||||
"""A switched Off at T (the SAFETY-95 check 5 end)."""
|
||||
if t >= T:
|
||||
c.store.data[unit_key(A, packmode.USE_KEY)] = False
|
||||
|
||||
|
||||
def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
|
||||
lag=10.0, noise=50.0, seed=103, phase=None, offs=None, extra=None,
|
||||
st=None, soc=None, dead=None, reject=None):
|
||||
"""Rows of one run. Each row is a dict: t, cmd (the add-on's commands),
|
||||
target, grid (true), true (each battery's power), read (each register),
|
||||
age (s since each register published), phase (maintenance phases).
|
||||
`dead` (u, from s): u gives nothing from then on (board at 0 W).
|
||||
`reject` {u: from s}: writes to u are refused from then on."""
|
||||
units = tuple("abc"[:n])
|
||||
phase = PHASE if phase is None else phase
|
||||
offs = OFF if offs is None else offs
|
||||
rnd = random.Random(seed)
|
||||
soc = soc or (lambda u, t: 50.0)
|
||||
|
||||
class Hass(rig._Hass):
|
||||
def __init__(self, *a):
|
||||
super().__init__(*a)
|
||||
self.tgt = {u: 0.0 for u in units}
|
||||
self.act = {u: 0.0 for u in units}
|
||||
self.reg = {u: 0.0 for u in units}
|
||||
self.pub_t = {u: 0.0 for u in units}
|
||||
self.slot = {u: None for u in units}
|
||||
self.tl = 0.0
|
||||
self.hn = 0.0
|
||||
|
||||
def advance(self, t):
|
||||
for k in range(int(round((t - self.tl) / 0.1))):
|
||||
now = self.tl + (k + 1) * 0.1
|
||||
for u in units:
|
||||
c = self.cmd[u]
|
||||
if dead is not None and u == dead[0] and now >= dead[1]:
|
||||
c = 0.0
|
||||
d = c - self.tgt[u]
|
||||
self.tgt[u] += math.copysign(min(abs(d), 80.0), d)
|
||||
self.act[u] += (self.tgt[u] - self.act[u]) * (
|
||||
1 - math.exp(-0.1 / lag))
|
||||
s = math.floor((now - phase.get(u, 0.0) * reg_s) / reg_s)
|
||||
if s != self.slot[u]:
|
||||
self.slot[u] = s
|
||||
self.reg[u] = float(round(self.act[u] + offs.get(u, 0.0)))
|
||||
self.pub_t[u] = now
|
||||
self.tl = t
|
||||
self.hn = rnd.uniform(-noise, noise) if noise else 0.0
|
||||
|
||||
def power(self, u):
|
||||
return self.reg[u]
|
||||
|
||||
def grid(self):
|
||||
h = house(self.t) if callable(house) else house
|
||||
return float(round(h + self.hn - sum(self.act.values())))
|
||||
|
||||
async def set_number(self, e, v):
|
||||
u = e[-1]
|
||||
if reject and u in reject and self.t >= reject[u]:
|
||||
return False
|
||||
self.cmd[u] = float(v)
|
||||
return True
|
||||
|
||||
opts = options(n, extra)
|
||||
db = os.path.join(tempfile.mkdtemp(prefix="s103-"), "tariff.db")
|
||||
|
||||
async def go():
|
||||
hass = Hass(units, soc, lambda u, t: False, lambda t: 0.0)
|
||||
c = Controller(opts, hass, st or store(n), rig._Pub(),
|
||||
stop=asyncio.Event(), peak_db_path=db)
|
||||
for i in c.inverters:
|
||||
i.dev_min, i.dev_max = -5000.0, 5000.0
|
||||
c.auto = True
|
||||
real_dt, clock = app_main.datetime, [0.0]
|
||||
|
||||
class _DT(real_dt):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return rig.BASE + timedelta(seconds=clock[0])
|
||||
app_main.datetime = _DT
|
||||
rows = []
|
||||
try:
|
||||
t = 0.0
|
||||
while t < dur:
|
||||
hass.advance(t)
|
||||
hass.t = clock[0] = t
|
||||
for inv in c.inverters:
|
||||
u = inv.name[-1]
|
||||
if reject and u in reject and t >= reject[u] \
|
||||
and inv.rejected_since is None:
|
||||
inv.rejected_since = rig.BASE + timedelta(seconds=t)
|
||||
if before is not None:
|
||||
r = before(c, t)
|
||||
if asyncio.iscoroutine(r):
|
||||
await r
|
||||
await c.read_inputs(now_mono=t)
|
||||
await c.cycle()
|
||||
rows.append(dict(
|
||||
t=t, cmd=tuple(i.target for i in c.inverters),
|
||||
target=c.target, grid=hass.grid(),
|
||||
true=tuple(round(hass.act[u]) for u in units),
|
||||
read=tuple(hass.reg[u] for u in units),
|
||||
age=tuple(round(t - hass.pub_t[u], 1) for u in units),
|
||||
phase=tuple(m.phase for m in c.maints),
|
||||
reason=c.loop_reason))
|
||||
t += tick
|
||||
finally:
|
||||
app_main.datetime = real_dt
|
||||
return rows, c
|
||||
return asyncio.run(go())
|
||||
|
||||
|
||||
def end_at(rows):
|
||||
"""The first cycle A's command left its drain (None if it never did)."""
|
||||
return next((r["t"] for r in rows if r["t"] >= T
|
||||
and r["cmd"][0] < 1990.0), None)
|
||||
|
||||
|
||||
def window(rows, span=23.0):
|
||||
"""The rows from the end cycle to `span` s after it (the settle window,
|
||||
PLANT_SETTLE_DOWN_S)."""
|
||||
te = end_at(rows)
|
||||
return [r for r in rows if te is not None and te <= r["t"] <= te + span]
|
||||
|
||||
|
||||
def swing(rows, i=1, span=23.0):
|
||||
"""Battery i's command in the settle window after the end cycle, and
|
||||
its largest swing there: a move that is later undone (up then down, or
|
||||
down then up), the smaller of its two legs. A steady climb scores 0."""
|
||||
w = [r["cmd"][i] for r in window(rows, span)][1:]
|
||||
best = 0.0
|
||||
for j in range(len(w)):
|
||||
lo_b = min(w[:j + 1])
|
||||
hi_b = max(w[:j + 1])
|
||||
lo_a = min(w[j:])
|
||||
hi_a = max(w[j:])
|
||||
best = max(best, min(w[j] - lo_b, w[j] - lo_a),
|
||||
min(hi_b - w[j], hi_a - w[j]))
|
||||
return best, w
|
||||
@@ -119,15 +119,15 @@ class _Plant:
|
||||
|
||||
|
||||
def run(load, *, margin=50, dur=2700.0, rating=2000.0, cap=3500.0,
|
||||
late_at=(), soc=60.0, batt_gap=()):
|
||||
late_at=(), soc=60.0, batt_gap=(), modes=True):
|
||||
"""Alarm times (s from the start of the first quarter), a digest of every
|
||||
cycle's target and written command, the Controller, the rows, and the
|
||||
times of the ERROR log line. A sample is also late at each `t % 900` in
|
||||
`late_at`."""
|
||||
`late_at`. `modes=False` (OPT-15) runs with battery modes off."""
|
||||
opts = rig._options(1, {
|
||||
"meter_source": "ha_signed", "p1_net_entity": "sensor.net",
|
||||
"meter_phases": 1, "peak_cap_w": cap, "peak_margin_w": margin,
|
||||
"battery_modes_enabled": True, "max_w": rating})
|
||||
"battery_modes_enabled": modes, "max_w": rating})
|
||||
store = rig._Store()
|
||||
store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
|
||||
store.data[packmode.USE_KEY] = False
|
||||
|
||||
@@ -767,6 +767,79 @@ ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
|
||||
show("the range is unchanged: 24 is refused (`at most 23`)",
|
||||
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
|
||||
|
||||
print("\n== J. OPT-17: several solar forecast entities per day ==")
|
||||
# The same question as §G, for `pv_forecast_entity` /
|
||||
# `pv_forecast_tomorrow_entity` (`str?` since 0.15.0). Each refusal is
|
||||
# varied from the accepted baseline and asserted on the key's name.
|
||||
# DECISIONS/OPT-17-several-forecast-entities.md.
|
||||
FC_STORED = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
|
||||
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
|
||||
_fc_old = {k: v for k, v in defaults.items()
|
||||
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
|
||||
_fc_base = MERGER.merge(copy.deepcopy(defaults),
|
||||
dict(_fc_old, **FC_STORED))
|
||||
ok_j, out_j = validate(_fc_base)
|
||||
show("a 0.17.0 blob (single forecast keys set, no list keys) validates "
|
||||
"unchanged, and both new keys default to []",
|
||||
ok_j and out_j.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"]
|
||||
and out_j.get("pv_forecast_entities") == []
|
||||
and out_j.get("pv_forecast_tomorrow_entities") == [],
|
||||
"" if ok_j else out_j)
|
||||
for _k in ("pv_forecast_entity", "pv_forecast_tomorrow_entity"):
|
||||
_s1 = dict(schema, **{_k: ["str"]})
|
||||
ok_j1, out_j1 = validate_with(_s1, _fc_base)
|
||||
show(f"`{_k}` declared as a LIST refuses the string an install stores",
|
||||
(not ok_j1) and f"Invalid list for option '{_k}'" in str(out_j1),
|
||||
out_j1)
|
||||
_m1 = MERGER.merge(copy.deepcopy(dict(defaults, **{_k: []})),
|
||||
copy.deepcopy(FC_STORED))
|
||||
ok_j2, out_j2 = validate_with(_s1, _m1)
|
||||
show("...and a `[]` default does not rescue it (list merge is override)",
|
||||
_m1[_k] == FC_STORED[_k] and not ok_j2, out_j2)
|
||||
_sm = dict(schema, **{_k: "match(.*)?"})
|
||||
_ok_s, _ = validate_with(_sm, _fc_base)
|
||||
_ok_l, _ = validate_with(_sm, dict(_fc_base, **{_k: ["sensor.a",
|
||||
"sensor.b"]}))
|
||||
_ok_n, _ = validate_with(_sm, dict(_fc_base, **{_k: 417}))
|
||||
_ui_m = _ui_of(_sm, _k)
|
||||
show(f"...`match(.*)?` on `{_k}` takes a string AND a list, but also the "
|
||||
"integer 417, and renders as a plain text box (no second row)",
|
||||
_ok_s and _ok_l and _ok_n and _ui_m
|
||||
and _ui_m[0].get("multiple") is not True,
|
||||
f"string={_ok_s} list={_ok_l} integer-417={_ok_n} "
|
||||
f"ui={json.dumps(_ui_m)}")
|
||||
_two = ["sensor.fc_east", "sensor.fc_west"]
|
||||
ok_j3, out_j3 = validate(MERGER.merge(copy.deepcopy(defaults), dict(
|
||||
_fc_old, pv_forecast_entities=_two,
|
||||
pv_forecast_tomorrow_entities=_two)))
|
||||
show("⭐ SHIPPED: two-entity lists for today and tomorrow validate",
|
||||
ok_j3 and out_j3.get("pv_forecast_entities") == _two
|
||||
and out_j3.get("pv_forecast_tomorrow_entities") == _two,
|
||||
"" if ok_j3 else out_j3)
|
||||
for _k in ("pv_forecast_entities", "pv_forecast_tomorrow_entities"):
|
||||
ok_j4, out_j4 = validate({k: v for k, v in defaults.items() if k != _k})
|
||||
show(f"...and the `options:` block with ONLY `{_k}` removed is refused "
|
||||
"by name, so its `[]` default is load-bearing",
|
||||
(not ok_j4) and f"Missing option '{_k}' in root" in str(out_j4),
|
||||
out_j4)
|
||||
_ui_j = _ui_of(schema, _k)
|
||||
show(f"...and `{_k}` renders as a repeatable string field",
|
||||
len(_ui_j) == 1 and _ui_j[0].get("multiple") is True
|
||||
and _ui_j[0].get("type") == "string", json.dumps(_ui_j))
|
||||
_flat_j = {k: v for k, v in schema.items()
|
||||
if k not in ("pv_forecast_entities", "pv_forecast_tomorrow_entities")}
|
||||
try:
|
||||
_rj = vol.Schema(vol.All(dict, AppOptions(
|
||||
None, _flat_j, cfg["name"], "local_goodwe_controller")))(
|
||||
MERGER.merge(copy.deepcopy(defaults), dict(
|
||||
FC_STORED, pv_forecast_entities=_two)))
|
||||
show("a DOWNGRADE to 0.17.0's schema drops the list keys and keeps the "
|
||||
"single ones",
|
||||
"pv_forecast_entities" not in _rj
|
||||
and _rj.get("pv_forecast_entity") == FC_STORED["pv_forecast_entity"])
|
||||
except vol.Invalid as e:
|
||||
show("a DOWNGRADE to 0.17.0's schema drops the list keys", False, str(e))
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -141,6 +141,8 @@ def _node(name, spec):
|
||||
lengthMin=float(m.group(2)), lengthMax=float(m.group(3)))
|
||||
elif spec.startswith("list("):
|
||||
out.update(type="select", options=spec[5:-1].split("|"))
|
||||
elif spec == "password":
|
||||
out.update(type="string", format="password")
|
||||
elif spec in ("bool",):
|
||||
out["type"] = "boolean"
|
||||
elif spec in ("int", "float"):
|
||||
|
||||
@@ -91,6 +91,12 @@ configuration:
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Solar forecast, tomorrow (kWh)"
|
||||
description: "Optional. The same forecast for tomorrow. It helps plan a cheap hour before midnight."
|
||||
pv_forecast_entities:
|
||||
name: "Hardware · Several solar forecasts, today (kWh)"
|
||||
description: "Optional. One row per forecast sensor, such as one per inverter, used instead of the single one above. They are added up."
|
||||
pv_forecast_tomorrow_entities:
|
||||
name: "Hardware · Several solar forecasts, tomorrow (kWh)"
|
||||
description: "Optional. The same list for tomorrow. If one sensor has no reading, that day's forecast is not used."
|
||||
solar_trust_pct:
|
||||
name: "Hardware · Trust in the forecast (%)"
|
||||
description: "Forecasts are often too sunny. At 70 % the add-on plans with 7 of every 10 kWh forecast."
|
||||
@@ -193,6 +199,9 @@ configuration:
|
||||
price_feed_url:
|
||||
name: "Tariff · Price feed URL override"
|
||||
description: "Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day."
|
||||
price_service_token:
|
||||
name: "Tariff · Price service token"
|
||||
description: "For the price service only. The token you were given for it. Leave it empty with any other price source."
|
||||
price_horizon_h:
|
||||
name: "Tariff · Price look-ahead (hours)"
|
||||
description: "How many hours of prices to ask the source for."
|
||||
|
||||
@@ -72,6 +72,12 @@ configuration:
|
||||
pv_forecast_tomorrow_entity:
|
||||
name: "Hardware · Zonneprognose, morgen (kWh)"
|
||||
description: "Optioneel. Dezelfde prognose voor morgen. Ze helpt een goedkoop uur voor middernacht te plannen."
|
||||
pv_forecast_entities:
|
||||
name: "Hardware · Meerdere zonneprognoses, vandaag (kWh)"
|
||||
description: "Optioneel. Eén rij per prognosesensor, bijvoorbeeld per omvormer, in plaats van de losse sensor hierboven. Ze worden opgeteld."
|
||||
pv_forecast_tomorrow_entities:
|
||||
name: "Hardware · Meerdere zonneprognoses, morgen (kWh)"
|
||||
description: "Optioneel. Dezelfde lijst voor morgen. Heeft één sensor geen meting, dan wordt de prognose voor die dag niet gebruikt."
|
||||
solar_trust_pct:
|
||||
name: "Hardware · Vertrouwen in de prognose (%)"
|
||||
description: "Prognoses zijn vaak te zonnig. Bij 70 % plant de add-on met 7 van elke 10 kWh uit de prognose."
|
||||
@@ -174,6 +180,9 @@ configuration:
|
||||
price_feed_url:
|
||||
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
||||
description: "Laat leeg om de eigen feed van de bron te gebruiken. Een afwijkende URL wordt letterlijk verstuurd en geeft vaak maar één dag terug."
|
||||
price_service_token:
|
||||
name: "Tarief · Token van de prijsdienst"
|
||||
description: "Alleen voor de prijsdienst. Het token dat u ervoor kreeg. Laat leeg bij een andere prijsbron."
|
||||
price_horizon_h:
|
||||
name: "Tarief · Prijzen vooruit (uren)"
|
||||
description: "Hoeveel uur aan prijzen de add-on bij de bron opvraagt."
|
||||
|
||||
Reference in New Issue
Block a user