MAINT-13: a full pack that stops at 98-99 % is not a failed charge (#181)
infra-pin / fresh (push) Successful in 2s
infra-pin / fresh (push) Successful in 2s
This commit was merged in pull request #181.
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
# MAINT-13 - a full pack that stops at 98-99 % is not a failed charge
|
||||||
|
|
||||||
|
**Status: for review.** Tests: `test_maint13.py`. No option added; every number
|
||||||
|
is a constant in `app/maintenance.py`.
|
||||||
|
|
||||||
|
**What happened (live, B).** The drain ended 2026-10-04 18:24 UTC and the charge
|
||||||
|
began. It ran in pieces behind the price gate, reached 99 % at 2026-10-06 04:00,
|
||||||
|
fell back to 98 % at 04:15 while the loop still asked about -1370 W, and never
|
||||||
|
reached `maintenance_soc_target` 100. At 06:24 (2160 min = 36 h of wall clock
|
||||||
|
after the charge began) it aborted "charge stuck at 98 %". `last_completed` was
|
||||||
|
not set, so B was still due and would have drained again from 98 % to 11 % at the
|
||||||
|
next start hour, with no notice. Three faults: a pack may never report 100 %
|
||||||
|
while the BMS balances; a failed cycle came back the next day; and the 36 h
|
||||||
|
counted hours the price gate kept the pack waiting.
|
||||||
|
|
||||||
|
## 1. The charge is complete when
|
||||||
|
|
||||||
|
1. SoC >= target (as before), or
|
||||||
|
2. SoC >= target - 3 (`CHARGE_NEAR_FULL_PCT`) and the SoC has not beaten its
|
||||||
|
best for 60 min (`CHARGE_STALL_MIN`) of allowed charging, or
|
||||||
|
3. the charging time (`CHARGE_TIMEOUT_MIN`, 2160 min) runs out at SoC >= target - 3.
|
||||||
|
|
||||||
|
Then the usual HOLD follows and `last_completed` is set after it. Each of 2 and
|
||||||
|
3 logs one plain line ("the battery takes no more charge at 98 %, so the charge
|
||||||
|
counts as done", "charge time is up at 98 %, close enough to full ...").
|
||||||
|
|
||||||
|
**Why "SoC has not beaten its best", and not asked/delivered watts.** SoC is the
|
||||||
|
one signal the live record gives us (99 at 04:00, 98 at 04:15); the asked power
|
||||||
|
was -1370 W but no delivered figure was kept, so a watt threshold (X, Y) would be
|
||||||
|
invented. SoC also covers the case where power flows but nothing is stored.
|
||||||
|
**Why 3 points.** The card's band; at target 100 it is 97, and the live pack sat
|
||||||
|
at 98-99. **Why 60 min.** A judgement, not a measurement: we have no per-minute record of
|
||||||
|
the pack taking charge near full. The one live fact is 2 h 24 min (04:00 to
|
||||||
|
06:24) without a new high, so 60 min is well inside it and long enough that a
|
||||||
|
pack still creeping up (one new high within the hour) keeps resetting the wait.
|
||||||
|
On the live run the rule fires after 60 gate-open minutes past the 99 %: 05:00
|
||||||
|
UTC at the earliest, before 06:24 if the gate was open. If the pack turns out to
|
||||||
|
step up more slowly than that, raise the constant. Falling back by one point
|
||||||
|
does not reset the wait; only a new best does.
|
||||||
|
|
||||||
|
## 2. A failed cycle backs off 7 days
|
||||||
|
|
||||||
|
A drain stuck 12 h or a charge that times out below target - 3 is a failure
|
||||||
|
(`FAIL_BACKOFF_DAYS` = 7). The machine goes IDLE as before, `last_failed` is
|
||||||
|
stored, and `wants_start()` is false until 7 days later. The operator's forced
|
||||||
|
start ignores it; a cycle that completes after the failure ends it. An operator
|
||||||
|
abort, an interlock stop and a switched-off pack are not failures.
|
||||||
|
|
||||||
|
**Why 7 days, not the 28 day interval.** A judgement: a failure may have a
|
||||||
|
short cause (several dull days with no cheap hours, a pack switched off), and
|
||||||
|
waiting the full interval leaves the coulomb counter uncalibrated for a month
|
||||||
|
more. A week is long enough that a bad pack costs one drain a week, with one
|
||||||
|
notice a week, not one a day. **Cost.** A truly faulty pack still drains once a week until someone looks;
|
||||||
|
the notice says so each time.
|
||||||
|
|
||||||
|
**Notice, once.** The tick that fails carries `failed`; the Controller sends one
|
||||||
|
persistent notification "Battery cycle did not finish" (EN, with the Dutch
|
||||||
|
below it; per pack at N=2). It is not repeated, and Home Assistant restarts
|
||||||
|
wipe it, so the status page shows a "Maintenance paused" row with the date for
|
||||||
|
as long as the wait lasts. The operator may also see the log lines.
|
||||||
|
|
||||||
|
## 3. The timeout counts only allowed time
|
||||||
|
|
||||||
|
`Controller._charge_gate_open()` says whether the pack may charge now. It is
|
||||||
|
true when no price gate is set up (no price entities, no fixed tariff: nothing
|
||||||
|
holds the charge back, and `cheap` is always false there), in a cheap window, or
|
||||||
|
while the pack is really taking charge (battery power at or below -50 W, which is
|
||||||
|
sun outside cheap hours). It is false only when a gate exists, is shut, and the
|
||||||
|
pack is not charging; a pack asked for charge that delivers nothing then does not
|
||||||
|
count, which is the case the clock cannot judge. A site with a price provider
|
||||||
|
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
|
||||||
|
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
|
||||||
|
top-up there and only the sun charges the pack, as on a site with no tariff. Its
|
||||||
|
time counts in full. When a provider plan starts to drive `cheap`,
|
||||||
|
`_price_gated` must become true for it. Whether a fixed tariff is set up is
|
||||||
|
worked out once an hour, not on every tick. Only open ticks add to `open_min`; one tick
|
||||||
|
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
|
||||||
|
while the gate is closed. The accounting is written to the state file at most
|
||||||
|
every 5 min (`charge_track`); a restart loses at most that. A state file from
|
||||||
|
before this change, or with no record for the current charge, starts from the
|
||||||
|
wall-clock minutes so far, which is the old behaviour.
|
||||||
|
|
||||||
|
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
|
||||||
|
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
|
||||||
|
complete at >= target - 3, a failure below.
|
||||||
|
|
||||||
|
**MAINT-12.** This is the smallest change that makes the timeout honest: one
|
||||||
|
boolean in, one counter. MAINT-12's block plan can replace the boolean (the
|
||||||
|
minutes it plans are the allowed ones) without touching the rest. Two things it
|
||||||
|
must keep: the back-off is in `wants_start()`, so any new entry point (such as
|
||||||
|
`price_forecast.begin_phase`, which is not wired today) must go through it or
|
||||||
|
check `backoff_until()`; and `gate_open` must stay "the pack is allowed to
|
||||||
|
charge now". The peak guard pausing the charge shaping is not seen as a closed
|
||||||
|
gate: that time still counts.
|
||||||
|
|
||||||
|
## 4. What does not change, and what this does not cover
|
||||||
|
|
||||||
|
- The drain, the HOLD, the held-battery rules and `arbitrate()` are unchanged. A
|
||||||
|
backed-off pack does not want a start, so at N=2 the other pack may take the
|
||||||
|
slot. One pack at a time at N=1 and N=2.
|
||||||
|
- `last_failed` is a maintenance key and moves with the others when a second
|
||||||
|
inverter is added (`MAINT_UNIT_KEYS`). The charge record is not moved; it
|
||||||
|
restarts from the wall-clock minutes.
|
||||||
|
- It does not find out why a pack stopped. A pack that stops at 96 % and never
|
||||||
|
gets near is still a failure.
|
||||||
|
- The Dutch text is only in the notification. The status row and the log lines
|
||||||
|
are English, like the rest of the status page.
|
||||||
|
- It does not change the target: a site that wants a different stop point sets
|
||||||
|
`maintenance_soc_target`.
|
||||||
@@ -1667,6 +1667,21 @@ hour saved. If you set an earlier hour to get an evening start in UTC (for
|
|||||||
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
|
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.
|
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)
|
### Tariff (all optional)
|
||||||
|
|
||||||
| option | meaning |
|
| option | meaning |
|
||||||
@@ -3250,6 +3265,14 @@ 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,
|
stuck reading can raise this month's peak. Check the meter. If it is stuck,
|
||||||
stop control on the add-on page.
|
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
|
### For installers: notifications
|
||||||
|
|
||||||
This part is for installers. The texts live in `app/messages.py` and follow
|
This part is for installers. The texts live in `app/messages.py` and follow
|
||||||
@@ -3269,6 +3292,7 @@ default `info` hides it.
|
|||||||
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
|
| Inverters working against each other | alarm | `goodwe_circulating_power` | SAFETY-31 |
|
||||||
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
| *name* is not delivering | alarm | `goodwe_not_delivering_<name>` | SAFETY-42 |
|
||||||
| Meter reading may be stuck | alarm | `goodwe_meter_frozen` | SAFETY-18 |
|
| 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
|
**The Details link.** Home Assistant 2026.2 and newer show add-on pages at
|
||||||
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
`/config/app/<slug>/documentation`; 2026.5 removed the old
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
|||||||
roster_entries, roster_names)
|
roster_entries, roster_names)
|
||||||
from .maintenance import (
|
from .maintenance import (
|
||||||
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
|
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
|
||||||
MaintResult, arbitrate as maint_arbitrate, site_zone,
|
FAIL_BACKOFF_DAYS, MaintResult, arbitrate as maint_arbitrate, site_zone,
|
||||||
)
|
)
|
||||||
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
|
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
|
||||||
MqttPublisher, announced_units, mode_configs,
|
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 required_spread as price_required_spread
|
||||||
from .price import uncertainty_of as price_uncertainty_of
|
from .price import uncertainty_of as price_uncertainty_of
|
||||||
from .price_history import PriceHistory
|
from .price_history import PriceHistory
|
||||||
from .static_tariff import resolve_tariff, static_status
|
from .static_tariff import resolve_tariff, static_gated, static_status
|
||||||
from .tariff import PeakStore, PeakTracker, next_quarter
|
from .tariff import PeakStore, PeakTracker, next_quarter
|
||||||
from .store import migrate_maint_keys, migrate_unit_keys
|
from .store import migrate_maint_keys, migrate_unit_keys
|
||||||
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
from .validate import (FIRMWARE_INGEST_VETO_S, ConfigRefused, refuse,
|
||||||
@@ -1188,6 +1188,8 @@ class Controller:
|
|||||||
self.grid = None
|
self.grid = None
|
||||||
self.peak_fc = None
|
self.peak_fc = None
|
||||||
self.cheap = False
|
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
|
# 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:
|
# 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.
|
# no" is an evidenced answer rather than a bare no.
|
||||||
@@ -3312,6 +3314,7 @@ class Controller:
|
|||||||
now_p = await self.hass.number(now_e)
|
now_p = await self.hass.number(now_e)
|
||||||
avg_p = await self.hass.number(avg_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.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"
|
self.tariff_words = "Prices from Home Assistant entities"
|
||||||
else:
|
else:
|
||||||
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
||||||
@@ -3335,8 +3338,9 @@ class Controller:
|
|||||||
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
|
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
|
||||||
# alongside the two Home Assistant entities, because this `if` picks
|
# alongside the two Home Assistant entities, because this `if` picks
|
||||||
# one and the loser is silent.
|
# one and the loser is silent.
|
||||||
self.cheap, self.tariff_words = static_status(
|
_now = datetime.now(timezone.utc)
|
||||||
o, datetime.now(timezone.utc))
|
self.cheap, self.tariff_words = static_status(o, _now)
|
||||||
|
self._price_gated = self._static_gated(o, _now)
|
||||||
|
|
||||||
# ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND
|
# ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND
|
||||||
# THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the
|
# THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the
|
||||||
@@ -3875,6 +3879,29 @@ class Controller:
|
|||||||
inv.refusal_note())]
|
inv.refusal_note())]
|
||||||
if n]
|
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:
|
async def cycle(self) -> None:
|
||||||
"""Gather claims, resolve precedence, write the winner.
|
"""Gather claims, resolve precedence, write the winner.
|
||||||
|
|
||||||
@@ -3923,10 +3950,24 @@ class Controller:
|
|||||||
if blocked is None:
|
if blocked is None:
|
||||||
maint_results.append(MaintResult(IDLE, False, None, False, []))
|
maint_results.append(MaintResult(IDLE, False, None, False, []))
|
||||||
continue
|
continue
|
||||||
res = machine.tick(now, inv.soc, blocked_by=blocked)
|
# MAINT-13: the charge timeout counts only the minutes the pack was
|
||||||
|
# allowed to charge (see `_charge_gate_open`).
|
||||||
|
res = machine.tick(now, inv.soc, blocked_by=blocked,
|
||||||
|
gate_open=self._charge_gate_open(inv))
|
||||||
for msg in res.events:
|
for msg in res.events:
|
||||||
self.log_event(msg, hint=messages.hint("maintenance",
|
self.log_event(msg, hint=messages.hint(
|
||||||
phase=machine.phase))
|
"maint_failed" if res.failed else "maintenance",
|
||||||
|
phase=machine.phase))
|
||||||
|
if res.failed:
|
||||||
|
# Said once: only the tick that failed carries `res.failed`.
|
||||||
|
multi = len(self.inverters) > 1
|
||||||
|
slug = (unit_slugs(self.units)[self.inverters.index(inv)]
|
||||||
|
if multi else "battery")
|
||||||
|
await self.hass.call(
|
||||||
|
"persistent_notification", "create",
|
||||||
|
messages.maint_failed(
|
||||||
|
res.failed, FAIL_BACKOFF_DAYS,
|
||||||
|
unit=inv.name if multi else "", slug=slug).data())
|
||||||
maint_results.append(res)
|
maint_results.append(res)
|
||||||
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
|
# ⚠️ 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),
|
# deferral is edge-logged (it lasts as long as the other pack's cycle),
|
||||||
@@ -7100,6 +7141,7 @@ class Controller:
|
|||||||
# date, unchanged.
|
# date, unchanged.
|
||||||
_now = datetime.now(timezone.utc)
|
_now = datetime.now(timezone.utc)
|
||||||
_dues = [m.next_due(_now) for m in self.maints]
|
_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)
|
next_due = None if any(d is None for d in _dues) else min(_dues)
|
||||||
rows = [
|
rows = [
|
||||||
("Grid", w(self.grid)),
|
("Grid", w(self.grid)),
|
||||||
@@ -7147,6 +7189,10 @@ class Controller:
|
|||||||
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
|
*((("Maintenance waiting", "; ".join(f"{n}: {why}"
|
||||||
for n, why in self._maint_deferred)),)
|
for n, why in self._maint_deferred)),)
|
||||||
if self._maint_deferred else ()),
|
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")),
|
("Maintenance due", "now" if next_due is None else next_due.strftime("%Y-%m-%d")),
|
||||||
("Cheap window", "yes" if self.cheap else "no"),
|
("Cheap window", "yes" if self.cheap else "no"),
|
||||||
("Tariff", self.tariff_words),
|
("Tariff", self.tariff_words),
|
||||||
@@ -7368,6 +7414,10 @@ class Controller:
|
|||||||
return None
|
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)
|
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
|
||||||
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
|
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
|
||||||
EXIT_CONTROL_DIED = 70
|
EXIT_CONTROL_DIED = 70
|
||||||
|
|||||||
@@ -65,10 +65,26 @@ 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
|
# 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.
|
# it is expected to span a night; 36 h still bounds it.
|
||||||
DRAIN_TIMEOUT_MIN = 720
|
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
|
CHARGE_TIMEOUT_MIN = 2160
|
||||||
# After an abort, do not immediately re-trigger in the same start hour.
|
# After an abort, do not immediately re-trigger in the same start hour.
|
||||||
RESTART_COOLDOWN_MIN = 90
|
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_*`
|
# ⚠️ 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.
|
# OPTION, and the only place in the add-on where one of these numbers is typed.
|
||||||
#
|
#
|
||||||
@@ -257,6 +273,9 @@ class MaintResult:
|
|||||||
# showing an idle machine and a due date in the past. Empty on every path
|
# 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.
|
# that is not deferring, so a roster of one never sees it.
|
||||||
deferred: str = ""
|
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:
|
class UnitHistory:
|
||||||
@@ -344,6 +363,10 @@ class Maintenance:
|
|||||||
# per cycle would flush the very context that explains it. Same pattern
|
# per cycle would flush the very context that explains it. Same pattern
|
||||||
# as `Controller._arb_action` and `_maint_shape_suspended`.
|
# as `Controller._arb_action` and `_maint_shape_suspended`.
|
||||||
self._deferred = ""
|
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 ---------------------------------------------------------------
|
# -- state ---------------------------------------------------------------
|
||||||
@property
|
@property
|
||||||
@@ -351,6 +374,10 @@ class Maintenance:
|
|||||||
return self.store.get("phase", IDLE)
|
return self.store.get("phase", IDLE)
|
||||||
|
|
||||||
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
|
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("phase", phase)
|
||||||
self.store.set_time("phase_started", now)
|
self.store.set_time("phase_started", now)
|
||||||
_LOG.info("%s", msg)
|
_LOG.info("%s", msg)
|
||||||
@@ -391,6 +418,21 @@ class Maintenance:
|
|||||||
return (last_try is None
|
return (last_try is None
|
||||||
or (now - last_try) >= timedelta(minutes=RESTART_COOLDOWN_MIN))
|
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:
|
def wants_start(self, now: datetime) -> bool:
|
||||||
"""Would this pack begin a cycle on this tick if the site let it?
|
"""Would this pack begin a cycle on this tick if the site let it?
|
||||||
|
|
||||||
@@ -402,7 +444,8 @@ class Maintenance:
|
|||||||
return (self.phase == IDLE and self.cfg.enabled
|
return (self.phase == IDLE and self.cfg.enabled
|
||||||
and in_start_slot(now, self.cfg.start_hour, self.tz)
|
and in_start_slot(now, self.cfg.start_hour, self.tz)
|
||||||
and self.due(now)
|
and self.due(now)
|
||||||
and self._cooled(now))
|
and self._cooled(now)
|
||||||
|
and self.backoff_until(now) is None)
|
||||||
|
|
||||||
# -- operations ----------------------------------------------------------
|
# -- operations ----------------------------------------------------------
|
||||||
def force_start(self, now: datetime, blocked_by: str = "") -> list:
|
def force_start(self, now: datetime, blocked_by: str = "") -> list:
|
||||||
@@ -430,6 +473,55 @@ class Maintenance:
|
|||||||
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
self._enter(DRAIN, now, events, "maintenance: forced start -> drain")
|
||||||
return events
|
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:
|
def abort(self, now: datetime, why: str) -> list:
|
||||||
events = []
|
events = []
|
||||||
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
self._enter(IDLE, now, events, f"maintenance: ABORT ({why})")
|
||||||
@@ -457,13 +549,16 @@ class Maintenance:
|
|||||||
|
|
||||||
# -- the machine ---------------------------------------------------------
|
# -- the machine ---------------------------------------------------------
|
||||||
def tick(self, now: datetime, soc: float | None,
|
def tick(self, now: datetime, soc: float | None,
|
||||||
blocked_by: str = "") -> MaintResult:
|
blocked_by: str = "", gate_open: bool = True) -> MaintResult:
|
||||||
"""One cycle of this pack's state machine.
|
"""One cycle of this pack's state machine.
|
||||||
|
|
||||||
`blocked_by` is `arbitrate()`'s answer for THIS pack: empty when the
|
`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
|
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 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.
|
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 = []
|
events: list = []
|
||||||
phase = self.phase
|
phase = self.phase
|
||||||
@@ -531,8 +626,8 @@ class Maintenance:
|
|||||||
f"after {elapsed:.0f} min -> charge")
|
f"after {elapsed:.0f} min -> charge")
|
||||||
return MaintResult(CHARGE, False, None, True, events)
|
return MaintResult(CHARGE, False, None, True, events)
|
||||||
if elapsed > DRAIN_TIMEOUT_MIN:
|
if elapsed > DRAIN_TIMEOUT_MIN:
|
||||||
events += self.abort(now, f"drain stuck at {soc:.0f} % after {elapsed:.0f} min")
|
return self._fail(now, "drain", f"drain stuck at {soc:.0f} % "
|
||||||
return MaintResult(IDLE, True, 0.0, False, events)
|
f"after {elapsed:.0f} min")
|
||||||
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
return MaintResult(DRAIN, True, self.cfg.discharge_w, False, events)
|
||||||
|
|
||||||
if phase == CHARGE:
|
if phase == CHARGE:
|
||||||
@@ -541,9 +636,27 @@ class Maintenance:
|
|||||||
f"maintenance: charge complete at {soc:.0f} % after "
|
f"maintenance: charge complete at {soc:.0f} % after "
|
||||||
f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance")
|
f"{elapsed:.0f} min -> holding {self.cfg.hold_min} min to balance")
|
||||||
return MaintResult(HOLD, True, 0.0, False, events)
|
return MaintResult(HOLD, True, 0.0, False, events)
|
||||||
if elapsed > CHARGE_TIMEOUT_MIN:
|
trk = self._charge_account(now, soc, gate_open)
|
||||||
events += self.abort(now, f"charge stuck at {soc:.0f} % after {elapsed:.0f} min")
|
near = soc >= self.cfg.soc_target - CHARGE_NEAR_FULL_PCT
|
||||||
return MaintResult(IDLE, True, 0.0, False, events)
|
# MAINT-13 rule 1: close to full and no longer taking charge.
|
||||||
|
if near and trk["flat_min"] >= CHARGE_STALL_MIN:
|
||||||
|
self._enter(HOLD, now, events,
|
||||||
|
f"maintenance: the battery takes no more charge at "
|
||||||
|
f"{soc:.0f} %, so the charge counts as done -> holding "
|
||||||
|
f"{self.cfg.hold_min} min to balance")
|
||||||
|
return MaintResult(HOLD, True, 0.0, False, events)
|
||||||
|
if (trk["open_min"] > CHARGE_TIMEOUT_MIN
|
||||||
|
or elapsed > CHARGE_WALL_TIMEOUT_MIN):
|
||||||
|
# MAINT-13 rule 2: out of time close to full is complete.
|
||||||
|
if near:
|
||||||
|
self._enter(HOLD, now, events,
|
||||||
|
f"maintenance: charge time is up at {soc:.0f} %, "
|
||||||
|
f"close enough to full, so the charge counts as "
|
||||||
|
f"done -> holding {self.cfg.hold_min} min to balance")
|
||||||
|
return MaintResult(HOLD, True, 0.0, False, events)
|
||||||
|
return self._fail(now, "charge",
|
||||||
|
f"charge stuck at {soc:.0f} % after "
|
||||||
|
f"{trk['open_min']:.0f} min of charging time")
|
||||||
return MaintResult(CHARGE, False, None, True, events)
|
return MaintResult(CHARGE, False, None, True, events)
|
||||||
|
|
||||||
if phase == HOLD:
|
if phase == HOLD:
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class Notice:
|
|||||||
title: str
|
title: str
|
||||||
body: str
|
body: str
|
||||||
section: str = "" # the DOCS.md heading, when it is not the title itself
|
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
|
@property
|
||||||
def docs(self) -> str:
|
def docs(self) -> str:
|
||||||
@@ -110,7 +111,9 @@ class Notice:
|
|||||||
def data(self) -> dict:
|
def data(self) -> dict:
|
||||||
"""The `persistent_notification.create` payload."""
|
"""The `persistent_notification.create` payload."""
|
||||||
return {"notification_id": self.id, "title": self.title,
|
return {"notification_id": self.id, "title": self.title,
|
||||||
"message": f"{self.body}\n\n{details(self.docs)}"}
|
"message": f"{self.body}\n\n"
|
||||||
|
+ (f"{self.body_nl}\n\n" if self.body_nl else "")
|
||||||
|
+ details(self.docs)}
|
||||||
|
|
||||||
|
|
||||||
# --- the catalogue ----------------------------------------------------------
|
# --- the catalogue ----------------------------------------------------------
|
||||||
@@ -124,6 +127,8 @@ T_CIRCULATING = "Inverters working against each other"
|
|||||||
T_NOT_DELIVERING = "{unit} is not delivering"
|
T_NOT_DELIVERING = "{unit} is not delivering"
|
||||||
S_NOT_DELIVERING = "An inverter is not delivering"
|
S_NOT_DELIVERING = "An inverter is not delivering"
|
||||||
T_METER_FROZEN = "Meter reading may be stuck"
|
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_LOOP_BOUND = "goodwe_integrator_bound"
|
||||||
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
ID_OUTPUT_CLAMP = "goodwe_output_clamp"
|
||||||
@@ -132,6 +137,7 @@ ID_PEAK_RESERVE = "goodwe_peak_soc_floor"
|
|||||||
ID_CIRCULATING = "goodwe_circulating_power"
|
ID_CIRCULATING = "goodwe_circulating_power"
|
||||||
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
ID_NOT_DELIVERING = "goodwe_not_delivering_{slug}"
|
||||||
ID_METER_FROZEN = "goodwe_meter_frozen"
|
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).
|
# Which limit cut a command (clamp.ClampResult.bound, plus the loop's own).
|
||||||
# The setting is named by its label, "Inverter rating (W)".
|
# The setting is named by its label, "Inverter rating (W)".
|
||||||
@@ -295,6 +301,24 @@ def meter_frozen(unchanged_s) -> Notice:
|
|||||||
f"stop control on the add-on page.")
|
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 status page (UX-08 phase 2b) ----------------------------------------
|
||||||
# The rows of the Commissioning list and the banner at the top of the add-on
|
# 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);
|
# page. `main.py` decides WHEN a row shows and its colour (ok / warn / bad);
|
||||||
@@ -947,6 +971,7 @@ HINTS = {
|
|||||||
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
|
||||||
"maintenance": "Battery maintenance: {phase}",
|
"maintenance": "Battery maintenance: {phase}",
|
||||||
"maint_paused": "Maintenance charging paused to stay under the peak limit",
|
"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_on": "{unit}: battery switched on",
|
||||||
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
|
"pack_off": "{unit}: battery switched off, it is asked for 0 W",
|
||||||
"pack_mode": "{unit}: battery mode changed",
|
"pack_mode": "{unit}: battery mode changed",
|
||||||
@@ -1118,6 +1143,7 @@ def status_samples() -> list:
|
|||||||
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
|
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
|
||||||
True)),
|
True)),
|
||||||
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
|
||||||
|
("row_maint_waits", AMBER, row_maint_waits("2026-10-13")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -1148,6 +1174,8 @@ def samples() -> list:
|
|||||||
not_delivering("inverter-b", "inverter_b"),
|
not_delivering("inverter-b", "inverter_b"),
|
||||||
meter_frozen(600),
|
meter_frozen(600),
|
||||||
meter_frozen(90),
|
meter_frozen(90),
|
||||||
|
maint_failed("charge", 7),
|
||||||
|
maint_failed("drain", 7, "inverter-b", "inverter_b"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1232,6 +1232,16 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
|
|||||||
profile, "fallback" if provisional else "static")
|
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):
|
def static_status(opts: dict, now: datetime):
|
||||||
"""(cheap, words) for the status page and for main.py's cheap-window flag.
|
"""(cheap, words) for the status page and for main.py's cheap-window flag.
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ DEFAULTS = {
|
|||||||
"phase_started": None,
|
"phase_started": None,
|
||||||
"last_completed": None,
|
"last_completed": None,
|
||||||
"last_start_attempt": None,
|
"last_start_attempt": None,
|
||||||
|
"last_failed": None,
|
||||||
"sign_tripped": False,
|
"sign_tripped": False,
|
||||||
"sign_reason": "",
|
"sign_reason": "",
|
||||||
"circ_tripped": False,
|
"circ_tripped": False,
|
||||||
@@ -171,7 +172,7 @@ MAINT_SCHEMA_KEY = "maint_schema_version"
|
|||||||
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
# silently stop being migrated - the same property `UNIT_STORE_KEYS` gives the
|
||||||
# SAFETY-02 pair.
|
# SAFETY-02 pair.
|
||||||
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
MAINT_UNIT_KEYS = ("phase", "phase_started", "last_completed",
|
||||||
"last_start_attempt")
|
"last_start_attempt", "last_failed")
|
||||||
|
|
||||||
|
|
||||||
def unit_key(unit: str, key: str) -> str:
|
def unit_key(unit: str, key: str) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,570 @@
|
|||||||
|
"""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")
|
||||||
@@ -622,6 +622,8 @@ def flat_store(**over):
|
|||||||
"phase_started": "2026-09-03T22:00:00+00:00",
|
"phase_started": "2026-09-03T22:00:00+00:00",
|
||||||
"last_completed": "2026-08-07T12:00:00+00:00",
|
"last_completed": "2026-08-07T12:00:00+00:00",
|
||||||
"last_start_attempt": "2026-09-03T10: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))
|
}, **over))
|
||||||
|
|
||||||
|
|
||||||
@@ -929,10 +931,11 @@ print(f" ok={_rep8.ok} lost={len(_rep8.lost)} kept={len(_rep8.kept)} "
|
|||||||
f"misdirected={len(_rep8.misdirected)}; marker "
|
f"misdirected={len(_rep8.misdirected)}; marker "
|
||||||
f"{_m8.data.get(MAINT_SCHEMA_KEY, '<<absent>>')!r}")
|
f"{_m8.data.get(MAINT_SCHEMA_KEY, '<<absent>>')!r}")
|
||||||
check("PRECONDITION: this fixture reaches the marker through the `lost` guard "
|
check("PRECONDITION: this fixture reaches the marker through the `lost` guard "
|
||||||
"and ONLY that one - two keys in `kept`, two in `lost`, `misdirected` "
|
"and ONLY that one - two keys in `kept`, three in `lost` (the three the "
|
||||||
|
"fixture records; MAINT-13 added `last_failed`), `misdirected` "
|
||||||
"empty. If it ever stops having a non-empty `kept` the mutation below "
|
"empty. If it ever stops having a non-empty `kept` the mutation below "
|
||||||
"goes invisible again, so the shape is asserted rather than assumed",
|
"goes invisible again, so the shape is asserted rather than assumed",
|
||||||
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 2
|
_rep8.ran and len(_rep8.kept) == 2 and len(_rep8.lost) == 3
|
||||||
and not _rep8.misdirected and not _rep8.ok)
|
and not _rep8.misdirected and not _rep8.ok)
|
||||||
check("⚠️ THE MARKER IS NOT STAMPED ON A MIGRATION THAT LOST A MONTH OF "
|
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 "
|
"HISTORY, and the two keys that DID land do not buy it one. `rep.ok` is "
|
||||||
@@ -1010,10 +1013,14 @@ check("...and nothing about a deferral, a slot or another pack appears in any "
|
|||||||
# pressure.
|
# pressure.
|
||||||
_solo2 = Maintenance(cfg, MemStore())
|
_solo2 = Maintenance(cfg, MemStore())
|
||||||
_solo2.force_start(t0)
|
_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 "
|
check("...and so is the abort, which is the string an operator reads under "
|
||||||
"pressure",
|
"pressure",
|
||||||
_solo2.tick(t0 + timedelta(hours=13), 60.0).events
|
_solo2.tick(t0 + timedelta(hours=13), 60.0).events
|
||||||
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)"])
|
== ["maintenance: ABORT (drain stuck at 60 % after 780 min)",
|
||||||
|
"maintenance: the cycle did not finish. It waits 7 days before it "
|
||||||
|
"tries again."])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import safety79_identity as s79 # noqa: E402
|
|||||||
import safety84_identity as s84 # noqa: E402
|
import safety84_identity as s84 # noqa: E402
|
||||||
from app import packmode # noqa: E402
|
from app import packmode # noqa: E402
|
||||||
from app.main import Controller # noqa: E402
|
from app.main import Controller # noqa: E402
|
||||||
from app.maintenance import (CHARGE_TIMEOUT_MIN, HOLD, IDLE, # noqa: E402
|
from app.maintenance import (CHARGE_TIMEOUT_MIN, DRAIN, HOLD, IDLE, # noqa: E402
|
||||||
MaintResult)
|
MaintResult)
|
||||||
from app.store import unit_key # noqa: E402
|
from app.store import unit_key # noqa: E402
|
||||||
from named_absence import first, present # noqa: E402
|
from named_absence import first, present # noqa: E402
|
||||||
@@ -221,9 +221,16 @@ def switch_off(at):
|
|||||||
return before
|
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 = {
|
ENDS = {
|
||||||
"timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
"timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||||
store=timed_out(1780.0)),
|
store=timed_out_b_done(1780.0)),
|
||||||
"Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
"Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
|
||||||
store=b_done(), before=switch_off(1780.0),
|
store=b_done(), before=switch_off(1780.0),
|
||||||
extra={"battery_modes_enabled": True}),
|
extra={"battery_modes_enabled": True}),
|
||||||
@@ -246,6 +253,31 @@ for k, fn in ENDS.items():
|
|||||||
f"the grid", at(old, 1780.0, "b") == -1600.0
|
f"the grid", at(old, 1780.0, "b") == -1600.0
|
||||||
and charging_hi(old, 1780, 1798, hi1) >= 1)
|
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")
|
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
|
# House 6000 W from 1500 s: the guard suspends the shaping, the loop has both
|
||||||
|
|||||||
Reference in New Issue
Block a user