Compare commits

..
Author SHA1 Message Date
admin 3a54b253cb Merge pull request 'Deploy 0.14.0 to master' (#170) from deploy-0.14.0 into master
Deploy 0.14.0 to master (#170)
2026-10-03 12:32:16 +02:00
admin 8b9680fc35 Merge pull request 'Deploy 0.13.0 to master' (#166) from deploy-0.13.0 into master
Reviewed-on: #166
2026-10-02 15:26:46 +02:00
admin 3ce18708b6 PKG-18: ship 0.12.0 to master, so TEL-12's pv_entities can reach an installed 0.11.0 (#118) 2026-09-24 23:22:34 +02:00
adminandClaude Opus 5 78acb558ff PKG-17: ship 0.11.0 to master, so SAFETY-56's wording fix and TEL-11's pv_entity can reach an installed 0.10.1 (#115)
master is what the Home Assistant Supervisor clones, and the Supervisor
compares config.yaml version strings - so 0.11.0 could not be offered to an
installed 0.10.1 without this bump reaching master.

Carries SAFETY-56 (#112), TEL-11 (#113), the CI-9 follow-up (#102) and SEC-2's
docs/RELEASE.md (#108), by way of the 0.11.0 cut (#114).

NOT A FAST-FORWARD. master was not an ancestor of release/1.0: 0f14c2a, the
pre-SEC-2 direct push, exists only on master. Its tree is the same object as
the merge base b0b24e5 (c1fef26), so it carries no content delta, and
`git merge-tree --write-tree` over the two branches returns a tree bit-identical
to cccb2fa's own (6670502). This merge commit therefore contributes exactly
release/1.0's tree - master synthesises nothing.

Reviewed as the release decision, not a re-review of content. Verified from the
remote: slug goodwe_controller and every identity key unchanged (cross-checked
against the live box's Supervisor export), version 0.10.1 -> 0.11.0, and the
green is this PR's own - run 828 / job 1953, path checks.yml@refs/pull/115/head,
PRNUM 115, GRADED refs/pull/115/head merged into master, run_attempt 1, 31 test
files all passing in 8m01s of real work.

predeploy_check.py re-run against the owner's current options (exported
2026-09-24 20:02, 75 keys, pv_entity unset): CLEARED, exit 0. That retires the
stale-2026-09-07-export warning in the pull request body. Option surface
75 -> 76 with only pv_entity added, str? defaulting to "", no key removed, no
default moved, no rule added to validate.py - no SystemExit(78) on this hop.

Merging is not installing. No tag is cut here and `ha store reload` /
`ha addons update` have not been run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-24 20:28:35 +02:00
glenn schrooyenandClaude Opus 5 0f14c2a5de PKG-16: ship 0.10.1 to master, so the Supervisor can offer SAFETY-57's fix
master is what Home Assistant clones. SAFETY-57 fixed a deadlock that parked
the reference install's battery for ~13 minutes while the site exported
2.4 kW, and the Supervisor compares config.yaml version strings - so the fix
could not reach an installed 0.10.0 without this bump reaching master.

Brings SAFETY-57 (#104) and the 0.10.1 cut (#105). master was a strict
ancestor; no conflict was possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:54:29 +02:00
73 changed files with 334 additions and 10334 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
3b4ff84757f9c2c5b63a4b4a53e593ccaeafb847
0c04488a9194039c2bf851b680a8ffc53beff671
-124
View File
@@ -1,129 +1,5 @@
# Changelog
## 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
added, removed or renamed, and no default changed (83 options). Options that
start 0.14.0 also start 0.15.0. The changes below apply with one inverter and
with two.
The add-on now learns and saves, per battery, what its power reading shows
at rest (`unit/<name>/idle_w`). It learns it after about two quiet minutes and
uses 0 W until then, as before. There is nothing to set.
Contents of this release, in merge order since 0.14.0 (`02ed120`):
| | |
|---|---|
| `cf705a9` | No more false "battery power reading stopped changing" errors at a new command after a rest, or at a change of phase. Each one paused control for a few seconds (several a day on a live site). A healthy battery that lags or turns around is no longer held as refusing for up to 15 min. With two inverters, a refusing battery whose reading at rest is about −100 W is now caught too. The rest value used is limited to ±75 W ([[SAFETY-94]], [[SAFETY-97]], also fixes [[SAFETY-58]]) |
| `3129dae` | CI tests against a newer `admin/goodwe` (CI only) ([[CI-14]]) |
### Known issues
- A false "Battery is not keeping up" can still show when the peak guard pauses a maintenance charge ([[SAFETY-92]]).
- After a gap in the P1 meter, the peak guard is blind for the rest of that quarter hour ([[SAFETY-90]]).
- 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]]).
- Two inverters: if one battery's drain stops early (e.g. switched Off), the other battery can jump to about 1.6 kW at once, and the surplus goes to the grid ([[SAFETY-95]]).
- Two inverters: after one battery's link drops, control can stall until a safety stop ([[SAFETY-96]]).
- 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]]).
### 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.14.0
**A minor bump: new behaviour with two inverters, and no option changes.**
@@ -1,67 +0,0 @@
# MAINT-10 — the maintenance start hour is local time, default 19:00
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
11:00 in winter. It is now read on the site's local clock, and the default moves
to 19 so the drain runs into the evening and night rather than the solar day.
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
peak guard outrank the drain.
## 1. Which zone
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
else the container's `TZ`, else UTC. One log line says which (WARNING only for
UTC). Resolved once per start: a zone change in HA takes effect on the next
add-on restart. HA can still be starting when the add-on starts (502 from the
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
already is HA's zone, so the rare UTC case gets a warning rather than more code.
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
not, so the tests grade DST on `tariff._EUTimezone` too.
## 2. DST
`maintenance.in_start_slot()` works from UTC instants only, so it does not
depend on how a tzinfo resolves a missing or doubled wall time:
- normally the slot is the local hour `start_hour`;
- clocks forward and the hour does not exist: the first hour after the jump;
- clocks back and the hour happens twice: the first pass only. Without this an
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
Every day has exactly one 60 min slot for every hour 0-23: checked against
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
over all of 2026.
## 3. Existing installs
The Supervisor merges the shipped defaults under the STORED options, and saving
the settings stores every key (the reference install's blob has all 76). So
almost every existing install keeps its stored 10, which now means 10:00 local
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
The add-on does not rewrite a stored option: a silent migration would override
an operator's choice with no way to tell it apart from the old default. A site
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
note (drafted on the PR, for the version bump) say this.
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
## 4. Two inverters
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
cycles at a time, and the one that lost the slot starts in the first start
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
a repeated hour does not start there. `test_maint10.py` part 10.
## 5. What does not change
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
same instant): every command, claim, reason and log line is identical. A
`Maintenance` built without a zone reads UTC, as before.
@@ -1,109 +0,0 @@
# 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`.
@@ -1,68 +0,0 @@
# 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.
@@ -1,47 +0,0 @@
# 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) and, since OPT-15, holds it there until the meter shows the step.
removed; about 0.1 x rating still overshoots as export).
- 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,63 +75,6 @@ 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).
## P4: the solar forecast and the day plan (2026-09-29)
- Input: a Home Assistant forecast sensor (`pv_forecast_entity`, kWh left
@@ -1,79 +0,0 @@
# 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.
@@ -1,80 +0,0 @@
# 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.
@@ -1,96 +0,0 @@
# 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.
@@ -1,98 +0,0 @@
# 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.
@@ -1,81 +0,0 @@
# SAFETY-90 - the peak guard goes on after a P1 meter gap
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
not reach the bill record). `peakguard.blindness()` then called the projection
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
The page still read "Keeping the grid near zero". At start-up mid-quarter the
same rule left the start-up quarter blind, and a held battery was drawn as last
resort (SAFETY-91 test, O2).
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
`Controller._fill_peak_gaps()`).
- Once readings are back, every second of a silence longer than
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
the gap, the import just after it)`, where "import" is the grid plus the
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
`meter_max_age_s`, which the averager credits at the last reading, is topped
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
blind and gives what keeps the quarter at the aim.
- **The bound.** The count is at or above the true import as long as, during
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
before and just after it, or under the limit, and the batteries did not
start charging (during a gap they hold their last command, then go to 0 W).
It never counts the gap below the limit. A load that rises above all three
only inside the gap is the one case it can miss.
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
battery energy.
- **While the gap lasts** nothing changes: the reading is too old to steer by,
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
before.
- **Before the first reading** (start-up or restart mid-quarter) the
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
`gap_w(the limit, the import seen first)`; after a restart the restored
reading's held `meter_max_age_s` is topped up the same way. The bound is
the gap's, with only the "after" side known. A load the batteries cannot
make up then raises "Load too high", which is true: that quarter is going
over. The page says so: the status row "Peak guard: part of this
15-minute period was not measured, counted as high use" and the home line
"Part of this 15-minute period was not measured." / "Een deel van dit
kwartier is niet gemeten." until the quarter ends.
*Review:* the first version counted them at the aim, with "nothing is
known" as the reason. The first reading does show the load, and with the
batteries at 0 W the import was that load, not the aim: a 60 s restart
during a 6500 W HOLD landed 3653 W against 3450 W.
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
was measured. The estimate steers the guard and is never banked.
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
the watts it was counted at.
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
`peak_cap_w` and `peak_margin_w`.
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
| case | before | after |
|---|---|---|
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
| meter never drops, N=1 and N=2 | | rows identical |
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
less than `meter_max_age_s` is not a gap, and the seconds since it are not
counted in the projection (on any quarter, not only after a gap). Near a
quarter's end the 900/remaining gain turns that into a large negative excess
and the floor drops for a cycle or more. Counting those seconds at the held
reading would fix it, but it changes every quarter's last seconds on a
healthy meter; it is handed back as its own card.
@@ -1,102 +0,0 @@
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
## The defects
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer. Live
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
1 s after its command; each register answered within 11 s. This is
SAFETY-58's shape.
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
frozen cycles if the reading was under 100 W at that moment. The frozen
cycles include a reversal through the battery's lag. At a 1 s meter the
12th cycle comes as a battery reversing out of a charge passes 0 W
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
15-minute probe. N>1 already required 22 s in the band, so it was safe
at a 10 s lag but not at 20 s.
## What changed
- **The gate counts only time the register was asked to move**
(`freshness.Freshness._oblige`). A read counts the time since the last
read when, at that read, the command is 250 W or more and has moved 250 W
from where it stood at the register's last change. A register change
(or a SAFETY-57 release) restarts the count. A command flapping in and
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
- **The bound is unchanged at 35 s and now means what its derivation
says.** The floor was always "how long a healthy register takes to answer
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
6 and 11 s after the command.
- **Refusal evidence is the same at every roster size**
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
The reading must stay under 100 W, one way, while asked over 200 W, for 12
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
needs it on top of its own freeze test. `None` (compute alone) keeps the
old law.
- **The evidence restarts when the reading moves 50 W towards the ask**
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
readings wander by under 25 W at rest. A first-order lag crossing the band
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
This also covers B, whose reading is 100 W below its power.
## Bounds and worst-case catch
| fault | before | after | budget |
|---|---|---|---|
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## Before -> after
- Live replay (part 1): 4 trips -> 0.
- Refusing B (its -100 W idle reading): see SAFETY-97.
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
refused, N=2 6 / 1; after 0 / 0.
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
a register held 351 s under a command hovering at the 250 W travel edge
(under 35 s of asking before it moved; survivor 2's shape), and a
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
refusal covers it).
## SAFETY-58 and SAFETY-74
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
start with the obligation and for the bound to be re-derived. Both are
done above. `test_freshness.py` survivor 5 is now graded as closed.
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
command that moved is exactly what the gate is for; telling "limited"
from "frozen" needs a second signal (the meter, or SoC).
## Known gaps
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
- A command that asks only part of the time (a fraction d) is caught after
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
SAFETY-30's per-unit check restarts when its command stops opposing the
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
SAFETY-31's circulation check (75 s) ignores the command, so its margin
holds only while d >= 0.47.
- A register frozen under a command that stays near the 250 W travel edge
is caught late or not at all (survivor 2).
@@ -1,69 +0,0 @@
# SAFETY-95 - a maintenance drain that ends hands back smoothly
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
**Cause.** During a drain the site target carries A's drain floor
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
command drops (pin, Off, abort, stale link, refusal), but the loop slews
from last cycle's target (2000 -> 1600) and only B is left to place it.
Under a solar surplus B gave 1600 W into the export.
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
only). On the cycle a drain ends:
1. The start point is what the others stood at plus A's new command (its
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
2. If the house needs more, the start is raised to the house's own point
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
but never above last cycle's start. So the others cover the house at
once and never take over the drain's export.
3. While A settles (SAFETY-96's window: within the deadband, at most
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
start, not the meter. The meter still shows A's tail and the others'
climb; chasing it sent B down and then up again.
4. Then the loop moves one slew step a cycle.
A drain ends when its battery leaves DRAIN, is not live, its claims
contradict, or it will not give (SAFETY-73's limit outranks the pin
without a contradiction, so the refusal is read from the battery; +1 only,
as `_held_back()`). Today a contradiction on a drain only arises through
a refusal; the clause stays because a contradiction sends A 0 W, which is
an end (tested directly). The end is kept
until the loop runs (a missed poll). A dropped draining battery is this
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
start by its own battery, and the drain end's raise reads the start the
others left.
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
784 W where base imports 42 W, as the others give up A's share of the house.
The house point keeps the grid at its aim; the cap at last cycle's start
keeps it from exporting.
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
meter; base = the same tree with the change taken out):
| case | before | after |
|---|---|---|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
| N=1, every drain end | | byte-identical |
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
`test_modes.py`. The other 45 digests are byte-identical.
**Not changed.** The peak guard's floor still binds (arbiter, after the
loop). Held batteries still give last; the idle clamp and the freeze test
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.
@@ -1,59 +0,0 @@
# SAFETY-96 - a battery the loop cannot move does not freeze it
**Status: implemented 2026-10-06, for review.** Tests: `test_safety96.py`.
**The defect** (SAFETY-93 rig test). At N=2 the saturation freeze compares
the site target with the site sum of the readings. After A's link went
stale, A was out of the split but its reading (-2000 W, still charging) was
in the sum: "saturation freeze (800 W vs -1903 W)", B held at 1200 W for
~236 cycles until the circulation trip. After a charge timeout or Off at
6000 W the loop froze ~14 s at 800 W while A came out of its charge. At a
drop, B also took A's whole share in one step.
**The rule** (`main.py`, `_held_out_w()`, `_reseed_drop()`, N>=2 only).
A battery is held out of the freeze test, counted at its command instead of
its reading, while:
- it is out of the split (writes refused for `LINK_STALE_S`);
- its last write was refused (its board may have gone to 0 W on its own
watchdog);
- it settles after a step the loop did not make: a charge end (SAFETY-93:
HOLD, timeout, Off, refusal, stale link) or a rejoin (then every battery,
as the split is made again). Settling ends when its reading is within the
deadband of its command, or after `PLANT_SETTLE_DOWN_S` (23 s, the
measured plant settle).
Its real power is not lost and not counted twice: the meter sees it, and
the loop follows the meter. Only the freeze stops reading it as the loop's.
The freeze still fires on the other batteries' own lag and on unplaced
watts.
At a drop the battery's share leaves the loop's start point, so the others
take over one slew step a cycle. At a rejoin it enters at its reading.
**Not a drop.** A missing reading or a stale register stops the site
(`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum:
it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30)
is pinned at 0 W and stays in the sum. New: no refusal is learned on a
battery whose writes are refused; its board going to 0 W is no refusal. Its
count is kept, not reset, so a link that refuses 1 write in 3 does not hide
a battery that really refuses (review).
**Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not
about the remaining battery's own lag):
| case | before | after |
|---|---|---|
| A drops while giving, A holds 1200 W | 128, B steps 1210 W | 0, B stays |
| A drops while charging, A holds -2000 W | 78 | 0 (circulation trip stays: 4 kW goes round) |
| A drops while charging, board to 0 W | 5 | 0 |
| A drops then comes back, A holds 1200 W | 46, grid +1.1 kW | 0, grid within 250 W |
| charge timeout at 6000 W | 7, B held at 400 W | 0, B climbs |
| A switched Off at 6000 W | 6, B held at 800 W | 0, B climbs |
| N=1, N=2 with no drop | | byte-identical |
**Not changed.** At a rejoin the allocator splits again at once (A 0 ->
1205, B 2410 -> 1205 W); the site total does not step. A battery that keeps
charging on its own after its link drops still trips the circulation check
(it is real circulation). A trade end (`_reseed_run_edge`) still starts
from the full site sum.
@@ -1,118 +0,0 @@
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
review.** Owner decision 2026-10-04: the band's centre is clamped to
+-75 W (see Bounds). Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`).
Closes SAFETY-94 review finding F1.
## The defect
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
B sat on the band's edge and never latched:
- N=2: never caught, before or after SAFETY-94.
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
## Design
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
new option: a required one is a setup step every client gets wrong, and an
optional one is a second source to reconcile. An override can be added
later if a site needs one.
- **When it learns:** control runs; the unit is not refused (unit or site),
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
than 50 W, and while refused it does not learn, so its refusal cannot be
learned as idle. A capped battery under a big ask is not sampled either.
- **How:** the first value is the mean of 120 s of samples; then it follows
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_SAMPLE_W`)
is dropped (a register held at a giving value). The band is centred on
the value clamped to 75 W (`IDLE_MAX_W`; owner decision below).
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
5 W move) and restored, clamped to 250 W. Without it a battery refusing from the
start is never quiet, so never learned, so never caught.
- **Until learned the idle reading is 0 W**: the rule as it was.
## Not chosen: the GoodWe LAN integration's DC battery power
Both inverters also appear through HA's GoodWe integration (B:
`sensor.kelder_dyness_battery_power`, 0 W at idle; found read-only on the
live HA 2026-10-04). Pointing `batt_entity` at it removes B's offset by
configuration, but:
- the band must be safe without it: other installs may not have it;
- it is polled over UDP on the inverter's Wi-Fi, a second channel that can
drop while the board keeps reading (HW-01 lost both once); a missing
reading stops the site;
- SAFETY-37's 35 s bound is derived from the board's 10 s publish; the
integration's poll interval (believed 10 s, not checked here) and latency
are not measured;
- DC power is not 0x0518, which SAFETY-30/31/42 were measured against.
It stays an install choice to test on the rig, not a code change here. With
it, the learned idle reading is simply ~0 W.
## Where it applies
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
| check | change |
|---|---|
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
| N=1 end in `_refusal_update` | centred |
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
| SAFETY-57 release | unchanged: it reads the command only |
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
## Bounds: owner decision 2026-10-04 (review R1)
Band: `|reading - centre| < 100 W`, centre = idle clamped to +-75 W.
- **Why a clamp under the band.** A wrong idle reading (taught during a
rest while the reading shows something else, e.g. a grid top-up of a unit
held at 0 W, or restored onto a swapped inverter) more than about 100 W off
hid every refusal on that battery, at N=1 and N=2; with the first +-250 W
clamp the rig missed a refusing A whenever the error was 110-250 W. At
+-75 W the band always keeps 0 W +-25 W, so a battery reading about 0 W
while refusing is caught whatever was learned or restored, and a wrong idle
cannot refuse a healthy 0 W battery (its ask is over 200 W).
- **The trade-off.** Options: keep +-250 W (covers idle readings to about
+-350 W, any 100 W mis-learn hides refusals); clamp to +-75 W (chosen);
"old band or centred band" (rejected: a correct B would need to give 200 W
to answer its 200 W probe).
- **Cost.** A battery whose idle reading is beyond about +-170 W is centred
at most 75 W out, so its refusal falls outside the band: it behaves as in
0.14.0 (not caught by this rule). B (-95 to -118 W) reads 20-43 W from its
clamped centre. B is caught across -150..+90 W of error in its idle
reading; learned 110 W or more too high it is missed, as in 0.14.0 at N=2.
- A frozen register is still caught by the stale gate at 35 s of asking.
- Recovery from a wrong value needs quiet time: tau 30 min.
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
| refusing | meter 1 s | 2 s | 5 s |
|---|---|---|---|
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
24 s; with nothing saved: never.
## Before -> after
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
0 refused. Reversals, lag 10/20 s, idle restored: 0.
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
+13 -89
View File
@@ -186,8 +186,6 @@ 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. |
@@ -232,7 +230,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. |
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
@@ -541,9 +539,6 @@ 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.
@@ -555,10 +550,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. The start of a new period does not stop it.
- 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.
- 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. 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.
- A battery that refuses to charge or give power takes no part in a run in that direction.
**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.
@@ -566,7 +561,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, 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.
*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.
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
@@ -595,7 +590,7 @@ accumulator is doing:
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
| `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance |
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
**When the battery refuses.** At its cut-out level the battery gives nothing,
@@ -605,15 +600,7 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
the battery gives 100 W or more, when the grid needs charging instead, and when
control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request. Nor is a battery whose reading is
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).
is not refusing and keeps its request.
**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
@@ -627,7 +614,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, SAFETY-73 and SAFETY-107. The event log reads
*For installers:* SAFETY-52 and SAFETY-73. 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
@@ -1652,45 +1639,13 @@ help - see "Why the tuning is what it is".
|---|---|---|
| `maintenance_enabled` | false | Enable the monthly cycle |
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) |
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
| `maintenance_soc_target` | 99 | Charge target |
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
**The start hour is local time.** The add-on reads Home Assistant's time zone
(Settings → System → General) at startup. If Home Assistant does not answer,
it uses the add-on's own time zone, and failing that UTC. The log says which
one it used, and warns if it is UTC: then restart the add-on once Home Assistant
is up. On the day the clocks go forward, a start hour that does not exist
starts at the first hour after the jump. On the day they go back, a repeated
hour starts only once, in its first pass. With two inverters both batteries use
the same local hour; one cycles at a time, and the other starts at that hour on
a later day.
**Upgrading.** The hour used to be read as UTC. An existing install keeps its
saved hour, and that hour now means local time: 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. 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 |
@@ -1724,12 +1679,6 @@ batteries the other one gives first. The hold still ends on its clock
(SAFETY-86).
When a charge ends, the other batteries take over one slew step a cycle,
and none starts charging from the grid (SAFETY-93).
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). While that battery settles,
an old battery reading does not move them (SAFETY-103).
#### Capacity-tariff exposure of the grid-following loop
@@ -1799,17 +1748,10 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
**When it cannot see, it forbids importing.** Three states are not evidence and
are checked before the projection is graded: no open quarter (a fresh averager
projects a confident 0 W, which is under every ceiling there is), a meter gap
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
conjunction. In all three the claim becomes a floor of **0 W**. That is the
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
a 900 s billing quarter and commands nothing, and this stops the import.
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
the higher of the peak limit and the import seen just before and just after
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
count the same way, at the import seen first, and the page says part of the
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
bound.
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
and commands nothing, and this stops the import.
**It trips, where the freeze detector only notifies,** and the captures are why.
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
@@ -3274,14 +3216,6 @@ 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.
### For installers: notifications
This part is for installers. The texts live in `app/messages.py` and follow
@@ -3301,7 +3235,6 @@ 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 |
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
`/config/app/<slug>/documentation`; 2026.5 removed the old
@@ -3478,7 +3411,6 @@ 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. |
@@ -3525,8 +3457,7 @@ reason code, and its full figures are on the DEBUG line right after it and in
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line says when it clears and names `STALE_READING`. The full reason,
with both possible causes, is on the DEBUG line after it. The 35 s counts
only while the command asks the reading to move (SAFETY-94).
with both possible causes, is on the DEBUG line after it.
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
meter goes quiet, "No meter reading for …", and an INFO line when readings
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
@@ -3537,13 +3468,6 @@ 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.
+2 -16
View File
@@ -484,8 +484,6 @@ def compute(
refused: int | None = None,
probe: bool = False,
bound: tuple | None = None,
gave_nothing: bool | None = None,
idle_w: float = 0.0,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -499,13 +497,6 @@ def compute(
`bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with
their modes applied (`allocator.loop_bound()`), or None. See
MODE_BOUND below. None is the law as it was.
`gave_nothing` SAFETY-94: the caller's span check - the reading stayed
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
out (compute() alone, as before).
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
nothing (app/idle.py). The refusal's band and its end are
centred on it. 0 is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
@@ -826,18 +817,13 @@ def compute(
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
# limit because it only removes an ask the battery is not meeting.
if refused is not None:
# SAFETY-94: and only when the reading has given nothing for the
# whole span, not just now. `sat_count` also counts the cycles a
# healthy battery spends reversing through its lag (-2000 W towards
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w - idle_w) < REFUSED_BELOW_W
and gave_nothing is not False):
and abs(actual_w) < REFUSED_BELOW_W):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W:
elif refused and refused * actual_w >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
+7 -53
View File
@@ -87,10 +87,6 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
AND |standing command now - standing command when the reading
last changed| >= MOVE_W
SAFETY-94: "unchanged for" counts only the reads at which both command terms
held, so a register that rested is not judged before it could answer.
DECISIONS/SAFETY-94-lagging-battery.md.
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
@@ -220,8 +216,6 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
property of this battery's firmware. It is not a property of this code, and the
add-on must not depend on it.
SAFETY-94: many were the gate judging a rested register on its first read
(SAFETY-58). Counted from the ask, the same days give 59 trips.
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts":
@@ -378,9 +372,7 @@ BOUNDS = {
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
"standing command below 250 W, because at 0 W the register "
"is obliged to nothing and the verdict cannot be tested "
"again from inside the state it creates. SAFETY-94: the "
"35 s counts only reads at which the command asks the "
"register to move"),
"again from inside the state it creates"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
@@ -424,9 +416,11 @@ class Freshness:
new grading window at that instant. The 1 Hz oscillation the latch exists to
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
no re-trip can land sooner than 35 s after a release, rather than on the
next read. SAFETY-94: the 35 s counts only while the command asks the
register to move, so a site that rests after a release is not re-tripped
on its first ask.
next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
the site rests for longer than the window before it asks for anything, the
window is already old and the re-trip lands on the first read at which the
command travels 250 W again. That is survivor 5's shape, it predates this
card, and its cost is one `stale_input_s` hold.
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
@@ -457,11 +451,6 @@ class Freshness:
# the change-stamps; `_release()` is where they diverge.
self.graded_from = None
self.standing_at_grade = None
# SAFETY-94. Seconds of this window during which the register was
# obliged to move (the command at least `move_w` and `move_w` away
# from `standing_at_grade`), and the instant of the last read.
self.obliged_acc = 0.0
self.last_read = None
# The instant the standing command last FELL below `move_w`, or None
# while an obligation is standing. The release timer runs off this.
self.rested_since = None
@@ -510,7 +499,6 @@ class Freshness:
# it reads in the order the state machine moves - withdraw, then
# re-grade - not because the code depends on it.
self._release(standing_w, now_mono)
self._oblige(standing_w, now_mono)
self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None:
@@ -522,35 +510,6 @@ class Freshness:
self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None
else float(standing_w))
# The read that opens a window is never an ask (no travel yet).
self.obliged_acc = 0.0
def _obliged(self, standing_w) -> bool:
"""Is the register asked to move: the command is at least `move_w` and
has travelled `move_w` since the window opened?"""
if standing_w is None or self.standing_at_grade is None:
return False
standing_w = float(standing_w)
return (abs(standing_w) >= self.bound.move_w
and abs(standing_w - self.standing_at_grade)
>= self.bound.move_w)
def _oblige(self, standing_w, now_mono) -> None:
"""SAFETY-94. Count only the time the register was asked to move.
A register that held still while nothing asked it to move has not
failed, so time before the command moved is not evidence. The time
since the previous read counts when the obligation stands at this
read; a command flapping in and out of the obligation still adds up.
"""
now_mono = float(now_mono)
if self.last_read is not None and self._obliged(standing_w):
self.obliged_acc += max(0.0, now_mono - self.last_read)
self.last_read = now_mono
def obliged_s(self) -> float:
"""Seconds of the current window the register was obliged to move."""
return self.obliged_acc
def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
@@ -584,12 +543,7 @@ class Freshness:
if self.stale or self.graded_from is None:
return
held_s = self.graded_s(now_mono)
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
# command asked the register to move, not the time since it last
# changed. A register that rested for minutes was otherwise judged on
# the first read after a new command, before it could answer (live,
# 2026-10-03: 1 s after the command).
if self.obliged_s() < self.bound.unchanged_s:
if held_s < self.bound.unchanged_s:
return
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
+2 -26
View File
@@ -11,8 +11,6 @@ import time
import aiohttp
from . import messages
_LOG = logging.getLogger("goodwe.hass")
CORE_API = "http://supervisor/core/api"
@@ -21,7 +19,6 @@ 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:
@@ -32,30 +29,15 @@ 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 "
"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))
"restarting. The add-on keeps trying.", what)
self.down_on.add(channel)
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
@@ -63,13 +45,7 @@ class Outage:
if channel in self.down_on:
self.down_on.discard(channel)
if not self.down_on:
_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
_LOG.info("Home Assistant answers again.")
OUTAGE = Outage()
+17 -25
View File
@@ -20,7 +20,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard, uitext
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE, next_slot_start
from .maintenance import IDLE as M_IDLE
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
@@ -439,8 +439,8 @@ def solar_block(c, now):
def maintenance_runs(c, day0, day1, now, shadow):
"""The monthly battery cycle: under way now, or starting later today.
The cycle starts in the hour `maintenance_start_hour` names, read in the
site's zone exactly as the control cycle reads it (MAINT-10)."""
The cycle starts in the hour `maintenance_start_hour` names, read as the
control cycle reads it (UTC hour of `now`), shown in local time."""
runs, lines = [], []
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
@@ -459,9 +459,10 @@ def maintenance_runs(c, day0, day1, now, shadow):
if not m.due(now) and (m.next_due(now) is None
or m.next_due(now) >= day1):
continue
start = next_slot_start(now, cfg.start_hour, m.tz)
if start is None:
continue
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
microsecond=0)
if start + timedelta(hours=1) <= now:
start += timedelta(days=1)
due_at = m.next_due(now)
if due_at is not None and due_at > start:
continue
@@ -903,20 +904,17 @@ def month_peak(c, now):
if pg is not None:
out["period_w"] = pg.projected_w
ceiling = pg.ceiling_w or limit_w
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
> 0.0)
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
return out
def state_of(c, level, shadow, waiting=False):
def state_of(c, level, shadow):
"""One word for the pill and the banner's colour: running, shadow,
stopped, alarm, waiting or warning. `waiting`: the red headline is
Home Assistant still starting (SAFETY-112), which needs nobody."""
stopped, alarm or warning."""
if shadow:
return "shadow"
if level == "bad":
return "waiting" if waiting else "alarm"
return "alarm"
if not c.auto:
return "stopped"
if level == "warn":
@@ -925,22 +923,20 @@ def state_of(c, level, shadow, waiting=False):
def build(c, *, level: str, checks: list, now: datetime | None = None,
lang: str = "en", held: bool | None = None,
waiting: bool = False) -> dict:
lang: str = "en", held: bool | None = None) -> 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,
waiting=waiting)
return _build(c, level=level, checks=checks, now=now, held=held)
finally:
_LANG[0] = "en"
def _build(c, *, level, checks, now, held=None, waiting=False):
def _build(c, *, level, checks, now, held=None):
now = now or datetime.now(timezone.utc)
shadow = bool(c.shadow)
kind = state_of(c, level, shadow, waiting)
kind = state_of(c, level, shadow)
# 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
@@ -1298,11 +1294,8 @@ def line_peak_not_covered() -> str:
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
def line_period(period_w, limit_w, shadow) -> str:
step = _w(shadow, "step_in", kw=_kw(limit_w))
if unmeasured:
# SAFETY-90: the add-on started during this period.
return _t("period_unmeasured") + " " + step
if period_w is None:
return step
return _t("period", kw=_kw(period_w)) + " " + step
@@ -1335,7 +1328,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", "waiting", "warning"):
for k in ("running", "shadow", "stopped", "alarm", "warning"):
out += [("title", pill(k)), ("line", banner_sub(k))]
for sh in (False, True):
out += [("title", tile_solar(v, s)) for v, s in
@@ -1376,7 +1369,6 @@ def _samples() -> list:
("plan", line_peak_limit(3500, sh)),
("plan", line_period(1240, 3500, sh)),
("plan", line_period(None, 3500, sh)),
("plan", line_period(1240, 3500, sh, unmeasured=True)),
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
-87
View File
@@ -1,87 +0,0 @@
"""SAFETY-97: what a battery's power reading shows while it gives nothing.
B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live
2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded
against this idle reading, not against 0 W, or a refusing B never latches.
DECISIONS/SAFETY-97-idle-offset.md.
Learned per unit, never configured:
- only while control runs, the unit is not refused, stale or tripped, and
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
is asked more than that, so it cannot teach its refusal as idle);
- a reading beyond IDLE_SAMPLE_W is not a sample;
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow
(IDLE_TAU_S);
- persisted per unit and restored, clamped to IDLE_SAMPLE_W, at start.
The band is centred on it clamped to IDLE_MAX_W (owner, 2026-10-04). Until
learned the idle reading is 0 W, the rule as it was.
"""
import math
IDLE_MAX_W = 75.0 # |idle| used: under the 100 W band, so it keeps 0 W +-25 W
IDLE_SAMPLE_W = 250.0 # a reading beyond this is not a sample
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
IDLE_TAU_S = 1800.0 # then follow drift this slowly
IDLE_SAVE_W = 5.0 # persist when the value moved this far
IDLE_GAP_S = 10.0 # a longer gap between reads counts as this
def _num(v):
try:
v = float(v)
except (TypeError, ValueError):
return None
return v if math.isfinite(v) else None
class IdleReading:
"""One unit's idle reading. `w` is what "gives nothing" is centred on."""
def __init__(self, saved=None):
v = _num(saved)
self.value = (None if v is None
else max(-IDLE_SAMPLE_W, min(IDLE_SAMPLE_W, v)))
self.saved = self.value
self.quiet_since = None
self.last = None
self.acc_w = 0.0
self.acc_s = 0.0
@property
def w(self) -> float:
"""The centre used: the learned value clamped to IDLE_MAX_W."""
if self.value is None:
return 0.0
return max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
"""One read. Returns True when the value should be persisted."""
now = float(now_mono)
dt = (0.0 if self.last is None
else max(0.0, min(IDLE_GAP_S, now - self.last)))
self.last = now
standing = _num(standing_w)
if not may_learn or standing is None or abs(standing) > IDLE_ASK_W:
self.quiet_since = None
return False
if self.quiet_since is None:
self.quiet_since = now
b = _num(reading)
if (now - self.quiet_since < IDLE_QUIET_S or b is None
or abs(b) > IDLE_SAMPLE_W or dt <= 0.0):
return False
if self.value is None:
self.acc_w += b * dt
self.acc_s += dt
if self.acc_s < IDLE_FIRST_S:
return False
self.value = self.acc_w / self.acc_s
else:
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
self.saved = self.value
return True
return False
+1 -10
View File
@@ -127,7 +127,6 @@ from . import messages
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
from .idle import IdleReading
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
# shipped default and a literal here would be the fourth defaults site that
@@ -322,21 +321,13 @@ class Inverter:
# take, 0 none), when that refusal or its last probe started, the
# current probe's start, and the cycles of evidence so far and when
# they began. Memory only, like SAFETY-52's: stop and start end it.
# The evidence count is also used at N=1 (SAFETY-94); the rest at N>1.
# Used at N>1 only.
self.refused = 0
self.refused_at = None
self.probe_from = None
self.ref_count = 0
self.ref_since = None
self.ref_sign = 0
self.ref_from = None
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
# is centred on it. Learned, persisted, restored (app/idle.py).
self.idle = IdleReading(data.get(self.key("idle_w")))
@property
def idle_w(self) -> float:
return self.idle.w
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
File diff suppressed because it is too large Load Diff
+27 -295
View File
@@ -34,7 +34,7 @@ simply not do.
import logging
import math
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone, tzinfo
from datetime import datetime, timedelta, timezone
from .store import MAINT_UNIT_KEYS, unit_key
@@ -65,30 +65,10 @@ 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.
#
@@ -132,9 +112,7 @@ NOSOC_NOTE_MIN = 15
MAINT_OPTION_DEFAULTS = {
"maintenance_enabled": False,
"maintenance_interval_days": 28,
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
# into the night rather than through the solar day.
"maintenance_start_hour": 19,
"maintenance_start_hour": 10,
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
# argument: both rates must stay at or below `max_w` (2000) or the
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
@@ -199,69 +177,6 @@ class MaintConfig:
)
# --------------------------------------------------------------------------- #
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
# --------------------------------------------------------------------------- #
def _load_zone(name):
"""A tzinfo for an IANA name, or `(None, why)`."""
if not name or not isinstance(name, str):
return None, "none given"
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name.strip()), ""
except Exception as err: # noqa: BLE001 - any failure
return None, f"{name!r} is not a known zone ({type(err).__name__})"
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
tz, why = _load_zone(ha_zone)
if tz is not None:
return tz, ha_zone, "ha", ""
ha_why = ha_why or why
tz, env_why = _load_zone(env_tz)
if tz is not None:
return tz, env_tz, "tz", ha_why
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
"""Is `now` inside today's one-hour start slot, read on the local clock?
Normally the slot is the local hour `start_hour`. On the day the clocks go
forward and that hour does not exist, it is the first hour after the jump.
On the day they go back and it happens twice, only the first one counts.
Worked from UTC instants only (`astimezone`), so it does not depend on how
a tzinfo resolves a wall time that is missing or doubled.
"""
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
here = now.astimezone(tz).replace(tzinfo=None)
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
if here.hour == start_hour:
# the second pass through a repeated hour: an hour ago read the same
return not (before.date() == here.date() and before.hour == start_hour)
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
# the clock jumped over the start of the slot within the last hour
return before < slot and here >= slot + timedelta(hours=1)
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
"""The start of the slot `now` is in, or else of the next one; None if
there is none within 50 h (no real zone does that)."""
step = timedelta(minutes=15)
t = now.replace(minute=now.minute - now.minute % 15, second=0,
microsecond=0) - timedelta(hours=1)
for _ in range(4 * 50):
if (in_start_slot(t, start_hour, tz)
and not in_start_slot(t - step, start_hour, tz)
and t + timedelta(hours=1) > now):
return t
t += step
return None
@dataclass
class MaintResult:
phase: str
@@ -277,9 +192,6 @@ 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,12 +256,8 @@ class Maintenance:
"""
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
name: str = "", mirror_flat: bool = False,
tz: tzinfo | None = None):
name: str = "", mirror_flat: bool = False):
self.cfg = cfg
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
# once at startup (`site_zone()`). UTC when nobody says, as before.
self.tz = tz or timezone.utc
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
# the one place that turns it into the key this pack owns. A raw
@@ -367,14 +275,6 @@ 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
@@ -382,11 +282,6 @@ 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)
@@ -427,21 +322,6 @@ 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?
@@ -451,10 +331,8 @@ class Maintenance:
about who was asking - so the IDLE branch of `tick()` calls this one.
"""
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.backoff_until(now) is None)
and now.hour == self.cfg.start_hour and self.due(now)
and self._cooled(now))
# -- operations ----------------------------------------------------------
def force_start(self, now: datetime, blocked_by: str = "") -> list:
@@ -482,140 +360,6 @@ 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})")
@@ -643,16 +387,13 @@ class Maintenance:
# -- the machine ---------------------------------------------------------
def tick(self, now: datetime, soc: float | None,
blocked_by: str = "", gate_open: bool = True) -> MaintResult:
blocked_by: str = "") -> 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
@@ -703,13 +444,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:
return self._no_soc(now, phase, gate_open, events)
self._nosoc_since = 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)
elapsed = self._elapsed_min(now)
@@ -720,8 +461,8 @@ class Maintenance:
f"after {elapsed:.0f} min -> charge")
return MaintResult(CHARGE, False, None, True, events)
if elapsed > DRAIN_TIMEOUT_MIN:
return self._fail(now, "drain", f"drain stuck at {soc:.0f} % "
f"after {elapsed:.0f} min")
events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min")
return MaintResult(IDLE, True, 0.0, False, events)
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
if phase == CHARGE:
@@ -730,31 +471,22 @@ 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)
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")
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)
return MaintResult(CHARGE, False, None, True, events)
if phase == HOLD:
return self._hold(now, events)
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)
_LOG.error("maintenance: unknown phase %r, forcing idle", phase)
events += self.abort(now, f"unknown phase {phase!r}")
+1 -66
View File
@@ -102,7 +102,6 @@ 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:
@@ -111,9 +110,7 @@ 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"
+ (f"{self.body_nl}\n\n" if self.body_nl else "")
+ details(self.docs)}
"message": f"{self.body}\n\n{details(self.docs)}"}
# --- the catalogue ----------------------------------------------------------
@@ -127,8 +124,6 @@ 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"
ID_LOOP_BOUND = "goodwe_integrator_bound"
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
@@ -137,7 +132,6 @@ 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}"
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
# The setting is named by its label, "Inverter rating (W)".
@@ -301,24 +295,6 @@ 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}")
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);
@@ -436,17 +412,6 @@ 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:
@@ -765,15 +730,6 @@ 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"
@@ -837,12 +793,6 @@ def row_peak_guard_blind(why: str) -> str:
f"meanwhile")
def row_peak_guard_unmeasured() -> str:
"""SAFETY-90. Before the first reading; that part counts as high use."""
return ("Peak guard: part of this 15-minute period was not measured, "
"counted as high use")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
@@ -962,7 +912,6 @@ HINTS = {
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
@@ -973,7 +922,6 @@ 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",
@@ -992,7 +940,6 @@ 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",
@@ -1066,10 +1013,6 @@ 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)),
@@ -1139,8 +1082,6 @@ 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)),
@@ -1156,7 +1097,6 @@ def status_samples() -> list:
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
@@ -1170,7 +1110,6 @@ 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")),
]
@@ -1201,8 +1140,6 @@ 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"),
]
@@ -1259,8 +1196,6 @@ 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",
+12 -19
View File
@@ -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, test_price_run_fixes.py):
Rules that keep this safe (tests: test_modes_p3.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, test_price_run_fixes.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 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);
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);
* 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,10 +43,7 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.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. 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;
run in that direction: the refusal wins over the run;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
"""
@@ -78,7 +75,6 @@ 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)
@@ -116,18 +112,15 @@ 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, probe=False) -> list:
def chargers(packs, ceiling_pct, done=(), targets=None) -> list:
"""Batteries that may charge from the grid in a cheap run: below the
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)."""
level `charge_to()` allows them (P4: the day plan's)."""
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 or (probe and p.probing))
and p.name not in done):
and p.refused >= 0 and p.name not in done):
out.append(p)
return out
@@ -179,8 +172,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, probe=True) \
if in_cheap else []
ch = chargers(packs, ceiling_pct, done_charge, targets) if in_cheap \
else []
if ch and draining:
ch, why = [], "cycle"
charging = False
-13
View File
@@ -312,19 +312,6 @@ 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."""
if self._last_t is None:
return None
return datetime.fromtimestamp(self._last_t, timezone.utc)
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
-53
View File
@@ -183,8 +183,6 @@ guard passes its own tests while being switched off:
record). Those seconds make the projection read LOW, which is the
under-guarding direction. The threshold is `max_hold_s` itself - the point
p1.py already stops trusting a held sample - not a new number.
SAFETY-90: once readings are back, main.py counts those seconds
(`fill_gaps()`), so this holds only while the gap lasts.
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
rather than re-written here. A frozen meter is the worst case of all: the
@@ -601,57 +599,6 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return None
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
# noise between the averager's sums and the wall clock, never a real gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the seconds the averager did not count
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
too: what the gap's first `max_hold_s`, credited at the reading before
it, lacks to reach `gap_w`. Returns
`(projection, gap seconds filled, unseen seconds filled)`.
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
as before (the averager credits it once the next reading lands). Past
that the gap is still open, nothing is filled and `blindness()` answers
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
+17 -113
View File
@@ -27,11 +27,6 @@ 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
@@ -54,12 +49,6 @@ 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:
@@ -234,52 +223,6 @@ 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
@@ -287,64 +230,25 @@ def read(today_state, tomorrow_state, now: datetime, *,
covers today only."""
if not configured:
return None, NOT_SET
day0, day1 = _bounds(now)
today, at, why = _today(today_state, day0, 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)
day0, day1 = d0.astimezone(timezone.utc), d1.astimezone(timezone.utc)
today, why = _day(today_state, day0, now, today=True)
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:
tomorrow, _w = _tomorrow(tomorrow_state, day0, day1, now)
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
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
+2 -2
View File
@@ -100,7 +100,7 @@
}
function renderPill() {
const st = S.state || null;
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad", waiting: " warn" }[st.kind] ?? " warn")
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[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));
@@ -399,7 +399,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", "waiting"].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"].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));
-10
View File
@@ -1232,16 +1232,6 @@ 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.
+1 -2
View File
@@ -94,7 +94,6 @@ DEFAULTS = {
"phase_started": None,
"last_completed": None,
"last_start_attempt": None,
"last_failed": None,
"sign_tripped": False,
"sign_reason": "",
"circ_tripped": False,
@@ -172,7 +171,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_failed")
"last_start_attempt")
def unit_key(unit: str, key: str) -> str:
+1 -14
View File
@@ -776,14 +776,9 @@ class PeakTracker:
return r.peak_w / self.cap_w
@staticmethod
def project(averager, now: datetime, roll: bool = False) -> Projection:
def project(averager, now: datetime) -> 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
@@ -803,14 +798,6 @@ 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
-5
View File
@@ -267,7 +267,6 @@
"shadow": "Shadow mode",
"stopped": "Stopped",
"alarm": "Needs you now",
"waiting": "Waiting for Home Assistant",
"warning": "Running, with a warning"
},
"banner_sub": {
@@ -275,7 +274,6 @@
"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": {
@@ -412,7 +410,6 @@
"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}.",
"period_unmeasured": "Part of this 15-minute period was not measured.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
@@ -433,7 +430,6 @@
"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",
@@ -452,7 +448,6 @@
"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"]
+2 -5
View File
@@ -230,8 +230,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", "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."},
"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."},
"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.",
@@ -347,14 +347,12 @@
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
"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",
@@ -373,7 +371,6 @@
"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"]
-4
View File
@@ -135,10 +135,6 @@ 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),
)),
)),
+11 -29
View File
@@ -1802,13 +1802,12 @@ 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.")
today = solarfc.entities(opts)
tomorrow = solarfc.entities(opts, tomorrow=True)
if (today or tomorrow) and \
if any(opts.get(k) for k in FORECAST_KEYS) 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 tomorrow and not today:
if opts.get("pv_forecast_tomorrow_entity") and \
not opts.get("pv_forecast_entity"):
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
@@ -2070,13 +2069,12 @@ 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 = solarfc.TODAY_KEYS + solarfc.TOMORROW_KEYS
FORECAST_KEYS = ("pv_forecast_entity", "pv_forecast_tomorrow_entity")
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. OPT-17: a day names its forecast
in the single key or in the list, not both."""
each forecast entity set is a sensor."""
out = []
raw = opts.get("solar_trust_pct")
trust = _number(opts, "solar_trust_pct", dayplan.DEFAULT_TRUST_PCT)
@@ -2084,30 +2082,14 @@ 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 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 "
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 "
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
+2 -15
View File
@@ -1,5 +1,5 @@
name: GoodWe RS485 Controller
version: "0.17.0"
version: "0.14.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
@@ -157,11 +157,6 @@ 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
@@ -635,8 +630,7 @@ options:
# contradiction. Changing one of these numbers now moves every consumer.
maintenance_enabled: false
maintenance_interval_days: 28
# MAINT-10: a LOCAL hour, in Home Assistant's time zone.
maintenance_start_hour: 19
maintenance_start_hour: 10
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
# than the 2500 they shipped as. The cheap-window charge shaping asks the
@@ -966,13 +960,6 @@ 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
+4 -7
View File
@@ -439,7 +439,7 @@ class _Hass:
return True
def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
def wired(grid, cycles=40, batt=0.0, track=True, **over):
async def go():
hass = _Hass(grid, batt=batt, track=track)
c = Controller(dict(
@@ -455,11 +455,8 @@ def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over):
# hysteresis clears it a few cycles later - so it is captured live
# rather than looked for after the run, when it is correctly gone.
c.red_snapshot = []
for i in range(cycles):
# `tick`: simulated seconds per cycle. SAFETY-94: a refusal needs
# its evidence to span REFUSED_SPAN_S, which back-to-back cycles
# on the real clock never do.
await c.read_inputs(now_mono=None if tick is None else i * tick)
for _ in range(cycles):
await c.read_inputs()
await c.cycle()
if c.clamp_wd.active and not c.red_snapshot:
c.red_snapshot = c.checks()
@@ -477,7 +474,7 @@ def clamp_notes(hass):
# unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the
# 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it
# reports 0 W however hard it is commanded, so the error never closes.
hass_r, ctl_r = wired(6000.0, cycles=60, track=False, tick=2.0)
hass_r, ctl_r = wired(6000.0, cycles=60, track=False)
emitted = [abs(w) for w in hass_r.writes]
# What the SAME error would have done to an accumulator with no bound - the
+54 -85
View File
@@ -900,51 +900,34 @@ def _loop_series(day, sig):
return out
class _Before94(Freshness):
"""The gate before SAFETY-94: the clock ran from the register's last
change, not from when the command asked it to move. The history below is
about that gate, so it is replayed on it."""
def obliged_s(self):
return self.graded_s(self.last_read)
def _replay16(cls):
"""Every committed day at 1 Hz. Returns (trips, waits for the next move)."""
waits, trips = [], 0
for _day in _loop_days:
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b)
if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = cls(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
trips += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
return trips, sorted(waits)
_t8b = time.monotonic()
_trips8b, _waits = _replay16(_Before94)
_trips94, _waits94 = _replay16(Freshness)
_waits, _trips8b = [], 0
for _day in _loop_days:
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
_trips8b += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
_waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
_waits.sort()
_p50 = nth(_waits, len(_waits) // 2)
print(f" {len(_loop_days)} days replayed at 1 Hz in "
f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before "
f"SAFETY-94, {_trips94} after")
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
print(f" wait for the register's next change after a trip: "
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
@@ -970,17 +953,6 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE
"is `stale_input_s + release_s` = 32 s whatever the register does",
sum(1 for w in _waits if w < 20.0) >= 20
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}"
f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest "
f"answer after a trip {nth(_waits94, 0):.1f} s")
check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, "
"NOT KEEP-ALIVE PULSES. The clock now counts only the time the command "
"asked the register to move. The same sixteen days give 59 trips, not "
"93, and only one of the 59 is answered within 20 s (21 before): the "
"gate no longer judges a register on the first read after a rest",
_trips94 == 59 and len(_waits94) == _trips94
and sum(1 for w in _waits94 if w < 20.0) == 1
and sum(1 for w in _waits if w < 20.0) == 21)
# ==============================================================================
@@ -1045,30 +1017,32 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
"each unit's own command travel and each unit's own number",
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
# S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58).
# The unchanged clock used to keep running while the site rested, so the
# FIRST read at which a command travelled 250 W after a long quiet tripped
# the gate. The clock now counts only time the register was asked to move.
# Live 2026-10-03: four such trips, each 1 s after the command.
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
# The unchanged clock keeps running while the site rests, so the FIRST read
# at which a command travels 250 W after a long quiet already satisfies all
# three terms - the register is given no time at all to answer the new
# command. Present on the shipped tree; the live capture in part 10 contains
# one (10:48:14, cleared one second later).
_s5 = Freshness(BOUNDS["batt"])
for t in range(0, 300): # five minutes of a resting site
_s5.submit(-3.0, 0.0, float(t))
_s5_quiet = _s5.stale
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
_s5_first = _s5.stale
for t in range(301, 334):
_s5.submit(-3.0, 1500.0, float(t))
_s5_before = _s5.stale
_s5.submit(-3.0, 1500.0, 334.0)
check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the "
"first read carrying a travelled command is NOT a trip, nor is any read "
"before 35 s of standing command - and a register that still has not "
"moved after 35 s of it IS",
not _s5_quiet and not _s5_first and not _s5_before and _s5.stale
and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0)
check("...and the evidence sentence still gives the register's own unchanged "
"time (334 s), which an operator compares with a history graph",
"unchanged for 334 s" in _s5.reason)
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
"long rest the unchanged clock is already past 35 s, so the very FIRST "
"read carrying a travelled command trips the gate - before the 17.0 s in "
"which a healthy register could have answered it. The gate accuses on "
"read one and the register answers on read eleven",
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
"here rather than fixed here: `cycle()` returns at `inputs missing "
"{n}s` for the whole of it, so nothing is commanded differently, and the "
"register's next publish clears it. Fixing it means measuring the "
"unchanged clock from when the OBLIGATION appeared rather than from the "
"register's last move, which re-derives the 35.0 s bound and is a "
"different card",
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
and float(option_default("stale_input_s")) >= 5.0)
# ==============================================================================
@@ -1379,23 +1353,20 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads "
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
"that is dead for good is re-graded for ever: over 1201 reads the held "
"number reaches a consumer on 631 of them against 36 before, and 17 "
"number reaches a consumer on 630 of them against 35 before, and 17 "
"releases are taken. This check exists so that cost cannot be quietly "
"forgotten, and so a change that makes it worse has to move a number "
"somebody reads",
sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
and _deadb.releases == 0)
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
"CONSUMER'S HOLD. Every unbroken run of exposure after a release is "
"exactly one 35.0 s window of standing command (the first is 36 s: the "
"fixture's command starts one read late), and 36 < 75 "
"(circulation_hold_s) < 85 "
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
"(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside "
"one, and the re-trip that ends each run hands them a None, which CLEARS "
"their windows rather than accumulating into them",
set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s}
and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0
and max(_runs57) < _circ_hold < _unit_hold)
set(_runs57) == {BOUNDS["batt"].unchanged_s}
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
check("...and nothing SAFETY-37 detects is given back by that, which is the "
"half that makes the trade a trade. A permanent None clears both windows "
"for ever too, so on the shipped-before module the same two detectors are "
@@ -1672,10 +1643,8 @@ check("...and a re-trip's reason names the TWO durations separately, each "
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
f.stale and f.releases == 1
# SAFETY-94: the re-trip comes 35 s after the command returns at
# 80 s (114 s), not 35 s after the release at 57 s.
and "unchanged for 114 s" in f.reason
and "checked 2 times over a 57 s window since the last stop was "
and "unchanged for 92 s" in f.reason
and "checked 2 times over a 35 s window since the last stop was "
"withdrawn" in f.reason)[-1])(
Freshness(BOUNDS["batt"])))
-9
View File
@@ -400,15 +400,6 @@ check("...with its duration from the battery's capacity and the cycle's rates "
"and hold time",
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
# MAINT-10: with the site's zone set, the hour is read on the local clock.
from app.tariff import _EUTimezone # noqa: E402
c_ml = make(dict(MAINT, maintenance_start_hour=14))
for _mm in c_ml.maints:
_mm.tz = _EUTimezone()
_cyc_l = [r for r in status(c_ml)["plan"]["runs"] if r["kind"] == "cycle"]
check("MAINT-10: start hour 14 in the local zone is drawn at 14:00, the "
"instant the cycle starts", len(_cyc_l) == 1
and field(nth(_cyc_l), "from") == "14:00")
c_m.maints[0].store.set("phase", "drain")
s_md = status(c_m)
check("a cycle under way is drawn from now, and the battery's now line says "
-543
View File
@@ -1,543 +0,0 @@
"""MAINT-10: the maintenance start hour is LOCAL time. `python3 test_maint10.py`
Graded here: the slot on ordinary days and on both DST change days, the zone
source and its fallbacks, the 19:00 default, the startup wiring, and that the
controller's commands are unchanged once the start instant is the same.
See DECISIONS/MAINT-10-local-start-hour.md.
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
⚠️ The CI image has no tzdata, so every DST check runs on tariff.py's encoded
EU rule and, where zoneinfo works, on Europe/Brussels from tzdata as well.
"""
import asyncio
import functools
import logging
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
import app.main as M # noqa: E402
from app import maintenance as MT # noqa: E402
from app.maintenance import ( # noqa: E402
DRAIN, IDLE, MAINT_OPTION_DEFAULTS, MaintConfig, Maintenance,
in_start_slot, next_slot_start, site_zone,
)
from app.tariff import _EUTimezone # noqa: E402
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)
ZONES = [("encoded EU rule", _EUTimezone())]
try:
from zoneinfo import ZoneInfo
ZONES.append(("zoneinfo Europe/Brussels", ZoneInfo("Europe/Brussels")))
except Exception as err: # noqa: BLE001
print(f" zoneinfo unavailable here ({err}); graded on the EU rule only")
SPRING = datetime(2026, 3, 29, tzinfo=UTC) # 02:00 CET -> 03:00 CEST
SPRING27 = datetime(2027, 3, 28, tzinfo=UTC) # the next one, last Sunday of March
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC) # 03:00 CEST -> 02:00 CET
SUMMER = datetime(2026, 7, 15, tzinfo=UTC)
WINTER = datetime(2026, 1, 15, tzinfo=UTC)
def slot_minutes(day_utc, hour, tz):
"""UTC minutes in the slot, over the LOCAL day of that date."""
t, out = day_utc - timedelta(hours=3), []
while t < day_utc + timedelta(hours=27):
if (t.astimezone(tz).date() == day_utc.date()
and in_start_slot(t, hour, tz)):
out.append(t)
t += timedelta(minutes=1)
return out
def one_block(mins):
"""`(start, minutes)` when the minutes form one contiguous run, else None."""
if not mins:
return None
for a, b in zip(mins, mins[1:]):
if b - a != timedelta(minutes=1):
return None
return mins[0], len(mins)
# =========================================================================== #
print("1. UTC stays what it was: the slot IS `now.hour == start_hour`")
t, same = datetime(2026, 1, 1, tzinfo=UTC), True
while t < datetime(2027, 1, 1, tzinfo=UTC):
for h in range(24):
if in_start_slot(t, h, UTC) != (t.hour == h):
same = False
t += timedelta(minutes=37)
check("over a year of instants, every hour 0-23: identical to the old test",
same)
check("a naive `now` is read as UTC, as before",
in_start_slot(datetime(2026, 7, 1, 19, 5), 19, UTC)
and not in_start_slot(datetime(2026, 7, 1, 18, 5), 19, UTC))
# =========================================================================== #
print("2. local time: 19 means 19:00 on the site's clock")
for label, tz in ZONES:
s = one_block(slot_minutes(SUMMER, 19, tz))
w = one_block(slot_minutes(WINTER, 19, tz))
check(f"[{label}] summer: 17:00-18:00 UTC (CEST), one hour",
s == (SUMMER + timedelta(hours=17), 60))
check(f"[{label}] winter: 18:00-19:00 UTC (CET), one hour",
w == (WINTER + timedelta(hours=18), 60))
check(f"[{label}] the old reading is gone: 19:00 UTC is not the slot "
f"in summer", not in_start_slot(SUMMER + timedelta(hours=19, minutes=5),
19, tz))
# =========================================================================== #
print("3. the DST change days, every start hour")
for label, tz in ZONES:
for name, day in (("spring", SPRING), ("spring 2027", SPRING27),
("autumn", AUTUMN)):
bad = []
for h in range(24):
b = one_block(slot_minutes(day, h, tz))
# the local day runs 23:00 UTC the day before to 22:00/23:00 UTC
if b is None or b[1] != 60:
bad.append((h, b))
check(f"[{label}] {name}: every hour 0-23 gives ONE slot of exactly "
f"60 minutes that day {bad or ''}", not bad)
sp2 = one_block(slot_minutes(SPRING, 2, tz))
check(f"[{label}] spring, hour 2 does not exist: it starts at 03:00 CEST "
f"(01:00 UTC), once", sp2 == (SPRING + timedelta(hours=1), 60))
check(f"[{label}] spring 2027 (28 March), hour 2: 01:00 UTC as well",
one_block(slot_minutes(SPRING27, 2, tz))
== (SPRING27 + timedelta(hours=1), 60))
check(f"[{label}] the evening before spring 2027, hour 19: 18:00 UTC "
f"(CET); that day, 17:00 UTC (CEST)",
one_block(slot_minutes(SPRING27 - timedelta(days=1), 19, tz))
== (SPRING27 - timedelta(hours=6), 60)
and one_block(slot_minutes(SPRING27, 19, tz))
== (SPRING27 + timedelta(hours=17), 60))
au2 = one_block(slot_minutes(AUTUMN, 2, tz))
check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST "
f"(00:00 UTC)", au2 == (AUTUMN, 60))
check(f"[{label}] autumn: the second 02:xx (CET) is not a slot",
not in_start_slot(AUTUMN + timedelta(hours=1, minutes=30), 2, tz))
check(f"[{label}] autumn, hour 19 still 18:00 UTC (CET)",
one_block(slot_minutes(AUTUMN, 19, tz))
== (AUTUMN + timedelta(hours=18), 60))
check(f"[{label}] spring, hour 19 already 17:00 UTC (CEST)",
one_block(slot_minutes(SPRING, 19, tz))
== (SPRING + timedelta(hours=17), 60))
# =========================================================================== #
class FakeStore:
def __init__(self):
self.data = {"phase": IDLE, "phase_started": None,
"last_completed": None, "last_start_attempt": None}
def set(self, k, v):
self.data[k] = v
def set_time(self, k, when):
self.data[k] = when
def get_time(self, k):
return self.data.get(k)
def get(self, k, default=None):
return self.data.get(k, default)
def save(self):
pass
def starts(day, hour, tz, *, abort_after=None, blocked=None):
"""Tick a due pack each minute around a day; the UTC instants in that
LOCAL day at which it entered DRAIN. `abort_after` aborts each drain that many minutes in (interlock);
`blocked(t)` defers it."""
m = Maintenance(MaintConfig(enabled=True, start_hour=hour), FakeStore(),
tz=tz)
out, since = [], None
t = day - timedelta(hours=2)
while t < day + timedelta(hours=26):
why = ""
if since is not None and abort_after is not None \
and t - since >= timedelta(minutes=abort_after):
why = "a test interlock"
if blocked is not None and blocked(t):
why = "the other battery is cycling"
was = m.phase
m.tick(t, soc=80, blocked_by=why)
if m.phase == DRAIN and was != DRAIN:
out.append(t)
since = t
if m.phase == IDLE:
since = None
t += timedelta(minutes=1)
return [x for x in out if x.astimezone(tz).date() == day.date()]
print("4. the state machine: one start per day, DST days included")
for label, tz in ZONES:
check(f"[{label}] spring, hour 2: one start, at 01:00 UTC (03:00 CEST)",
starts(SPRING, 2, tz, abort_after=5)
== [SPRING + timedelta(hours=1)])
# An aborted start waits RESTART_COOLDOWN_MIN (90). The repeated hour is
# 120 minutes long, so without the first-pass rule it would start again.
check(f"[{label}] autumn, hour 2, aborted after 5 min: NO second start "
f"in the repeated hour",
starts(AUTUMN, 2, tz, abort_after=5) == [AUTUMN])
check(f"[{label}] autumn, hour 2, deferred for the whole first pass: it "
f"does not start in the second pass either",
starts(AUTUMN, 2, tz, blocked=lambda t: t < AUTUMN + timedelta(hours=1))
== [])
check(f"[{label}] summer, hour 19: one start at 17:00 UTC",
starts(SUMMER, 19, tz, abort_after=5)
== [SUMMER + timedelta(hours=17)])
# =========================================================================== #
print("5. where the start is shown (homeview) is where it happens")
for label, tz in ZONES:
ok = True
for day in (SPRING, AUTUMN, SUMMER):
for h in (0, 2, 3, 10, 19, 23):
first = next(iter(slot_minutes(day, h, tz)), None)
if first is None:
continue
for probe in (first - timedelta(hours=5), first,
first + timedelta(minutes=59)):
if next_slot_start(probe, h, tz) != first:
ok = False
check(f"[{label}] next_slot_start() lands on the slot's first minute, "
f"from before it and from inside it", ok)
# =========================================================================== #
print("6. which zone: Home Assistant, else the container's TZ, else UTC")
_FAKE = {"Europe/Brussels": ZONES[-1][1], "America/New_York": object(),
"UTC": UTC}
_real_load = MT._load_zone
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
else (None, f"{n!r} is not a known zone"
if n else "none given"))
try:
tz, name, src, _ = site_zone("Europe/Brussels", "", "America/New_York")
check("Home Assistant's zone wins over the container's TZ",
name == "Europe/Brussels" and src == "ha"
and tz is _FAKE["Europe/Brussels"])
tz, name, src, why = site_zone(None, "ClientConnectorError",
"America/New_York")
check("Home Assistant unavailable: the container's TZ, and why",
name == "America/New_York" and src == "tz"
and why == "ClientConnectorError")
tz, name, src, why = site_zone("Mars/Base", "", "Europe/Brussels")
check("an unknown Home Assistant zone falls back too",
name == "Europe/Brussels" and src == "tz" and "Mars/Base" in why)
tz, name, src, why = site_zone(None, "no answer", None)
check("neither: UTC", tz is UTC and src == "utc" and "no answer" in why)
finally:
MT._load_zone = _real_load
if len(ZONES) > 1:
check("the real loader resolves a real name and refuses a made-up one",
MT._load_zone("Europe/Brussels")[0] is not None
and MT._load_zone("Mars/Base")[0] is None)
check("the real loader refuses an empty name without raising",
MT._load_zone("")[0] is None and MT._load_zone(None)[0] is None)
# =========================================================================== #
print("7. the default is 19, an explicit value is honoured")
cfg_text = (HERE / "config.yaml").read_text()
opts_block = cfg_text.split("\nschema:")[0]
check("config.yaml options: ships 19",
re.search(r"^ maintenance_start_hour: 19$", opts_block, re.M) is not None)
check("config.yaml schema: still int(0,23)",
re.search(r"^ maintenance_start_hour: int\(0,23\)$", cfg_text, re.M)
is not None)
check("the in-code mirror says 19",
MAINT_OPTION_DEFAULTS["maintenance_start_hour"] == 19
and MaintConfig().start_hour == 19
and MaintConfig.from_options({}).start_hour == 19)
check("an explicit 10 stays 10 (a stored option is never rewritten)",
MaintConfig.from_options({"maintenance_start_hour": 10}).start_hour == 10)
_eu = ZONES[0][1]
_m10 = Maintenance(MaintConfig.from_options(
{"maintenance_enabled": True, "maintenance_start_hour": 10}),
FakeStore(), tz=_eu)
check("...and now means 10:00 local: 08:00 UTC in summer, not 10:00 UTC",
_m10.wants_start(SUMMER + timedelta(hours=8, minutes=1))
and not _m10.wants_start(SUMMER + timedelta(hours=10, minutes=1)))
check("a Maintenance built with no zone reads UTC, as before",
Maintenance(MaintConfig(), FakeStore()).tz is UTC)
# =========================================================================== #
print("8. startup: Home Assistant's zone is asked for and reaches the machines")
class _Resp:
def __init__(self, body, status=200):
self.body, self.status = body, status
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def json(self, content_type=None):
if isinstance(self.body, Exception):
raise self.body
return self.body
async def text(self):
return "{}"
class _Session:
def __init__(self, zone_body):
self.zone_body, self.asked = zone_body, []
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
def get(self, url, **kw):
self.asked.append(url)
self.timeouts = getattr(self, "timeouts", []) + [kw.get("timeout")]
if url.endswith("/core/api/config"):
return _Resp(self.zone_body)
return _Resp({"data": {}})
s = _Session({"time_zone": "Europe/Brussels"})
check("ha_time_zone() reads /core/api/config's time_zone",
asyncio.run(M.ha_time_zone(s, "tok")) == ("Europe/Brussels", "")
and s.asked == [M.CORE_API + "/config"])
# Review: HA can take minutes to start (2026-10-05); the ask must not wait on it.
check("...with a time budget of at most 3 s, so a slow HA cannot hold up start",
s.timeouts == [M.DOCS_LINK_TIMEOUT_S] and 0 < M.DOCS_LINK_TIMEOUT_S <= 3)
check("...and a failure is None plus the reason, never a raise",
asyncio.run(M.ha_time_zone(_Session(OSError("down")), "tok"))
== (None, "OSError: down")
and asyncio.run(M.ha_time_zone(_Session({}), "tok"))[0] is None)
class _Built(Exception):
pass
def amain_zone(zone_body, env_tz):
"""Run the real amain() up to the Controller; the tz its machines get."""
got, logs = {}, []
class _Capture(logging.Handler):
def emit(self, rec):
logs.append((rec.levelname, rec.getMessage()))
class _Hass:
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def mqtt_service(self):
raise RuntimeError("no broker in this test")
real = {k: getattr(M, k) for k in ("load_options", "HomeAssistant",
"validate_startup_config",
"mode_announcement", "Controller")}
real_session, real_store = M.aiohttp.ClientSession, None
import app.store
real_store = app.store.Store
def _controller(opts, hass, store, pub, *, stop, site_tz=None, **kw):
got["tz"] = site_tz
raise _Built()
M.load_options = lambda: {"setpoint_entity": "number.sp"}
M.HomeAssistant = lambda session, **kw: _Hass()
M.validate_startup_config = lambda *a, **k: []
M.mode_announcement = lambda opts, store: ((), ())
M.Controller = _controller
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
app.store.Store = lambda *a, **k: object()
old_tz = os.environ.pop("TZ", None)
if env_tz:
os.environ["TZ"] = env_tz
h = _Capture()
logging.getLogger("goodwe").addHandler(h)
try:
asyncio.run(M.amain())
except _Built:
pass
finally:
logging.getLogger("goodwe").removeHandler(h)
for k, v in real.items():
setattr(M, k, v)
M.aiohttp.ClientSession = real_session
app.store.Store = real_store
os.environ.pop("TZ", None)
if old_tz is not None:
os.environ["TZ"] = old_tz
return got.get("tz"), [m for lvl, m in logs if "Maintenance start" in m], logs
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
else (None, "none given"))
try:
tz, lines, _ = amain_zone({"time_zone": "Europe/Brussels"}, "America/New_York")
check("amain(): Home Assistant's zone reaches the Controller",
tz is _FAKE["Europe/Brussels"])
check("...logged once, naming the zone and the source",
lines == ["Maintenance start hour is local time in Europe/Brussels, "
"from Home Assistant."])
tz, lines, _ = amain_zone(OSError("down"), "America/New_York")
check("amain(), Home Assistant unavailable: the container's TZ, said "
"plainly", tz is _FAKE["America/New_York"]
and lines == ["Maintenance start hour is local time in "
"America/New_York, from the add-on's time zone. Home "
"Assistant gave none: OSError: down."])
tz, lines, logs = amain_zone(OSError("down"), None)
check("amain(), neither: UTC, with a WARNING line",
tz is UTC and any(lvl == "WARNING" and "uses UTC" in m
for lvl, m in logs))
# Review: HA still starting (502) and the Supervisor's TZ is UTC.
tz, lines, logs = amain_zone(OSError("502"), "UTC")
check("amain(), Home Assistant unavailable and TZ is UTC: a WARNING "
"that says to restart", tz is UTC
and [(lvl, m) for lvl, m in logs if "Maintenance start" in m]
== [("WARNING", "Maintenance start hour uses UTC, because Home "
"Assistant did not answer. Restart the add-on when it is up.")])
tz, lines, logs = amain_zone(OSError("502"), "America/New_York")
check("...but a real TZ is used without a warning",
not any(lvl == "WARNING" for lvl, m in logs
if "Maintenance start" in m))
finally:
MT._load_zone = _real_load
# =========================================================================== #
print("9. nothing else in control moves: the same start instant, the same "
"commands")
import opt11_identity as rig # noqa: E402
import safety78_identity as s78 # noqa: E402
# rig's clock starts 2026-09-20 11:00 UTC = 13:00 CEST. The same drain,
# asked for as UTC hour 11 (the old reading) and as local hour 13 (the new).
def rows_with(start_hour, tz, n=1, both=False):
"""The rig's rows; `both=True` also returns the Controller."""
real_drain, real_ctl = s78.DRAIN, rig.Controller
s78.DRAIN = {"maintenance_enabled": True,
"maintenance_start_hour": start_hour}
rig.Controller = functools.partial(real_ctl, site_tz=tz)
try:
# s78.run() wraps rig.Controller once more for its own tariff db.
rows, c = s78.run(n, dur=600.0)
return (rows, c) if both else rows
finally:
s78.DRAIN, rig.Controller = real_drain, real_ctl
_utc11 = rows_with(11, None)
_loc13 = rows_with(13, _eu)
check("precondition: the drain really starts in the UTC-11 run",
any("drain" in r[-1][4] for r in _utc11))
check(f"start hour 11 UTC and 13 Europe/Brussels: every command, claim, "
f"reason and log line identical over {len(_utc11)} cycles",
_utc11 == _loc13)
_utc12 = rows_with(12, None)
_loc14 = rows_with(14, _eu)
check("precondition: no drain in the not-yet-due-hour run",
not any("drain" in r[-1][4] for r in _utc12))
check("outside the start hour (12 UTC / 14 local): identical as well",
_utc12 == _loc14)
check("...and the zone matters: hour 11 read in Brussels does NOT start",
not any("drain" in r[-1][4] for r in rows_with(11, _eu)))
# =========================================================================== #
print("10. two inverters: every pack reads the start hour in local time")
_r2u, _c2u = rows_with(11, None, n=2, both=True)
_r2l, _c2l = rows_with(13, _eu, n=2, both=True)
check("N=2: both packs get the site's zone, not just the first",
len(_c2l.maints) == 2 and all(mm.tz is _eu for mm in _c2l.maints))
check("N=2 precondition: a drain starts in the UTC-11 run",
any(DRAIN in r[-1][4] for r in _r2u))
check(f"N=2: start hour 11 UTC and 13 Europe/Brussels identical over "
f"{len(_r2u)} cycles", _r2u == _r2l)
check("N=2: hour 11 read in Brussels does NOT start either pack",
not any(DRAIN in r[-1][4] for r in rows_with(11, _eu, n=2)))
def pair_starts(day0, days, hour, tz, cycle_min=70):
"""Two due packs, ticked each minute through `arbitrate()` as main.py
does; a cycle takes about `cycle_min` minutes. `(pack, "DD HH:MM" UTC)`s."""
cfg = MaintConfig(enabled=True, start_hour=hour, hold_min=10)
ms = [Maintenance(cfg, FakeStore(), name=u, tz=tz)
for u in ("A", "B")]
ms[0].store.set_time("last_completed", day0 - timedelta(days=60))
ms[1].store.set_time("last_completed", day0 - timedelta(days=40))
half = (cycle_min - 10) // 2
soc, out = [80.0, 80.0], []
t = day0 - timedelta(hours=3)
while t < day0 + timedelta(days=days):
blocks = MT.arbitrate(ms, t)
for i, mm in enumerate(ms):
was = mm.phase
mm.tick(t, soc=soc[i], blocked_by=blocks[i])
if mm.phase == DRAIN:
soc[i] -= (80.0 - cfg.soc_floor) / half
elif mm.phase == MT.CHARGE:
soc[i] += (cfg.soc_target - cfg.soc_floor) / half
if mm.phase == DRAIN and was != DRAIN:
out.append((mm.name, t))
t += timedelta(minutes=1)
return [(n, x.strftime("%d %H:%M")) for n, x in out]
def _at(*pairs):
return [(n, x.strftime("%d %H:%M")) for n, x in pairs]
for label, tz in ZONES:
# 24 Oct (CEST) and 25 Oct (CET): 19:00 local is 17:00 then 18:00 UTC.
got = pair_starts(AUTUMN - timedelta(days=1), 2, 19, tz)
check(f"[{label}] N=2 across the autumn change, hour 19: A at 19:00 "
f"local on the 24th, B at 19:00 local on the 25th {got}",
got == _at(("A", AUTUMN - timedelta(hours=7)),
("B", AUTUMN + timedelta(hours=18))))
got = pair_starts(SPRING27 - timedelta(days=1), 2, 19, tz)
check(f"[{label}] N=2 across the spring 2027 change, hour 19: 18:00 UTC "
f"then 17:00 UTC {got}",
got == _at(("A", SPRING27 - timedelta(hours=6)),
("B", SPRING27 + timedelta(hours=17))))
# Hour 2 on 25 Oct: A takes the first pass (00:00 UTC) and is done in its
# second pass. B must not start there - it waits for the next day.
got = pair_starts(AUTUMN, 2, 2, tz)
check(f"[{label}] N=2, autumn, hour 2: B does not start in the repeated "
f"hour after A is done; it starts at 02:00 CET on the 26th {got}",
got == _at(("A", AUTUMN),
("B", AUTUMN + timedelta(days=1, hours=1))))
got = pair_starts(SPRING27, 2, 2, tz)
check(f"[{label}] N=2, spring 2027, hour 2: A at 03:00 CEST (01:00 UTC), "
f"B the next night at 02:00 CEST (00:00 UTC) {got}",
got == _at(("A", SPRING27 + timedelta(hours=1)),
("B", SPRING27 + timedelta(days=1))))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")
-570
View File
@@ -1,570 +0,0 @@
"""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):
return [x for x in h.calls if x[0] == "persistent_notification"]
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")
-403
View File
@@ -1,403 +0,0 @@
"""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")
-299
View File
@@ -1,299 +0,0 @@
"""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")
+4 -12
View File
@@ -122,8 +122,7 @@ 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: MAINT-14 the drain holds at 0 W, never discharges",
r.owns_setpoint and r.setpoint_w == 0.0)
check("no SoC: still commands the drain rate", r.setpoint_w == 2500)
m = Maintenance(cfg, FakeStore())
m.store.set("phase", "banana")
@@ -623,8 +622,6 @@ 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))
@@ -932,11 +929,10 @@ 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`, three in `lost` (the three the "
"fixture records; MAINT-13 added `last_failed`), `misdirected` "
"and ONLY that one - two keys in `kept`, two in `lost`, `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) == 3
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 2
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 "
@@ -1014,14 +1010,10 @@ 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: the cycle did not finish. It waits 7 days before it "
"tries again."])
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)"])
+2 -5
View File
@@ -1469,11 +1469,8 @@ _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 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.
"is restarting. The add-on keeps trying."]
and _info == ["Home Assistant answers again."] and len(_warn) == 2)
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)
+4 -15
View File
@@ -129,11 +129,6 @@ finally:
# cd4d9cb5b7e29ff4ab4fe65dab5106683d8a599f09323fbdee4018ad9b28fcd1.
SAFETY78_N2_MAINT = \
"1afcd46b1b987868b110d2b0f4a7555848c9aa4728a836d4a2ce2a2107268497"
# SAFETY-95 changes it again ON PURPOSE: A's drain ends at t=1655 (its
# refusal), and B now stays at the house's 1420 W instead of jumping to
# 2500 W (1100 W export) and slewing back. Rows differ only at 1655-2105 s.
SAFETY95_N2_MAINT = \
"949e7c093dcf065257417de3f2f908e4d57311790abc9fba0a09aa4b5d64c0d2"
# SAFETY-91 changes n2 and n2_off ON PURPOSE: from t=1600 battery A is at its
# 20 % reserve, so B carries the house first and A gives last. With
# `_held_back()` reverted to SAFETY-87's (held only for the peak) both are
@@ -158,20 +153,14 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every "
"is the arbiter on 6000 claim lists",
{k: v for k, v in _now.items() if k not in _S91}
== {k: v for k, v in A5DDDAD.items() if k not in _S91})
# SAFETY-94: with SAFETY-87's held rule, A (cut out at 1600 s) now trips the
# stale gate at 1640 s, not 1635 s - 35 s after its command moved rather than
# after its reading last changed - so that run is no longer a5dddad's. It was
# a5dddad's (a60003b1...) before SAFETY-94. The shipped n2 is unchanged.
SAFETY94_N2_S87 = \
"4ddea7ef9dc1efa437af899675db442cd531b084ae4cec35951d7c7bb3d6d8b5"
check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives "
"last), and SAFETY-87's held rule gives SAFETY-94's",
"last), and SAFETY-87's held rule gives a5dddad's back",
_now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"]
and _n2_s87 == SAFETY94_N2_S87)
and _n2_s87 == A5DDDAD["n2"])
check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad "
"refused on 392 of 540 cycles now runs (test_safety78.py), bounded by "
"SAFETY-85, its end handed back by SAFETY-95",
_now["n2_maint"] == SAFETY95_N2_MAINT != A5DDDAD["n2_maint"])
"SAFETY-85",
_now["n2_maint"] == SAFETY78_N2_MAINT != A5DDDAD["n2_maint"])
check("...and no cycle in those runs filed a `mode` claim, and only "
"n2_maint's (SAFETY-85: maintenance at N=2) handed `compute()` a bound",
_claims and not any(c.source == "mode" for cl in _claims for c in cl)
+5 -11
View File
@@ -74,7 +74,7 @@ AT_7A00F12 = {
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
}
_rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
_rows = {k: ident.run(**v)[0] for k, v in ident.SCENARIOS.items()}
_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,16 +482,10 @@ _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 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)
"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)
and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2)
# ============================================================================ #
@@ -522,7 +516,7 @@ print("\n7. a run edge reseeds the loop")
def _no_reseed(c):
c._reseed_run_edge = lambda claims, now: None
c._reseed_run_edge = lambda claims: None
def edge(rows, kind):
+13 -20
View File
@@ -60,42 +60,37 @@ 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":
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
"n2_sf_ls":
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
"n2_ch_ls":
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
"n2_ls_ls":
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
"n2_sf_ch":
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
"n1_ls_fc_down":
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
"n1_ch_fc_down":
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
"ff3fb2cdf849bc74185864e0fd87c04c70a947b2f2b08d3f5c9b35ee8b75f490",
"n2_sf_ls_fc_down":
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
"n2_ch_ls_fc_down":
"9c54abddf877ddacbb3511d9cbe5231ddbda74986a89f396b83c5b2d22dc16f6",
"63e09c4323148ec4e1e6766a3c50ab270b6575ec13c35aaa1115d2c5cabc342b",
"n2_ls_ls_fc_down":
"98e180d43fa2aeecbe693838d2097dd4cb698c60aba8213c198d80d4e7166265",
"n2_sf_ch_fc_down":
"d16d3cac0d9f720b68212c5899624d2a02fb6f7ecae258ddfc8dbba560d47bcf",
"95908916505bc5fa460db7b9829d5b56ec216038caa47be4bbf7be775241c7a2",
}
import opt11p4_identity as ident # noqa: E402
got = ident.digests()
for k, v in BASE_DIGESTS.items():
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
got.get(k) == v)
check(f"{k}: same digest as 5a4cdc2", 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",
@@ -118,21 +113,19 @@ 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)))
# OPT-17: the controller reads through `read_sum()`, so that is the seam.
_read = solarfc.read_sum
_read = solarfc.read
def _as_dark(today, tomorrow, now, **kw):
return _read([("sensor.f", {"state": "0", "attributes": {
"unit_of_measurement": "kWh"}, "last_updated": now.isoformat()})],
[], now)
return _read({"state": "0", "attributes": {"unit_of_measurement":
"kWh"},
"last_updated": now.isoformat()}, None, 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_sum", _as_dark),
lambda: setattr(solarfc, "read_sum", _read)))
_moved("n2_sf_ls_fc_down", lambda: setattr(solarfc, "read", _as_dark),
lambda: setattr(solarfc, "read", _read)))
# ============================================================================ #
print("2. the forecast sensors")
-498
View File
@@ -1,498 +0,0 @@
"""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")
+4 -5
View File
@@ -716,11 +716,10 @@ check("the drain's commitment window is DERIVED from the phase machine's own "
"timeout, so the gate and maintenance.py cannot drift apart",
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
# A drain started at 10:00 local runs into the evening - hours a morning-only
# day-ahead print has not covered. That is the ticket's scenario, so the hour is
# pinned here: 10 was the shipped default until MAINT-10 moved it to 19, and an
# install that stored 10 still starts then.
START = D0 + timedelta(hours=10)
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
# schedule runs into the evening - hours a morning-only day-ahead print has not
# covered. That is the ticket's scenario and it is where the window is placed.
START = D0 + timedelta(hours=MaintConfig().start_hour)
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
f"whole hours, one per hour of the timeout",
-635
View File
@@ -1,635 +0,0 @@
"""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")
-401
View File
@@ -1,401 +0,0 @@
"""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")
-307
View File
@@ -1,307 +0,0 @@
"""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")
-440
View File
@@ -1,440 +0,0 @@
"""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")
+1 -1
View File
@@ -187,7 +187,7 @@ check("⭐ a draining battery that is dropped (stale link) adds no floor: B "
"covers the 1500 W house, not the 2000 W drain rate, and the wait is "
"logged once",
"dropped inverter-a" in _l_rows[-1][7]
and cmd(_l_rows[-1])["b"] == 1510.0
and cmd(_l_rows[-1])["b"] == 1500.0
and sum("cannot give power now" in e for e in _l.events) == 1)
# ============================================================================ #
+6 -46
View File
@@ -57,28 +57,6 @@ 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()}
@@ -87,23 +65,7 @@ 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]} (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)
check(f"{k}: digest {digest[:12]}", digest == BASE[k][1])
# ============================================================================ #
print("2. no false alarm (5a4cdc2's are listed)")
@@ -114,17 +76,16 @@ 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 (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])):
# quarter-edge tick restarts the hold.
for k, want in (("step6500", [1211.0, 1813.0]),
("miss5560", [1213.0, 1813.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, 1811.0])
RUN["step6500_gap"][0] == [1215.0, 1813.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",
@@ -135,7 +96,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] == [1317.0, 1811.0])
RUN["ramp6500"][0] == [1316.0, 1813.0])
check("step7500 under the peak reserve: not this alarm (the guard is not "
"allowed to discharge)", RUN["step7500_reserve"][0] == [])
@@ -189,7 +150,6 @@ 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
-483
View File
@@ -1,483 +0,0 @@
"""Runnable check for SAFETY-90: the peak guard goes on after a P1 meter gap.
`python3 test_safety90.py`
A gap past `meter_max_age_s` left seconds the quarter average never counted,
so the guard was blind for the rest of the quarter: a HOLD gave 0 W and the
quarter raised the month peak (SAFETY-86 rig case 7b: 3964.8 W against a
3450 W aim). Now the missed seconds count at `peakguard.gap_w()`, and so do
the seconds before the add-on's first reading, with a plain line on the
page. Drives the REAL Controller on a lagging battery (10 s), with the P1
telegrams stopped for each gap. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.
1. N=1 HOLD: gaps of 30 s, 60 s and 5 min mid-quarter land at or under the aim.
2. N=2 HOLD, B able: the same.
3. N=2 HOLD, B held at its reserve: the same, and B gives nothing (O2).
4. A gap across a quarter edge; the house rising during a gap.
5. Start-up mid-quarter: the guard runs, the page says what it assumed.
6. While the gap lasts the guard is blind as before; no gap, no change.
7. fill_gaps() and gap_w() at their boundaries.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import math
import os
import sys
import types
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
import app.p1 as p1mod # noqa: E402
import app.tariff as tariff # noqa: E402
from app import homeview, messages, peakguard # noqa: E402
from app.maintenance import HOLD # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
TOL = 5.0 # W over the aim a quarter may land (SAFETY-79)
CUT = 10.0
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def store(phase_of, n):
s = rig._Store()
for u in (A, B)[:n]:
key = (lambda k: k) if n == 1 else (lambda k, u=u: unit_key(u, k))
if u in phase_of:
s.data[key("phase")] = phase_of[u]
s.data[key("phase_started")] = rig.BASE.isoformat()
else:
s.data[key("last_completed")] = rig.BASE.isoformat()
return s
def run(house, socs, dur, *, n, gaps=(), start=0.0, lag=10.0, on_cycle=None):
"""Rows and controller. A holds full; `socs` are the starting levels
(B's follows its power). `gaps`: (t0, t1), no telegram for t0 <= t < t1.
`start`: the add-on starts this many s into the run (mid-quarter). The
P1 clock is simulated, so a gap really goes stale. `c.truth` is the grid
each second, which the quarter really averages."""
clock = [0.0]
soc, last, truth = dict(socs), [start], []
def before(c, t0):
if on_cycle is not None:
on_cycle(c, t0) # the state the last cycle left
t = t0 + start
clock[0] = t
g = c.hass.grid()
truth.append((t, g))
for u in soc:
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
/ 3600.0 / 153.6)
last[0] = t
if any(a <= t < b for a, b in gaps):
return
imp, exp = split_signed(g)
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t0),
ingest_mono=1e6 + t))
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for _ in range(int(round((self.t - tl) / 0.1))):
d = self.cmd[u] - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
real = (rig._Hass.power, rig.TICK, rig.BASE, p1mod.time)
rig._Hass.power, rig.TICK = power, 1.0
rig.BASE = real[2] + timedelta(seconds=start)
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + clock[0])
try:
rows, c = s84.run(
n, load=house if callable(house) else (lambda t: house),
cheap=False, store=store({A: HOLD}, n), before=before, dur=dur,
soc=lambda u, t: soc[u],
extra={"maintenance_hold_min": 600})
finally:
rig._Hass.power, rig.TICK, rig.BASE, p1mod.time = real
c.truth = truth
return rows, c
def without_fix(fn):
"""release/1.0 3ed3302: nothing is filled, so a gap blinds the guard."""
real = peakguard.fill_gaps
peakguard.fill_gaps = lambda p, **kw: (p, 0.0, 0.0)
try:
return fn()
finally:
peakguard.fill_gaps = real
def quarter(c, q):
"""What quarter `q` (0 = the run's first) really averages, in W."""
return sum(max(g, 0.0) for t, g in c.truth if int(t // QUARTER_S) == q) \
/ QUARTER_S
def cmd(r):
return dict(r[2])
def mean(rows, u, lo, hi):
rs = [cmd(r)[u] for r in rows if lo <= r[0] < hi]
return round(sum(rs) / len(rs))
TABLE = []
def case(tag, n, house, socs, gaps, q=0, dur=1800.0):
"""One gap case, after and before the fix; prints and records it."""
new = run(house, socs, dur, n=n, gaps=gaps)
old = without_fix(lambda: run(house, socs, dur, n=n, gaps=gaps))
a, b = quarter(new[1], q), quarter(old[1], q)
print(f" {tag}: quarter {a:.1f} W (before the fix {b:.1f} W), aim "
f"{AIM:.0f}")
TABLE.append((tag, n, round(a, 1), round(b, 1)))
return new, old, a, b
# ============================================================================ #
print("1. N=1 HOLD, house 6500 W (5500 W for 5 min, so it can be held)")
for tag, gap, house in (("30 s", (300, 330), 6500.0),
("60 s", (300, 360), 6500.0),
("5 min", (300, 600), 5500.0)):
(rows, c), _old, a, b = case(f"N=1 gap {tag}", 1, house, {"a": 99.0},
[gap])
check(f"⭐ N=1, {tag} gap mid-quarter: the quarter lands at or under the "
f"aim ({a:.1f} W)", a <= AIM + TOL)
if tag != "30 s":
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
b > AIM + 100)
check("...and once readings are back the battery gives at least the "
"house over the aim",
mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
check("...the action log says what the gap was counted at",
any("PEAK_GUARD_GAP" in e for e in c.events))
# ============================================================================ #
print("\n2. N=2 HOLD (A holding), B able at 60 %, house 6500 W")
for tag, gap, house in (("30 s", (300, 330), 6500.0),
("60 s", (300, 360), 6500.0),
("5 min", (300, 600), 5500.0)):
(rows, c), _old, a, b = case(f"N=2 gap {tag}", 2, house,
{"a": 99.0, "b": 60.0}, [gap])
check(f"⭐ N=2, {tag} gap: the quarter lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
if tag != "30 s":
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
b > AIM + 100)
check("...B gives first: at its full 2500 W once readings are back",
mean(rows, "b", gap[1] + 30, 900) >= 2450)
# ============================================================================ #
print("\n3. N=2 HOLD, B held at 15 % (under its 20 % reserve), house 5500 W "
"(4500 W for 5 min: A alone gives 2500 W)")
for tag, gap, house in (("30 s", (300, 330), 5500.0),
("60 s", (300, 360), 5500.0),
("5 min", (300, 600), 4500.0)):
(rows, c), (orows, _oc), a, b = case(
f"N=2 B held, gap {tag}", 2, house, {"a": 99.0, "b": 15.0}, [gap])
check(f"⭐ N=2 B held, {tag} gap: the quarter lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
check("⭐ O2: the held B gives nothing once readings are back; the "
"holding A gives the need",
all(cmd(r)["b"] == 0.0 for r in rows if gap[1] <= r[0] < 900)
and mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
if tag != "30 s":
check("...non-vacuous: before the fix the blind guard left A at 0 W "
"and the held B was drawn at 2500 W as last resort",
mean(orows, "a", gap[1] + 30, 900) == 0
and mean(orows, "b", gap[1] + 30, 900) >= 2450)
rows, c = run(5500.0, {"a": 99.0, "b": 15.0}, 1000.0, n=2,
gaps=[(300, 360), (899, 910)])
edge = [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows if 900 <= r[0] < 915]
print(f" a 60 s gap, then the edge's telegram 11 s late: {edge[:4]}; the "
f"second before the edge (O1, not this card): "
f"{[(r[0], cmd(r)['a'], cmd(r)['b']) for r in rows if r[0] == 899]}")
check("⭐ ...a quarter that had a gap still hands over at its edge (SAFETY-91): "
"with the edge's telegram late the held B gives nothing and A keeps "
"giving", all(b == 0.0 and a > 1500.0 for _t, a, b in edge))
# ============================================================================ #
print("\n4. a gap across a quarter edge, and a house that rises in a gap")
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 60.0})):
(rows, c), _o, a, b = case(f"N={n} gap across the edge", n, 6500.0, socs,
[(870, 930)], q=1)
check(f"⭐ N={n}: the quarter the readings come back in lands at or "
f"under the aim ({a:.1f} W; before the fix {b:.1f} W)",
a <= AIM + TOL and b > AIM + 100)
check("...the quarter that ended in the gap could not be steered after "
"it, and stays under the limit itself",
quarter(c, 0) <= 3500.0)
def rising(t):
return 3000.0 if t < 320 else 6500.0
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
house = rising if n == 1 else (lambda t: 3000.0 if t < 320 else 5500.0)
(rows, c), _o, a, b = case(f"N={n} house rises in a 60 s gap", n, house,
socs, [(300, 360)])
check(f"⭐ N={n}: the house goes from 3000 W to over the limit during "
f"the gap; the import seen after it is counted and the quarter "
f"lands at or under the aim ({a:.1f} W)", a <= AIM + TOL)
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
big = 6500.0 if n == 1 else 5500.0
(rows, c), _o, a, b = case(
f"N={n} house falls in a 5 min gap", n,
lambda t, big=big: big if t < 560 else 2000.0, socs, [(300, 600)])
check(f"⭐ N={n}: the house falls to 2000 W just before readings return; "
f"the import before the gap (batteries at 0 W) is counted and the "
f"quarter lands at or under the aim ({a:.1f} W; before {b:.1f} W, "
f"at N=2 by drawing the held B)",
a <= AIM + TOL and (n == 2 or b > AIM + 100))
(rows, c), _o, a, b = case(
"N=1 5 min gap at 3000 W, then 60 s gap at 6500 W next quarter", 1,
lambda t: 3000.0 if t < 900 else 6500.0, {"a": 99.0},
[(300, 600), (1200, 1260)], q=1)
check(f"⭐ ...each quarter's gap is counted on its own: a long gap at a low "
f"load does not hide a shorter one in the next quarter ({a:.1f} W)",
a <= AIM + TOL and sum("PEAK_GUARD_GAP" in e for e in c.events) == 2)
def dip(t):
return 3000.0 if 450 <= t < 700 else 6500.0
(rows, c), _o, a, b = case("N=1 two gaps, the second at a lower load", 1,
dip, {"a": 99.0}, [(300, 360), (500, 560)])
at = [float(e.split("counted at ")[1].split(" W")[0]) for e in c.events
if "PEAK_GUARD_GAP" in e]
check(f"⭐ ...two gaps in one quarter: the second, at a lower load, does not "
f"price the first one down (counted at {at}; {a:.1f} W)",
a <= AIM + TOL and len(at) == 2 and min(at) >= 6400.0)
# ============================================================================ #
print("\n5. start-up mid-quarter (the add-on starts 450 s into the quarter)")
UI = {}
def grab(c, t):
if t == 60.0:
UI["unmeasured"] = c.peak_unmeasured_s
UI["rows"] = [r for r in c.checks() if r.get("id") == "peak_guard"]
UI["en"] = homeview.month_peak(c, c.cycle_at)["line"]
homeview._LANG[0] = "nl"
try:
UI["nl"] = homeview.month_peak(c, c.cycle_at)["line"]
finally:
homeview._LANG[0] = "en"
if t == 453.0:
UI["later"] = (c.peak_unmeasured_s,
[r for r in c.checks() if r.get("id") == "peak_guard"])
def restored(t_dead, start=450.0):
"""TARIFF-01 restores the averager at start-up: 0..t_dead measured at
the aim, the held sample at t_dead (the restart's first second)."""
def fake(self, averager, now=None):
bs = int(rig.BASE.timestamp() - start)
averager.restore({"block": bs, "acc": AIM * t_dead,
"pp_acc": [AIM * t_dead], "elapsed": t_dead,
"last_t": bs + t_dead, "last_net": AIM,
"last_pp": [AIM], "phases": 1})
self.restore_state = "resumed"
return self.restore_state
return fake
def startup(house, socs, n, *, t_dead=None, on_cycle=None):
"""The add-on starts 450 s in. While it was down the batteries were at
0 W (the ESP32 watchdog), so the meter saw the house: the quarter is
that, or the restored part at the aim, plus what this run measured."""
real = tariff.PeakTracker.restore
if t_dead is not None:
tariff.PeakTracker.restore = restored(t_dead)
try:
rows, c = run(house, socs, 455.0, n=n, start=450.0,
on_cycle=on_cycle)
finally:
tariff.PeakTracker.restore = real
pre = 450.0 * house if t_dead is None else (
t_dead * AIM + (450.0 - t_dead) * house)
return rows, c, (pre + sum(max(g, 0.0) for t, g in c.truth if t < 900)) \
/ QUARTER_S
for n, socs, house in ((1, {"a": 99.0}, 5000.0),
(2, {"a": 99.0, "b": 15.0}, 4500.0)):
rows, c, land = startup(house, socs, n, on_cycle=grab if n == 1 else None)
orows, _oc, old = without_fix(lambda: startup(house, socs, n))
TABLE.append((f"N={n} start-up mid-quarter", n, round(land, 1),
round(old, 1)))
check(f"⭐ N={n}: the first 450 s (house {house:.0f} W, batteries at 0 W) "
f"count at the import seen first; the quarter lands at or under "
f"the aim ({land:.1f} W, before the fix {old:.1f} W)",
land <= AIM + TOL and (n == 2 or old > AIM + 100))
if n == 2:
check("...and the held B gives nothing (O2 at start-up); before the "
"fix it was drawn at 2500 W",
all(cmd(r)["b"] == 0.0 for r in rows if 1 <= r[0] < 450)
and mean(orows, "b", 30, 450) >= 2450)
check("...non-vacuous: before the fix A gave 0 W for the quarter",
mean(orows, "a", 30, 450) == 0)
rows, c, land = startup(6500.0, {"a": 99.0}, 1)
check(f"⭐ N=1, house 6500 W: more than the battery can make up; A gives its "
f"full 5000 W and the alarm says so ({land:.1f} W)",
mean(rows, "a", 30, 450) >= 4900
and any("PEAK_GUARD_SATURATED" in e for e in c.events))
for t_dead in (300.0, 390.0, 440.0):
rows, c, land = startup(6500.0, {"a": 99.0}, 1, t_dead=t_dead)
TABLE.append((f"N=1 restart, down {450 - t_dead:.0f} s", 1,
round(land, 1), round(without_fix(lambda: startup(
6500.0, {"a": 99.0}, 1, t_dead=t_dead))[2], 1)))
check(f"⭐ restart mid-quarter, down {450 - t_dead:.0f} s at house "
f"6500 W (the restored reading's held 30 s topped up too): the "
f"quarter lands at or under the aim ({land:.1f} W)",
land <= AIM + TOL)
rows, c = run(6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
gaps=[(1200, 1260)])
a = quarter(c, 1)
TABLE.append(("N=1 start-up, then 60 s gap next quarter", 1, round(a, 1),
round(quarter(without_fix(lambda: run(
6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
gaps=[(1200, 1260)]))[1], 1), 1)))
check(f"...the start-up assumption stays in its own quarter: a 60 s gap in "
f"the next one is counted as a gap and lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
print(f" page: {UI.get('rows')}\n EN {UI.get('en')!r}\n"
f" NL {UI.get('nl')!r}")
check("⭐ the page says plainly that part of the period was not measured, "
"in English and Dutch",
UI.get("unmeasured", 0) > 400
and UI["rows"][0]["text"] == messages.row_peak_guard_unmeasured()
and UI["en"].startswith("Part of this 15-minute period was not "
"measured.")
and UI["nl"].startswith("Een deel van dit kwartier is niet gemeten."))
check("...and stops saying it in the next quarter",
UI["later"][0] == 0.0 and UI["later"][1][0]["text"]
!= messages.row_peak_guard_unmeasured())
# ============================================================================ #
print("\n6. while the gap lasts, and with no gap at all")
LIVE = {}
def live(c, t):
if t == 350.0:
LIVE["g"] = c.peak_guard
run(6500.0, {"a": 99.0}, 400.0, n=1, gaps=[(300, 360)], on_cycle=live)
check("⭐ 50 s into a gap the guard is blind as before (floor 0 W, no "
"charging): nothing is filled while the gap is open",
LIVE["g"].blind is not None and LIVE["g"].floor_w == 0.0)
for n, socs, house in ((1, {"a": 99.0}, 6500.0),
(2, {"a": 99.0, "b": 60.0}, 6500.0),
(2, {"a": 99.0, "b": 15.0}, 5500.0)):
rows, c = run(house, socs, 1000.0, n=n)
orows, _ = without_fix(lambda: run(house, socs, 1000.0, n=n))
check(f"⭐ N={n}, meter never drops: every row is identical with and "
f"without the fix, and nothing is filled",
rows == orows and not any("PEAK_GUARD_GAP" in e for e in c.events))
# ============================================================================ #
print("\n7. fill_gaps() and gap_w() at their boundaries")
T0 = rig.BASE
def proj(wall, covered, held=1000.0, acc_w=1000.0):
return Projection(projected_w=(acc_w * covered + held * (QUARTER_S - wall))
/ QUARTER_S, elapsed_w=acc_w, elapsed_s=wall,
covered_frac=covered / wall, complete=False,
block_start=T0, held_w=held)
def fill(p, tail, unseen=0.0, gw=4000.0, uw=3450.0):
return peakguard.fill_gaps(p, tail_s=tail, max_hold_s=30.0,
unseen_s=unseen, gap_w=gw, unseen_w=uw)
p = proj(400.0, 300.0) # 100 s uncounted
f, g, u = fill(p, 10.0)
check("90 s missed (100 s uncounted less a 10 s tail): counted at gap_w, "
"the tail left as before, no longer blind",
g == 90.0 and u == 0.0
and abs(f.projected_w - (p.projected_w + 90.0 * 4000.0 / QUARTER_S))
< 1e-9 and abs(peakguard.uncovered_s(f) - 10.0) < 1e-9
and peakguard.blindness(f, 30.0, False) is None
and peakguard.blindness(p, 30.0, False) is not None)
check("tail exactly max_hold_s: filled; one past it: the gap is open, "
"nothing filled",
fill(p, 30.0)[1] == 70.0 and fill(p, 30.001) == (p, 0.0, 0.0))
f, g, u = fill(p, 10.0, unseen=60.0)
check("unseen seconds come first and count at the aim, the rest at gap_w",
u == 60.0 and g == 30.0
and abs(f.projected_w - (p.projected_w + (60 * 3450.0 + 30 * 4000.0)
/ QUARTER_S)) < 1e-9)
check("nothing missed (or under 1 ms): the projection object is returned "
"unchanged",
fill(proj(400.0, 400.0), 0.0)[0] is not None
and fill(proj(400.0, 400.0), 0.0) == (proj(400.0, 400.0), 0.0, 0.0)
and fill(proj(400.0, 399.9995), 0.0)[1] == 0.0)
f, g, u = fill(proj(400.0, 400.0), 0.0)
f2 = peakguard.fill_gaps(proj(400.0, 400.0), tail_s=0.0, max_hold_s=30.0,
unseen_s=0.0, gap_w=4000.0, unseen_w=3450.0,
held_ws=9000.0)
check("held_ws (the gap's first 30 s topped up) is added even when nothing "
"is missing any more",
g == 0.0 and abs(f2[0].projected_w - f.projected_w - 10.0) < 1e-9)
z = peakguard.fill_gaps(Projection(0, 0, 0, 0, False, T0, 0), tail_s=0.0,
max_hold_s=30.0, unseen_s=0.0, gap_w=4000.0,
unseen_w=3450.0, held_ws=9000.0)
check("the first instant of a block (0 s elapsed) with held_ws: no division "
"by zero", abs(z[0].projected_w - 10.0) < 1e-9)
check("no open block: unchanged (blindness says so)",
fill(Projection(0, 0, 0, 0, False, None, 0), 0.0)[1:] == (0.0, 0.0))
check("gap_w(): the highest of the limit and the imports seen, None skipped",
peakguard.gap_w(3500, None, 3000.0) == 3500.0
and peakguard.gap_w(3500, 6500.0, None) == 6500.0
and peakguard.gap_w(3500, 3000.0, 4500.0) == 4500.0
and peakguard.gap_w(3500) == 3500.0)
# ============================================================================ #
print("\n case N quarter W before W aim")
for tag, n, a, b in TABLE:
print(f" {tag:<34}{n} {a:>9.1f} {b:>9.1f} {AIM:.0f}")
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all SAFETY-90 checks passed")
+2 -34
View File
@@ -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, DRAIN, HOLD, IDLE, # noqa: E402
from app.maintenance import (CHARGE_TIMEOUT_MIN, HOLD, IDLE, # noqa: E402
MaintResult)
from app.store import unit_key # noqa: E402
from named_absence import first, present # noqa: E402
@@ -221,16 +221,9 @@ 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_b_done(1780.0)),
store=timed_out(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}),
@@ -253,31 +246,6 @@ 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
-361
View File
@@ -1,361 +0,0 @@
"""Runnable check for SAFETY-94: a healthy battery that lags or reverses is
not flagged as frozen or refusing. `python3 test_safety94.py`
Two false alarms, both pre-existing:
- The stale-reading gate (SAFETY-37) counted its 35 s from the register's last
change. After a rest that clock was already past 35 s, so the first read
after a new command tripped it, before the register could answer (SAFETY-58;
live 2026-10-03, four trips, each 1 s after a command).
- At N=1 the refusal (SAFETY-52) engaged after 12 frozen cycles if the reading
was under 100 W at that moment. At a 1 s meter, a battery reversing out of a
charge through its lag is passing 0 W just then (SAFETY-85 rig, O1). It was
held at 0 W until the next probe, up to 15 min.
Now the gate counts only the time the command asked the register to move, and
a refusal needs the reading to have stayed under 100 W, not moving towards the
ask, for 12 cycles spanning 22 s, at every roster size.
DECISIONS/SAFETY-94-lagging-battery.md.
1. The four live trips, replayed through the gate.
2. The REAL Controller on a lagging battery (tools/safety94_rig.py), N=1 and
N=2: rest then a step, maintenance drain start, drain -> charge, a charge
reversing into a discharge. No stale trip and no refusal.
3. A frozen register and a refusing battery are still caught, and how fast.
4. The rules at their edges.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.CRITICAL)
import safety94_rig as rig # noqa: E402
import app.inverter as app_inverter # noqa: E402
import app.main as app_main # noqa: E402
from app import control # noqa: E402
from app.freshness import BOUNDS, Freshness # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class Before94(Freshness):
"""The gate before SAFETY-94: the clock ran from the last change."""
def obliged_s(self):
return self.graded_s(self.last_read)
def without_fix(fn):
"""Run `fn` with the gate's old clock and the old refusal evidence."""
real_f, real_c = app_inverter.Freshness, app_main.compute
real_p = app_main.REFUSED_PROGRESS_W
app_inverter.Freshness = Before94
app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None))
app_main.REFUSED_PROGRESS_W = float("inf")
try:
return fn()
finally:
app_inverter.Freshness, app_main.compute = real_f, real_c
app_main.REFUSED_PROGRESS_W = real_p
# ============================================================================ #
print("1. the four live trips of 2026-10-03, replayed")
# HA history (only changes are recorded), local time: the unit's setpoint
# (what stands on its wire) and its battery power register (0x0518).
LIVE = {
"17:39:16 inverter-b": (
[("17:38:00", -24), ("17:38:14.7", 0), ("17:38:25.4", 5),
("17:38:40.5", 16), ("17:38:44.7", 26), ("17:38:55.7", 37),
("17:39:05.1", 42), ("17:39:09.8", 58), ("17:39:15.6", 268),
("17:39:25.0", 479), ("17:39:30.7", 689), ("17:39:34.8", 900)],
[("17:38:00", -118), ("17:38:21.6", -99), ("17:39:21.6", 179),
("17:39:31.6", 399)]),
"17:39:25 inverter-a": (
[("17:38:00", -16), ("17:38:14.9", 0), ("17:38:25.0", 5),
("17:38:40.0", 14), ("17:38:44.7", 24), ("17:38:55.2", 33),
("17:39:04.7", 38), ("17:39:09.3", 52), ("17:39:15.0", 242),
("17:39:24.6", 431), ("17:39:30.3", 621), ("17:39:34.8", 810)],
[("17:38:00", -3), ("17:38:25.9", 0), ("17:39:25.9", 318),
("17:39:36.0", 654)]),
"19:00:04 inverter-a": (
[("18:58:30.0", 290), ("18:58:43.3", 305), ("18:58:48.0", 314),
("18:58:52.5", 300), ("19:00:03.1", 2500)],
[("18:58:30.0", 292), ("19:00:06.0", 951), ("19:00:16.0", 2360),
("19:00:26.0", 2376)]),
"21:21:22 inverter-b": (
[("21:19:30.0", 0), ("21:21:21.9", 2100), ("21:21:26.6", 1700),
("21:21:31.1", 1300), ("21:21:36.9", 900), ("21:21:41.0", 500),
("21:21:47.3", 200)],
[("21:19:30.0", -98), ("21:21:22.8", -97), ("21:21:32.8", 1411),
("21:21:42.8", 798)]),
}
def _sec(hms):
h, m, s = hms.split(":")
return int(h) * 3600 + int(m) * 60 + float(s)
def replay(cls, setp, reg):
"""1 Hz reads, as `run_control()` does. Returns the trip times."""
f, trips = cls(BOUNDS["batt"]), []
t, end = _sec(setp[0][0]), _sec(reg[-1][0])
def at(series, t):
return [float(v) for ts, v in series if _sec(ts) <= t][-1]
while t <= end:
was = f.stale
f.submit(at(reg, t), at(setp, t), t)
if f.stale and not was:
trips.append(t)
t += 1.0
return trips
for name, (setp, reg) in LIVE.items():
old, new = replay(Before94, setp, reg), replay(Freshness, setp, reg)
print(f" {name}: before {[f'{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:04.1f}' for t in old]}"
f", now {len(new)} trips")
check(f"{name}: the shipped trip reproduces 1-2 s after the command, and "
f"now there is none (the register answered within 11 s)",
len(old) == 1 and abs(old[0] - _sec(name.split()[0])) < 1.0
and new == [])
# ============================================================================ #
print("\n2. the REAL Controller on a lagging battery (10 s lag)")
# Two register shapes: published every 10 s and repeating its value (the
# boards, live), and every 1 s with +-1 W of dither (the SAFETY-85 rig).
SHAPES = ((10.0, False), (1.0, True))
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
def sweep(fn=lambda f: f()):
out = {}
for n in (1, 2):
for name in NAMES:
for tick in (1.0, 2.0, 5.0):
for pub, dith in SHAPES:
s, r, _ = fn(lambda: rig.counts(n, name, tick=tick,
publish_s=pub,
dither=dith))
out[(n, name, tick, pub)] = (s, r)
return out
NOW = sweep()
OLD = sweep(without_fix)
for n in (1, 2):
for name in NAMES:
now = [NOW[k] for k in NOW if k[:2] == (n, name)]
old = [OLD[k] for k in OLD if k[:2] == (n, name)]
print(f" N={n} {name:12s} stale/refused now "
f"{sum(a for a, _ in now)}/{sum(b for _, b in now)}, before "
f"{sum(a for a, _ in old)}/{sum(b for _, b in old)}")
check("⭐ no stale trip and no refusal, at N=1 and N=2, at 1, 2 and 5 s meters, "
"on both register shapes: a step after a rest, a drain start, drain -> "
"charge and a charge reversing into a discharge",
all(v == (0, 0) for v in NOW.values()) and len(NOW) == 48)
check("non-vacuous: before, the same runs trip the gate (drain start and "
"drain -> charge at both N, as live) and refuse at N=1 on a 1 s meter",
sum(OLD[(n, nm, t, 10.0)][0] for n in (1, 2)
for nm in ("drain_start", "drain_charge") for t in (1.0, 2.0, 5.0))
>= 12 and OLD[(1, "drain_charge", 1.0, 1.0)][1] >= 1)
def reversals(n, tick, taus=(10.0, 20.0)):
"""A charge reversing into a discharge: (stale trips, refusals) summed."""
s = r = 0
for tau in taus:
for charge in (-1500.0, -2500.0):
for house in (300.0, 800.0, 2000.0):
for pub, dith in SHAPES:
x = rig.run(n, rig.reversal(300.0, charge, house), 600.0,
tau=tau, tick=tick, publish_s=pub,
dither=dith)
s, r = s + len(x["stale"]), r + len(x["refused"])
return s, r
REV = {(n, t): reversals(n, t) for n in (1, 2) for t in (1.0, 2.0)}
REV_OLD = {(n, 1.0): without_fix(lambda n=n: reversals(n, 1.0)) for n in (1, 2)}
print(f" reversals (lag 10 and 20 s): now {REV}, before {REV_OLD}")
check("⭐ SAFETY-94's case: a battery reversing out of a charge (1500 or 2500 "
"W) into a 300-2000 W house, lag 10 or 20 s, 1 or 2 s meter, is never "
"refused or flagged, at N=1 or N=2",
all(v == (0, 0) for v in REV.values()))
check("non-vacuous: before, the 1 s meter refuses at N=1 (lag 20 s) and at "
"N=2 (B, lag 20 s, its reading 100 W below its power)",
REV_OLD[(1, 1.0)][1] >= 1 and REV_OLD[(2, 1.0)][1] >= 1)
# ============================================================================ #
print("\n3. a frozen register and a refusing battery are still caught")
def obliged_at(rows, t_f):
"""First cycle from `t_f` where A's command is 250 W or more and has moved
250 W from where it stood at `t_f`."""
c0 = None
for row in rows:
if row[0] >= t_f and c0 is None:
c0 = row[2]["a"]
if c0 is not None and abs(row[2]["a"]) >= 250 \
and abs(row[2]["a"] - c0) >= 250:
return row[0]
return None
CATCH = {}
for n in (1, 2):
for tick in (1.0, 5.0):
# frozen while giving 1000 W, then the house steps
r1 = rig.run(n, lambda t: 3000.0 if t >= 400 else 1000.0, 520.0,
tick=tick, publish_s=1.0, dither=True,
freeze_at=lambda u: 300.0 if u == "a" else None)
ob = obliged_at(r1["rows"], 300.0)
st = [t for t, u in r1["stale"] if u == "inverter-a"]
# frozen at rest (HW-01's shape), the house steps 500 s later
r2 = rig.run(n, lambda t: 2500.0 if t >= 600 else 0.0, 700.0,
tick=tick, publish_s=1.0, dither=True,
freeze_at=lambda u: 100.0 if u == "a" else None)
ob2 = obliged_at(r2["rows"], 100.0)
park = min([t for t, u in r2["stale"] if u == "inverter-a"]
+ [t for t, _u in r2["refused"]] or [None])
# a refusing battery from 300 s
r3 = rig.run(n, lambda t: 3000.0, 420.0, tick=tick, publish_s=10.0,
cut=lambda u, t: u == "a" and t >= 300.0)
ref = [t for t, _u in r3["refused"]]
CATCH[(n, tick)] = (st[0] - ob if st and ob is not None else None,
park - ob2 if park is not None and ob2 is not None
else None, ref[0] - 300.0 if ref else None)
print(" (N, meter): (frozen giving: trip after the command moved, frozen "
"at rest: 0 W after, refusing: refused after the cut), in s")
print(f" {CATCH}")
check("⭐ a register frozen while giving is caught 35 s after the command asks "
"it to move (the SAFETY-37 bound, now counted from the ask)",
all(v[0] == BOUNDS["batt"].unchanged_s for v in CATCH.values()))
check("⭐ a register frozen at rest reads like a refusing battery: the "
"battery is at 0 W, refused or stale, within 36 s of the ask",
all(v[1] is not None and v[1] <= 36.0 for v in CATCH.values()))
check("⭐ a refusing battery is still refused: within 22 s plus one 10 s "
"publish at a 1 s meter, and within 12 cycles plus a publish at 5 s",
all(CATCH[(n, 1.0)][2] is not None and CATCH[(n, 1.0)][2] <= 32.0
for n in (1, 2))
and all(CATCH[(n, 5.0)][2] is not None
and CATCH[(n, 5.0)][2] <= 12 * 5.0 + 10.0 for n in (1, 2)))
# ============================================================================ #
print("\n4. the rules at their edges")
# The gate counts time the register was asked to move, across a flapping
# command too.
f = Freshness(BOUNDS["batt"])
trip = None
for t in range(0, 200):
f.submit(-3.0, 1500.0 if (t // 5) % 2 else 0.0, float(t))
if f.stale and trip is None:
trip = t
print(f" command flapping 0 / 1500 W every 5 s, register frozen: trip at "
f"{trip} s")
check("a frozen register under a command that flaps in and out of the ask is "
"still caught (after 35 s of asking, about 70 s here)",
trip is not None and 65 <= trip <= 75)
f = Freshness(BOUNDS["batt"])
for t in range(0, 30):
f.submit(-3.0, 1500.0, float(t))
f.submit(400.0, 1500.0, 30.0) # it answers
for t in range(31, 60):
f.submit(400.0, 1500.0, float(t))
check("a register that answers starts a fresh count: 30 s asked, then it "
"moves, then 29 s more is not stale",
not f.stale and f.obliged_s() < 35.0)
f = Freshness(BOUNDS["batt"])
for t in range(0, 600):
f.submit(-3.0, 200.0 if t % 60 < 3 else 0.0, float(t))
check("a command that never asks 250 W is never an ask (SAFETY-52's 200 W "
"probe cannot arm the gate)",
not f.stale and f.obliged_s() == 0.0)
# Review: time under 250 W is not counted, even after the command travelled.
f = Freshness(BOUNDS["batt"])
for t in range(0, 61):
f.submit(-300.0, -300.0 if t == 0 else 200.0, float(t))
for t in range(61, 131):
f.submit(-300.0, 260.0, float(t))
if t == 90:
early = f.stale
check("a command that has travelled 500 W but stands at 200 W for a minute "
"adds nothing: 30 s after it reaches 260 W the register is not stale, "
"and by 70 s it is",
not early and f.stale)
# compute(): `gave_nothing` gates the N=1 engage; None keeps the old law.
T = control.Tuning(max_w=4000.0, slew_w=400.0)
args = dict(prev_w=1200.0, grid_w=1200.0, actual_w=-20.0, tuning=T,
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0)
check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with "
"gave_nothing None (compute alone, as before) or True, and not with "
"False",
control.compute(**args).refused == 1
and control.compute(**args, gave_nothing=True).refused == 1
and control.compute(**args, gave_nothing=False).refused == 0)
# The evidence restarts on progress towards the ask, not on jitter.
print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span "
f"{app_main.REFUSED_SPAN_S:.0f} s")
class _Inv:
def __init__(self):
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
self.ref_from = None
self.idle_w = 0.0 # SAFETY-97: A, ~0 W at idle
self.ask = 800.0
def standing_w(self):
return self.ask
class _C:
_now_mono = 0.0
def evidence(readings, tick=1.0, ask=lambda i: 800.0):
c, inv = _C(), _Inv()
got = []
for i, b in enumerate(readings):
c._now_mono, inv.batt, inv.ask = i * tick, b, ask(i)
got.append(app_main.Controller._gave_nothing(c, inv))
return got
flat = evidence([-4.0 + (i % 3) for i in range(30)])
climb = evidence([-95.0 + 4.0 * i for i in range(48)])
check("a flat reading (+-1 W) under an 800 W ask is evidence after 22 s; one "
"climbing 4 W/s through the band is never",
1 in flat and flat.index(1) == 22 and not any(climb))
flap = evidence([-4.0] * 60, ask=lambda i: 800.0 if i % 2 else -800.0)
away = evidence([40.0] * 5 + [-40.0] * 25)
check("a command that swaps direction every cycle is never evidence, and a "
"reading moving 80 W away from the ask does not restart it (latched at "
"22 s, counted from the first reading)",
not any(flap) and 1 in away and away.index(1) == 22)
check("REFUSED_PROGRESS_W is under the band's width and over the live "
"readings' rest wander (25 W)",
25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
-578
View File
@@ -1,578 +0,0 @@
"""Runnable check for SAFETY-95: a maintenance drain that ends hands back
smoothly at N>=2. `python3 test_safety95.py`
At N=2, when battery A's drain ended (A switched Off, say) with the house at
1200 W, the drain's floor stayed in the loop's site target and landed on B:
B went 0 -> +1600 W in one cycle and exported. Now the loop starts from what
the others stood at plus A's new command, raised to the house's own point
when the house needs more (never above last cycle's start), and counts every
battery at its command while A settles. Drives the REAL Controller on a
lagging battery (10 s, 2 s meter, as on the rig), at N=2 and N=3.
DECISIONS/SAFETY-95-drain-end.md.
1. Every drain end (complete -> charge, Off, timeout, operator abort, stale
link, refusal) at houses of 1200 W and 4000 W and under a solar surplus.
2. The peak guard still binds across a drain end.
3. A drain end in the same cycle as another battery's drop; a missed poll.
4. N=1 is untouched; the rule at its boundaries.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import asyncio
import functools
import hashlib
import logging
import math
import os
import sys
import tempfile
from datetime import timedelta
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
from app import main as app_main, packmode # noqa: E402
from app.arbiter import Claim, P_MAINTENANCE, P_SAFETY # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import (CHARGE, DRAIN, DRAIN_TIMEOUT_MIN, # noqa: E402
HOLD, IDLE, MaintResult)
from app.store import unit_key # noqa: E402
fails = []
SLEW = 400.0 # the rig's slew_w
TOL = SLEW / 4 # the loop's own overshoot on a lagging battery
AIM = -10.0 # the rig's target_grid_w
T = 300.0 # the drain ends here
M = {"maintenance_enabled": True, "maintenance_start_hour": 11}
A = "inverter-a"
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# None on a tree without the fix, where this file prints the base numbers
# and runs red.
fixed = getattr(Controller, "_reseed_drain_end", None)
real_grid = getattr(Controller, "_drain_grid_w", None)
real_drop = Controller._reseed_drop
def _base_drop(self, live):
"""SAFETY-96's drop reseed as it was: a draining battery is not
skipped."""
keep, self._drain_ended = self._drain_ended, frozenset()
try:
real_drop(self, live)
finally:
self._drain_ended = keep
def without(fn):
"""Run `fn` with SAFETY-95 taken out (the base behaviour)."""
if fixed is None:
return fn()
Controller._reseed_drain_end = lambda self, r, live: setattr(
self, "_drain_ended", frozenset())
Controller._drain_grid_w = lambda self, live: float(self.grid)
Controller._reseed_drop = _base_drop
try:
return fn()
finally:
Controller._reseed_drain_end = fixed
Controller._drain_grid_w = real_grid
Controller._reseed_drop = real_drop
def options(n, extra):
o = rig._options(n, extra)
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 run(n, house, *, store, extra=None, soc=None, reject=None, dead=None,
before=None, dur=T + 120.0, lag=10.0, p1=False, back=None):
"""Rows of one run at N=`n` on plantsim's battery (800 W/s ramp, `lag`
s first-order lag) at a 2 s meter tick. Writes to the units in `reject`
(name -> from s) are refused, until `back` s; `dead` (u, from s[, to
s]): u gives nothing from then on (until `to`). Each row: (t, the
add-on's commands, site target, grid, phases, loop reason, peak floor).
opt11_identity's cycle, with a third battery."""
units = tuple("abc"[:n])
soc = soc or (lambda u, t: 50.0)
def refused(u, t):
return bool(reject) and u in reject and t >= reject[u] and (
back is None or t < back)
class Hass(rig._Hass):
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for k in range(int(round((self.t - tl) / 0.1))):
now = tl + (k + 1) * 0.1
c = self.cmd[u]
if dead is not None and u == dead[0] and dead[1] <= now < (
dead[2] if len(dead) > 2 else math.inf):
c = 0.0
d = c - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return float(round(a))
async def set_number(self, e, v):
if refused(e[-1], self.t):
return False
self.cmd[e[-1]] = float(v)
return True
opts = options(n, dict(M, **(s79.P1 if p1 else {}), **(extra or {})))
db = os.path.join(tempfile.mkdtemp(prefix="s95-"), "tariff.db")
async def go():
hass = Hass(units, soc, lambda u, t: False,
lambda t: house(t) if callable(house) else house)
c = Controller(opts, hass, store, 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.t = clock[0] = t
for inv in c.inverters:
u = inv.name[-1]
if refused(u, t) 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
if p1:
s79._feed(c, t)
await c.read_inputs(now_mono=t)
await c.cycle()
g = c.peak_guard
rows.append((t, tuple(i.target for i in c.inverters),
c.target,
hass.grid(), tuple(m.phase for m in c.maints),
c.loop_reason,
None if g is None or not g.engaged
else g.floor_w))
t += 2.0
finally:
app_main.datetime = real_dt
return rows, c
return asyncio.run(go())
def store(n, **a):
"""A due for its monthly cycle (it drains from the first cycle); the
others finished theirs, so none starts here."""
s = rig._Store()
if n == 1:
for k, v in a.items():
s.data[k] = v
return s
for u in "bc"[:n - 1]:
s.data[unit_key(f"inverter-{u}", "last_completed")] = \
rig.BASE.isoformat()
for k, v in a.items():
s.data[unit_key(A, k)] = v
return s
def timed_store(n):
"""A's drain passes DRAIN_TIMEOUT_MIN at T; its last start was at 0 s,
so the abort's cooldown keeps it from starting again."""
return store(n, phase=DRAIN, phase_started=(
rig.BASE + timedelta(seconds=T - DRAIN_TIMEOUT_MIN * 60 - 1)
).isoformat(), last_start_attempt=rig.BASE.isoformat())
def off(c, t):
if t >= T:
c.store.data[unit_key(A, packmode.USE_KEY)] = False
async def abort(c, t):
if t == T:
await c.handle_action("maint_abort")
ENDS = {
"complete": lambda n, h, **k: run(
n, h, store=store(n), soc=lambda u, t:
5.0 if (u == "a" and t >= T) else 50.0, **k),
"Off": lambda n, h, **k: run(n, h, store=store(n), before=off,
extra={"battery_modes_enabled": True}, **k),
"timeout": lambda n, h, **k: run(n, h, store=timed_store(n), **k),
"abort": lambda n, h, **k: run(n, h, store=store(n), before=abort, **k),
# A's link goes stale; its board's watchdog sends it to 0 W after 25 s.
"stale link": lambda n, h, **k: run(n, h, store=store(n),
reject={"a": T}, dead=("a", T + 25.0),
dur=T + 200.0, **k),
# A gives nothing from T; SAFETY-73 learns it ~55 s later.
"refusal": lambda n, h, **k: run(n, h, store=store(n), dead=("a", T),
dur=T + 300.0, **k),
}
HOUSES = (1200.0, 4000.0, -1500.0)
def end_at(rows):
"""The first cycle A is not draining, or its command left the drain;
None if its drain never ended."""
return next((r[0] for r in rows if r[0] >= T and (
r[4][0] != DRAIN or r[1][0] < 2000.0)), None)
def measure(rows, n, house):
"""From the end on: the site target's largest step after the end
cycle (the slew's subject); the commanded export in the 20 s after it (the site command over
what the house needs, or over what the others stood at); the largest
grid import; the export beyond what the site exported before the end;
the largest grid step; the others' largest command step."""
te = end_at(rows)
if te is None: # no end: every measure is NaN, so every check FAILs
return dict(end=math.nan, site_step=math.nan, cmd_export=math.nan,
imp=math.nan, exp=math.nan, gstep=math.nan,
ostep=math.nan, rows=rows)
pre = next((r for r in reversed(rows) if r[0] < te), rows[0])
seg = [r for r in rows if r[0] >= te - 2.0]
aft = [r for r in rows if te <= r[0] <= te + 120.0]
others0 = sum(pre[1][1:])
site = [r[2] for r in seg]
return dict(
end=te,
site_step=max(abs(b - a) for a, b in zip(site[1:], site[2:])),
cmd_export=max(sum(r[1]) - max(house - AIM, others0) for r in aft
if r[0] <= te + 20.0),
imp=max(r[3] for r in aft),
exp=min(min(pre[3], AIM) - r[3] for r in aft),
gstep=max(abs(b[3] - a[3]) for a, b in zip(seg, seg[1:])),
ostep=max(abs(b[1][i] - a[1][i]) for a, b in zip(seg, seg[1:])
for i in range(1, n)),
rows=rows)
TABLE = []
def case(kind, n, house):
now = measure(ENDS[kind](n, house)[0], n, house)
old = measure(without(lambda: ENDS[kind](n, house))[0], n, house)
TABLE.append((kind, n, house, now, old))
return now, old
# ============================================================================ #
print("1. every drain end, N=2 and N=3, houses 1200 W, 4000 W and -1500 W")
for kind in ENDS:
for n in (2, 3):
for house in HOUSES:
now, old = case(kind, n, house)
tag = f"{kind}, N={n}, house {house:.0f} W"
# Abort zeroes the site (operator); a stale link's start is
# SAFETY-96's drop. Those are graded against base.
own = kind not in ("abort", "stale link")
check(f"{'⭐' if own else ' '} {tag}: no battery gives into an "
f"export after the end (commanded export "
f"{now['cmd_export']:.0f} W, base {old['cmd_export']:.0f}) "
f"and the site target moves at most one slew step a cycle "
f"after it ({now['site_step']:.0f} W, base "
f"{old['site_step']:.0f})",
now["cmd_export"] <= (TOL if own else max(
TOL, old["cmd_export"] + TOL))
and now["site_step"] <= SLEW)
check(f" {tag}: the grid imports no more than base (+50 W) "
f"({now['imp']:.0f} W, base {old['imp']:.0f}) and exports "
f"no more than it did before the end, or base (+50 W) "
f"({now['exp']:.0f} W more, base {old['exp']:.0f})",
now["imp"] <= old["imp"] + 50.0
and now["exp"] <= max(0.0, old["exp"]) + 50.0)
_by = {(k, n, h): (a, b) for k, n, h, a, b in TABLE}
for kind in ("complete", "Off", "timeout", "refusal"):
_now, _old = _by[(kind, 2, 1200.0)]
check(f"non-vacuous ({kind}, N=2, 1200 W): without the fix B steps "
f"more than a slew step into an export",
_old["cmd_export"] >= 300.0 and _old["ostep"] > SLEW
and _now["ostep"] < _old["ostep"])
_now, _old = _by[(kind, 2, -1500.0)]
check(f"non-vacuous ({kind}, solar surplus): without the fix B gives "
f"into the surplus", _old["cmd_export"] >= 1000.0
and _now["cmd_export"] <= TOL)
_now, _old = _by[("complete", 2, 1200.0)]
check("⭐ the rig case (A's drain completes at 1200 W): B covers the house "
"at once (0 -> 1210 W, not 1600), and the grid stays within 50 W of "
"its aim while A settles (base: +559 W import, B down to 230 W)",
[r[1][1] for r in _now["rows"] if r[0] == _now["end"]] == [1210.0]
and _now["imp"] <= 50.0 and _old["imp"] > 400.0)
_now, _old = _by[("complete", 3, 4000.0)]
check("N=3 at 4000 W: the others take A's share at once, as before (the "
"site does not drop), so the grid does not import",
_now["imp"] <= 50.0 and abs(_now["imp"] - _old["imp"]) <= 50.0)
_now, _old = _by[("stale link", 3, 4000.0)]
check(f"⭐ stale link, N=3, 4000 W: a dropped draining battery is this "
f"rule's, not the link-drop reseed's, so the grid imports at least "
f"50 W less than base ({_now['imp']:.0f} W, base {_old['imp']:.0f})",
_now["imp"] <= _old["imp"] - 50.0)
# ============================================================================ #
print("\n2. the peak guard still binds across a drain end")
_g = ENDS["Off"](2, 4000.0, p1=True, dur=T + 120.0)[0]
_gs = [r for r in _g if r[0] >= T - 20 and r[6] is not None]
print(f" guard engaged on {len(_gs)} cycles around the end; site "
f"{[sum(r[1]) for r in _g if T - 4 <= r[0] <= T + 8]}")
check("⭐ house 4000 W over a 2500 W limit, A switched Off mid-drain: on "
"every cycle the guard is engaged, the site gives at least its floor "
"(or every battery is at its rating)",
_gs and all(sum(r[1]) >= min(r[6], 2500.0 * 1) - 1.0 for r in _gs))
# ============================================================================ #
print("\n3. a drain end with another edge in the same cycle; a missed poll")
def c_drops(c, t):
"""C's writes are refused from T - 30 s, so it drops at T, the cycle A
is switched Off; its board goes to 0 W at once (T - 30 s)."""
off(c, t)
_e3 = run(3, 4000.0, store=store(3), before=c_drops, reject={"c": T - 30.0},
dead=("c", T - 30.0), extra={"battery_modes_enabled": True})[0]
_e3m = measure(_e3, 3, 4000.0)
print(f" A Off as C drops, house 4000 W: B "
f"{[r[1][1] for r in _e3 if T - 4 <= r[0] <= T + 10]}")
check("⭐ A switched Off on the cycle C drops (N=3, 4000 W): B takes over "
"with no export and the site moves one slew step a cycle after",
_e3m["cmd_export"] <= TOL and _e3m["site_step"] <= SLEW
and _e3m["end"] == T)
def c_back(c, t):
"""C's link (stale since T - 60 s, its board holding 0 W) answers again
at T, the cycle A is switched Off."""
off(c, t)
if t == T:
c.inverters[2].note_write(True, rig.BASE + timedelta(seconds=t))
_r3 = run(3, 1200.0, store=store(3), before=c_back, reject={"c": T - 60.0},
back=T, dead=("c", T - 60.0, T), extra={"battery_modes_enabled": True})[0]
_r3m = measure(_r3, 3, 1200.0)
print(f" A Off as C comes back, house 1200 W: B, C "
f"{[r[1][1:] for r in _r3 if T - 4 <= r[0] <= T + 6]}")
check("⭐ A switched Off on the cycle C comes back (N=3, 1200 W): no "
"export, one slew step a cycle after",
_r3m["cmd_export"] <= TOL and _r3m["site_step"] <= SLEW
and _r3m["end"] == T)
def missed_poll(at_t):
"""Battery B's power reading is missing on the cycle at `at_t` s."""
def before(c, t):
off(c, t)
if not getattr(c.hass, "_s95", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.batt_b" and c.hass.t == at_t:
return None
return await read(e, invert)
c.hass.number, c.hass._s95 = number, True
return before
_mp = run(2, 1200.0, store=store(2), before=missed_poll(T),
extra={"battery_modes_enabled": True})[0]
_mpo = without(lambda: run(2, 1200.0, store=store(2), before=missed_poll(T),
extra={"battery_modes_enabled": True}))[0]
print(f" a missed poll on the end cycle: B "
f"{[r[1][1] for r in _mp if T - 2 <= r[0] <= T + 8]}, without the fix "
f"{[r[1][1] for r in _mpo if T - 2 <= r[0] <= T + 8]}")
check("⭐ a missed poll on the cycle A is switched Off: the end still "
"reaches the loop on the next cycle (B covers the house, no export)",
max(sum(r[1]) for r in _mp if r[0] > T) <= 1200.0 - AIM + TOL
and max(sum(r[1]) for r in _mpo if r[0] > T) >= 1500.0)
# ============================================================================ #
print("\n4. N=1 is untouched; the rule at its boundaries")
def digest(rows):
return hashlib.sha256(repr(rows).encode()).hexdigest()
_n1 = [functools.partial(ENDS[k], 1, h) for k in ENDS
for h in (1200.0, -1500.0)]
check("⭐ N=1, every drain end (complete, Off, timeout, abort, stale link, "
"refusal), at 1200 W and under a surplus: every row byte-identical "
"with and without SAFETY-95",
[digest(f()[0]) for f in _n1] == [digest(without(f)[0]) for f in _n1])
_wave = functools.partial(run, 2, lambda t: 2400.0 + 1500 * math.sin(t / 40),
store=store(2, last_completed=rig.BASE.isoformat()),
dur=600.0)
check("N=2 healthy (a moving house, no maintenance): every row "
"byte-identical with and without SAFETY-95",
digest(_wave()[0]) == digest(without(_wave)[0]))
if fixed is None:
print(f"\n{len(fails)} FAILED (no _reseed_drain_end): " + ", ".join(fails))
sys.exit(1)
_c = run(2, 1200.0, store=store(2), dur=4.0)[1]
_pin = MaintResult(IDLE, True, 0.0, False, [])
_free = MaintResult(CHARGE, False, None, True, [])
_b = MaintResult(IDLE, False, None, False, [])
AB = {"inverter-a", "inverter-b"}
def reseeded(target, shares, batts, grid, res=_pin, live=AB, ended=(A,)):
"""`_reseed_drain_end` on a 2-battery controller: (target, i_w,
A's start)."""
_c.target, _c.i_w, _c._drain_ended = target, 123.0, frozenset(ended)
_c._loop_start, _c._settle, _c._drain_tail = {}, {}, set()
_c._now_mono, _c.grid = 50.0, grid
for inv in _c.inverters:
inv.target, inv.batt = shares[inv.name[-1]], batts[inv.name[-1]]
_c._reseed_drain_end([res, _b], set(live))
return _c.target, round(_c.i_w, 6), _c._loop_start.get(A)
# House 1200 W: A drains 2000, B 0, grid -800.
check("the house needs more than the others stand at: the start is the "
"house's own point (1210 W), the integrator with it",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0) == (1210.0, 1210.0, 0.0)
and _c._settle == {A: 50.0} and _c._drain_tail == {A}
and _c._drain_ended == frozenset())
check("...and while A settles the loop's error is the house's point minus "
"its start (0 here: it holds), not the meter's -790 W",
_c._drain_grid_w(AB) - AIM == 0.0)
check("a solar surplus (house -1500 W): the start is what the others stood "
"at plus A's new command (0 W), and the loop moves down from there",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0)[0] == 0.0)
check("the house needs more than stood placed (4000 W, B at 2000): never "
"above last cycle's start",
reseeded(4000.0, {"a": 2000.0, "b": 2000.0},
{"a": 2000.0, "b": 2000.0}, 0.0)[0] == 4000.0)
check("a free battery (drain complete -> charge): its drain leaves the "
"start; a pin's value is its new command",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_free)[2] == 0.0
and reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=MaintResult(HOLD, True, 0.0, False, []))[2]
== 0.0)
check("a dropped battery's power is the meter's: a board stuck at 2000 W "
"keeps B where it stood, and there is no tail to count",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0, live={"inverter-b"})[0] == 0.0
and _c._drain_tail == set())
_chg = MaintResult(CHARGE, True, -1500.0, False, [])
check("a drain that ends into a pinned charge: the pin is A's new command "
"while A is live, but 0 W once it is dropped (it is sent 0 W)",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_chg)[2] == -1500.0
and reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-3500.0, res=_chg, live={"inverter-b"}) == (0.0, 0.0, 0.0))
check("no drain ended: nothing moves (the integrator either)",
reseeded(2000.0, {"a": 2000.0, "b": 0.0}, {"a": 2000.0, "b": 0.0},
-800.0, ended=()) == (2000.0, 123.0, None))
_c._drain_tail, _c._settle, _c.grid = {A}, {}, -800.0
check("the tail ends with A's settling (SAFETY-96's window)",
_c._drain_grid_w(AB) == -800.0 and _c._drain_tail == set())
_UA, _UB = (_c.actuator_of("maintenance", i) for i in _c.inverters)
_drain = MaintResult(DRAIN, True, 2000.0, False, [])
def drain_ends(extra, refused=0):
"""`_charge_track` with A draining last cycle, its drain SET and the
claims `extra` on A: which drains it ends."""
_c._draining_was, _c._drain_ended = frozenset({A}), frozenset()
_c._charging_was, _c._charge_ended = frozenset(), frozenset()
_c.live_units = lambda: (_UA, _UB)
_c.inverters[0].refused = refused
try:
_c._charge_track([Claim.set("maintenance", P_MAINTENANCE, 2000.0,
actuator=_UA)] + extra, [_drain, _b])
finally:
del _c.live_units
_c.inverters[0].refused = 0
return set(_c._drain_ended)
_rating = [Claim.limit("device", P_SAFETY, -2500.0, 2500.0, actuator=_UA)]
check("a drain that still stands does not end (its rating binds, no "
"contradiction, no refusal)", drain_ends(_rating) == set())
check("a drain whose limits contradict ends: A is sent 0 W (SAFETY-91's "
"pin as a floor against a closed pack)",
drain_ends(_rating + [
Claim.limit("maintenance", P_MAINTENANCE, lo=2000.0, actuator=_UA),
Claim.limit("pack", P_SAFETY, hi=0.0, actuator=_UA)]) == {A})
check("a refusal ends a drain only if A will not give (+1); one that will "
"not take (-1) leaves the drain standing",
drain_ends(_rating, refused=1) == {A}
and drain_ends(_rating, refused=-1) == set())
_n1c = run(1, 1200.0, store=store(1), dur=4.0)[1]
_n1c.target, _n1c.i_w, _n1c._drain_ended = 2000.0, 5.0, frozenset({A})
_n1c._reseed_drain_end([_pin], {A})
check("N=1: nothing moves", (_n1c.target, _n1c.i_w) == (2000.0, 5.0))
_seen = []
def _spy(self, maint_results, live):
_seen.append(sorted(self._drain_ended))
return fixed(self, maint_results, live)
Controller._reseed_drain_end = _spy
try:
run(2, 1200.0, store=store(2), soc=lambda u, t:
5.0 if (u == "a" and T <= t < T + 200) else 99.5 if u == "a" and
t >= T + 200 else 50.0, dur=T + 400.0,
extra={"maintenance_hold_min": 2})
finally:
Controller._reseed_drain_end = fixed
check("it fires once, at the drain end: not at the charge end, the HOLD "
"end, or on any other cycle", [x for x in _seen if x] == [[A]]
and len(_seen) > 300)
print("\n drain end | N | house W | site step W head/base | commanded "
"export W head/base | others' step W head/base | grid import W "
"head/base | grid step W head/base | export beyond the end's W head/base")
for k, n, h, a, b in TABLE:
print(f" {k} | {n} | {h:.0f} | {a['site_step']:.0f}/"
f"{b['site_step']:.0f} | {a['cmd_export']:.0f}/{b['cmd_export']:.0f}"
f" | {a['ostep']:.0f}/{b['ostep']:.0f} | {a['imp']:.0f}/"
f"{b['imp']:.0f} | {a['gstep']:.0f}/{b['gstep']:.0f} | "
f"{a['exp']:.0f}/{b['exp']:.0f}")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-95 checks passed")
-573
View File
@@ -1,573 +0,0 @@
"""Runnable check for SAFETY-96: a battery the loop cannot move does not
freeze the loop. `python3 test_safety96.py`
At N=2, after A's link went stale the site sum still counted A's power, so
`compute()` saw the site "not following" and froze B (SAFETY-93 rig test:
B held at 1200 W for ~236 cycles). After a charge timeout or Off at 6000 W
it froze ~14 s while A came out of its charge. Now a battery out of the
split, with refused writes, or settling after a step the loop did not make
counts at its command; its real power reaches the loop through the meter.
Drives the REAL Controller on a lagging battery (10 s, 2 s meter).
DECISIONS/SAFETY-96-dropped-battery.md.
1. A's link drops while it discharges: A holds its last command (stuck), or
its board goes to 0 W 25 s after the first refused write.
2. A's link drops while it charges (maintenance).
3. B's link drops; both drop.
4. A drops, then comes back.
5. A's charge times out, or A is switched Off, at 6000 W.
6. N=1 is untouched; 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
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 opt11_identity as rig # noqa: E402
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, IDLE, MaintResult # noqa: E402
from app.store import unit_key # noqa: E402
from named_absence import first, present # noqa: E402
fails = []
SLEW = 400.0
FROZEN = ("saturated-freeze", "freeze-released")
OPT = dict(s84.OPT, peak_cap_w=3500)
PLAIN = dict(s79.P1, peak_cap_w=3500)
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# None on a tree without the fix, where this file prints the base numbers
# and runs red.
fixed = getattr(Controller, "_held_out_w", None)
real_drop = getattr(Controller, "_reseed_drop", None)
def without(fn):
"""Run `fn` with SAFETY-96 taken out (the base behaviour)."""
if fixed is None:
return fn()
Controller._held_out_w = lambda self, live: 0.0
Controller._reseed_drop = lambda self, live: setattr(
self, "_live_was", frozenset(live))
try:
return fn()
finally:
Controller._held_out_w, Controller._reseed_drop = fixed, real_drop
def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None,
soc=None, reject=None, back=None, wd=None, before=None, lag=10.0,
blind=None, flaky=None, dead=None):
"""Rows of one run. Writes to the units in `reject` (name -> from s) are
refused from that time until `back` s; the add-on sees the refusal at
once (a heartbeat write). A refused unit holds its last command, or with
`wd` its board goes to 0 W `wd` s after the first refused write. B's
reading is missing in the `blind` (from, to) window. `flaky(u, t)`:
writes to u refused at t, each cycle's verdict seen (a heartbeat).
`dead` (u, from s): u gives nothing from then on, whatever it is told.
Each row: (t, plant commands, add-on commands, loop reason, site target,
grid, circulation tripped)."""
reject = reject or {}
real_p, real_s, real_tick = rig._Hass.power, rig._Hass.set_number, rig.TICK
def refused(u, t):
if flaky is not None:
return flaky(u, t)
return u in reject and t >= reject[u] and (back is None or t < back)
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for k in range(int(round((self.t - tl) / 0.1))):
now = tl + (k + 1) * 0.1
c = self.cmd[u]
if wd is not None and refused(u, now) and now - reject[u] >= wd:
c = 0.0
if dead is not None and u == dead[0] and now >= dead[1]:
c = 0.0
d = c - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return float(round(a))
async def set_number(self, e, v):
if refused(e[-1], self.t):
return False
self.cmd[e[-1]] = float(v)
return True
rows = []
def hook(c, t):
for inv in c.inverters:
if flaky is not None:
inv.note_write(not flaky(inv.name[-1], t),
rig.BASE + timedelta(seconds=t))
elif refused(inv.name[-1], t) and inv.rejected_since is None:
inv.rejected_since = rig.BASE + timedelta(seconds=t)
if before is not None:
before(c, t)
if blind is not None and not getattr(c.hass, "_s96", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.batt_b" and blind[0] <= c.hass.t < blind[1]:
return None
return await read(e, invert)
c.hass.number, c.hass._s96 = number, True
(s84.feed(True) if opt is not None else s79._feed)(c, t)
def on_cycle(c, t):
rows.append((t, tuple(sorted(c.hass.cmd.items())),
tuple(inv.target for inv in c.inverters), c.loop_reason,
c.target, c.hass.grid(), c.circ_wd.tripped,
tuple(inv.batt for inv in c.inverters)))
rig._Hass.power, rig._Hass.set_number, rig.TICK = power, set_number, 2.0
try:
_r, ctl = rig.run(n, extra=dict(opt if opt is not None else PLAIN),
dur=dur, load=house, soc=soc or (lambda u, t: 60.0),
cut=lambda u, t: False, before=hook,
on_cycle=on_cycle, store=store or rig._Store())
finally:
rig._Hass.power, rig._Hass.set_number, rig.TICK = real_p, real_s, \
real_tick
return rows, ctl
def freeze(rows, lo, hi=1e9):
return sum(1 for r in rows if lo <= r[0] < hi and r[3] in FROZEN)
def phantom(rows, k, lo, hi=1e9):
"""Freeze cycles while battery `k` (the one taking over) reads within
saturation_w (500 W) of what the loop last asked of it plus anything
unplaced (last site target less the other's command): the freeze is
then about the other battery, not about `k`'s own lag."""
return sum(1 for a, b in zip(rows, rows[1:]) if lo <= b[0] < hi
and b[3] in FROZEN
and abs(a[4] - a[2][1 - k] - b[7][k]) <= 500.0)
def ramp(rows, k, lo, hi=1e9):
"""The largest change of unit `k`'s command (0 = A) between cycles."""
return max(abs(b[2][k] - a[2][k]) for a, b in zip(rows, rows[1:])
if lo < b[0] <= hi)
def site_step(rows, lo, hi=1e9):
return max(abs(b[4] - a[4]) for a, b in zip(rows, rows[1:])
if lo < b[0] <= hi)
def grid_err(rows, lo, hi=1e9):
"""Mean |grid - aim| over [lo, hi) (aim -10 W)."""
xs = [abs(r[5] + 10.0) for r in rows if lo <= r[0] < hi]
return sum(xs) / len(xs)
def trip_at(rows):
"""When the circulation guard tripped (its own 0 W step is no ramp)."""
return next((r[0] for r in rows if r[6]), 1e9)
def tripped(rows):
return rows[-1][6]
def at(rows, t, k):
return first((r[2][k] for r in rows if r[0] == t), f"row {t}")
TABLE = []
LAST = [None] # the controller of the last run with the fix
def case(name, fn, lo, k, hi=1e9):
"""Run `fn` with and without SAFETY-96, print and record the numbers:
freeze cycles (and those not about `k`'s own lag), `k`'s largest step,
the mean grid error, a circulation trip."""
(now, LAST[0]), old = fn(), without(fn)[0]
row = (name, freeze(now, lo, hi), freeze(old, lo, hi),
phantom(now, k, lo, hi), phantom(old, k, lo, hi),
ramp(now, k, lo, min(hi, trip_at(now) - 1)),
ramp(old, k, lo, min(hi, trip_at(old) - 1)),
grid_err(now, lo, hi), grid_err(old, lo, hi),
tripped(now), tripped(old))
TABLE.append(row)
print(f" {name}: freeze {row[1]} (base {row[2]}), not about its own lag "
f"{row[3]} (base {row[4]}); step {row[5]:.0f} (base {row[6]:.0f}) "
f"W/cycle; grid error {row[7]:.0f} (base {row[8]:.0f}) W; "
f"circulation trip {row[9]} (base {row[10]})")
return now, old
STUCK, BOARD = ("stuck", None), ("board 0 W", 25.0)
# ============================================================================ #
print("1. A's link drops while it discharges (house 2400 W, each 1200 W)")
for v, w in (STUCK, BOARD):
now, old = case(f"A drop discharging, {v}",
lambda w=w: run(reject={"a": 300.0}, wd=w), 300.0, 1)
check(f"⭐ {v}: no freeze about A, B moves at most one slew step a "
f"cycle, no circulation trip", phantom(now, 1, 300) == 0
and ramp(now, 1, 300) <= SLEW and not tripped(now))
check(f"non-vacuous ({v}): without the fix the loop freezes about A, or "
f"B jumps more than a slew step at the drop",
phantom(old, 1, 300) >= 10 or ramp(old, 1, 300) > SLEW)
if w is None:
check("stuck: B stays at its share, since A still gives its 1200 W "
"(no freeze at all, grid within 15 W on average)",
freeze(now, 300) == 0 and grid_err(now, 300) < 15.0
and freeze(old, 300) >= 100)
else:
check("board 0 W: B ends up carrying the whole house (2410 W)",
abs(now[-1][2][1] - 2410.0) <= 20.0)
# ============================================================================ #
print("\n2. A's link drops while it charges (maintenance, house 1200 W)")
def charging(**kw):
return run(house=lambda t: 1200.0, dur=1500.0,
store=s79.store_charging(2), opt=dict(
OPT, maintenance_hold_min=600),
soc=lambda u, t: 90.0 if u == "a" else 60.0, **kw)
for v, w in (STUCK, BOARD):
now, old = case(f"A drop charging, {v}",
lambda w=w: charging(reject={"a": 1300.0}, wd=w),
1300.0, 1)
trip = trip_at(now)
check(f"⭐ {v}: no freeze about A, and B climbs at most one slew step a "
f"cycle and never charges (until any circulation trip)",
phantom(now, 1, 1300, trip) == 0
and ramp(now, 1, 1300, trip - 1) <= SLEW
and min(r[2][1] for r in now if r[0] >= 1300) >= 0.0)
check(f"non-vacuous ({v}): without the fix the loop freezes about A",
phantom(old, 1, 1300) >= 3)
if w is None:
check("stuck (A charges 2000 W on its own): B reaches its 2500 W "
"before the circulation trip, which still trips on the real "
"4000 W going round (as on base)",
max(r[2][1] for r in now if r[0] < trip) == 2500.0
and tripped(now) and tripped(old))
else:
check("board 0 W: B carries the house (grid within 50 W from 100 s "
"after the drop), no circulation trip",
grid_err(now, 1400) < 50.0 and not tripped(now))
# ============================================================================ #
print("\n3. B's link drops; both drop")
for v, w in (STUCK, BOARD):
now, old = case(f"B drop discharging, {v}",
lambda w=w: run(reject={"b": 300.0}, wd=w), 300.0, 0)
check(f"⭐ B drops ({v}): no freeze about B, A moves at most one slew "
f"step a cycle, no circulation trip", phantom(now, 0, 300) == 0
and ramp(now, 0, 300) <= SLEW and not tripped(now))
for v, w in (STUCK, BOARD):
now, old = case(f"both drop, {v}",
lambda w=w: run(reject={"a": 300.0, "b": 300.0}, wd=w),
300.0, 1)
check(f"⭐ both drop ({v}): no freeze; both are commanded 0 W once "
f"nothing can be placed", freeze(now, 300) == 0
and now[-1][2] == (0.0, 0.0) and not tripped(now))
# ============================================================================ #
print("\n4. A drops, then comes back")
def rejoined(rows):
return next((r[0] for r in rows if r[0] > 420 and r[2][0] != 0.0), 1e9)
for v, w in (STUCK, BOARD):
now, old = case(f"A drop and rejoin, {v}",
lambda w=w: run(reject={"a": 300.0}, back=420.0, wd=w,
dur=700.0), 300.0, 1)
back = rejoined(now)
check(f"rejoin ({v}): A is back in the split within 4 s of its link "
f"returning (no refusal was learned while its writes were refused)",
back <= 424.0 and not any("REFUSES" in e for e in LAST[0].events))
print(f" A back in the split at {back:.0f} s; A "
f"{[r[2][0] for r in now if back - 2 <= r[0] <= back + 6]}, B "
f"{[r[2][1] for r in now if back - 2 <= r[0] <= back + 6]}; grid "
f"{[round(r[5]) for r in now if back - 2 <= r[0] <= back + 6]}; "
f"base grid {[round(r[5]) for r in old if back - 2 <= r[0] <= back + 6]}")
a0 = first((r for r in now if r[0] == back - 2), "row before the rejoin")
a1 = first((r for r in now if r[0] == back), "the rejoin row")
check(f"⭐ rejoin ({v}): no freeze after it; the site target takes in only "
f"A's own reading, then moves at most one slew step a cycle; the "
f"grid stays within 250 W of its aim; no circulation trip",
present(a0) and present(a1) and freeze(now, back) == 0
and abs(a1[4] - a0[4] - a0[7][0]) <= SLEW
and site_step(now, back) <= SLEW
and max((abs(r[5] + 10.0) for r in now if r[0] >= back),
default=1e9) < 250.0
and not tripped(now))
if w is None:
check("non-vacuous (stuck): without the fix the grid swings over "
"1000 W after the rejoin", max((abs(r[5] + 10.0) for r in old
if r[0] >= back), default=0.0)
>= 1000.0)
# Review: a battery that does refuse, behind a link that refuses one write
# in three, is still found out (the count is kept over a refused write).
_fl_rows, _fl = run(dur=700.0, dead=("a", 300.0),
flaky=lambda u, t: u == "a" and t >= 300.0
and int(t / 2) % 3 == 0)
_fl_at = next((r[0] for r in _fl_rows if r[2][0] == 0.0 and r[0] > 300.0),
None)
_fl_end = first((r[2] for r in _fl_rows[-1:]), "the last row")
print(f" A gives nothing from 300 s, 1 write in 3 refused: A's share is "
f"taken off at {_fl_at} s; A {_fl.inverters[0].refused}, last "
f"commands {_fl_end}")
check("⭐ a battery that refuses behind a link refusing 1 write in 3: its "
"refusal is still learned and B carries the house",
_fl.inverters[0].refused == 1 and _fl_at is not None
and _fl_at <= 400.0 and present(_fl_end)
and abs(_fl_end[1] - 2410.0) <= 20.0)
# Review: a fast battery whose board goes to 0 W 1 s after the first refused
# write reads ~0 W long before its link is stale (30 s): no refusal either.
_ref5 = []
_e5_rows, _e5 = run(reject={"a": 300.0}, back=420.0, wd=1.0, lag=1.0,
dur=700.0,
before=lambda c, t: _ref5.append(c.inverters[0].refused))
check("fast battery, board 0 W 1 s after the first refused write: no "
"refusal is learned, A is back in the split within 4 s of its link "
"returning",
not any(_ref5) and rejoined(_e5_rows) <= 424.0)
_seen = []
def blind_seen(c, t):
"""Record the last cycle: whether the loop ran, is A live, what settles."""
if t > 340.0:
_seen.append((t - 2.0, c.loop_reason, "inverter-a" in c.live_units(),
dict(c._settle)))
run(reject={"a": 300.0}, back=420.0, blind=(340.0, 470.0), dur=490.0,
before=blind_seen)
_back = first((x for x in _seen if x[1] != "not-running" and x[0] > 400),
"the restart")
print(f" B's reading missing 340-470 s, A back at 420 s: control holds from "
f"{first((x[0] for x in _seen if x[1] == 'not-running'), '?')} s, "
f"restarts at {_back[0]:.0f} s, settling then: {_back[3]}")
check("A drops, then comes back while control holds (a reading missing): no "
"rejoin at the restart, which starts from what is on the wire",
any(x[1] == "not-running" and x[2] for x in _seen) and _back[3] == {})
# ============================================================================ #
print("\n5. A's charge times out, or A is switched Off, at a 6000 W house")
def timed_out(end):
"""A's charge passes CHARGE_TIMEOUT_MIN at `end` s; A then stays free
(its cycle started today, so no restart) and B is not due."""
s = s79.store_charging(2)
s.data[unit_key("inverter-a", "phase_started")] = (
rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1)
).isoformat()
s.data[unit_key("inverter-a", "last_start_attempt")] = rig.BASE.isoformat()
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
def b_done():
s = s79.store_charging(2)
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
def switch_off(at_t):
def before(c, t):
if t >= at_t:
c.store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
return before
def high(t):
return 6000.0 if t >= 1760.0 else 1200.0
ENDS = {
"timeout": lambda: run(house=high, dur=1798.0, store=timed_out(1780.0),
opt=dict(OPT, maintenance_hold_min=600),
soc=lambda u, t: 90.0 if u == "a" else 60.0),
"Off": lambda: run(house=high, dur=1798.0, store=b_done(),
before=switch_off(1780.0),
opt=dict(OPT, maintenance_hold_min=600,
battery_modes_enabled=True),
soc=lambda u, t: 90.0 if u == "a" else 60.0),
}
for k, fn in ENDS.items():
now, old = case(f"{k} at 6000 W", fn, 1780.0, 1)
print(f" B {[r[2][1] for r in now if 1778 <= r[0] <= 1796]}, base "
f"{[r[2][1] for r in old if 1778 <= r[0] <= 1796]}")
check(f"⭐ {k}: no freeze about A while it comes out of its charge; B "
f"climbs one slew step a cycle and never charges",
phantom(now, 1, 1780) == 0 and ramp(now, 1, 1780) <= SLEW
and min(r[2][1] for r in now if r[0] >= 1780) >= 0.0)
check(f"non-vacuous ({k}): without the fix the loop freezes about A and "
f"B ends lower", phantom(old, 1, 1780) >= 3
and at(now, 1796.0, 1) > at(old, 1796.0, 1))
# ============================================================================ #
print("\n6. N=1 is untouched; the rule's parts")
def digest(rows):
return hashlib.sha256(repr(rows).encode()).hexdigest()
def wave(t):
return 2400.0 + 1500 * math.sin(t / 40)
_n1 = [lambda: run(1, reject={"a": 300.0}, back=420.0, wd=25.0),
lambda: run(1, house=high, dur=1798.0, store=timed_out(1780.0),
opt=dict(OPT, maintenance_hold_min=600)),
lambda: run(1, house=wave)]
check("⭐ N=1 (a refused link that comes back, a charge timeout, a moving "
"house): every row byte-identical with and without SAFETY-96",
[digest(f()[0]) for f in _n1] == [digest(without(f)[0]) for f in _n1])
check("N=2 healthy (a moving house, no drop): every row byte-identical with "
"and without SAFETY-96",
digest(run(house=wave)[0]) == digest(without(lambda: run(
house=wave))[0]))
if fixed is None:
print(f"\n{len(fails)} FAILED (no _held_out_w): " + ", ".join(fails))
sys.exit(1)
_c = run(dur=4.0)[1]
AB = ("inverter-a", "inverter-b")
def held(batts, targets, live=AB, rejected=(), settle=None, now=100.0,
start=None):
for inv, b, t in zip(_c.inverters, batts, targets):
inv.batt, inv.target = b, t
inv.rejected_since = rig.BASE if inv.name in rejected else None
_c._settle = dict(settle or {})
_c._loop_start = dict(start or {})
_c._now_mono = now
return _c._held_out_w(set(live))
check("a healthy pair: nothing held out, however far a reading lags",
held((-2000.0, 400.0), (0.0, 400.0)) == 0.0)
check("a dropped battery counts at its command (0 W), not its reading",
held((-2000.0, 800.0), (0.0, 800.0), live=("inverter-b",)) == 2000.0)
check("a battery whose writes are refused counts at its command",
held((900.0, 800.0), (1200.0, 800.0), rejected=("inverter-a",))
== 300.0)
check("a settling battery counts at its command until its reading is within "
"the deadband (15 W) of it, then it is the loop's again",
held((-20.0, 800.0), (0.0, 800.0), settle={"inverter-a": 95.0})
== 20.0 and "inverter-a" in _c._settle
and held((-10.0, 800.0), (0.0, 800.0), settle={"inverter-a": 95.0})
== 0.0 and "inverter-a" not in _c._settle)
check("...but not on the cycle it starts",
held((-10.0, 800.0), (0.0, 800.0), settle={"inverter-a": 100.0})
== 10.0 and "inverter-a" in _c._settle)
check("...or until PLANT_SETTLE_DOWN_S (23 s) has passed, whichever is first",
held((-2000.0, 800.0), (0.0, 800.0), settle={"inverter-a": 77.1})
== 2000.0 and held((-2000.0, 800.0), (0.0, 800.0),
settle={"inverter-a": 77.0}) == 0.0)
check("a reseeded start (SAFETY-93's charge end) is the command it counts at",
held((-2000.0, 800.0), (-2000.0, 800.0), settle={"inverter-a": 99.0},
start={"inverter-a": 0.0}) == 2000.0)
def dropped(was, live, target, shares, batts=(0.0, 0.0), start=None):
_c._live_was, _c.target, _c.i_w = (None if not was else frozenset(was),
target, 123.0)
_c._loop_start, _c._settle, _c._now_mono = dict(start or {}), {}, 50.0
for inv, b in zip(_c.inverters, batts):
inv.target, inv.batt = shares[inv.name[-1]], b
_c._reseed_drop(set(live))
return _c.target, _c.i_w
check("a drop: the dropped battery's share leaves the start point, the "
"integrator with it", dropped(AB, ("inverter-b",), 2400.0,
{"a": 1200.0, "b": 1200.0})
== (1200.0, 1200.0) and _c._live_was == frozenset({"inverter-b"}))
_c._charge_ended = frozenset({"inverter-a"})
check("...but a battery whose charge ended too is left to SAFETY-93's reseed",
dropped(AB, ("inverter-b",), -2000.0, {"a": -2000.0, "b": 0.0})
== (-2000.0, -2000.0))
_c._charge_ended = frozenset()
_free = MaintResult(IDLE, False, None, False, [])
def charge_end(rejected_s):
"""SAFETY-93's reseed for A (giving 1385 W under the loop, its charge
suspended), A's writes refused for `rejected_s` s: (target, start)."""
_c.target, _c.i_w, _c._charge_ended = 2770.0, 0.0, frozenset({AB[0]})
_c._loop_start, _c._settle = {}, {}
for inv in _c.inverters:
inv.target, inv.rejected_since = 1385.0, None
_c.inverters[0].rejected_since = datetime.now(timezone.utc) - timedelta(
seconds=rejected_s)
_c._reseed_charge_end([_free, _free])
_c.inverters[0].rejected_since = None
return _c.target, _c._loop_start[AB[0]]
check("a charge that ends as its battery drops: the battery's start is 0 W "
"(it is sent 0 W), not its last give; still live, its give stays",
charge_end(31.0) == (1385.0, 0.0) and charge_end(5.0) == (2770.0, 1385.0))
check("a rejoin: the battery enters the start point at its reading, and "
"both settle while the split is made again",
dropped(("inverter-b",), AB, 1200.0, {"a": 0.0, "b": 1200.0},
batts=(-700.0, 1200.0)) == (500.0, 500.0)
and _c._settle == {"inverter-a": 50.0, "inverter-b": 50.0})
check("no change: nothing moves (the integrator either)",
dropped(AB, AB, 1200.0, {"a": 600.0, "b": 600.0}) == (1200.0, 123.0)
and dropped((), ("inverter-b",), 1200.0, {"a": 0.0, "b": 1200.0})
== (1200.0, 123.0))
_n1c = run(1, dur=4.0)[1]
_n1c._live_was, _n1c.target, _n1c.i_w = frozenset({"inverter-a"}), 900.0, 5.0
_n1c._reseed_drop(set())
check("N=1: nothing moves and nothing is held out",
(_n1c.target, _n1c.i_w) == (900.0, 5.0)
and _n1c._held_out_w(set()) == 0.0)
print("\n case | freeze head/base | not about its own lag head/base | "
"step W head/base | grid error W head/base | circulation trip head/base")
for r in TABLE:
print(f" {r[0]} | {r[1]}/{r[2]} | {r[3]}/{r[4]} | {r[5]:.0f}/{r[6]:.0f} |"
f" {r[7]:.0f}/{r[8]:.0f} | {r[9]}/{r[10]}")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-96 checks passed")
-441
View File
@@ -1,441 +0,0 @@
"""Runnable check for SAFETY-97: "the battery gives nothing" is graded against
each battery's own idle reading. `python3 test_safety97.py`
B's power reading shows -95 to -118 W at idle (standby draw; live
2026-10-03). The refusal evidence (SAFETY-52/73, shared since SAFETY-94)
needed the reading under 100 W in magnitude, so a refusing B never latched,
at N=2 ever, and at N=1 since SAFETY-94 (review F1).
Now each unit learns its idle reading (app/idle.py) and the band, the
refusal's end and the stale-path refusal are centred on it.
DECISIONS/SAFETY-97-idle-offset.md.
1. A refusing B is caught at N=1 and N=2, 1/2/5 s meters; A unchanged.
2. A healthy B with its offset is never refused or flagged (SAFETY-94's
scenarios), learned or restored.
3. Learning: not while refusing or asked, bounded, restored, follows drift.
4. The rules at their edges.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.CRITICAL)
import safety94_rig as rig # noqa: E402
import app.idle as idle # noqa: E402
import app.main as app_main # noqa: E402
from app import control # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def without_fix(fn):
"""Run `fn` with every idle reading at 0 W (the rule before SAFETY-97)."""
real = idle.IdleReading.w
idle.IdleReading.w = property(lambda self: 0.0)
try:
return fn()
finally:
idle.IdleReading.w = real
def with_offset(n, fn):
"""At N=1 the rig's only unit is "a": give it B's offset."""
saved = dict(rig.OFFSET)
if n == 1:
rig.OFFSET["a"] = rig.OFFSET["b"]
try:
return fn()
finally:
rig.OFFSET.clear()
rig.OFFSET.update(saved)
REST, CUT = 400.0, 600.0 # rest (it learns), then ask, then cut
REST_LOAD = (lambda t: 0.0 if t < REST else 3000.0)
def caught(n, unit, tick, *, offset=True, dur=None, store=None, load=None,
cut_at=CUT, **kw):
"""Seconds from the cut to the refusal, or None, and the run."""
cu = "a" if n == 1 else unit
def go():
return rig.run(n, load or REST_LOAD, dur or cut_at + 300.0,
tick=tick, noise=4.0, store=store,
cut=lambda u, t: u == cu and t >= cut_at, **kw)
r = with_offset(n, go) if offset and unit == "b" else go()
ref = [t - cut_at for t, _u in r["refused"]]
return (ref[0] if ref else None), r
# ============================================================================ #
print("1. a refusing battery, B with its -100 W idle reading (+-4 W noise)")
TIMES, OLD = {}, {}
for unit in ("a", "b"):
for n in (1, 2):
for tick in (1.0, 2.0, 5.0):
TIMES[(unit, n, tick)] = caught(n, unit, tick)[0]
OLD[(unit, n, tick)] = without_fix(
lambda: caught(n, unit, tick)[0])
for k in TIMES:
print(f" refusing {k[0].upper()} N={k[1]} meter {k[2]:.0f} s: refused "
f"{TIMES[k]} s after the cut (before {OLD[k]})")
check("⭐ a refusing B is caught at N=1 and N=2: within 22 s plus one 10 s "
"publish at 1 and 2 s meters, 12 cycles plus a publish at 5 s",
all(TIMES[("b", n, t)] is not None
and TIMES[("b", n, t)] <= (32.0 if t < 5 else 12 * 5.0 + 10.0)
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
check("non-vacuous: before, a refusing B is never caught (300 s), at N=1 "
"or N=2",
all(OLD[("b", n, t)] is None for n in (1, 2) for t in (1.0, 2.0, 5.0)))
check("⭐ a refusing A is caught exactly as before",
all(TIMES[("a", n, t)] == OLD[("a", n, t)] is not None
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
# B refusing for 800 s: one refusal, held, not ended by its own idle reading.
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
starts = [x for x in r["refused"] if x[1] == "inverter-b"]
check("a refusing B stays refused until its probe: its idle reading is not "
"read as taking power the other way (one refusal in 800 s)",
len(starts) == 1 and r["c"].inverters[1].refused == 1)
# A register frozen at rest on B: refused or stale within 36 s of the ask.
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, tick=1.0,
publish_s=1.0, dither=True,
freeze_at=lambda u: 300.0 if u == "b" else None)
park = min([t for t, u in r["stale"] if u == "inverter-b"]
+ [t for t, u in r["refused"] if u == "inverter-b"] or [None])
print(f" B's register frozen at rest from 300 s, asked at 600 s: 0 W at "
f"{park}")
check("a register frozen at rest on B is caught as on A (within 36 s of "
"the ask)", park is not None and park - 600.0 <= 36.0)
# At a 5 s meter the stale trip comes first: its refusal is centred too.
saved = dict(rig.OFFSET)
rig.OFFSET["b"] = -110.0
try:
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 760.0, tick=5.0,
publish_s=1.0, dither=True,
freeze_at=lambda u: 300.0 if u == "b" else None)
finally:
rig.OFFSET.clear()
rig.OFFSET.update(saved)
st = [t for t, u in r["stale"] if u == "inverter-b"]
ref = [t for t, u in r["refused"] if u == "inverter-b"]
print(f" the same at a 5 s meter, B idle -110 W: stale {st}, refused {ref}")
check("...and at a 5 s meter the stale trip on B's held -110 W is a refusal "
"of B (one trip), not a repeating site stop",
len(st) == 1 and ref == st)
# Review: the two centrings with no check of their own.
# N=1, 5 s meter: the stale trip on a held -110 W idle reading is a refusal.
saved = dict(rig.OFFSET)
rig.OFFSET["a"] = -110.0
try:
r = rig.run(1, lambda t: 2500.0 if t >= 600 else 0.0, 800.0, tick=5.0,
publish_s=1.0, dither=True, freeze_at=lambda u: 300.0)
finally:
rig.OFFSET.clear()
rig.OFFSET.update(saved)
st = [t for t, _u in r["stale"]]
ref = [t for t, _u in r["refused"]]
print(f" N=1, 5 s meter, register frozen on -110 W: stale {st}, refused "
f"{ref}")
check("...and at N=1 the same stale trip is a refusal (one trip), not a "
"repeating site stop", len(st) == 1 and ref == st)
# SAFETY-42: B asked under 200 W while giving nothing (its idle reading) is
# parked, not named as not delivering; A carries the house.
def small_ask():
return rig.run(2, lambda t: 150.0, 700.0, tick=1.0, noise=4.0,
store={unit_key("inverter-b", "idle_w"): -100.0},
cut=lambda u, t: u == "b")["c"].inverters[1]
b, b_old = small_ask(), without_fix(small_ask)
check("SAFETY-42: B giving nothing at a small ask, idle -100 W, is parked "
"(not latched as not delivering in 700 s; with idle 0 W it is)",
not b.delivery_wd.tripped and b.idle.value == -100.0
and b_old.delivery_wd.tripped)
# ============================================================================ #
print("\n2. a healthy B with its offset is never refused or flagged")
SHAPES = ((10.0, False), (1.0, True))
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
SAVED = {"b": {unit_key("inverter-b", "idle_w"): -100.0},
"a": {unit_key("inverter-a", "idle_w"): -100.0}}
bad = []
runs = 0
for n in (1, 2):
for name in NAMES:
for tick in (1.0, 2.0, 5.0):
for pub, dith in SHAPES:
for store in (None, SAVED["a" if n == 1 else "b"]):
s, f, _r = with_offset(n, lambda: rig.counts(
n, name, tick=tick, publish_s=pub, dither=dith,
store=store))
runs += 1
if s or f:
bad.append((n, name, tick, pub, bool(store), s, f))
print(f" {runs} runs, stale or refused: {bad}")
check("⭐ SAFETY-94's scenarios with B's offset (N=1 and N=2, 1/2/5 s "
"meters, both register shapes, idle learned or restored): no stale "
"trip and no refusal", not bad and runs == 96)
rev = []
for n in (1, 2):
for tau in (10.0, 20.0):
for charge in (-1500.0, -2500.0):
for house in (300.0, 800.0, 2000.0):
x = with_offset(n, lambda: rig.run(
n, rig.reversal(300.0, charge, house), 600.0, tau=tau,
tick=1.0, store=SAVED["a" if n == 1 else "b"]))
if x["stale"] or x["refused"]:
rev.append((n, tau, charge, house))
check("⭐ a charge reversing into a discharge (lag 10 and 20 s, 1 s meter) "
"with B's idle reading restored is never refused or flagged",
not rev)
# ============================================================================ #
print("\n3. learning the idle reading")
_t, r = caught(2, "b", 1.0)
c = r["c"]
check("learned during the rest: A about 0 W, B about -100 W (used: -75 W), "
"and saved",
abs(c.inverters[0].idle_w) < 5
and abs(c.inverters[1].idle.value + 100) < 5
and c.inverters[1].idle_w == -idle.IDLE_MAX_W
and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100)
< 5)
# A refusing B from the start, no rest, nothing saved: nothing to learn from.
_t, r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, dur=1200.0)
check("a battery refusing from the start teaches nothing: asked the whole "
"time, then held at 0 W by its refusal (none learned, none saved)",
r["c"].inverters[1].idle.value is None
and unit_key("inverter-b", "idle_w") not in r["c"].store.data)
# ... which is why the value is saved: restored, the same battery is caught.
t_saved, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0,
store=SAVED["b"])
t_none, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0)
print(f" refusing from the start: saved idle {t_saved} s, none {t_none}")
check("⭐ after a restart the saved idle reading is used: a B refusing from "
"the start is caught in 32 s; with nothing saved it is not",
t_saved is not None and t_saved <= 32.0 and t_none is None)
# Not learned while refused: a refusing B reading -160 W (in the band) does
# not pull its idle reading during the 0 W its refusal holds.
class _P(rig.Plant):
def advance(self, t):
super().advance(t)
for u in self.units:
if self.cut(u, self.t):
self.reg[u] = -160.0
real_plant = rig.Plant
rig.Plant = _P
try:
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
finally:
rig.Plant = real_plant
b = r["c"].inverters[1]
print(f" refusing B reading -160 W: refused at {_t} s, idle "
f"{b.idle.value:.1f}")
check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves "
"B's idle reading at -100 W",
_t is not None and b.refused and abs(b.idle.value + 100.0) < 1.0)
# Not learned while control is stopped (another owner may drive the battery)
# or while the unit's sign watchdog has latched.
class _Stopped(app_main.Controller):
auto = property(lambda self: False, lambda self, v: None)
rig.Controller = _Stopped
try:
r = rig.run(2, lambda t: 0.0, 600.0, tick=1.0)
finally:
rig.Controller = app_main.Controller
r2 = rig.run(2, lambda t: 0.0, 600.0, tick=1.0,
store={unit_key("inverter-b", "sign_tripped"): True})
check("not learned while control is stopped, nor on a unit whose sign "
"watchdog has latched (600 s at 0 W); its sibling learns",
all(i.idle.value is None for i in r["c"].inverters)
and r2["c"].inverters[1].idle.value is None
and r2["c"].inverters[0].idle.value is not None)
# A battery capped at 150 W under a big ask is not idle.
x = idle.IdleReading()
for t in range(0, 3600):
x.observe(150.0, 2000.0, float(t), True)
check("a reading under a command over 50 W is never a sample (a capped "
"battery is not idle)", x.value is None and x.w == 0.0)
x = idle.IdleReading()
for t in range(0, 239):
x.observe(-100.0, 10.0, float(t), True)
first = x.value
x.observe(-100.0, 10.0, 239.0, True)
x.observe(-100.0, 10.0, 240.0, True)
check("the first value needs 120 s asked ~0 and then 120 s of samples",
first is None and abs(x.value + 100.0) < 1e-6)
y = idle.IdleReading()
for t in range(0, 400):
y.observe(-100.0, 60.0 if t % 100 == 0 else 0.0, float(t), True)
check("a command over 50 W restarts the quiet time (a 60 W blip every "
"100 s: never learned)", y.value is None)
z = idle.IdleReading()
for t in range(0, 400):
z.observe(-100.0, 0.0, float(t), False)
check("nothing is learned while learning is not allowed (refused, stale, "
"tripped or stopped)", z.value is None)
for t in range(240, 300):
x.observe(-130.0, 0.0, float(t), True)
after_min = x.value
for t in range(300, 240 + 3600):
x.observe(-130.0, 0.0, float(t), True)
print(f" drift -100 -> -130 W: {after_min:.1f} after 60 s, {x.value:.1f} "
f"after an hour")
check("drift is followed slowly: under 2 W in the first minute, most of it "
"in an hour", -102.0 < after_min < -100.0 and -130 < x.value < -124)
o = idle.IdleReading()
for t in range(0, 600):
o.observe(-300.0, 0.0, float(t), True)
p = idle.IdleReading()
for t in range(0, 600):
p.observe(2000.0 if t < 300 else -240.0, 0.0, float(t), True)
check("a reading beyond 250 W is not a sample (-300 W: never learned; a "
"register held at 2000 W then -240 W: -240 W)",
o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6)
check("a saved value is clamped to 250 W, the band's centre to 75 W, and "
"garbage is ignored",
idle.IdleReading(900).value == idle.IDLE_SAMPLE_W
and idle.IdleReading(900).w == idle.IDLE_MAX_W
and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W
and idle.IdleReading(-60.0).w == -60.0
and idle.IdleReading("x").value is None
and idle.IdleReading(float("nan")).value is None)
# ============================================================================ #
print("\n3b. a wrong idle reading (owner, 2026-10-04: centre clamped to 75 W)")
def wrong_idle(n, unit, err, restore):
"""Seconds from the cut to the refusal when the idle reading is `err` W
off: taught wrong during the rest, or restored wrong."""
cu = "a" if n == 1 else unit
true = -100.0 if unit == "b" else 0.0
if restore:
return caught(n, unit, 1.0, store={
unit_key(f"inverter-{cu}", "idle_w"): true + err})[0]
class _W(rig.Plant):
def advance(self, t):
rig.OFFSET[cu] = true + (err if t < REST else 0.0)
super().advance(t)
saved, real = dict(rig.OFFSET), rig.Plant
rig.Plant = _W
try:
return caught(n, unit, 1.0)[0]
finally:
rig.Plant = real
rig.OFFSET.clear()
rig.OFFSET.update(saved)
A_WRONG = {(n, e, rs): wrong_idle(n, "a", e, rs) for n in (1, 2)
for e, rs in ((-250.0, False), (-150.0, False), (150.0, False),
(250.0, False), (-250.0, True), (200.0, True),
(250.0, True))}
print(f" A (reads about 0 W) refusing, idle off by E: {A_WRONG}")
check("⭐ a battery reading about 0 W while refusing is caught whatever idle "
"reading was learned or restored (A, E -250..+250 W, N=1 and N=2)",
all(v is not None and v <= 32.0 for v in A_WRONG.values()))
check("⭐ ...including a wrong saved idle_w of +200 W on A",
all(A_WRONG[(n, 200.0, True)] is not None for n in (1, 2)))
B_WRONG = {(n, e): wrong_idle(n, "b", e, False) for n in (1, 2)
for e in (-150.0, -50.0, 90.0)}
print(f" B refusing, idle taught wrong by E: {B_WRONG}")
check("B is caught across -150..+90 W of error in its idle reading",
all(v is not None and v <= 32.0 for v in B_WRONG.values()))
# ============================================================================ #
print("\n4. the rules at their edges")
class _Inv:
def __init__(self, idle_w):
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
self.ref_from, self.idle_w = None, idle_w
def standing_w(self):
return 800.0
class _C:
_now_mono = 0.0
def evidence(readings, idle_w):
c, inv = _C(), _Inv(idle_w)
got = []
for i, b in enumerate(readings):
c._now_mono, inv.batt = float(i), b
got.append(app_main.Controller._gave_nothing(c, inv))
return got
flat_b = evidence([-104.0 + (i % 3) for i in range(30)], -100.0)
flat_b0 = evidence([-104.0 + (i % 3) for i in range(30)], 0.0)
gives = evidence([0.0] * 30, -100.0)
takes = evidence([-200.0] * 30, -100.0)
check("the band is centred on the idle reading: B flat at -104 W is evidence "
"at 22 s with idle -100 W and never with idle 0 W; reading 0 W (giving "
"100 W) or -200 W (taking 100 W) is not",
1 in flat_b and flat_b.index(1) == 22 and not any(flat_b0)
and not any(gives) and not any(takes))
T = control.Tuning(max_w=4000.0, slew_w=400.0)
args = dict(prev_w=1200.0, grid_w=1200.0, tuning=T,
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0,
gave_nothing=True)
check("compute(): the N=1 engage is centred on idle_w (-104 W refuses with "
"idle -100, not with 0; -4 W refuses with idle 0, not with -150)",
control.compute(actual_w=-104.0, idle_w=-100.0, **args).refused == 1
and control.compute(actual_w=-104.0, **args).refused == 0
and control.compute(actual_w=-4.0, **args).refused == 1
and control.compute(actual_w=-4.0, idle_w=-150.0, **args).refused == 0)
args.update(refused=-1, i_w=-1200.0, prev_w=0.0, grid_w=-1200.0)
check("compute(): a charge refusal ends when the battery takes 100 W more "
"than its idle reading, not at its idle reading",
control.compute(actual_w=-110.0, idle_w=-100.0, **args).refused == -1
and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused
== 0
and control.compute(actual_w=-110.0, **args).refused == 0)
check("the band's centre stays within 75 W of 0 W, so the band keeps 0 W "
"+-25 W; samples to 250 W",
idle.IDLE_MAX_W == 75.0 and idle.IDLE_SAMPLE_W == 250.0
and control.REFUSED_BELOW_W - idle.IDLE_MAX_W >= 25.0
and idle.IDLE_ASK_W < control.PROBE_W)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+2 -23
View File
@@ -19,14 +19,12 @@ 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
@@ -55,21 +53,6 @@ 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
@@ -87,13 +70,10 @@ 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, lag=False):
def run(n, *, extra=None, dur=DUR):
opts = dict(s79.P1, **ARB, **(extra or {}))
trade = []
@@ -105,8 +85,7 @@ def run(n, *, extra=None, dur=DUR, lag=False):
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_lag if lag else _feed,
cut=lambda u, t: False, before=_feed,
on_cycle=on_cycle)
finally:
rig.Controller = real
+17 -92
View File
@@ -18,11 +18,7 @@ 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). 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
optionally a device limit or a refusal to charge), 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.
@@ -108,28 +104,10 @@ 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
@@ -137,8 +115,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 / self.tau))
dp = max(-self.ramp * dt, min(self.ramp * dt, want - self.p))
want = self.p + (c - self.p) * (1 - math.exp(-dt / 3.0))
dp = max(-800.0 * dt, min(800.0 * 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
@@ -147,52 +125,17 @@ class Plant:
class Hass:
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None,
lag=None):
def __init__(self, units, house_at, soc0=None, refuse=(), limits=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)
@@ -202,8 +145,7 @@ class Hass:
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.grid() if self.lag is None else \
self._past(self.lag.meter_s)[0]
return self.grid()
u = e[-1]
if e.startswith("sensor.soc"):
return round(self.plants[u].soc, 1)
@@ -211,17 +153,11 @@ 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
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 float(round(self.plants[u].p) + j)
return None
async def set_number(self, e, 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)))
self.plants[e[-1]].cmd = float(v)
return True
@@ -303,7 +239,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, lag=None):
limits=None):
modes, arb, var_choice = VARIANTS[variant]
choice = var_choice if choice is None else choice
units = tuple("ab"[:n])
@@ -336,8 +272,7 @@ 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, lag)
tick = TICK if lag is None else lag.tick
hass = Hass(units, house_at, soc0, refuse, limits)
import tempfile
db = os.path.join(tempfile.mkdtemp(prefix="p3r-"), "tariff.db")
c = Controller(dict(options(modes, arb, n), **(extra or {})), hass,
@@ -364,8 +299,6 @@ 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,
@@ -373,25 +306,17 @@ 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
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()))
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(tick, 10.0)
for p in hass.plants.values():
p.step(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
@@ -414,7 +339,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
-195
View File
@@ -1,195 +0,0 @@
"""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
+3 -3
View File
@@ -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=(), modes=True):
late_at=(), soc=60.0, batt_gap=()):
"""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`. `modes=False` (OPT-15) runs with battery modes off."""
`late_at`."""
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": modes, "max_w": rating})
"battery_modes_enabled": True, "max_w": rating})
store = rig._Store()
store.data[unit_key("inverter-a", packmode.USE_KEY)] = False
store.data[packmode.USE_KEY] = False
-271
View File
@@ -1,271 +0,0 @@
#!/usr/bin/env python3
"""SAFETY-94: the REAL Controller on a lagging battery whose reading is late.
python3 tools/safety94_rig.py # prints every scenario's counts
Each battery ramps at 800 W/s, then follows a first-order lag (`tau`, 10 s
as on the SAFETY-85 rig). Its reading (register 0x0518) is published every
10 s, at a different phase per unit, and repeats its value between publishes,
as the live boards do. B reads 100 W below what it gives (its standby draw:
-100 W at idle, live 2026-10-03). The meter updates every `tick` s; the add-on
reads every 1 s and cycles on a new meter value, as `run_control()` does.
A frozen register stops publishing at `freeze_at`; a refusing battery (`cut`)
gives 0 W and reads -4 W plus its offset (B: -104 W), with `noise` W of
seeded jitter (SAFETY-97). Uses only the Controller API release/1.0 02ed120
has, so the same file runs on the tree before SAFETY-94.
"""
import asyncio
import math
import os
import random
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
BASE = datetime(2026, 10, 3, 10, 50, tzinfo=timezone.utc) # drain from 600 s
PUBLISH_S = 10.0
PHASE = {"a": 6.0, "b": 1.6} # live: A publishes at :x6, B at :x1.6
OFFSET = {"a": 0.0, "b": -100.0} # B's standby draw, live 2026-10-03
CUT_W = -4.0
LIVE = {"gain": 0.6, "slew_w": 400, "target_grid_w": -10, "deadband_w": 15,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"auto_start": True, "meter_entity": "sensor.grid",
"meter_source": "off"}
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11,
"maintenance_discharge_w": 2500, "maintenance_hold_min": 30}
class _Store:
def __init__(self, data=None):
from app.store import DEFAULTS
self.data = dict(DEFAULTS, **(data or {}))
def set(self, k, v):
self.data[k] = v
def get_time(self, k):
v = self.data.get(k)
return datetime.fromisoformat(v) if v else None
def set_time(self, k, when):
self.data[k] = when.isoformat() if when else None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class Plant:
"""The batteries, their registers and the meter, in simulated seconds."""
def __init__(self, units, load, tau, tick, soc, cut, freeze_at,
publish_s=PUBLISH_S, dither=False, noise=0.0, seed=0):
self.units, self.load, self.tau, self.tick = units, load, tau, tick
self.noise, self.rng = noise, random.Random(seed)
self.publish_s, self.dither, self.n_pub = publish_s, dither, 0
self.soc_fn, self.cut, self.freeze_at = soc, cut, freeze_at
self.cmd = {u: 0.0 for u in units}
self.ramp = {u: 0.0 for u in units}
self.real = {u: 0.0 for u in units}
self.reg = {u: round(OFFSET[u]) for u in units}
self.grid_v = float(load(0.0))
self.t = 0.0
def advance(self, t):
"""Integrate to `t` in 0.1 s steps; publish and sample on schedule."""
while self.t < t - 1e-9:
self.t = round(self.t + 0.1, 6)
for u in self.units:
want = 0.0 if self.cut(u, self.t) else self.cmd[u]
d = want - self.ramp[u]
self.ramp[u] += math.copysign(min(abs(d), 80.0), d)
self.real[u] += ((self.ramp[u] - self.real[u])
* (1 - math.exp(-0.1 / self.tau)))
frozen = (self.freeze_at is not None
and self.freeze_at(u) is not None
and self.t >= self.freeze_at(u))
ph = (self.t - PHASE[u] % self.publish_s) / self.publish_s
if not frozen and abs(ph - round(ph)) < 1e-6:
# `dither`: the +-1 W a settled register shows (live).
self.n_pub += 1
jit = (self.n_pub * 7 % 3 - 1) if self.dither else 0
self.reg[u] = (
float(round(CUT_W + OFFSET[u] + self.rng.uniform(
-self.noise, self.noise)))
if self.cut(u, self.t)
else float(round(self.real[u] + OFFSET[u]) + jit))
if abs(self.t / self.tick - round(self.t / self.tick)) < 1e-6:
self.grid_v = float(round(self.load(self.t)
- sum(self.real.values())))
class _Hass:
def __init__(self, plant):
self.p = plant
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.p.grid_v
u = e[-1]
if e.startswith("sensor.soc"):
return self.p.soc_fn(u, self.p.t)
if e.startswith("sensor.batt"):
return self.p.reg[u]
return None
async def set_number(self, e, v):
self.p.cmd[e[-1]] = float(v)
return True
def options(n, extra=None):
o = dict(LIVE)
if n == 1:
o.update({"setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 4000, "bms_soc_floor": 10})
else:
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 "ab"[:n]]
o.update(extra or {})
return o
def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
freeze_at=None, extra=None, floor_s=15.0, publish_s=PUBLISH_S,
dither=False, noise=0.0, seed=0, store=None):
"""Returns a dict: per-unit stale trips [(t, unit)], refusals
[(t, unit or 'site')], rows [(t, grid, {u: cmd}, {u: real}, {u: reg},
phases)] and the controller. `store`: saved state to start from."""
units = tuple("ab"[:n])
soc = soc or (lambda u, t: 50.0)
cut = cut or (lambda u, t: False)
plant = Plant(units, load, tau, tick, soc, cut, freeze_at, publish_s,
dither, noise, seed)
async def go():
c = Controller(options(n, extra), _Hass(plant), _Store(store), _Pub(),
stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
out = {"stale": [], "refused": [], "rows": [], "c": c}
was = {i.name: False for i in c.inverters}
was_ref = {i.name: 0 for i in c.inverters}
was_site = 0
last_grid, last_cycle = None, None
try:
t = 0.0
while t < dur:
plant.advance(t)
clock[0] = t
await c.read_inputs(now_mono=t)
for i in c.inverters:
if i.batt_fresh.stale and not was[i.name]:
out["stale"].append((t, i.name))
was[i.name] = i.batt_fresh.stale
cycled = (c.grid != last_grid or last_cycle is None
or t - last_cycle >= floor_s)
if cycled:
last_grid, last_cycle = c.grid, t
await c.cycle()
# A refusal can start on a read (a gate trip) or in a cycle.
for i in c.inverters:
if i.refused and not was_ref[i.name]:
out["refused"].append((t, i.name))
was_ref[i.name] = i.refused
if c.batt_refused and not was_site:
out["refused"].append((t, "site"))
was_site = c.batt_refused
if cycled:
out["rows"].append((
t, plant.grid_v, dict(plant.cmd),
{u: round(v) for u, v in plant.real.items()},
dict(plant.reg), [mm.phase for mm in c.maints]))
t += 1.0
finally:
m.datetime = real_dt
return out
return asyncio.run(go())
# --- the scenarios ---------------------------------------------------------
def rest_then(step_at, w):
"""A house at 0 W (the batteries rest) until `step_at`, then `w`."""
return lambda t: w if t >= step_at else 0.0
def reversal(at_s, charge_w, house_w):
"""Solar surplus (the batteries charge), then a house load from `at_s`."""
return lambda t: house_w if t >= at_s else charge_w
def drain_soc(floor_at):
"""A drains (maintenance) and reaches its floor at `floor_at`; B at 50 %."""
def soc(u, t):
if u != "a":
return 50.0
return 50.0 if t < floor_at else 10.0
return soc
SCENARIOS = {
# first command after a long rest (the live 17:39 shape)
"rest_step": dict(load=rest_then(600.0, 2400.0), dur=900.0),
# maintenance drain starts after a rest (the live 19:00 shape)
"drain_start": dict(load=lambda t: 300.0, dur=900.0,
extra=MAINT, soc=drain_soc(1e9)),
# A's drain ends and its charge starts (the live 21:21 shape)
"drain_charge": dict(load=lambda t: 300.0, dur=1200.0, extra=MAINT,
soc=drain_soc(900.0)),
# a charge reverses into a discharge through the lag (SAFETY-94)
"reversal": dict(load=reversal(300.0, -2500.0, 800.0), dur=600.0),
}
def counts(n, name, **kw):
sc = dict(SCENARIOS[name], **kw)
r = run(n, **sc)
return len(r["stale"]), len(r["refused"]), r
if __name__ == "__main__":
for n in (1, 2):
for name in SCENARIOS:
for tick in (1.0, 2.0, 5.0):
for pub, dith in ((10.0, False), (1.0, True)):
s, f, r = counts(n, name, tick=tick, publish_s=pub,
dither=dith)
print(f"N={n} {name:13s} tick {tick:.0f} s publish "
f"{pub:2.0f} s{' dither' if dith else ' '}: "
f"stale {s} {r['stale'][:2]}, refusals {f} "
f"{r['refused'][:2]}")
@@ -749,97 +749,6 @@ show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
print("\n== I. MAINT-10: `maintenance_start_hour` now defaults to 19 ==")
_mk = "maintenance_start_hour"
ok_i, out_i = validate(defaults)
show("the shipped `options:` block validates and carries 19",
ok_i and out_i.get(_mk) == 19, "" if ok_i else out_i)
# What the Supervisor hands the add-on is deepmerge(options:, stored).
_stored10 = dict(defaults, **{_mk: 10})
ok_i2, out_i2 = validate(MERGER.merge(copy.deepcopy(defaults), _stored10))
show("a stored explicit 10 (what a blob saved before MAINT-10 holds) stays 10",
ok_i2 and out_i2.get(_mk) == 10, "" if ok_i2 else out_i2)
_absent = {k: v for k, v in defaults.items() if k != _mk}
ok_i3, out_i3 = validate(MERGER.merge(copy.deepcopy(defaults), _absent))
show("a stored blob WITHOUT the key gets the new default, 19",
ok_i3 and out_i3.get(_mk) == 19, "" if ok_i3 else out_i3)
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.
+2 -8
View File
@@ -91,12 +91,6 @@ 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."
@@ -230,8 +224,8 @@ configuration:
name: "Maintenance · Minimum days between cycles"
description: "The shortest gap between two maintenance cycles."
maintenance_start_hour:
name: "Maintenance · Start hour (local time)"
description: "The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00."
name: "Maintenance · Start hour"
description: "The local hour, 0 to 23, at which a due cycle starts."
maintenance_discharge_w:
name: "Maintenance · Draw-down rate (W)"
description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer."
+2 -8
View File
@@ -72,12 +72,6 @@ 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."
@@ -211,8 +205,8 @@ configuration:
name: "Onderhoud · Minimum dagen tussen cycli"
description: "De kortste tijd tussen twee onderhoudscycli."
maintenance_start_hour:
name: "Onderhoud · Startuur (lokale tijd)"
description: "Het uur op je lokale klok, 0 tot 23, waarop een geplande cyclus begint. 19 is 19:00."
name: "Onderhoud · Startuur"
description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint."
maintenance_discharge_w:
name: "Onderhoud · Ontlaadvermogen (W)"
description: "Hoe snel de cyclus de batterij leegmaakt. Boven het vermogen van de omvormer wordt het daartoe beperkt, en duurt de cyclus gewoon langer."