Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d28daf8a0 | ||
|
|
0b6b34f563 | ||
|
|
08fb1235fc | ||
|
|
7ef73474dc | ||
|
|
24dbbd18ad | ||
|
|
8dfe91e1d2 | ||
|
|
9d3fbe1be9 | ||
|
|
3ed3302d2f | ||
|
|
2f5ab1f378 | ||
|
|
fbdd552555 | ||
|
|
02c0a3dd7b |
@@ -0,0 +1,67 @@
|
||||
# MAINT-10 — the maintenance start hour is local time, default 19:00
|
||||
|
||||
Owner decision 2026-09-29 (option A). `maintenance_start_hour` was compared
|
||||
with the UTC hour, so the shipped 10 started at 12:00 in a Belgian summer and
|
||||
11:00 in winter. It is now read on the site's local clock, and the default moves
|
||||
to 19 so the drain runs into the evening and night rather than the solar day.
|
||||
|
||||
Evening drains meet higher house loads; SAFETY-83 (merged first) lets the
|
||||
peak guard outrank the drain.
|
||||
|
||||
## 1. Which zone
|
||||
|
||||
Home Assistant's `time_zone` (`GET /core/api/config`, 3 s budget) at startup,
|
||||
else the container's `TZ`, else UTC. One log line says which (WARNING only for
|
||||
UTC). Resolved once per start: a zone change in HA takes effect on the next
|
||||
add-on restart. HA can still be starting when the add-on starts (502 from the
|
||||
proxy, 2026-10-05); then `TZ` holds. HA pushes its zone to the Supervisor
|
||||
(hassio `push_config`), which stores it and sets it as the add-on's `TZ`
|
||||
(`docker/app.py`, Supervisor 2026.08.0), so the hour stays right. Only a `TZ` of
|
||||
UTC there logs a WARNING asking for a restart. No later re-resolve: the fallback
|
||||
already is HA's zone, so the rare UTC case gets a warning rather than more code.
|
||||
The add-on image has tzdata (checked in `amd64-base:3.20`); the CI image does
|
||||
not, so the tests grade DST on `tariff._EUTimezone` too.
|
||||
|
||||
## 2. DST
|
||||
|
||||
`maintenance.in_start_slot()` works from UTC instants only, so it does not
|
||||
depend on how a tzinfo resolves a missing or doubled wall time:
|
||||
|
||||
- normally the slot is the local hour `start_hour`;
|
||||
- clocks forward and the hour does not exist: the first hour after the jump;
|
||||
- clocks back and the hour happens twice: the first pass only. Without this an
|
||||
aborted start (90 min cooldown) would start again in the 120 min repeated hour.
|
||||
|
||||
Every day has exactly one 60 min slot for every hour 0-23: checked against
|
||||
zoneinfo for Europe/Brussels, America/New_York, America/Santiago (midnight
|
||||
transitions), Asia/Kathmandu (+05:45) and Australia/Lord_Howe (30 min DST)
|
||||
over all of 2026.
|
||||
|
||||
## 3. Existing installs
|
||||
|
||||
The Supervisor merges the shipped defaults under the STORED options, and saving
|
||||
the settings stores every key (the reference install's blob has all 76). So
|
||||
almost every existing install keeps its stored 10, which now means 10:00 local
|
||||
instead of 12:00 (summer) or 11:00 (winter). Only a blob without the key gets 19.
|
||||
The add-on does not rewrite a stored option: a silent migration would override
|
||||
an operator's choice with no way to tell it apart from the old default. A site
|
||||
that set an earlier hour to make up for UTC (17 for 19:00 summer) must set it
|
||||
back to 19 after the upgrade, or it starts two hours early. DOCS and the release
|
||||
note (drafted on the PR, for the version bump) say this.
|
||||
`tools/supervisor_shape_probe.py` §I measures this on Supervisor 2026.08.0.
|
||||
|
||||
## 4. Two inverters
|
||||
|
||||
`Controller` hands the same zone to every pack's `Maintenance`, so every pack's
|
||||
`wants_start()` reads the local hour. `arbitrate()` is unchanged: one pack
|
||||
cycles at a time, and the one that lost the slot starts in the first start
|
||||
slot after the other is done (a later day: a cycle outlasts its start hour). On the day the clocks go back, a pack freed during the second pass of
|
||||
a repeated hour does not start there. `test_maint10.py` part 10.
|
||||
|
||||
## 5. What does not change
|
||||
|
||||
Only the start decision (`Maintenance.wants_start`) and the Home screen's shown
|
||||
start time read the zone. `test_maint10.py` parts 9 and 10 run the real
|
||||
Controller at N=1 and N=2 with start hour 11 UTC against 13 Europe/Brussels (the
|
||||
same instant): every command, claim, reason and log line is identical. A
|
||||
`Maintenance` built without a zone reads UTC, as before.
|
||||
@@ -0,0 +1,81 @@
|
||||
# SAFETY-90 - the peak guard goes on after a P1 meter gap
|
||||
|
||||
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
|
||||
|
||||
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
|
||||
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
|
||||
not reach the bill record). `peakguard.blindness()` then called the projection
|
||||
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
|
||||
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
|
||||
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
|
||||
The page still read "Keeping the grid near zero". At start-up mid-quarter the
|
||||
same rule left the start-up quarter blind, and a held battery was drawn as last
|
||||
resort (SAFETY-91 test, O2).
|
||||
|
||||
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
|
||||
`Controller._fill_peak_gaps()`).
|
||||
- Once readings are back, every second of a silence longer than
|
||||
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
|
||||
the gap, the import just after it)`, where "import" is the grid plus the
|
||||
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
|
||||
`meter_max_age_s`, which the averager credits at the last reading, is topped
|
||||
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
|
||||
blind and gives what keeps the quarter at the aim.
|
||||
- **The bound.** The count is at or above the true import as long as, during
|
||||
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
|
||||
before and just after it, or under the limit, and the batteries did not
|
||||
start charging (during a gap they hold their last command, then go to 0 W).
|
||||
It never counts the gap below the limit. A load that rises above all three
|
||||
only inside the gap is the one case it can miss.
|
||||
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
|
||||
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
|
||||
battery energy.
|
||||
- **While the gap lasts** nothing changes: the reading is too old to steer by,
|
||||
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
|
||||
before.
|
||||
- **Before the first reading** (start-up or restart mid-quarter) the
|
||||
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
|
||||
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
|
||||
`gap_w(the limit, the import seen first)`; after a restart the restored
|
||||
reading's held `meter_max_age_s` is topped up the same way. The bound is
|
||||
the gap's, with only the "after" side known. A load the batteries cannot
|
||||
make up then raises "Load too high", which is true: that quarter is going
|
||||
over. The page says so: the status row "Peak guard: part of this
|
||||
15-minute period was not measured, counted as high use" and the home line
|
||||
"Part of this 15-minute period was not measured." / "Een deel van dit
|
||||
kwartier is niet gemeten." until the quarter ends.
|
||||
*Review:* the first version counted them at the aim, with "nothing is
|
||||
known" as the reason. The first reading does show the load, and with the
|
||||
batteries at 0 W the import was that load, not the aim: a 60 s restart
|
||||
during a 6500 W HOLD landed 3653 W against 3450 W.
|
||||
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
|
||||
was measured. The estimate steers the guard and is never banked.
|
||||
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
|
||||
the watts it was counted at.
|
||||
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
|
||||
`peak_cap_w` and `peak_margin_w`.
|
||||
|
||||
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
|
||||
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
|
||||
|
||||
| case | before | after |
|
||||
|---|---|---|
|
||||
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
|
||||
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
|
||||
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
|
||||
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
|
||||
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 W |
|
||||
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
|
||||
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
|
||||
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
|
||||
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
|
||||
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
|
||||
| meter never drops, N=1 and N=2 | | rows identical |
|
||||
|
||||
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
|
||||
less than `meter_max_age_s` is not a gap, and the seconds since it are not
|
||||
counted in the projection (on any quarter, not only after a gap). Near a
|
||||
quarter's end the 900/remaining gain turns that into a large negative excess
|
||||
and the floor drops for a cycle or more. Counting those seconds at the held
|
||||
reading would fix it, but it changes every quarter's last seconds on a
|
||||
healthy meter; it is handed back as its own card.
|
||||
@@ -230,7 +230,7 @@ The label and description each setting shows on the Configuration tab, in tab or
|
||||
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
|
||||
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
|
||||
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
|
||||
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
|
||||
| Maintenance | Start hour (local time) | `maintenance_start_hour` | The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00. |
|
||||
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
|
||||
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
|
||||
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
|
||||
@@ -1643,13 +1643,30 @@ help - see "Why the tuning is what it is".
|
||||
|---|---|---|
|
||||
| `maintenance_enabled` | false | Enable the monthly cycle |
|
||||
| `maintenance_interval_days` | 28 | Minimum gap between cycles |
|
||||
| `maintenance_start_hour` | 10 | Hour of day a due cycle begins |
|
||||
| `maintenance_start_hour` | 19 | Local hour a due cycle begins (see below) |
|
||||
| `maintenance_discharge_w` | 2000 | Drain rate (exports the surplus). The peak guard may ask for more (SAFETY-83; at N≥2 while another battery is at its peak reserve, SAFETY-87), and at N≥2 so may the house while another battery is at its peak reserve (SAFETY-91). Above `max_w` it is **clamped** to `max_w` — the drain phase SETs the setpoint and the arbiter bounds it, saying so on the status page (`limited by supervised(max_w)`). Warned about, not refused: the cycle still completes, just slower |
|
||||
| `maintenance_charge_w` | 2000 | Charge ceiling, capped again by peak headroom. Must stay **at or below `max_w`** or the cheap-window shaping (`hi = -maintenance_charge_w`) contradicts the supervised limit (`lo = -max_w`), the arbiter resolves it to **0 W**, and maintenance silently never charges in a cheap window — **the add-on refuses to start** |
|
||||
| `maintenance_soc_floor` | 11 | Drain target — stay just above the inverter's own floor. ⚠️ **This is not `peak_soc_floor`.** It is the depth the monthly recalibration deliberately goes to, once, on purpose; `peak_soc_floor` is the depth a money-saving discharge may never reach on any day. The right values differ (11 vs 20 as shipped), which is why they are two keys |
|
||||
| `maintenance_soc_target` | 99 | Charge target |
|
||||
| `maintenance_hold_min` | 120 | Hold at full so the BMS can balance |
|
||||
|
||||
**The start hour is local time.** The add-on reads Home Assistant's time zone
|
||||
(Settings → System → General) at startup. If Home Assistant does not answer,
|
||||
it uses the add-on's own time zone, and failing that UTC. The log says which
|
||||
one it used, and warns if it is UTC: then restart the add-on once Home Assistant
|
||||
is up. On the day the clocks go forward, a start hour that does not exist
|
||||
starts at the first hour after the jump. On the day they go back, a repeated
|
||||
hour starts only once, in its first pass. With two inverters both batteries use
|
||||
the same local hour; one cycles at a time, and the other starts at that hour on
|
||||
a later day.
|
||||
|
||||
**Upgrading.** The hour used to be read as UTC. An existing install keeps its
|
||||
saved hour, and that hour now means local time: a saved 10 now starts at 10:00,
|
||||
not 12:00 in summer. The new default, 19, only reaches installs that have no
|
||||
hour saved. If you set an earlier hour to get an evening start in UTC (for
|
||||
example 17 for 19:00 in summer), set it to 19. The add-on never changes a saved
|
||||
value. *For installers:* DECISIONS/MAINT-10-local-start-hour.md.
|
||||
|
||||
### Tariff (all optional)
|
||||
|
||||
| option | meaning |
|
||||
@@ -1752,10 +1769,17 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
|
||||
**When it cannot see, it forbids importing.** Three states are not evidence and
|
||||
are checked before the projection is graded: no open quarter (a fresh averager
|
||||
projects a confident 0 W, which is under every ceiling there is), a meter gap
|
||||
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
|
||||
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
|
||||
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
|
||||
and commands nothing, and this stops the import.
|
||||
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
|
||||
conjunction. In all three the claim becomes a floor of **0 W**. That is the
|
||||
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
|
||||
a 900 s billing quarter and commands nothing, and this stops the import.
|
||||
|
||||
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
|
||||
the higher of the peak limit and the import seen just before and just after
|
||||
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
|
||||
count the same way, at the import seen first, and the page says part of the
|
||||
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
|
||||
bound.
|
||||
|
||||
**It trips, where the freeze detector only notifies,** and the captures are why.
|
||||
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
|
||||
|
||||
@@ -20,7 +20,7 @@ from datetime import datetime, timedelta, timezone
|
||||
|
||||
from . import arbitrage, messages, peakguard, uitext
|
||||
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
|
||||
from .maintenance import IDLE as M_IDLE
|
||||
from .maintenance import IDLE as M_IDLE, next_slot_start
|
||||
from .packmode import CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR, SOLAR_FIRST
|
||||
from .price import Absence, Uncertainty, brussels
|
||||
from .static_tariff import static_plan
|
||||
@@ -439,8 +439,8 @@ def solar_block(c, now):
|
||||
|
||||
def maintenance_runs(c, day0, day1, now, shadow):
|
||||
"""The monthly battery cycle: under way now, or starting later today.
|
||||
The cycle starts in the hour `maintenance_start_hour` names, read as the
|
||||
control cycle reads it (UTC hour of `now`), shown in local time."""
|
||||
The cycle starts in the hour `maintenance_start_hour` names, read in the
|
||||
site's zone exactly as the control cycle reads it (MAINT-10)."""
|
||||
runs, lines = [], []
|
||||
waiting = []
|
||||
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
|
||||
@@ -459,10 +459,9 @@ def maintenance_runs(c, day0, day1, now, shadow):
|
||||
if not m.due(now) and (m.next_due(now) is None
|
||||
or m.next_due(now) >= day1):
|
||||
continue
|
||||
start = now.replace(hour=cfg.start_hour, minute=0, second=0,
|
||||
microsecond=0)
|
||||
if start + timedelta(hours=1) <= now:
|
||||
start += timedelta(days=1)
|
||||
start = next_slot_start(now, cfg.start_hour, m.tz)
|
||||
if start is None:
|
||||
continue
|
||||
due_at = m.next_due(now)
|
||||
if due_at is not None and due_at > start:
|
||||
continue
|
||||
@@ -904,7 +903,9 @@ def month_peak(c, now):
|
||||
if pg is not None:
|
||||
out["period_w"] = pg.projected_w
|
||||
ceiling = pg.ceiling_w or limit_w
|
||||
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
|
||||
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
|
||||
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
|
||||
> 0.0)
|
||||
return out
|
||||
|
||||
|
||||
@@ -1294,8 +1295,11 @@ def line_peak_not_covered() -> str:
|
||||
return _t("peak_not_covered")
|
||||
|
||||
|
||||
def line_period(period_w, limit_w, shadow) -> str:
|
||||
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
|
||||
step = _w(shadow, "step_in", kw=_kw(limit_w))
|
||||
if unmeasured:
|
||||
# SAFETY-90: the add-on started during this period.
|
||||
return _t("period_unmeasured") + " " + step
|
||||
if period_w is None:
|
||||
return step
|
||||
return _t("period", kw=_kw(period_w)) + " " + step
|
||||
@@ -1369,6 +1373,7 @@ def _samples() -> list:
|
||||
("plan", line_peak_limit(3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh)),
|
||||
("plan", line_period(None, 3500, sh)),
|
||||
("plan", line_period(1240, 3500, sh, unmeasured=True)),
|
||||
("plan", control_text(True, sh)),
|
||||
("plan", control_text(False, sh))]
|
||||
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
|
||||
|
||||
@@ -30,6 +30,7 @@ import functools
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -68,7 +69,7 @@ from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
from .hass import HomeAssistant
|
||||
from .hass import CORE_API, HomeAssistant
|
||||
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
|
||||
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
|
||||
# window an operator would look up here is the one the roster actually uses.
|
||||
@@ -76,7 +77,7 @@ from .inverter import (LINK_STALE_S, UNIT_A, build_roster,
|
||||
roster_entries, roster_names)
|
||||
from .maintenance import (
|
||||
CHARGE, DRAIN, HOLD, IDLE, POWER_MOVING, MaintConfig, Maintenance,
|
||||
MaintResult, arbitrate as maint_arbitrate,
|
||||
MaintResult, arbitrate as maint_arbitrate, site_zone,
|
||||
)
|
||||
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
|
||||
MqttPublisher, announced_units, mode_configs,
|
||||
@@ -424,7 +425,8 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
||||
class Controller:
|
||||
def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop,
|
||||
peak_db_path: str = "/data/tariff.db",
|
||||
price_history_path: str = "/data/price_history.json"):
|
||||
price_history_path: str = "/data/price_history.json",
|
||||
site_tz=None):
|
||||
# UX-15: a read-only HomeAssistant belongs to setup-only mode, which
|
||||
# builds no controller. Refused here so no controller can hold one.
|
||||
if getattr(hass, "read_only", False) is True:
|
||||
@@ -665,7 +667,8 @@ class Controller:
|
||||
name=inv.name,
|
||||
# The rollback view, and only ever the first pack's: the flat
|
||||
# keys mean `inverters[0]` on every install that has them.
|
||||
mirror_flat=(not _one_pack and i == 0))
|
||||
mirror_flat=(not _one_pack and i == 0),
|
||||
tz=site_tz) # MAINT-10: the start hour's zone, from amain()
|
||||
for i, inv in enumerate(self.inverters)]
|
||||
|
||||
# SAFETY-02. ONE INSTANCE PER ACTUATOR - which is what watchdog.py's own
|
||||
@@ -947,6 +950,22 @@ class Controller:
|
||||
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
|
||||
self._peak_proj = None
|
||||
self._peak_batt_prev = None
|
||||
# SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`).
|
||||
self._gap_seen_w = None
|
||||
self._gap_w = None
|
||||
self._gap_block = None
|
||||
self._gap_filled_s = 0.0
|
||||
self._gap_held_ws = 0.0
|
||||
self._gap_pre = None
|
||||
self._unseen = None
|
||||
# A held sample TARIFF-01 restored from before this restart: the
|
||||
# averager credits it max_hold_s into the restart, at 0 W batteries.
|
||||
_held = self.p1.averager.held_since
|
||||
self._run_pre = (None if _held is None
|
||||
else (_held, self.p1.averager.held_net_w))
|
||||
self._unseen_held_ws = 0.0
|
||||
self.peak_gap_s = 0.0
|
||||
self.peak_unmeasured_s = 0.0
|
||||
self._peak_alarm_block = None
|
||||
self._peak_high_since = None
|
||||
self._peak_floor_limited = False
|
||||
@@ -2693,6 +2712,74 @@ class Controller:
|
||||
lo=p, reason=c.reason,
|
||||
actuator=u)
|
||||
|
||||
def _fill_peak_gaps(self, proj, now, reading):
|
||||
"""SAFETY-90: `proj` with a meter gap's uncounted seconds counted at
|
||||
`peakguard.gap_w()` of the imports seen just before and just after it,
|
||||
and those before this run's first reading likewise at the import seen
|
||||
first (shown as not measured).
|
||||
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
self.peak_gap_s = self.peak_unmeasured_s = 0.0
|
||||
avg = self.p1.averager
|
||||
since = avg.held_since
|
||||
if proj is None or proj.block_start is None or since is None:
|
||||
return proj
|
||||
if self._unseen is None and self.p1.last is not None:
|
||||
self._unseen = [proj.block_start, max(0.0, (
|
||||
since - proj.block_start).total_seconds() - avg.elapsed_s),
|
||||
since, None]
|
||||
seen = (None if self.grid is None or self.site_batt_w is None
|
||||
else float(self.grid) + max(0.0, float(self.site_batt_w)))
|
||||
if proj.block_start != self._gap_block:
|
||||
self._gap_block, self._gap_w = proj.block_start, None
|
||||
self._gap_filled_s = self._gap_held_ws = 0.0
|
||||
cap = float(self.o.get("peak_cap_w", 3500))
|
||||
ceiling = peakguard.quarter_ceiling_w(cap, reading)[0]
|
||||
unseen_on = (self._unseen is not None
|
||||
and self._unseen[0] == proj.block_start)
|
||||
if unseen_on and self._unseen[3] is None and seen is not None:
|
||||
# While the add-on was down the batteries were at 0 W, so the
|
||||
# seconds before its first reading count like a gap, at the
|
||||
# import seen first; a restored reading's held part is topped up.
|
||||
self._unseen[3] = peakguard.gap_w(ceiling, seen)
|
||||
if self._run_pre is not None:
|
||||
s0, g0 = self._run_pre
|
||||
h = (min(s0 + timedelta(seconds=avg.max_hold_s),
|
||||
self._unseen[2])
|
||||
- max(s0, proj.block_start)).total_seconds()
|
||||
self._unseen_held_ws = max(0.0, h) * max(
|
||||
0.0, self._unseen[3] - max(0.0, g0))
|
||||
kw = dict(tail_s=(now - since).total_seconds(),
|
||||
max_hold_s=avg.max_hold_s,
|
||||
unseen_s=self._unseen[1] if unseen_on else 0.0,
|
||||
unseen_w=(self._unseen[3] if unseen_on
|
||||
and self._unseen[3] is not None else ceiling))
|
||||
_p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw)
|
||||
if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S:
|
||||
self._gap_w = peakguard.gap_w(ceiling, self._gap_w,
|
||||
self._gap_seen_w, seen)
|
||||
if self._gap_pre is not None:
|
||||
# The averager credited the gap's first max_hold_s at the
|
||||
# reading before it; top that part up to gap_w as well.
|
||||
s0, g0 = self._gap_pre
|
||||
h = (min(s0 + timedelta(seconds=avg.max_hold_s), since)
|
||||
- max(s0, proj.block_start)).total_seconds()
|
||||
self._gap_held_ws += max(0.0, h) * max(
|
||||
0.0, self._gap_w - max(0.0, g0))
|
||||
self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s "
|
||||
f"of this 15-minute period, counted at "
|
||||
f"{self._gap_w:.0f} W",
|
||||
hint=messages.hint("peak_gap"))
|
||||
self._gap_filled_s = max(self._gap_filled_s, gap_s)
|
||||
if seen is not None:
|
||||
self._gap_seen_w = seen
|
||||
if self.grid is not None:
|
||||
self._gap_pre = (since, float(self.grid))
|
||||
out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps(
|
||||
proj, gap_w=self._gap_w if self._gap_w is not None else ceiling,
|
||||
held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on
|
||||
else 0.0), **kw)
|
||||
return out
|
||||
|
||||
def _peak_over_drain(self, claims, maint_results) -> None:
|
||||
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
|
||||
|
||||
@@ -4218,6 +4305,10 @@ class Controller:
|
||||
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
|
||||
self._peak_proj = (None if not self.p1_enabled
|
||||
else self.peak.project(self.p1.averager, now))
|
||||
_reading = self.peak.peak(now) if self.p1_enabled else None
|
||||
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
|
||||
# guard goes on after the readings return.
|
||||
self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading)
|
||||
# SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter,
|
||||
# so a HOLD's ceiling does not lapse for a cycle onto a held battery.
|
||||
if self._peak_proj is not None and len(self.inverters) > 1:
|
||||
@@ -4232,7 +4323,7 @@ class Controller:
|
||||
batt_w=self.site_batt_w,
|
||||
cap_w=float(self.o.get("peak_cap_w", 3500)),
|
||||
margin_w=self.peak_margin_w,
|
||||
reading=self.peak.peak(now),
|
||||
reading=_reading,
|
||||
# ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each
|
||||
# unit contributes `min(its max_w, |its own device bounds|)`, so
|
||||
# inverter B's rating bounds inverter B. Conjoining the SUMS instead
|
||||
@@ -6390,6 +6481,11 @@ class Controller:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_blind(
|
||||
peakguard.blind_kind(_pg.blind))})
|
||||
elif self.peak_unmeasured_s > 0.0:
|
||||
# SAFETY-90: the start of this quarter came before the first
|
||||
# reading, and the guard counts it as high use.
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_unmeasured()})
|
||||
elif _pg.engaged:
|
||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
||||
"text": messages.row_peak_guard_on(
|
||||
@@ -7157,6 +7253,47 @@ async def docs_link_facts(session, token: str) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
async def ha_time_zone(session, token: str) -> tuple:
|
||||
"""MAINT-10: `(zone, why)` - Home Assistant's configured time zone, or None
|
||||
and a short reason. Same 3 s budget as the docs link: never holds up start."""
|
||||
try:
|
||||
async with session.get(f"{CORE_API}/config", timeout=DOCS_LINK_TIMEOUT_S,
|
||||
headers={"Authorization": f"Bearer {token}"}) as r:
|
||||
body = await r.json(content_type=None)
|
||||
zone = body.get("time_zone") if isinstance(body, dict) else None
|
||||
return (zone, "") if zone else (None, "no time_zone in its answer")
|
||||
except Exception as err: # noqa: BLE001 - a fallback exists, never a stop
|
||||
text = str(err).strip()
|
||||
return None, f"{type(err).__name__}: {text}" if text else type(err).__name__
|
||||
|
||||
|
||||
_UTC_NAMES = {"UTC", "ETC/UTC", "GMT", "ETC/GMT", "UCT", "ETC/UCT",
|
||||
"UNIVERSAL", "ETC/UNIVERSAL", "ZULU", "ETC/ZULU"}
|
||||
|
||||
|
||||
async def maint_site_tz(session, token: str):
|
||||
"""MAINT-10: the zone the maintenance start hour is read in, logged once."""
|
||||
zone, why = await ha_time_zone(session, token)
|
||||
tz, name, source, why = site_zone(zone, why, os.environ.get("TZ"))
|
||||
if source == "ha":
|
||||
_LOG.info("Maintenance start hour is local time in %s, from Home "
|
||||
"Assistant.", name)
|
||||
elif source == "tz" and name.strip().upper() in _UTC_NAMES:
|
||||
# The Supervisor sets TZ to its copy of HA's zone, or to UTC if it has
|
||||
# none: HA did not answer here, and UTC may not be the site's clock.
|
||||
_LOG.warning("Maintenance start hour uses UTC, because Home Assistant "
|
||||
"did not answer. Restart the add-on when it is up.")
|
||||
_LOG.debug("maintenance time zone: %s", why)
|
||||
elif source == "tz":
|
||||
_LOG.info("Maintenance start hour is local time in %s, from the "
|
||||
"add-on's time zone. Home Assistant gave none: %s.", name, why)
|
||||
else:
|
||||
_LOG.warning("Maintenance start hour uses UTC, because no time zone "
|
||||
"was found. Set the time zone in Home Assistant.")
|
||||
_LOG.debug("maintenance time zone: %s", why)
|
||||
return tz
|
||||
|
||||
|
||||
async def amain() -> None:
|
||||
opts = load_options()
|
||||
logging.basicConfig(
|
||||
@@ -7266,7 +7403,8 @@ async def amain() -> None:
|
||||
pub = _NoMqtt()
|
||||
|
||||
stop = asyncio.Event()
|
||||
controller = Controller(opts, hass, store, pub, stop=stop)
|
||||
controller = Controller(opts, hass, store, pub, stop=stop,
|
||||
site_tz=await maint_site_tz(session, _tok))
|
||||
if callable(getattr(pub, "bind", None)):
|
||||
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
|
||||
runner = await web.start(controller, port=INGRESS_PORT)
|
||||
|
||||
@@ -34,7 +34,7 @@ simply not do.
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
|
||||
from .store import MAINT_UNIT_KEYS, unit_key
|
||||
|
||||
@@ -112,7 +112,9 @@ RESTART_COOLDOWN_MIN = 90
|
||||
MAINT_OPTION_DEFAULTS = {
|
||||
"maintenance_enabled": False,
|
||||
"maintenance_interval_days": 28,
|
||||
"maintenance_start_hour": 10,
|
||||
# MAINT-10: a LOCAL hour (site_zone() below), 19:00 so the drain runs
|
||||
# into the night rather than through the solar day.
|
||||
"maintenance_start_hour": 19,
|
||||
# ⚠️ 2000, NOT THE 2500 THIS SHIPPED AS, and config.yaml carries the
|
||||
# argument: both rates must stay at or below `max_w` (2000) or the
|
||||
# cheap-window charge shaping asks the arbiter for `hi = -charge_w` against
|
||||
@@ -177,6 +179,69 @@ class MaintConfig:
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MAINT-10: the start hour is local time. See DECISIONS/MAINT-10-local-start-hour.md.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _load_zone(name):
|
||||
"""A tzinfo for an IANA name, or `(None, why)`."""
|
||||
if not name or not isinstance(name, str):
|
||||
return None, "none given"
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
return ZoneInfo(name.strip()), ""
|
||||
except Exception as err: # noqa: BLE001 - any failure
|
||||
return None, f"{name!r} is not a known zone ({type(err).__name__})"
|
||||
|
||||
|
||||
def site_zone(ha_zone, ha_why: str, env_tz) -> tuple:
|
||||
"""`(tzinfo, name, source, why)`: Home Assistant's zone (source "ha"), else
|
||||
the container's TZ ("tz"), else UTC ("utc"). `why` says what was missing."""
|
||||
tz, why = _load_zone(ha_zone)
|
||||
if tz is not None:
|
||||
return tz, ha_zone, "ha", ""
|
||||
ha_why = ha_why or why
|
||||
tz, env_why = _load_zone(env_tz)
|
||||
if tz is not None:
|
||||
return tz, env_tz, "tz", ha_why
|
||||
return timezone.utc, "UTC", "utc", f"Home Assistant: {ha_why}, TZ: {env_why}"
|
||||
|
||||
|
||||
def in_start_slot(now: datetime, start_hour: int, tz: tzinfo) -> bool:
|
||||
"""Is `now` inside today's one-hour start slot, read on the local clock?
|
||||
|
||||
Normally the slot is the local hour `start_hour`. On the day the clocks go
|
||||
forward and that hour does not exist, it is the first hour after the jump.
|
||||
On the day they go back and it happens twice, only the first one counts.
|
||||
Worked from UTC instants only (`astimezone`), so it does not depend on how
|
||||
a tzinfo resolves a wall time that is missing or doubled.
|
||||
"""
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
here = now.astimezone(tz).replace(tzinfo=None)
|
||||
before = (now - timedelta(hours=1)).astimezone(tz).replace(tzinfo=None)
|
||||
if here.hour == start_hour:
|
||||
# the second pass through a repeated hour: an hour ago read the same
|
||||
return not (before.date() == here.date() and before.hour == start_hour)
|
||||
slot = here.replace(hour=start_hour, minute=0, second=0, microsecond=0)
|
||||
# the clock jumped over the start of the slot within the last hour
|
||||
return before < slot and here >= slot + timedelta(hours=1)
|
||||
|
||||
|
||||
def next_slot_start(now: datetime, start_hour: int, tz: tzinfo):
|
||||
"""The start of the slot `now` is in, or else of the next one; None if
|
||||
there is none within 50 h (no real zone does that)."""
|
||||
step = timedelta(minutes=15)
|
||||
t = now.replace(minute=now.minute - now.minute % 15, second=0,
|
||||
microsecond=0) - timedelta(hours=1)
|
||||
for _ in range(4 * 50):
|
||||
if (in_start_slot(t, start_hour, tz)
|
||||
and not in_start_slot(t - step, start_hour, tz)
|
||||
and t + timedelta(hours=1) > now):
|
||||
return t
|
||||
t += step
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaintResult:
|
||||
phase: str
|
||||
@@ -256,8 +321,12 @@ class Maintenance:
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: MaintConfig, store, unit: str | None = None,
|
||||
name: str = "", mirror_flat: bool = False):
|
||||
name: str = "", mirror_flat: bool = False,
|
||||
tz: tzinfo | None = None):
|
||||
self.cfg = cfg
|
||||
# MAINT-10: the zone `cfg.start_hour` is read in - main.py resolves it
|
||||
# once at startup (`site_zone()`). UTC when nobody says, as before.
|
||||
self.tz = tz or timezone.utc
|
||||
# ⚠️ `self.store` IS THE VIEW, NOT THE Store. Everything in this class
|
||||
# names an UNQUALIFIED key - "phase", "last_completed" - and the view is
|
||||
# the one place that turns it into the key this pack owns. A raw
|
||||
@@ -331,7 +400,8 @@ class Maintenance:
|
||||
about who was asking - so the IDLE branch of `tick()` calls this one.
|
||||
"""
|
||||
return (self.phase == IDLE and self.cfg.enabled
|
||||
and now.hour == self.cfg.start_hour and self.due(now)
|
||||
and in_start_slot(now, self.cfg.start_hour, self.tz)
|
||||
and self.due(now)
|
||||
and self._cooled(now))
|
||||
|
||||
# -- operations ----------------------------------------------------------
|
||||
|
||||
@@ -793,6 +793,12 @@ def row_peak_guard_blind(why: str) -> str:
|
||||
f"meanwhile")
|
||||
|
||||
|
||||
def row_peak_guard_unmeasured() -> str:
|
||||
"""SAFETY-90. Before the first reading; that part counts as high use."""
|
||||
return ("Peak guard: part of this 15-minute period was not measured, "
|
||||
"counted as high use")
|
||||
|
||||
|
||||
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
|
||||
if shadow:
|
||||
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
|
||||
@@ -912,6 +918,7 @@ HINTS = {
|
||||
"peak_on": "Peak guard started",
|
||||
"peak_off": "Peak guard idle",
|
||||
"peak_blind": "Peak guard cannot read the meter well",
|
||||
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
|
||||
"peak_saturated": T_PEAK_SATURATED,
|
||||
"peak_reserve": T_PEAK_RESERVE,
|
||||
"meter_frozen": T_METER_FROZEN,
|
||||
@@ -1097,6 +1104,7 @@ def status_samples() -> list:
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
|
||||
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
|
||||
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
|
||||
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
|
||||
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
|
||||
for k in BLIND_WORDS),
|
||||
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
|
||||
|
||||
@@ -312,6 +312,13 @@ class QuarterAverager:
|
||||
"""
|
||||
return self._last_net
|
||||
|
||||
@property
|
||||
def held_since(self) -> datetime | None:
|
||||
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
|
||||
if self._last_t is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(self._last_t, timezone.utc)
|
||||
|
||||
@property
|
||||
def offtake_avg_w(self) -> float:
|
||||
"""Average offtake over the part of the open block seen so far."""
|
||||
|
||||
@@ -183,6 +183,8 @@ guard passes its own tests while being switched off:
|
||||
record). Those seconds make the projection read LOW, which is the
|
||||
under-guarding direction. The threshold is `max_hold_s` itself - the point
|
||||
p1.py already stops trusting a held sample - not a new number.
|
||||
SAFETY-90: once readings are back, main.py counts those seconds
|
||||
(`fill_gaps()`), so this holds only while the gap lasts.
|
||||
|
||||
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
|
||||
rather than re-written here. A frozen meter is the worst case of all: the
|
||||
@@ -599,6 +601,57 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
|
||||
# noise between the averager's sums and the wall clock, never a real gap.
|
||||
GAP_TOL_S = 1e-3
|
||||
|
||||
|
||||
def gap_w(ceiling_w, *seen_w) -> float:
|
||||
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
|
||||
limit and `seen_w`, the import with the batteries at 0 W (grid + their
|
||||
discharge) just before and just after the gap; None entries are skipped.
|
||||
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
|
||||
|
||||
|
||||
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
|
||||
held_ws=0.0):
|
||||
"""SAFETY-90: `projection` with the seconds the averager did not count
|
||||
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
|
||||
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
|
||||
too: what the gap's first `max_hold_s`, credited at the reading before
|
||||
it, lacks to reach `gap_w`. Returns
|
||||
`(projection, gap seconds filled, unseen seconds filled)`.
|
||||
|
||||
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
|
||||
as before (the averager credits it once the next reading lands). Past
|
||||
that the gap is still open, nothing is filled and `blindness()` answers
|
||||
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
|
||||
if projection.block_start is None or max_hold_s is None:
|
||||
return projection, 0.0, 0.0
|
||||
wall = float(projection.elapsed_s)
|
||||
tail = min(max(0.0, float(tail_s)), wall)
|
||||
if tail > float(max_hold_s):
|
||||
return projection, 0.0, 0.0
|
||||
covered = wall - uncovered_s(projection)
|
||||
missing = wall - covered - tail
|
||||
if missing <= GAP_TOL_S:
|
||||
missing = 0.0
|
||||
if held_ws <= 0.0:
|
||||
return projection, 0.0, 0.0
|
||||
unseen = min(missing, max(0.0, float(unseen_s)))
|
||||
gap = missing - unseen
|
||||
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
|
||||
done = covered + missing
|
||||
return (replace(
|
||||
projection,
|
||||
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
|
||||
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
|
||||
if done > 0.0 else float(projection.elapsed_w)),
|
||||
covered_frac=(done / wall if wall > 0.0
|
||||
else float(projection.covered_frac))), gap, unseen)
|
||||
|
||||
|
||||
def discharge_permitted(soc, soc_floor) -> str | None:
|
||||
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
|
||||
|
||||
|
||||
@@ -410,6 +410,7 @@
|
||||
"step_in": "The batteries step in above {kw}.",
|
||||
"step_in_would": "The batteries would step in above {kw}.",
|
||||
"period": "This 15-minute period is heading for {kw}.",
|
||||
"period_unmeasured": "Part of this 15-minute period was not measured.",
|
||||
"prices": {
|
||||
"now": "Price now {eur} per kWh, all in.",
|
||||
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
|
||||
|
||||
@@ -347,6 +347,7 @@
|
||||
"step_in": "De batterijen springen bij boven {kw}.",
|
||||
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
|
||||
"period": "Dit kwartier gaat richting {kw}.",
|
||||
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
|
||||
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
|
||||
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
|
||||
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
|
||||
|
||||
@@ -630,7 +630,8 @@ options:
|
||||
# contradiction. Changing one of these numbers now moves every consumer.
|
||||
maintenance_enabled: false
|
||||
maintenance_interval_days: 28
|
||||
maintenance_start_hour: 10
|
||||
# MAINT-10: a LOCAL hour, in Home Assistant's time zone.
|
||||
maintenance_start_hour: 19
|
||||
|
||||
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
||||
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
||||
|
||||
@@ -400,6 +400,15 @@ check("...with its duration from the battery's capacity and the cycle's rates "
|
||||
"and hold time",
|
||||
present(nth(_cyc)) and _cyc[0]["amount"].startswith("Takes about ")
|
||||
and _cyc[0]["end_h"] > _cyc[0]["start_h"] + 2)
|
||||
# MAINT-10: with the site's zone set, the hour is read on the local clock.
|
||||
from app.tariff import _EUTimezone # noqa: E402
|
||||
c_ml = make(dict(MAINT, maintenance_start_hour=14))
|
||||
for _mm in c_ml.maints:
|
||||
_mm.tz = _EUTimezone()
|
||||
_cyc_l = [r for r in status(c_ml)["plan"]["runs"] if r["kind"] == "cycle"]
|
||||
check("MAINT-10: start hour 14 in the local zone is drawn at 14:00, the "
|
||||
"instant the cycle starts", len(_cyc_l) == 1
|
||||
and field(nth(_cyc_l), "from") == "14:00")
|
||||
c_m.maints[0].store.set("phase", "drain")
|
||||
s_md = status(c_m)
|
||||
check("a cycle under way is drawn from now, and the battery's now line says "
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
"""MAINT-10: the maintenance start hour is LOCAL time. `python3 test_maint10.py`
|
||||
|
||||
Graded here: the slot on ordinary days and on both DST change days, the zone
|
||||
source and its fallbacks, the 19:00 default, the startup wiring, and that the
|
||||
controller's commands are unchanged once the start instant is the same.
|
||||
See DECISIONS/MAINT-10-local-start-hour.md.
|
||||
|
||||
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
||||
|
||||
⚠️ The CI image has no tzdata, so every DST check runs on tariff.py's encoded
|
||||
EU rule and, where zoneinfo works, on Europe/Brussels from tzdata as well.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "tools"))
|
||||
|
||||
import app.main as M # noqa: E402
|
||||
from app import maintenance as MT # noqa: E402
|
||||
from app.maintenance import ( # noqa: E402
|
||||
DRAIN, IDLE, MAINT_OPTION_DEFAULTS, MaintConfig, Maintenance,
|
||||
in_start_slot, next_slot_start, site_zone,
|
||||
)
|
||||
from app.tariff import _EUTimezone # noqa: E402
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
UTC = timezone.utc
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
ZONES = [("encoded EU rule", _EUTimezone())]
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
ZONES.append(("zoneinfo Europe/Brussels", ZoneInfo("Europe/Brussels")))
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f" zoneinfo unavailable here ({err}); graded on the EU rule only")
|
||||
|
||||
SPRING = datetime(2026, 3, 29, tzinfo=UTC) # 02:00 CET -> 03:00 CEST
|
||||
SPRING27 = datetime(2027, 3, 28, tzinfo=UTC) # the next one, last Sunday of March
|
||||
AUTUMN = datetime(2026, 10, 25, tzinfo=UTC) # 03:00 CEST -> 02:00 CET
|
||||
SUMMER = datetime(2026, 7, 15, tzinfo=UTC)
|
||||
WINTER = datetime(2026, 1, 15, tzinfo=UTC)
|
||||
|
||||
|
||||
def slot_minutes(day_utc, hour, tz):
|
||||
"""UTC minutes in the slot, over the LOCAL day of that date."""
|
||||
t, out = day_utc - timedelta(hours=3), []
|
||||
while t < day_utc + timedelta(hours=27):
|
||||
if (t.astimezone(tz).date() == day_utc.date()
|
||||
and in_start_slot(t, hour, tz)):
|
||||
out.append(t)
|
||||
t += timedelta(minutes=1)
|
||||
return out
|
||||
|
||||
|
||||
def one_block(mins):
|
||||
"""`(start, minutes)` when the minutes form one contiguous run, else None."""
|
||||
if not mins:
|
||||
return None
|
||||
for a, b in zip(mins, mins[1:]):
|
||||
if b - a != timedelta(minutes=1):
|
||||
return None
|
||||
return mins[0], len(mins)
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
print("1. UTC stays what it was: the slot IS `now.hour == start_hour`")
|
||||
t, same = datetime(2026, 1, 1, tzinfo=UTC), True
|
||||
while t < datetime(2027, 1, 1, tzinfo=UTC):
|
||||
for h in range(24):
|
||||
if in_start_slot(t, h, UTC) != (t.hour == h):
|
||||
same = False
|
||||
t += timedelta(minutes=37)
|
||||
check("over a year of instants, every hour 0-23: identical to the old test",
|
||||
same)
|
||||
check("a naive `now` is read as UTC, as before",
|
||||
in_start_slot(datetime(2026, 7, 1, 19, 5), 19, UTC)
|
||||
and not in_start_slot(datetime(2026, 7, 1, 18, 5), 19, UTC))
|
||||
|
||||
# =========================================================================== #
|
||||
print("2. local time: 19 means 19:00 on the site's clock")
|
||||
for label, tz in ZONES:
|
||||
s = one_block(slot_minutes(SUMMER, 19, tz))
|
||||
w = one_block(slot_minutes(WINTER, 19, tz))
|
||||
check(f"[{label}] summer: 17:00-18:00 UTC (CEST), one hour",
|
||||
s == (SUMMER + timedelta(hours=17), 60))
|
||||
check(f"[{label}] winter: 18:00-19:00 UTC (CET), one hour",
|
||||
w == (WINTER + timedelta(hours=18), 60))
|
||||
check(f"[{label}] the old reading is gone: 19:00 UTC is not the slot "
|
||||
f"in summer", not in_start_slot(SUMMER + timedelta(hours=19, minutes=5),
|
||||
19, tz))
|
||||
|
||||
# =========================================================================== #
|
||||
print("3. the DST change days, every start hour")
|
||||
for label, tz in ZONES:
|
||||
for name, day in (("spring", SPRING), ("spring 2027", SPRING27),
|
||||
("autumn", AUTUMN)):
|
||||
bad = []
|
||||
for h in range(24):
|
||||
b = one_block(slot_minutes(day, h, tz))
|
||||
# the local day runs 23:00 UTC the day before to 22:00/23:00 UTC
|
||||
if b is None or b[1] != 60:
|
||||
bad.append((h, b))
|
||||
check(f"[{label}] {name}: every hour 0-23 gives ONE slot of exactly "
|
||||
f"60 minutes that day {bad or ''}", not bad)
|
||||
sp2 = one_block(slot_minutes(SPRING, 2, tz))
|
||||
check(f"[{label}] spring, hour 2 does not exist: it starts at 03:00 CEST "
|
||||
f"(01:00 UTC), once", sp2 == (SPRING + timedelta(hours=1), 60))
|
||||
check(f"[{label}] spring 2027 (28 March), hour 2: 01:00 UTC as well",
|
||||
one_block(slot_minutes(SPRING27, 2, tz))
|
||||
== (SPRING27 + timedelta(hours=1), 60))
|
||||
check(f"[{label}] the evening before spring 2027, hour 19: 18:00 UTC "
|
||||
f"(CET); that day, 17:00 UTC (CEST)",
|
||||
one_block(slot_minutes(SPRING27 - timedelta(days=1), 19, tz))
|
||||
== (SPRING27 - timedelta(hours=6), 60)
|
||||
and one_block(slot_minutes(SPRING27, 19, tz))
|
||||
== (SPRING27 + timedelta(hours=17), 60))
|
||||
au2 = one_block(slot_minutes(AUTUMN, 2, tz))
|
||||
check(f"[{label}] autumn, hour 2 happens twice: only the first, 02:00 CEST "
|
||||
f"(00:00 UTC)", au2 == (AUTUMN, 60))
|
||||
check(f"[{label}] autumn: the second 02:xx (CET) is not a slot",
|
||||
not in_start_slot(AUTUMN + timedelta(hours=1, minutes=30), 2, tz))
|
||||
check(f"[{label}] autumn, hour 19 still 18:00 UTC (CET)",
|
||||
one_block(slot_minutes(AUTUMN, 19, tz))
|
||||
== (AUTUMN + timedelta(hours=18), 60))
|
||||
check(f"[{label}] spring, hour 19 already 17:00 UTC (CEST)",
|
||||
one_block(slot_minutes(SPRING, 19, tz))
|
||||
== (SPRING + timedelta(hours=17), 60))
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
class FakeStore:
|
||||
def __init__(self):
|
||||
self.data = {"phase": IDLE, "phase_started": None,
|
||||
"last_completed": None, "last_start_attempt": None}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def set_time(self, k, when):
|
||||
self.data[k] = when
|
||||
|
||||
def get_time(self, k):
|
||||
return self.data.get(k)
|
||||
|
||||
def get(self, k, default=None):
|
||||
return self.data.get(k, default)
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def starts(day, hour, tz, *, abort_after=None, blocked=None):
|
||||
"""Tick a due pack each minute around a day; the UTC instants in that
|
||||
LOCAL day at which it entered DRAIN. `abort_after` aborts each drain that many minutes in (interlock);
|
||||
`blocked(t)` defers it."""
|
||||
m = Maintenance(MaintConfig(enabled=True, start_hour=hour), FakeStore(),
|
||||
tz=tz)
|
||||
out, since = [], None
|
||||
t = day - timedelta(hours=2)
|
||||
while t < day + timedelta(hours=26):
|
||||
why = ""
|
||||
if since is not None and abort_after is not None \
|
||||
and t - since >= timedelta(minutes=abort_after):
|
||||
why = "a test interlock"
|
||||
if blocked is not None and blocked(t):
|
||||
why = "the other battery is cycling"
|
||||
was = m.phase
|
||||
m.tick(t, soc=80, blocked_by=why)
|
||||
if m.phase == DRAIN and was != DRAIN:
|
||||
out.append(t)
|
||||
since = t
|
||||
if m.phase == IDLE:
|
||||
since = None
|
||||
t += timedelta(minutes=1)
|
||||
return [x for x in out if x.astimezone(tz).date() == day.date()]
|
||||
|
||||
|
||||
print("4. the state machine: one start per day, DST days included")
|
||||
for label, tz in ZONES:
|
||||
check(f"[{label}] spring, hour 2: one start, at 01:00 UTC (03:00 CEST)",
|
||||
starts(SPRING, 2, tz, abort_after=5)
|
||||
== [SPRING + timedelta(hours=1)])
|
||||
# An aborted start waits RESTART_COOLDOWN_MIN (90). The repeated hour is
|
||||
# 120 minutes long, so without the first-pass rule it would start again.
|
||||
check(f"[{label}] autumn, hour 2, aborted after 5 min: NO second start "
|
||||
f"in the repeated hour",
|
||||
starts(AUTUMN, 2, tz, abort_after=5) == [AUTUMN])
|
||||
check(f"[{label}] autumn, hour 2, deferred for the whole first pass: it "
|
||||
f"does not start in the second pass either",
|
||||
starts(AUTUMN, 2, tz, blocked=lambda t: t < AUTUMN + timedelta(hours=1))
|
||||
== [])
|
||||
check(f"[{label}] summer, hour 19: one start at 17:00 UTC",
|
||||
starts(SUMMER, 19, tz, abort_after=5)
|
||||
== [SUMMER + timedelta(hours=17)])
|
||||
|
||||
# =========================================================================== #
|
||||
print("5. where the start is shown (homeview) is where it happens")
|
||||
for label, tz in ZONES:
|
||||
ok = True
|
||||
for day in (SPRING, AUTUMN, SUMMER):
|
||||
for h in (0, 2, 3, 10, 19, 23):
|
||||
first = next(iter(slot_minutes(day, h, tz)), None)
|
||||
if first is None:
|
||||
continue
|
||||
for probe in (first - timedelta(hours=5), first,
|
||||
first + timedelta(minutes=59)):
|
||||
if next_slot_start(probe, h, tz) != first:
|
||||
ok = False
|
||||
check(f"[{label}] next_slot_start() lands on the slot's first minute, "
|
||||
f"from before it and from inside it", ok)
|
||||
|
||||
# =========================================================================== #
|
||||
print("6. which zone: Home Assistant, else the container's TZ, else UTC")
|
||||
_FAKE = {"Europe/Brussels": ZONES[-1][1], "America/New_York": object(),
|
||||
"UTC": UTC}
|
||||
_real_load = MT._load_zone
|
||||
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
|
||||
else (None, f"{n!r} is not a known zone"
|
||||
if n else "none given"))
|
||||
try:
|
||||
tz, name, src, _ = site_zone("Europe/Brussels", "", "America/New_York")
|
||||
check("Home Assistant's zone wins over the container's TZ",
|
||||
name == "Europe/Brussels" and src == "ha"
|
||||
and tz is _FAKE["Europe/Brussels"])
|
||||
tz, name, src, why = site_zone(None, "ClientConnectorError",
|
||||
"America/New_York")
|
||||
check("Home Assistant unavailable: the container's TZ, and why",
|
||||
name == "America/New_York" and src == "tz"
|
||||
and why == "ClientConnectorError")
|
||||
tz, name, src, why = site_zone("Mars/Base", "", "Europe/Brussels")
|
||||
check("an unknown Home Assistant zone falls back too",
|
||||
name == "Europe/Brussels" and src == "tz" and "Mars/Base" in why)
|
||||
tz, name, src, why = site_zone(None, "no answer", None)
|
||||
check("neither: UTC", tz is UTC and src == "utc" and "no answer" in why)
|
||||
finally:
|
||||
MT._load_zone = _real_load
|
||||
if len(ZONES) > 1:
|
||||
check("the real loader resolves a real name and refuses a made-up one",
|
||||
MT._load_zone("Europe/Brussels")[0] is not None
|
||||
and MT._load_zone("Mars/Base")[0] is None)
|
||||
check("the real loader refuses an empty name without raising",
|
||||
MT._load_zone("")[0] is None and MT._load_zone(None)[0] is None)
|
||||
|
||||
# =========================================================================== #
|
||||
print("7. the default is 19, an explicit value is honoured")
|
||||
cfg_text = (HERE / "config.yaml").read_text()
|
||||
opts_block = cfg_text.split("\nschema:")[0]
|
||||
check("config.yaml options: ships 19",
|
||||
re.search(r"^ maintenance_start_hour: 19$", opts_block, re.M) is not None)
|
||||
check("config.yaml schema: still int(0,23)",
|
||||
re.search(r"^ maintenance_start_hour: int\(0,23\)$", cfg_text, re.M)
|
||||
is not None)
|
||||
check("the in-code mirror says 19",
|
||||
MAINT_OPTION_DEFAULTS["maintenance_start_hour"] == 19
|
||||
and MaintConfig().start_hour == 19
|
||||
and MaintConfig.from_options({}).start_hour == 19)
|
||||
check("an explicit 10 stays 10 (a stored option is never rewritten)",
|
||||
MaintConfig.from_options({"maintenance_start_hour": 10}).start_hour == 10)
|
||||
_eu = ZONES[0][1]
|
||||
_m10 = Maintenance(MaintConfig.from_options(
|
||||
{"maintenance_enabled": True, "maintenance_start_hour": 10}),
|
||||
FakeStore(), tz=_eu)
|
||||
check("...and now means 10:00 local: 08:00 UTC in summer, not 10:00 UTC",
|
||||
_m10.wants_start(SUMMER + timedelta(hours=8, minutes=1))
|
||||
and not _m10.wants_start(SUMMER + timedelta(hours=10, minutes=1)))
|
||||
check("a Maintenance built with no zone reads UTC, as before",
|
||||
Maintenance(MaintConfig(), FakeStore()).tz is UTC)
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
print("8. startup: Home Assistant's zone is asked for and reaches the machines")
|
||||
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, body, status=200):
|
||||
self.body, self.status = body, status
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def json(self, content_type=None):
|
||||
if isinstance(self.body, Exception):
|
||||
raise self.body
|
||||
return self.body
|
||||
|
||||
async def text(self):
|
||||
return "{}"
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, zone_body):
|
||||
self.zone_body, self.asked = zone_body, []
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, url, **kw):
|
||||
self.asked.append(url)
|
||||
self.timeouts = getattr(self, "timeouts", []) + [kw.get("timeout")]
|
||||
if url.endswith("/core/api/config"):
|
||||
return _Resp(self.zone_body)
|
||||
return _Resp({"data": {}})
|
||||
|
||||
|
||||
s = _Session({"time_zone": "Europe/Brussels"})
|
||||
check("ha_time_zone() reads /core/api/config's time_zone",
|
||||
asyncio.run(M.ha_time_zone(s, "tok")) == ("Europe/Brussels", "")
|
||||
and s.asked == [M.CORE_API + "/config"])
|
||||
# Review: HA can take minutes to start (2026-10-05); the ask must not wait on it.
|
||||
check("...with a time budget of at most 3 s, so a slow HA cannot hold up start",
|
||||
s.timeouts == [M.DOCS_LINK_TIMEOUT_S] and 0 < M.DOCS_LINK_TIMEOUT_S <= 3)
|
||||
check("...and a failure is None plus the reason, never a raise",
|
||||
asyncio.run(M.ha_time_zone(_Session(OSError("down")), "tok"))
|
||||
== (None, "OSError: down")
|
||||
and asyncio.run(M.ha_time_zone(_Session({}), "tok"))[0] is None)
|
||||
|
||||
|
||||
class _Built(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def amain_zone(zone_body, env_tz):
|
||||
"""Run the real amain() up to the Controller; the tz its machines get."""
|
||||
got, logs = {}, []
|
||||
|
||||
class _Capture(logging.Handler):
|
||||
def emit(self, rec):
|
||||
logs.append((rec.levelname, rec.getMessage()))
|
||||
|
||||
class _Hass:
|
||||
async def limits(self, e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def mqtt_service(self):
|
||||
raise RuntimeError("no broker in this test")
|
||||
|
||||
real = {k: getattr(M, k) for k in ("load_options", "HomeAssistant",
|
||||
"validate_startup_config",
|
||||
"mode_announcement", "Controller")}
|
||||
real_session, real_store = M.aiohttp.ClientSession, None
|
||||
import app.store
|
||||
real_store = app.store.Store
|
||||
|
||||
def _controller(opts, hass, store, pub, *, stop, site_tz=None, **kw):
|
||||
got["tz"] = site_tz
|
||||
raise _Built()
|
||||
|
||||
M.load_options = lambda: {"setpoint_entity": "number.sp"}
|
||||
M.HomeAssistant = lambda session, **kw: _Hass()
|
||||
M.validate_startup_config = lambda *a, **k: []
|
||||
M.mode_announcement = lambda opts, store: ((), ())
|
||||
M.Controller = _controller
|
||||
M.aiohttp.ClientSession = lambda *a, **k: _Session(zone_body)
|
||||
app.store.Store = lambda *a, **k: object()
|
||||
old_tz = os.environ.pop("TZ", None)
|
||||
if env_tz:
|
||||
os.environ["TZ"] = env_tz
|
||||
h = _Capture()
|
||||
logging.getLogger("goodwe").addHandler(h)
|
||||
try:
|
||||
asyncio.run(M.amain())
|
||||
except _Built:
|
||||
pass
|
||||
finally:
|
||||
logging.getLogger("goodwe").removeHandler(h)
|
||||
for k, v in real.items():
|
||||
setattr(M, k, v)
|
||||
M.aiohttp.ClientSession = real_session
|
||||
app.store.Store = real_store
|
||||
os.environ.pop("TZ", None)
|
||||
if old_tz is not None:
|
||||
os.environ["TZ"] = old_tz
|
||||
return got.get("tz"), [m for lvl, m in logs if "Maintenance start" in m], logs
|
||||
|
||||
|
||||
MT._load_zone = lambda n: ((_FAKE[n], "") if n in _FAKE
|
||||
else (None, "none given"))
|
||||
try:
|
||||
tz, lines, _ = amain_zone({"time_zone": "Europe/Brussels"}, "America/New_York")
|
||||
check("amain(): Home Assistant's zone reaches the Controller",
|
||||
tz is _FAKE["Europe/Brussels"])
|
||||
check("...logged once, naming the zone and the source",
|
||||
lines == ["Maintenance start hour is local time in Europe/Brussels, "
|
||||
"from Home Assistant."])
|
||||
tz, lines, _ = amain_zone(OSError("down"), "America/New_York")
|
||||
check("amain(), Home Assistant unavailable: the container's TZ, said "
|
||||
"plainly", tz is _FAKE["America/New_York"]
|
||||
and lines == ["Maintenance start hour is local time in "
|
||||
"America/New_York, from the add-on's time zone. Home "
|
||||
"Assistant gave none: OSError: down."])
|
||||
tz, lines, logs = amain_zone(OSError("down"), None)
|
||||
check("amain(), neither: UTC, with a WARNING line",
|
||||
tz is UTC and any(lvl == "WARNING" and "uses UTC" in m
|
||||
for lvl, m in logs))
|
||||
# Review: HA still starting (502) and the Supervisor's TZ is UTC.
|
||||
tz, lines, logs = amain_zone(OSError("502"), "UTC")
|
||||
check("amain(), Home Assistant unavailable and TZ is UTC: a WARNING "
|
||||
"that says to restart", tz is UTC
|
||||
and [(lvl, m) for lvl, m in logs if "Maintenance start" in m]
|
||||
== [("WARNING", "Maintenance start hour uses UTC, because Home "
|
||||
"Assistant did not answer. Restart the add-on when it is up.")])
|
||||
tz, lines, logs = amain_zone(OSError("502"), "America/New_York")
|
||||
check("...but a real TZ is used without a warning",
|
||||
not any(lvl == "WARNING" for lvl, m in logs
|
||||
if "Maintenance start" in m))
|
||||
finally:
|
||||
MT._load_zone = _real_load
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
print("9. nothing else in control moves: the same start instant, the same "
|
||||
"commands")
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety78_identity as s78 # noqa: E402
|
||||
|
||||
# rig's clock starts 2026-09-20 11:00 UTC = 13:00 CEST. The same drain,
|
||||
# asked for as UTC hour 11 (the old reading) and as local hour 13 (the new).
|
||||
|
||||
|
||||
def rows_with(start_hour, tz, n=1, both=False):
|
||||
"""The rig's rows; `both=True` also returns the Controller."""
|
||||
real_drain, real_ctl = s78.DRAIN, rig.Controller
|
||||
s78.DRAIN = {"maintenance_enabled": True,
|
||||
"maintenance_start_hour": start_hour}
|
||||
rig.Controller = functools.partial(real_ctl, site_tz=tz)
|
||||
try:
|
||||
# s78.run() wraps rig.Controller once more for its own tariff db.
|
||||
rows, c = s78.run(n, dur=600.0)
|
||||
return (rows, c) if both else rows
|
||||
finally:
|
||||
s78.DRAIN, rig.Controller = real_drain, real_ctl
|
||||
|
||||
|
||||
_utc11 = rows_with(11, None)
|
||||
_loc13 = rows_with(13, _eu)
|
||||
check("precondition: the drain really starts in the UTC-11 run",
|
||||
any("drain" in r[-1][4] for r in _utc11))
|
||||
check(f"start hour 11 UTC and 13 Europe/Brussels: every command, claim, "
|
||||
f"reason and log line identical over {len(_utc11)} cycles",
|
||||
_utc11 == _loc13)
|
||||
_utc12 = rows_with(12, None)
|
||||
_loc14 = rows_with(14, _eu)
|
||||
check("precondition: no drain in the not-yet-due-hour run",
|
||||
not any("drain" in r[-1][4] for r in _utc12))
|
||||
check("outside the start hour (12 UTC / 14 local): identical as well",
|
||||
_utc12 == _loc14)
|
||||
check("...and the zone matters: hour 11 read in Brussels does NOT start",
|
||||
not any("drain" in r[-1][4] for r in rows_with(11, _eu)))
|
||||
|
||||
# =========================================================================== #
|
||||
print("10. two inverters: every pack reads the start hour in local time")
|
||||
_r2u, _c2u = rows_with(11, None, n=2, both=True)
|
||||
_r2l, _c2l = rows_with(13, _eu, n=2, both=True)
|
||||
check("N=2: both packs get the site's zone, not just the first",
|
||||
len(_c2l.maints) == 2 and all(mm.tz is _eu for mm in _c2l.maints))
|
||||
check("N=2 precondition: a drain starts in the UTC-11 run",
|
||||
any(DRAIN in r[-1][4] for r in _r2u))
|
||||
check(f"N=2: start hour 11 UTC and 13 Europe/Brussels identical over "
|
||||
f"{len(_r2u)} cycles", _r2u == _r2l)
|
||||
check("N=2: hour 11 read in Brussels does NOT start either pack",
|
||||
not any(DRAIN in r[-1][4] for r in rows_with(11, _eu, n=2)))
|
||||
|
||||
|
||||
def pair_starts(day0, days, hour, tz, cycle_min=70):
|
||||
"""Two due packs, ticked each minute through `arbitrate()` as main.py
|
||||
does; a cycle takes about `cycle_min` minutes. `(pack, "DD HH:MM" UTC)`s."""
|
||||
cfg = MaintConfig(enabled=True, start_hour=hour, hold_min=10)
|
||||
ms = [Maintenance(cfg, FakeStore(), name=u, tz=tz)
|
||||
for u in ("A", "B")]
|
||||
ms[0].store.set_time("last_completed", day0 - timedelta(days=60))
|
||||
ms[1].store.set_time("last_completed", day0 - timedelta(days=40))
|
||||
half = (cycle_min - 10) // 2
|
||||
soc, out = [80.0, 80.0], []
|
||||
t = day0 - timedelta(hours=3)
|
||||
while t < day0 + timedelta(days=days):
|
||||
blocks = MT.arbitrate(ms, t)
|
||||
for i, mm in enumerate(ms):
|
||||
was = mm.phase
|
||||
mm.tick(t, soc=soc[i], blocked_by=blocks[i])
|
||||
if mm.phase == DRAIN:
|
||||
soc[i] -= (80.0 - cfg.soc_floor) / half
|
||||
elif mm.phase == MT.CHARGE:
|
||||
soc[i] += (cfg.soc_target - cfg.soc_floor) / half
|
||||
if mm.phase == DRAIN and was != DRAIN:
|
||||
out.append((mm.name, t))
|
||||
t += timedelta(minutes=1)
|
||||
return [(n, x.strftime("%d %H:%M")) for n, x in out]
|
||||
|
||||
|
||||
def _at(*pairs):
|
||||
return [(n, x.strftime("%d %H:%M")) for n, x in pairs]
|
||||
|
||||
|
||||
for label, tz in ZONES:
|
||||
# 24 Oct (CEST) and 25 Oct (CET): 19:00 local is 17:00 then 18:00 UTC.
|
||||
got = pair_starts(AUTUMN - timedelta(days=1), 2, 19, tz)
|
||||
check(f"[{label}] N=2 across the autumn change, hour 19: A at 19:00 "
|
||||
f"local on the 24th, B at 19:00 local on the 25th {got}",
|
||||
got == _at(("A", AUTUMN - timedelta(hours=7)),
|
||||
("B", AUTUMN + timedelta(hours=18))))
|
||||
got = pair_starts(SPRING27 - timedelta(days=1), 2, 19, tz)
|
||||
check(f"[{label}] N=2 across the spring 2027 change, hour 19: 18:00 UTC "
|
||||
f"then 17:00 UTC {got}",
|
||||
got == _at(("A", SPRING27 - timedelta(hours=6)),
|
||||
("B", SPRING27 + timedelta(hours=17))))
|
||||
# Hour 2 on 25 Oct: A takes the first pass (00:00 UTC) and is done in its
|
||||
# second pass. B must not start there - it waits for the next day.
|
||||
got = pair_starts(AUTUMN, 2, 2, tz)
|
||||
check(f"[{label}] N=2, autumn, hour 2: B does not start in the repeated "
|
||||
f"hour after A is done; it starts at 02:00 CET on the 26th {got}",
|
||||
got == _at(("A", AUTUMN),
|
||||
("B", AUTUMN + timedelta(days=1, hours=1))))
|
||||
got = pair_starts(SPRING27, 2, 2, tz)
|
||||
check(f"[{label}] N=2, spring 2027, hour 2: A at 03:00 CEST (01:00 UTC), "
|
||||
f"B the next night at 02:00 CEST (00:00 UTC) {got}",
|
||||
got == _at(("A", SPRING27 + timedelta(hours=1)),
|
||||
("B", SPRING27 + timedelta(days=1))))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -716,10 +716,11 @@ check("the drain's commitment window is DERIVED from the phase machine's own "
|
||||
"timeout, so the gate and maintenance.py cannot drift apart",
|
||||
near(MAINTENANCE_DRAIN.commits_h, DRAIN_TIMEOUT_MIN / 60.0)
|
||||
and near(MAINTENANCE_CHARGE.commits_h, CHARGE_TIMEOUT_MIN / 60.0))
|
||||
# The shipped `maintenance_start_hour` is 10:00 local, so a drain started on
|
||||
# schedule runs into the evening - hours a morning-only day-ahead print has not
|
||||
# covered. That is the ticket's scenario and it is where the window is placed.
|
||||
START = D0 + timedelta(hours=MaintConfig().start_hour)
|
||||
# A drain started at 10:00 local runs into the evening - hours a morning-only
|
||||
# day-ahead print has not covered. That is the ticket's scenario, so the hour is
|
||||
# pinned here: 10 was the shipped default until MAINT-10 moved it to 19, and an
|
||||
# install that stored 10 still starts then.
|
||||
START = D0 + timedelta(hours=10)
|
||||
_hours = derived(lambda: committed_hours(START, MAINTENANCE_DRAIN))
|
||||
check(f"...and it commits {len(_hours) if not isinstance(_hours, _Raised) else _hours} "
|
||||
f"whole hours, one per hour of the timeout",
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""Runnable check for SAFETY-90: the peak guard goes on after a P1 meter gap.
|
||||
`python3 test_safety90.py`
|
||||
|
||||
A gap past `meter_max_age_s` left seconds the quarter average never counted,
|
||||
so the guard was blind for the rest of the quarter: a HOLD gave 0 W and the
|
||||
quarter raised the month peak (SAFETY-86 rig case 7b: 3964.8 W against a
|
||||
3450 W aim). Now the missed seconds count at `peakguard.gap_w()`, and so do
|
||||
the seconds before the add-on's first reading, with a plain line on the
|
||||
page. Drives the REAL Controller on a lagging battery (10 s), with the P1
|
||||
telegrams stopped for each gap. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.
|
||||
|
||||
1. N=1 HOLD: gaps of 30 s, 60 s and 5 min mid-quarter land at or under the aim.
|
||||
2. N=2 HOLD, B able: the same.
|
||||
3. N=2 HOLD, B held at its reserve: the same, and B gives nothing (O2).
|
||||
4. A gap across a quarter edge; the house rising during a gap.
|
||||
5. Start-up mid-quarter: the guard runs, the page says what it assumed.
|
||||
6. While the gap lasts the guard is blind as before; no gap, no change.
|
||||
7. fill_gaps() and gap_w() at their boundaries.
|
||||
|
||||
Mutations (MUTATION.md, purged copies) are listed in the pull request.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from datetime import timedelta
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "tools"))
|
||||
logging.disable(logging.WARNING)
|
||||
|
||||
import opt11_identity as rig # noqa: E402
|
||||
import safety84_identity as s84 # noqa: E402
|
||||
import app.p1 as p1mod # noqa: E402
|
||||
import app.tariff as tariff # noqa: E402
|
||||
from app import homeview, messages, peakguard # noqa: E402
|
||||
from app.maintenance import HOLD # noqa: E402
|
||||
from app.p1 import make_sample, split_signed # noqa: E402
|
||||
from app.store import unit_key # noqa: E402
|
||||
from app.tariff import QUARTER_S, Projection # noqa: E402
|
||||
|
||||
fails = []
|
||||
A, B = "inverter-a", "inverter-b"
|
||||
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
|
||||
TOL = 5.0 # W over the aim a quarter may land (SAFETY-79)
|
||||
CUT = 10.0
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
def store(phase_of, n):
|
||||
s = rig._Store()
|
||||
for u in (A, B)[:n]:
|
||||
key = (lambda k: k) if n == 1 else (lambda k, u=u: unit_key(u, k))
|
||||
if u in phase_of:
|
||||
s.data[key("phase")] = phase_of[u]
|
||||
s.data[key("phase_started")] = rig.BASE.isoformat()
|
||||
else:
|
||||
s.data[key("last_completed")] = rig.BASE.isoformat()
|
||||
return s
|
||||
|
||||
|
||||
def run(house, socs, dur, *, n, gaps=(), start=0.0, lag=10.0, on_cycle=None):
|
||||
"""Rows and controller. A holds full; `socs` are the starting levels
|
||||
(B's follows its power). `gaps`: (t0, t1), no telegram for t0 <= t < t1.
|
||||
`start`: the add-on starts this many s into the run (mid-quarter). The
|
||||
P1 clock is simulated, so a gap really goes stale. `c.truth` is the grid
|
||||
each second, which the quarter really averages."""
|
||||
clock = [0.0]
|
||||
soc, last, truth = dict(socs), [start], []
|
||||
|
||||
def before(c, t0):
|
||||
if on_cycle is not None:
|
||||
on_cycle(c, t0) # the state the last cycle left
|
||||
t = t0 + start
|
||||
clock[0] = t
|
||||
g = c.hass.grid()
|
||||
truth.append((t, g))
|
||||
for u in soc:
|
||||
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
|
||||
/ 3600.0 / 153.6)
|
||||
last[0] = t
|
||||
if any(a <= t < b for a, b in gaps):
|
||||
return
|
||||
imp, exp = split_signed(g)
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t0),
|
||||
ingest_mono=1e6 + t))
|
||||
|
||||
def power(self, u):
|
||||
st = self.__dict__.setdefault("_plant", {})
|
||||
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
|
||||
for _ in range(int(round((self.t - tl) / 0.1))):
|
||||
d = self.cmd[u] - tgt
|
||||
tgt += math.copysign(min(abs(d), 80.0), d)
|
||||
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
|
||||
st[u] = (a, tgt, self.t)
|
||||
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
|
||||
|
||||
real = (rig._Hass.power, rig.TICK, rig.BASE, p1mod.time)
|
||||
rig._Hass.power, rig.TICK = power, 1.0
|
||||
rig.BASE = real[2] + timedelta(seconds=start)
|
||||
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + clock[0])
|
||||
try:
|
||||
rows, c = s84.run(
|
||||
n, load=house if callable(house) else (lambda t: house),
|
||||
cheap=False, store=store({A: HOLD}, n), before=before, dur=dur,
|
||||
soc=lambda u, t: soc[u],
|
||||
extra={"maintenance_hold_min": 600})
|
||||
finally:
|
||||
rig._Hass.power, rig.TICK, rig.BASE, p1mod.time = real
|
||||
c.truth = truth
|
||||
return rows, c
|
||||
|
||||
|
||||
def without_fix(fn):
|
||||
"""release/1.0 3ed3302: nothing is filled, so a gap blinds the guard."""
|
||||
real = peakguard.fill_gaps
|
||||
peakguard.fill_gaps = lambda p, **kw: (p, 0.0, 0.0)
|
||||
try:
|
||||
return fn()
|
||||
finally:
|
||||
peakguard.fill_gaps = real
|
||||
|
||||
|
||||
def quarter(c, q):
|
||||
"""What quarter `q` (0 = the run's first) really averages, in W."""
|
||||
return sum(max(g, 0.0) for t, g in c.truth if int(t // QUARTER_S) == q) \
|
||||
/ QUARTER_S
|
||||
|
||||
|
||||
def cmd(r):
|
||||
return dict(r[2])
|
||||
|
||||
|
||||
def mean(rows, u, lo, hi):
|
||||
rs = [cmd(r)[u] for r in rows if lo <= r[0] < hi]
|
||||
return round(sum(rs) / len(rs))
|
||||
|
||||
|
||||
TABLE = []
|
||||
|
||||
|
||||
def case(tag, n, house, socs, gaps, q=0, dur=1800.0):
|
||||
"""One gap case, after and before the fix; prints and records it."""
|
||||
new = run(house, socs, dur, n=n, gaps=gaps)
|
||||
old = without_fix(lambda: run(house, socs, dur, n=n, gaps=gaps))
|
||||
a, b = quarter(new[1], q), quarter(old[1], q)
|
||||
print(f" {tag}: quarter {a:.1f} W (before the fix {b:.1f} W), aim "
|
||||
f"{AIM:.0f}")
|
||||
TABLE.append((tag, n, round(a, 1), round(b, 1)))
|
||||
return new, old, a, b
|
||||
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. N=1 HOLD, house 6500 W (5500 W for 5 min, so it can be held)")
|
||||
for tag, gap, house in (("30 s", (300, 330), 6500.0),
|
||||
("60 s", (300, 360), 6500.0),
|
||||
("5 min", (300, 600), 5500.0)):
|
||||
(rows, c), _old, a, b = case(f"N=1 gap {tag}", 1, house, {"a": 99.0},
|
||||
[gap])
|
||||
check(f"⭐ N=1, {tag} gap mid-quarter: the quarter lands at or under the "
|
||||
f"aim ({a:.1f} W)", a <= AIM + TOL)
|
||||
if tag != "30 s":
|
||||
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
|
||||
b > AIM + 100)
|
||||
check("...and once readings are back the battery gives at least the "
|
||||
"house over the aim",
|
||||
mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
|
||||
check("...the action log says what the gap was counted at",
|
||||
any("PEAK_GUARD_GAP" in e for e in c.events))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. N=2 HOLD (A holding), B able at 60 %, house 6500 W")
|
||||
for tag, gap, house in (("30 s", (300, 330), 6500.0),
|
||||
("60 s", (300, 360), 6500.0),
|
||||
("5 min", (300, 600), 5500.0)):
|
||||
(rows, c), _old, a, b = case(f"N=2 gap {tag}", 2, house,
|
||||
{"a": 99.0, "b": 60.0}, [gap])
|
||||
check(f"⭐ N=2, {tag} gap: the quarter lands at or under the aim "
|
||||
f"({a:.1f} W)", a <= AIM + TOL)
|
||||
if tag != "30 s":
|
||||
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
|
||||
b > AIM + 100)
|
||||
check("...B gives first: at its full 2500 W once readings are back",
|
||||
mean(rows, "b", gap[1] + 30, 900) >= 2450)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. N=2 HOLD, B held at 15 % (under its 20 % reserve), house 5500 W "
|
||||
"(4500 W for 5 min: A alone gives 2500 W)")
|
||||
for tag, gap, house in (("30 s", (300, 330), 5500.0),
|
||||
("60 s", (300, 360), 5500.0),
|
||||
("5 min", (300, 600), 4500.0)):
|
||||
(rows, c), (orows, _oc), a, b = case(
|
||||
f"N=2 B held, gap {tag}", 2, house, {"a": 99.0, "b": 15.0}, [gap])
|
||||
check(f"⭐ N=2 B held, {tag} gap: the quarter lands at or under the aim "
|
||||
f"({a:.1f} W)", a <= AIM + TOL)
|
||||
check("⭐ O2: the held B gives nothing once readings are back; the "
|
||||
"holding A gives the need",
|
||||
all(cmd(r)["b"] == 0.0 for r in rows if gap[1] <= r[0] < 900)
|
||||
and mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
|
||||
if tag != "30 s":
|
||||
check("...non-vacuous: before the fix the blind guard left A at 0 W "
|
||||
"and the held B was drawn at 2500 W as last resort",
|
||||
mean(orows, "a", gap[1] + 30, 900) == 0
|
||||
and mean(orows, "b", gap[1] + 30, 900) >= 2450)
|
||||
|
||||
rows, c = run(5500.0, {"a": 99.0, "b": 15.0}, 1000.0, n=2,
|
||||
gaps=[(300, 360), (899, 910)])
|
||||
edge = [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows if 900 <= r[0] < 915]
|
||||
print(f" a 60 s gap, then the edge's telegram 11 s late: {edge[:4]}; the "
|
||||
f"second before the edge (O1, not this card): "
|
||||
f"{[(r[0], cmd(r)['a'], cmd(r)['b']) for r in rows if r[0] == 899]}")
|
||||
check("⭐ ...a quarter that had a gap still hands over at its edge (SAFETY-91): "
|
||||
"with the edge's telegram late the held B gives nothing and A keeps "
|
||||
"giving", all(b == 0.0 and a > 1500.0 for _t, a, b in edge))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. a gap across a quarter edge, and a house that rises in a gap")
|
||||
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 60.0})):
|
||||
(rows, c), _o, a, b = case(f"N={n} gap across the edge", n, 6500.0, socs,
|
||||
[(870, 930)], q=1)
|
||||
check(f"⭐ N={n}: the quarter the readings come back in lands at or "
|
||||
f"under the aim ({a:.1f} W; before the fix {b:.1f} W)",
|
||||
a <= AIM + TOL and b > AIM + 100)
|
||||
check("...the quarter that ended in the gap could not be steered after "
|
||||
"it, and stays under the limit itself",
|
||||
quarter(c, 0) <= 3500.0)
|
||||
|
||||
|
||||
def rising(t):
|
||||
return 3000.0 if t < 320 else 6500.0
|
||||
|
||||
|
||||
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
|
||||
house = rising if n == 1 else (lambda t: 3000.0 if t < 320 else 5500.0)
|
||||
(rows, c), _o, a, b = case(f"N={n} house rises in a 60 s gap", n, house,
|
||||
socs, [(300, 360)])
|
||||
check(f"⭐ N={n}: the house goes from 3000 W to over the limit during "
|
||||
f"the gap; the import seen after it is counted and the quarter "
|
||||
f"lands at or under the aim ({a:.1f} W)", a <= AIM + TOL)
|
||||
|
||||
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
|
||||
big = 6500.0 if n == 1 else 5500.0
|
||||
(rows, c), _o, a, b = case(
|
||||
f"N={n} house falls in a 5 min gap", n,
|
||||
lambda t, big=big: big if t < 560 else 2000.0, socs, [(300, 600)])
|
||||
check(f"⭐ N={n}: the house falls to 2000 W just before readings return; "
|
||||
f"the import before the gap (batteries at 0 W) is counted and the "
|
||||
f"quarter lands at or under the aim ({a:.1f} W; before {b:.1f} W, "
|
||||
f"at N=2 by drawing the held B)",
|
||||
a <= AIM + TOL and (n == 2 or b > AIM + 100))
|
||||
|
||||
(rows, c), _o, a, b = case(
|
||||
"N=1 5 min gap at 3000 W, then 60 s gap at 6500 W next quarter", 1,
|
||||
lambda t: 3000.0 if t < 900 else 6500.0, {"a": 99.0},
|
||||
[(300, 600), (1200, 1260)], q=1)
|
||||
check(f"⭐ ...each quarter's gap is counted on its own: a long gap at a low "
|
||||
f"load does not hide a shorter one in the next quarter ({a:.1f} W)",
|
||||
a <= AIM + TOL and sum("PEAK_GUARD_GAP" in e for e in c.events) == 2)
|
||||
|
||||
|
||||
|
||||
def dip(t):
|
||||
return 3000.0 if 450 <= t < 700 else 6500.0
|
||||
|
||||
|
||||
(rows, c), _o, a, b = case("N=1 two gaps, the second at a lower load", 1,
|
||||
dip, {"a": 99.0}, [(300, 360), (500, 560)])
|
||||
at = [float(e.split("counted at ")[1].split(" W")[0]) for e in c.events
|
||||
if "PEAK_GUARD_GAP" in e]
|
||||
check(f"⭐ ...two gaps in one quarter: the second, at a lower load, does not "
|
||||
f"price the first one down (counted at {at}; {a:.1f} W)",
|
||||
a <= AIM + TOL and len(at) == 2 and min(at) >= 6400.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. start-up mid-quarter (the add-on starts 450 s into the quarter)")
|
||||
UI = {}
|
||||
|
||||
|
||||
def grab(c, t):
|
||||
if t == 60.0:
|
||||
UI["unmeasured"] = c.peak_unmeasured_s
|
||||
UI["rows"] = [r for r in c.checks() if r.get("id") == "peak_guard"]
|
||||
UI["en"] = homeview.month_peak(c, c.cycle_at)["line"]
|
||||
homeview._LANG[0] = "nl"
|
||||
try:
|
||||
UI["nl"] = homeview.month_peak(c, c.cycle_at)["line"]
|
||||
finally:
|
||||
homeview._LANG[0] = "en"
|
||||
if t == 453.0:
|
||||
UI["later"] = (c.peak_unmeasured_s,
|
||||
[r for r in c.checks() if r.get("id") == "peak_guard"])
|
||||
|
||||
|
||||
def restored(t_dead, start=450.0):
|
||||
"""TARIFF-01 restores the averager at start-up: 0..t_dead measured at
|
||||
the aim, the held sample at t_dead (the restart's first second)."""
|
||||
def fake(self, averager, now=None):
|
||||
bs = int(rig.BASE.timestamp() - start)
|
||||
averager.restore({"block": bs, "acc": AIM * t_dead,
|
||||
"pp_acc": [AIM * t_dead], "elapsed": t_dead,
|
||||
"last_t": bs + t_dead, "last_net": AIM,
|
||||
"last_pp": [AIM], "phases": 1})
|
||||
self.restore_state = "resumed"
|
||||
return self.restore_state
|
||||
return fake
|
||||
|
||||
|
||||
def startup(house, socs, n, *, t_dead=None, on_cycle=None):
|
||||
"""The add-on starts 450 s in. While it was down the batteries were at
|
||||
0 W (the ESP32 watchdog), so the meter saw the house: the quarter is
|
||||
that, or the restored part at the aim, plus what this run measured."""
|
||||
real = tariff.PeakTracker.restore
|
||||
if t_dead is not None:
|
||||
tariff.PeakTracker.restore = restored(t_dead)
|
||||
try:
|
||||
rows, c = run(house, socs, 455.0, n=n, start=450.0,
|
||||
on_cycle=on_cycle)
|
||||
finally:
|
||||
tariff.PeakTracker.restore = real
|
||||
pre = 450.0 * house if t_dead is None else (
|
||||
t_dead * AIM + (450.0 - t_dead) * house)
|
||||
return rows, c, (pre + sum(max(g, 0.0) for t, g in c.truth if t < 900)) \
|
||||
/ QUARTER_S
|
||||
|
||||
|
||||
for n, socs, house in ((1, {"a": 99.0}, 5000.0),
|
||||
(2, {"a": 99.0, "b": 15.0}, 4500.0)):
|
||||
rows, c, land = startup(house, socs, n, on_cycle=grab if n == 1 else None)
|
||||
orows, _oc, old = without_fix(lambda: startup(house, socs, n))
|
||||
TABLE.append((f"N={n} start-up mid-quarter", n, round(land, 1),
|
||||
round(old, 1)))
|
||||
check(f"⭐ N={n}: the first 450 s (house {house:.0f} W, batteries at 0 W) "
|
||||
f"count at the import seen first; the quarter lands at or under "
|
||||
f"the aim ({land:.1f} W, before the fix {old:.1f} W)",
|
||||
land <= AIM + TOL and (n == 2 or old > AIM + 100))
|
||||
if n == 2:
|
||||
check("...and the held B gives nothing (O2 at start-up); before the "
|
||||
"fix it was drawn at 2500 W",
|
||||
all(cmd(r)["b"] == 0.0 for r in rows if 1 <= r[0] < 450)
|
||||
and mean(orows, "b", 30, 450) >= 2450)
|
||||
check("...non-vacuous: before the fix A gave 0 W for the quarter",
|
||||
mean(orows, "a", 30, 450) == 0)
|
||||
|
||||
rows, c, land = startup(6500.0, {"a": 99.0}, 1)
|
||||
check(f"⭐ N=1, house 6500 W: more than the battery can make up; A gives its "
|
||||
f"full 5000 W and the alarm says so ({land:.1f} W)",
|
||||
mean(rows, "a", 30, 450) >= 4900
|
||||
and any("PEAK_GUARD_SATURATED" in e for e in c.events))
|
||||
|
||||
for t_dead in (300.0, 390.0, 440.0):
|
||||
rows, c, land = startup(6500.0, {"a": 99.0}, 1, t_dead=t_dead)
|
||||
TABLE.append((f"N=1 restart, down {450 - t_dead:.0f} s", 1,
|
||||
round(land, 1), round(without_fix(lambda: startup(
|
||||
6500.0, {"a": 99.0}, 1, t_dead=t_dead))[2], 1)))
|
||||
check(f"⭐ restart mid-quarter, down {450 - t_dead:.0f} s at house "
|
||||
f"6500 W (the restored reading's held 30 s topped up too): the "
|
||||
f"quarter lands at or under the aim ({land:.1f} W)",
|
||||
land <= AIM + TOL)
|
||||
|
||||
rows, c = run(6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
|
||||
gaps=[(1200, 1260)])
|
||||
a = quarter(c, 1)
|
||||
TABLE.append(("N=1 start-up, then 60 s gap next quarter", 1, round(a, 1),
|
||||
round(quarter(without_fix(lambda: run(
|
||||
6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
|
||||
gaps=[(1200, 1260)]))[1], 1), 1)))
|
||||
check(f"...the start-up assumption stays in its own quarter: a 60 s gap in "
|
||||
f"the next one is counted as a gap and lands at or under the aim "
|
||||
f"({a:.1f} W)", a <= AIM + TOL)
|
||||
|
||||
print(f" page: {UI.get('rows')}\n EN {UI.get('en')!r}\n"
|
||||
f" NL {UI.get('nl')!r}")
|
||||
check("⭐ the page says plainly that part of the period was not measured, "
|
||||
"in English and Dutch",
|
||||
UI.get("unmeasured", 0) > 400
|
||||
and UI["rows"][0]["text"] == messages.row_peak_guard_unmeasured()
|
||||
and UI["en"].startswith("Part of this 15-minute period was not "
|
||||
"measured.")
|
||||
and UI["nl"].startswith("Een deel van dit kwartier is niet gemeten."))
|
||||
check("...and stops saying it in the next quarter",
|
||||
UI["later"][0] == 0.0 and UI["later"][1][0]["text"]
|
||||
!= messages.row_peak_guard_unmeasured())
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n6. while the gap lasts, and with no gap at all")
|
||||
LIVE = {}
|
||||
|
||||
|
||||
def live(c, t):
|
||||
if t == 350.0:
|
||||
LIVE["g"] = c.peak_guard
|
||||
|
||||
|
||||
run(6500.0, {"a": 99.0}, 400.0, n=1, gaps=[(300, 360)], on_cycle=live)
|
||||
check("⭐ 50 s into a gap the guard is blind as before (floor 0 W, no "
|
||||
"charging): nothing is filled while the gap is open",
|
||||
LIVE["g"].blind is not None and LIVE["g"].floor_w == 0.0)
|
||||
for n, socs, house in ((1, {"a": 99.0}, 6500.0),
|
||||
(2, {"a": 99.0, "b": 60.0}, 6500.0),
|
||||
(2, {"a": 99.0, "b": 15.0}, 5500.0)):
|
||||
rows, c = run(house, socs, 1000.0, n=n)
|
||||
orows, _ = without_fix(lambda: run(house, socs, 1000.0, n=n))
|
||||
check(f"⭐ N={n}, meter never drops: every row is identical with and "
|
||||
f"without the fix, and nothing is filled",
|
||||
rows == orows and not any("PEAK_GUARD_GAP" in e for e in c.events))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n7. fill_gaps() and gap_w() at their boundaries")
|
||||
T0 = rig.BASE
|
||||
|
||||
|
||||
def proj(wall, covered, held=1000.0, acc_w=1000.0):
|
||||
return Projection(projected_w=(acc_w * covered + held * (QUARTER_S - wall))
|
||||
/ QUARTER_S, elapsed_w=acc_w, elapsed_s=wall,
|
||||
covered_frac=covered / wall, complete=False,
|
||||
block_start=T0, held_w=held)
|
||||
|
||||
|
||||
def fill(p, tail, unseen=0.0, gw=4000.0, uw=3450.0):
|
||||
return peakguard.fill_gaps(p, tail_s=tail, max_hold_s=30.0,
|
||||
unseen_s=unseen, gap_w=gw, unseen_w=uw)
|
||||
|
||||
|
||||
p = proj(400.0, 300.0) # 100 s uncounted
|
||||
f, g, u = fill(p, 10.0)
|
||||
check("90 s missed (100 s uncounted less a 10 s tail): counted at gap_w, "
|
||||
"the tail left as before, no longer blind",
|
||||
g == 90.0 and u == 0.0
|
||||
and abs(f.projected_w - (p.projected_w + 90.0 * 4000.0 / QUARTER_S))
|
||||
< 1e-9 and abs(peakguard.uncovered_s(f) - 10.0) < 1e-9
|
||||
and peakguard.blindness(f, 30.0, False) is None
|
||||
and peakguard.blindness(p, 30.0, False) is not None)
|
||||
check("tail exactly max_hold_s: filled; one past it: the gap is open, "
|
||||
"nothing filled",
|
||||
fill(p, 30.0)[1] == 70.0 and fill(p, 30.001) == (p, 0.0, 0.0))
|
||||
f, g, u = fill(p, 10.0, unseen=60.0)
|
||||
check("unseen seconds come first and count at the aim, the rest at gap_w",
|
||||
u == 60.0 and g == 30.0
|
||||
and abs(f.projected_w - (p.projected_w + (60 * 3450.0 + 30 * 4000.0)
|
||||
/ QUARTER_S)) < 1e-9)
|
||||
check("nothing missed (or under 1 ms): the projection object is returned "
|
||||
"unchanged",
|
||||
fill(proj(400.0, 400.0), 0.0)[0] is not None
|
||||
and fill(proj(400.0, 400.0), 0.0) == (proj(400.0, 400.0), 0.0, 0.0)
|
||||
and fill(proj(400.0, 399.9995), 0.0)[1] == 0.0)
|
||||
f, g, u = fill(proj(400.0, 400.0), 0.0)
|
||||
f2 = peakguard.fill_gaps(proj(400.0, 400.0), tail_s=0.0, max_hold_s=30.0,
|
||||
unseen_s=0.0, gap_w=4000.0, unseen_w=3450.0,
|
||||
held_ws=9000.0)
|
||||
check("held_ws (the gap's first 30 s topped up) is added even when nothing "
|
||||
"is missing any more",
|
||||
g == 0.0 and abs(f2[0].projected_w - f.projected_w - 10.0) < 1e-9)
|
||||
z = peakguard.fill_gaps(Projection(0, 0, 0, 0, False, T0, 0), tail_s=0.0,
|
||||
max_hold_s=30.0, unseen_s=0.0, gap_w=4000.0,
|
||||
unseen_w=3450.0, held_ws=9000.0)
|
||||
check("the first instant of a block (0 s elapsed) with held_ws: no division "
|
||||
"by zero", abs(z[0].projected_w - 10.0) < 1e-9)
|
||||
check("no open block: unchanged (blindness says so)",
|
||||
fill(Projection(0, 0, 0, 0, False, None, 0), 0.0)[1:] == (0.0, 0.0))
|
||||
check("gap_w(): the highest of the limit and the imports seen, None skipped",
|
||||
peakguard.gap_w(3500, None, 3000.0) == 3500.0
|
||||
and peakguard.gap_w(3500, 6500.0, None) == 6500.0
|
||||
and peakguard.gap_w(3500, 3000.0, 4500.0) == 4500.0
|
||||
and peakguard.gap_w(3500) == 3500.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n case N quarter W before W aim")
|
||||
for tag, n, a, b in TABLE:
|
||||
print(f" {tag:<34}{n} {a:>9.1f} {b:>9.1f} {AIM:.0f}")
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
sys.exit(1)
|
||||
print("all SAFETY-90 checks passed")
|
||||
@@ -749,6 +749,24 @@ show("(d) GLOBAL `pv_invert: bool` validates too - it loses on SEMANTICS (one "
|
||||
"sign for a mixed roster), not on the schema", ok_d, "" if ok_d else out_d)
|
||||
|
||||
|
||||
print("\n== I. MAINT-10: `maintenance_start_hour` now defaults to 19 ==")
|
||||
_mk = "maintenance_start_hour"
|
||||
ok_i, out_i = validate(defaults)
|
||||
show("the shipped `options:` block validates and carries 19",
|
||||
ok_i and out_i.get(_mk) == 19, "" if ok_i else out_i)
|
||||
# What the Supervisor hands the add-on is deepmerge(options:, stored).
|
||||
_stored10 = dict(defaults, **{_mk: 10})
|
||||
ok_i2, out_i2 = validate(MERGER.merge(copy.deepcopy(defaults), _stored10))
|
||||
show("a stored explicit 10 (what a blob saved before MAINT-10 holds) stays 10",
|
||||
ok_i2 and out_i2.get(_mk) == 10, "" if ok_i2 else out_i2)
|
||||
_absent = {k: v for k, v in defaults.items() if k != _mk}
|
||||
ok_i3, out_i3 = validate(MERGER.merge(copy.deepcopy(defaults), _absent))
|
||||
show("a stored blob WITHOUT the key gets the new default, 19",
|
||||
ok_i3 and out_i3.get(_mk) == 19, "" if ok_i3 else out_i3)
|
||||
ok_i4, out_i4 = validate(dict(defaults, **{_mk: 24}))
|
||||
show("the range is unchanged: 24 is refused (`at most 23`)",
|
||||
(not ok_i4) and "at most 23" in str(out_i4), out_i4)
|
||||
|
||||
# ⚠️ THE EXIT CODE IS PART OF THE INSTRUMENT. Without it the probe prints FAIL
|
||||
# and still succeeds, and a transcript is only evidence if a wrong answer is
|
||||
# distinguishable from a right one by something other than reading it.
|
||||
|
||||
@@ -224,8 +224,8 @@ configuration:
|
||||
name: "Maintenance · Minimum days between cycles"
|
||||
description: "The shortest gap between two maintenance cycles."
|
||||
maintenance_start_hour:
|
||||
name: "Maintenance · Start hour"
|
||||
description: "The local hour, 0 to 23, at which a due cycle starts."
|
||||
name: "Maintenance · Start hour (local time)"
|
||||
description: "The hour on your local clock, 0 to 23, when a due cycle starts. 19 means 19:00."
|
||||
maintenance_discharge_w:
|
||||
name: "Maintenance · Draw-down rate (W)"
|
||||
description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer."
|
||||
|
||||
@@ -205,8 +205,8 @@ configuration:
|
||||
name: "Onderhoud · Minimum dagen tussen cycli"
|
||||
description: "De kortste tijd tussen twee onderhoudscycli."
|
||||
maintenance_start_hour:
|
||||
name: "Onderhoud · Startuur"
|
||||
description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint."
|
||||
name: "Onderhoud · Startuur (lokale tijd)"
|
||||
description: "Het uur op je lokale klok, 0 tot 23, waarop een geplande cyclus begint. 19 is 19:00."
|
||||
maintenance_discharge_w:
|
||||
name: "Onderhoud · Ontlaadvermogen (W)"
|
||||
description: "Hoe snel de cyclus de batterij leegmaakt. Boven het vermogen van de omvormer wordt het daartoe beperkt, en duurt de cyclus gewoon langer."
|
||||
|
||||
Reference in New Issue
Block a user