Compare commits

..
Author SHA1 Message Date
admin df154a26d1 Deploy 0.16.0 to master (#179) 2026-10-08 01:43:37 +02:00
admin 21599938ea Merge pull request 'Deploy 0.15.0 to master' (#174) from deploy-0.15.0 into master
Deploy 0.15.0 to master (#174)
2026-10-05 10:17:38 +02:00
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
13 changed files with 25 additions and 1697 deletions
@@ -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,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 -26
View File
@@ -1667,21 +1667,6 @@ 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 |
@@ -1719,8 +1704,7 @@ 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).
they move one slew step a cycle (SAFETY-95).
#### Capacity-tariff exposure of the grid-following loop
@@ -3265,14 +3249,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
@@ -3292,7 +3268,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
+10 -111
View File
@@ -77,7 +77,7 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
roster_entries, roster_names)
from .maintenance import (
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
FAIL_BACKOFF_DAYS, MaintResult, arbitrate as maint_arbitrate, site_zone,
MaintResult, arbitrate as maint_arbitrate, site_zone,
)
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_configs,
@@ -91,7 +91,7 @@ from .price import Absence, Uncertainty, brussels, price_option, provider_config
from .price import required_spread as price_required_spread
from .price import uncertainty_of as price_uncertainty_of
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_gated, static_status
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker, next_quarter
from .store import migrate_maint_keys, migrate_unit_keys
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
@@ -1031,11 +1031,6 @@ class Controller:
# SAFETY-95: batteries settling after their drain ended; meanwhile
# the loop's error counts every battery at its command.
self._drain_tail = set()
# SAFETY-103: per battery, the range its power can be in since its
# reading last changed (that reading and every command since), and
# the house point held while a drain's tail settles.
self._read_span = {}
self._drain_house = None
# SAFETY-96: last cycle's commandable batteries, this cycle's start
# point per battery where a reseed moved it, and batteries settling
# after a step the loop did not make ({name: since, monotonic}).
@@ -1188,8 +1183,6 @@ class Controller:
self.grid = None
self.peak_fc = None
self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up
self._gated_key = None # MAINT-13: (options, hour) of the cache
# TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window:
# no" is an evidenced answer rather than a bare no.
@@ -2534,56 +2527,13 @@ class Controller:
most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and
the others' climb, so the loop would chase them (B down, then up
again). Meanwhile the batteries count at the site command: the error
is the house's own point minus the loop's start.
SAFETY-103: a reading can be up to a register period (10 s) older
than the meter, so the house point is held, and moved only as far as
the meter proves: to the nearest value the batteries can be at (each
between its last changed reading and its commands since; a reading
that changed this cycle, or the side a battery refuses, is taken as
it reads). DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
"""
is the house's own point minus the loop's start."""
grid = float(self.grid)
self._drain_tail &= set(self._settle) & set(live)
if not self._drain_tail:
self._drain_house = None
return grid
aim = self.tuning.target_grid_w
lo = hi = grid - aim
for inv in self.inverters:
if inv.name not in live:
continue
r = float(inv.batt)
span = self._read_span.get(inv.name)
if (span is None or span[2] != inv.batt_fresh.changed_mono
or span[2] == self._now_mono):
span = (r, r)
# A battery that refuses one way (SAFETY-73) is not heading
# that way, whatever it was asked: that side is its reading.
if (inv.refused or 0) > 0:
span = (min(span[0], r), r)
elif (inv.refused or 0) < 0:
span = (r, max(span[1], r))
lo, hi = lo + span[0], hi + span[1]
held = (float(self.target) if self._drain_house is None
else self._drain_house)
self._drain_house = min(hi, max(lo, held))
return self._drain_house - float(self.target) + aim
def _track_reads(self) -> None:
"""SAFETY-103, N>=2: per battery, the range its power can be in now:
its last changed reading and every command on its wire since (a
lagging battery moves from one toward the others, never beyond)."""
if len(self.inverters) < 2:
return
for inv in self.inverters:
if inv.batt is None:
continue
cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono
lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None))
if was is None or since != was:
lo = hi = float(inv.batt)
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
return grid - float(self.target) + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
@@ -3314,7 +3264,6 @@ class Controller:
now_p = await self.hass.number(now_e)
avg_p = await self.hass.number(avg_e)
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self._price_gated = True
self.tariff_words = "Prices from Home Assistant entities"
else:
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
@@ -3338,9 +3287,8 @@ class Controller:
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
# alongside the two Home Assistant entities, because this `if` picks
# one and the loser is silent.
_now = datetime.now(timezone.utc)
self.cheap, self.tariff_words = static_status(o, _now)
self._price_gated = self._static_gated(o, _now)
self.cheap, self.tariff_words = static_status(
o, datetime.now(timezone.utc))
# ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND
# THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the
@@ -3879,29 +3827,6 @@ class Controller:
inv.refusal_note())]
if n]
def _static_gated(self, o: dict, now: datetime) -> bool:
"""`static_gated()`, worked out once an hour: it builds the same
month-long plan as `static_status()` (about 20 ms with a profile), and
whether a fixed tariff is set up does not change from tick to tick
(options change only with a restart)."""
key = (id(o), now.strftime("%Y-%m-%d %H"))
if key != self._gated_key:
self._gated_key, self._gated = key, static_gated(o, now)
return self._gated
def _charge_gate_open(self, inv) -> bool:
"""MAINT-13: may this pack charge now, for the maintenance timeout?
True when no price gate is set up (no price entities, no fixed tariff:
nothing holds the charge back), in a cheap window, or while the pack is
really taking charge (sun outside cheap hours). Closed only when a gate
exists, it is shut, and the pack is not charging.
"""
if not self._price_gated or self.cheap:
return True
batt = inv.batt
return batt is not None and float(batt) <= -MAINT_CHARGING_W
async def cycle(self) -> None:
"""Gather claims, resolve precedence, write the winner.
@@ -3950,24 +3875,10 @@ class Controller:
if blocked is None:
maint_results.append(MaintResult(IDLE, False, None, False, []))
continue
# MAINT-13: the charge timeout counts only the minutes the pack was
# allowed to charge (see `_charge_gate_open`).
res = machine.tick(now, inv.soc, blocked_by=blocked,
gate_open=self._charge_gate_open(inv))
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg, hint=messages.hint(
"maint_failed" if res.failed else "maintenance",
phase=machine.phase))
if res.failed:
# Said once: only the tick that failed carries `res.failed`.
multi = len(self.inverters) > 1
slug = (unit_slugs(self.units)[self.inverters.index(inv)]
if multi else "battery")
await self.hass.call(
"persistent_notification", "create",
messages.maint_failed(
res.failed, FAIL_BACKOFF_DAYS,
unit=inv.name if multi else "", slug=slug).data())
self.log_event(msg, hint=messages.hint("maintenance",
phase=machine.phase))
maint_results.append(res)
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
# deferral is edge-logged (it lasts as long as the other pack's cycle),
@@ -5003,9 +4914,6 @@ class Controller:
# level: the two halves of §6.2's identity break in opposite
# directions. See `site_batt_w` and test_alloc.py part 8.
site_batt = self.site_batt_w
# SAFETY-103: what each battery can be doing (its reading may be
# older than the meter).
self._track_reads()
# SAFETY-96: a battery the loop cannot move counts at its command,
# so its own power does not freeze the loop (the meter has it).
site_batt += self._held_out_w(_live96)
@@ -7141,7 +7049,6 @@ class Controller:
# date, unchanged.
_now = datetime.now(timezone.utc)
_dues = [m.next_due(_now) for m in self.maints]
_waits = [u for u in (m.backoff_until(_now) for m in self.maints) if u]
next_due = None if any(d is None for d in _dues) else min(_dues)
rows = [
("Grid", w(self.grid)),
@@ -7189,10 +7096,6 @@ class Controller:
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
for n, why in self._maint_deferred)),)
if self._maint_deferred else ()),
# MAINT-13: only while a failed cycle waits, so a site with nothing
# failed carries exactly the rows it carried before.
*((("Maintenance paused", messages.row_maint_waits(
min(_waits).strftime("%Y-%m-%d"))),) if _waits else ()),
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
("Cheap window", "yes" if self.cheap else "no"),
("Tariff", self.tariff_words),
@@ -7414,10 +7317,6 @@ class Controller:
return None
# MAINT-13: a battery power reading at or below minus this many W is a pack
# that is taking charge.
MAINT_CHARGING_W = 50.0
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
EXIT_CONTROL_DIED = 70
+7 -120
View File
@@ -65,26 +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
# ⚠️ 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.
#
@@ -273,9 +257,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:
@@ -363,10 +344,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
# -- state ---------------------------------------------------------------
@property
@@ -374,10 +351,6 @@ class Maintenance:
return self.store.get("phase", IDLE)
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> 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)
@@ -418,21 +391,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?
@@ -444,8 +402,7 @@ class Maintenance:
return (self.phase == IDLE and self.cfg.enabled
and in_start_slot(now, self.cfg.start_hour, self.tz)
and self.due(now)
and self._cooled(now)
and self.backoff_until(now) is None)
and self._cooled(now))
# -- operations ----------------------------------------------------------
def force_start(self, now: datetime, blocked_by: str = "") -> list:
@@ -473,55 +430,6 @@ class Maintenance:
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
return events
def _charge_account(self, now: datetime, soc: float, 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.
"""
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": 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 > trk["best"]:
trk["best"], trk["flat_min"] = float(soc), 0.0
elif gate_open:
trk["flat_min"] += dt
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 abort(self, now: datetime, why: str) -> list:
events = []
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
@@ -549,16 +457,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
@@ -626,8 +531,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:
@@ -636,27 +541,9 @@ 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:
+1 -29
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);
@@ -971,7 +947,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",
@@ -1143,7 +1118,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")),
]
@@ -1174,8 +1148,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"),
]
-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:
-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")
+3 -10
View File
@@ -622,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))
@@ -931,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 "
@@ -1013,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)"])
-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")
+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
-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