Compare commits

...
6 Commits
Author SHA1 Message Date
glenn schrooyenandClaude c1809b4ca9 UX-46: test_ux46 points at MUTATION.md and reads rows without raising
checks / unit (pull_request) Successful in 44m51s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 03:41:18 +02:00
glenn schrooyenandClaude 7a392a5367 UX-46: Dutch status rows, banners, notes and wizard lines on the NL pages
Rows and banners are built once with their Dutch beside them (messages.Bi)
and status_payload() picks one by the page's language. Settings notes and the
wizard's checks carry a Dutch twin (Note.nl) that /config/validate picks by
the posted language. The peak forecast label drops "(W)" in EN and NL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-22 03:21:41 +02:00
admin e077ae7f97 Merge pull request 'SAFETY-121: a recurring overload warns every quarter' (#204) from SAFETY-121 into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-10-21 12:41:12 +02:00
glenn schrooyenandClaude Opus 5.5 24f31a9c3a SAFETY-121 review: DECISIONS limits state the jitter spread and the slow-cycle case
checks / unit (pull_request) Successful in 44m29s
infra-pin / fresh (pull_request) Successful in 1s
The jittery-meter numbers depend on the draw (review: 4, 4, 5 of 11 at the
quarter edge; up to 4 quiet quarters at 20 s; first warning up to 61 min
later). Above about 40 s per cycle, a short bound can still use up the
budget when the wire changes hands every few cycles. Text only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:55:24 +02:00
glenn schrooyenandClaude 8bf25696cb SAFETY-121: a recurring overload warns every quarter
checks / unit (pull_request) Successful in 44m51s
infra-pin / fresh (pull_request) Successful in 1s
TEST-23 F-1: on a 5 s meter a steady overload warned once in 70 min. The
loop was at its bound 4-5 cycles a quarter, all inside SAFETY-119's settle
of 12 cycles (60 s) and under its budget of 8 per 900 s. The simulation
fed the meter a cycle behind the battery reading, so the guard and the
charge chased each other and gave ~20 bound cycles a quarter.

The settle is now 4 cycles and 22 s (HANDBACK_CYCLES, SHORT_SPAN_S), and
the rolling budget one number, 11 per 900 s, at every cycle length. The
cycle length is no longer tracked. Commands unchanged; the re-pinned
digests of test_safety84/87 differ from bee7431 in OUTPUT_CLAMP rows only.
test_safety119 runs the rig's shape (meter in the same cycle) at 1-15 s
and checks a stop keeps the budget (review F2, X3).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-21 10:09:14 +02:00
admin bee7431793 Merge pull request 'SAFETY-119: no "not keeping up" when the loop takes the wire back' (#203) from SAFETY-119 into release/1.0
infra-pin / fresh (push) Successful in 1s
Merge pull request 'SAFETY-119: no "not keeping up" when the loop takes the wire back' (#203) from SAFETY-119 into release/1.0
2026-10-21 03:32:38 +02:00
15 changed files with 1544 additions and 339 deletions
@@ -1,6 +1,9 @@
# SAFETY-119 - no "not keeping up" when the loop takes the wire back
**Status: for review.** Tests: `test_safety119.py`.
**Status: merged (bee7431); the settle and the budget changed in SAFETY-121**
(DECISIONS/SAFETY-121-bound-every-quarter.md: 4 cycles and 22 s, one budget
of 11). The rolling-budget sections below describe bee7431. Tests:
`test_safety119.py`.
**Cause.** When another owner (the peak guard, a maintenance charge) has set
the wire and the loop wins again, `cycle()` re-seeds the integrator from that
@@ -0,0 +1,95 @@
# SAFETY-121 - a recurring overload warns every quarter
**Status: for review.** Tests: `test_safety119.py` (parts 2, 4, 5, 6).
Follows DECISIONS/SAFETY-119-bound-after-hand-back.md.
**The defect (TEST-23, SAFETY-119 rig test F-1).** 5 s meter, 2 x 2500 W,
cap 5000 W, maintenance charge, no forecast, house 5100 W. bee7431 warned
once in 70 min (04:31:01), then not at 04:45, 05:00 or 05:15. Base 75f0804
warned at every quarter start. The loop held the wire once a quarter, at its
start, and was at its bound on 4 or 5 cycles. SAFETY-119's settle (12
cycles, 60 s at 5 s) covered all of them, and 5 cycles a quarter fit in its
budget of 8 per 900 s.
**Why the simulation said "every quarter".** `test_safety119.py` fed the
meter one cycle behind the battery reading (`delay` counts cycles: 5 s at a
5 s meter). Just after the loop leaves its rail, the guard's charge
headroom (house = meter + battery) is then off by up to 5000 W, the charge
pulls 2000 W, the guard steps in again, and the two chase each other: 8 to
10 hand-backs and about 20 bound cycles a quarter at 5 s (rig: 1 and 4-5).
With the meter read in the same cycle (`delay` 0, the rig's shape) the sim
gives the rig's numbers: 3 to 5 bound cycles a quarter at 5 s, all inside
SAFETY-119's settle, and bee7431 warns in 0 to 2 of 7 quarters. The test
now runs both shapes.
**Decision.** After a hand-back the loop's bound is hidden until 4 cycles
(`HANDBACK_CYCLES`) and 22 s (`SHORT_SPAN_S`) have passed; across all
hand-backs on at most 11 cycles (`ROLLING_BOUND_CYCLES`) in any 900 s. The
budget no longer follows the cycle length, and the cycle length is not
tracked. A stop ends the settle and keeps the budget. An over-rating always
counts. Commands are unchanged.
**Why it holds for any number of bound cycles a quarter.** The settle is
fixed: at most 3 cycles or 22 s after a hand-back, whichever is longer. A
bound that lasts past it counts on its first cycle after it. So one
hand-back a quarter, with the loop at its rail past the settle, warns in
that quarter, whether it is 1 bound cycle or 100. A bound that never lasts
past a settle counts once more than 11 are hidden within 900 s. Hidden
are only shapes with at most 11 bound cycles in 900 s, each within 3 cycles
or 22 s of a hand-back. That is the quarter-edge kick: in every steady-meter
run below it is on the 1st to 3rd cycle after its hand-back (up to 30 s at
a 30 s meter). In the rig's shape at 5 s the bound comes 30 to 55 s after
the hand-back.
**Sweep** (`goodwe-addon-checks:1`, 2 h runs, scratch/SAFETY-121/dev/).
Quarter edge = test_safety119 part 1 (house 800 W, cap 2500 W, no
forecast). Overload = part 4 (2 x 2500 W, cap 5000 W, house 5050 or
5100 W, seeds 0-3). "Delay 1" is the old sim, "delay 0" the rig's shape.
| meter | quarter edge, runs with a warning (new / bee7431 / no fix) | overload delay 1, quarters warned of 7 (new / bee7431 / no fix) | overload delay 0, 5100 W (new / bee7431 / no fix) |
|---|---|---|---|
| 1 s | 0/70 / 0/70 / 68/70 | 7 / 7 / 7 | 7 / 7 / 7 |
| 2 s | 0/70 / 0/70 / 39/70 | 7 / 7 / 7 | 7 / 7 / 7 |
| 3 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 7 / 7 (5050 W: 7 / 1-7 / 7) |
| 5 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0-2 / 7 |
| 10 s | 0/70 / 0/70 / 40/70 | 7 / 7 / 7 | 7 / 0 / 7 |
| 15 s | 0/70 / 0/70 / 40/70 | 7 / 4-7 / 7 | no fix warns 0-1 (5300 W: 7 / 0 / 7) |
| 20 s | 0/60 / 0/60 / 30/60 | 7 / 5-7 / 7 | no fix warns 0 (5300 W: 7 / 0 / 7) |
| 30 s | 0/60 / 0/60 / 30/60 | 7 / 3-5 / 7 | no fix warns 0 (5300 W: 6-7 / 0 / 6-7) |
Quarter-edge seeds: 1-30 with the meter in the same cycle, and 1-40 (1-30
at 20 and 30 s) with the test's 1-2 cycle delay. 5300 W: seeds 1-5.
Commands identical to no fix and to bee7431 in all 1013 runs (these and
the limits below). Choosing the numbers: the runs above were recorded
without the fix and replayed through candidate rules; with 3, 4 or 5
cycles, budgets 9 to 13 met both cases at 1-30 s. 8 warns at the quarter
edge (2 s, seed 25) and 14 misses quarters in the rig's shape (1 s,
5050 W), both checked in part 4. 11 is the middle, and 4 the middle of 3
to 5; with a jittery meter (below) 3 gave more false alarms, 5 more quiet
quarters (replay).
**First warning after one hand-back** (the loop at its rail; later than
no fix by): 11 s at 1 s, 22 s at 2 s, 24 s at 3 s, 25 s at 5 s (default),
30 s at 10 s, 45 s at 15 s, 60 s at 20 s, 90 s at 30 s, 180 s at 60 s.
bee7431: 10, 20, 30, 40, 80, 90, 80, 120, 240 s. In the overload runs at
1 to 30 s, the first warning was at most 90 s later than without the fix.
**Limits (measured, not fixed).**
- A meter that jitters by 30 % with `cycle_floor_s` 15 (11 seeds): with
the test's 1-2 cycle meter delay the quarter edge warns on 2 of 11 seeds
at 15 s, 2 at 20 s and 5 at 30 s (bee7431: 4, 0, 10; no fix: 11 each);
with the meter in the same cycle on none (no fix: 10, 9, 11). The overload at 5100 W leaves up
to 4 quiet quarters in a row at 15 and 20 s (bee7431: no warning at all
on some seeds); at 5200 W at most 1. Its first warning came up to 61 min
later than without the fix. The numbers depend on the jitter draw: the
review's draw gave 4, 4 and 5 of 11 at the quarter edge (bee7431: 4, 1,
9) and up to 2 and 4 quiet quarters at 15 and 20 s.
- With cycles over about 40 s, a bound that is never past a settle uses up
11 in 900 s only if the wire changes hands every few cycles; otherwise
it is never counted (part 5 prints it at 60 s).
- Hidden as designed: a real bound with the kick's shape (at most 11 cycles
in 900 s, each within 3 cycles or 22 s of a hand-back).
**Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ from
bee7431 and from no fix; commands, claims, guard and phases are the same.
+1 -1
View File
@@ -211,7 +211,7 @@ The label and description each setting shows on the Configuration tab, in tab or
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
| Limits | Margin below the peak limit (W) | `peak_margin_w` | The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it. |
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
| Limits | 15-minute demand forecast entity | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit. |
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
+30 -32
View File
@@ -299,14 +299,14 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
# was told over the same span as above (the plant's measured settle time), so
# a healthy battery still settling on a new command is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
# SAFETY-119: across all hand-backs, the loop's bound is hidden on at most
# ROLLING_BUDGET cycles in any ROLLING_S seconds, fewer on a slower cycle:
# (cycle seconds up to, cycles), measured in
# DECISIONS/SAFETY-119-bound-after-hand-back.md. Without a monotonic clock
# the budget never refills.
# SAFETY-119/121: after a hand-back the loop's bound is hidden until
# HANDBACK_CYCLES cycles and SHORT_SPAN_S have passed; across all hand-backs
# on at most ROLLING_BOUND_CYCLES cycles in any ROLLING_S seconds. Measured in
# DECISIONS/SAFETY-121-bound-every-quarter.md. Without a monotonic clock the
# budget never refills.
HANDBACK_CYCLES = 4
ROLLING_S = 900.0
ROLLING_BUDGET = ((3.5, 10), (11.0, 8), (16.0, 6), (float("inf"), 4))
ROLLING_BOUND_CYCLES = 8 # the budget while the cycle length is unknown
ROLLING_BOUND_CYCLES = 11
def _refused_interval(sign: int) -> tuple:
@@ -1282,12 +1282,11 @@ class Controller:
self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle; the window
# after a hand-back (monotonic s, the loop's cycles in a row); the
# monotonic s of the bound cycles hidden in the last ROLLING_S; the
# last cycle's monotonic s and the cycle length.
# monotonic s of the bound cycles hidden in the last ROLLING_S (a
# stop does not clear them).
self._wire_lost = False
self._handback = None
self._hidden = ()
self._settle_t, self._settle_dt = None, None
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -5830,7 +5829,6 @@ class Controller:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
self._settle_t = None # a stop is not a cycle length
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
@@ -5871,20 +5869,15 @@ class Controller:
"""SAFETY-119: does the loop's bound on this cycle count? Not within
a settle after another owner handed the wire back: its integrator is
re-seeded from that owner's command and kicked by a meter that does
not show it yet. The settle is SHORT_CYCLES cycles and SHORT_SPAN_S
after the hand-back; losing the wire restarts it. Across all
hand-backs the bound is hidden on at most ROLLING_BUDGET[cycle
length] cycles in any ROLLING_S, so a bound that stays is counted
however often the wire changes hands.
DECISIONS/SAFETY-119-bound-after-hand-back.md."""
not show it yet. SAFETY-121: the settle is HANDBACK_CYCLES cycles and
SHORT_SPAN_S after the hand-back, so a bound that outlasts it counts
at any cycle length; losing the wire restarts it. Across all
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles
in any ROLLING_S, so a bound that never outlasts a settle is counted
too. DECISIONS/SAFETY-121-bound-every-quarter.md."""
def aged(t0, span):
return now is None or t0 is None or now - t0 >= span
# The cycle length (a gap over two minutes is not one).
if now is not None and self._settle_t is not None \
and 0.0 < now - self._settle_t <= 120.0:
self._settle_dt = now - self._settle_t
self._settle_t = now
if not owns:
self._wire_lost, self._handback = True, None
return False
@@ -5895,16 +5888,13 @@ class Controller:
tc, n = self._handback
n += 1
self._handback = (tc, n)
if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES:
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
self._handback = None
return False
if self.loop_clamped[0] and not self.loop_clamped[2]:
kept = tuple(t for t in self._hidden if now is None or (
t is not None and now - t < ROLLING_S))
dt = self._settle_dt
budget = ROLLING_BOUND_CYCLES if dt is None else next(
m for up, m in ROLLING_BUDGET if dt <= up)
if len(kept) >= budget:
if len(kept) >= ROLLING_BOUND_CYCLES:
self._hidden = kept
return False
self._hidden = kept + (now,)
@@ -7474,7 +7464,8 @@ class Controller:
level, banner = "warn", messages.banner_stopped()
elif runs:
level, running_alarm = "bad", True
banner = runs[0].get("banner") or messages.headline(runs[0]["text"])
banner = runs[0].get("banner") or messages.headline_bi(
runs[0]["text"])
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
@@ -7497,11 +7488,11 @@ class Controller:
# character; at two, "Maintenance: drain" without a name is a
# banner an operator cannot act on, because the remedy is at one
# device and the site has two.
level, banner = "warn", f"Maintenance: {self.maint.phase}"
if len(self.inverters) > 1:
banner += f" ({self.maint_unit.name})"
level, banner = "warn", messages.banner_maintenance(
self.maint.phase,
self.maint_unit.name if len(self.inverters) > 1 else "")
else:
level, banner = "ok", "Running — holding grid at zero"
level, banner = "ok", messages.banner_running()
def w(v):
return "—" if v is None else f"{v:,.0f} W".replace(",", " ")
@@ -7605,6 +7596,13 @@ class Controller:
now=datetime.now(timezone.utc), why=self.why_words)),
]
held = level == "bad" and not running_alarm
# UX-46. The rows were built in English with their Dutch beside them
# (`messages.Bi`); the page's language is chosen once, here, for the
# Home sentences, the checks list and the rows that carry one. The
# banner above stays English: it is the plain fallback.
checks = [dict(c, text=messages.pick(c["text"], lang)) for c in checks]
rows = [(k, messages.pick(v, lang)) for k, v in rows]
banner = messages.pick(banner, lang)
out = {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -54,10 +54,12 @@ class SetupOnly:
def status_payload(self, lang: str = "en") -> dict:
return {"setup_only": True, "mode": "setup_only",
"banner": messages.banner_setup_only(), "level": "warn",
"banner": messages.pick(messages.banner_setup_only(),
lang), "level": "warn",
"rows": [["Control", "not started, in setup"]],
"checks": [{"ok": False, "warn": True,
"text": messages.row_setup_only()}],
"text": messages.pick(messages.row_setup_only(),
lang)}],
"hint": self.hint, "events": list(self.events)}
async def handle_action(self, what):
+2 -2
View File
@@ -17,7 +17,7 @@ from . import uilayout as L
from . import uitext
from . import uimodel as U
from .configui import help_of, label_of, problems_by_field, split_group
from .validate import power_request_entities
from .validate import note_text, power_request_entities
# A choice the page cannot make, only keep. `price_service` needs a token, and
# the token is a secret this page never draws (uilayout.HIDDEN), so it is set on
@@ -314,7 +314,7 @@ def anchors(problems, notes, model, keys, lang="en") -> list:
add(None, None, "bad", text)
for note in notes:
tier = note.tier if note.tier in _TIERS else "info"
text = messages.NOTE_NL.get(note.text, note.text) if lang == "nl" else note.text
text = note_text(note, lang)
for key in note.keys or (None,):
for field, card in (targets(key, note.text, model) if key
else [(None, None)]):
+129 -39
View File
@@ -386,12 +386,23 @@ class Note(NamedTuple):
# also "ok" and "bad" (setup_checks)
keys: tuple
text: str
nl: str = "" # UX-46: the same sentence in Dutch
def _advise(notes, tier: str, keys, text: str) -> None:
"""Add a Note when the caller collects them (the settings page)."""
def _advise(notes, tier: str, keys, text: str, nl: str = "") -> None:
"""Add a Note when the caller collects them (the settings page). `nl` is
the Dutch twin of `text`; the page picks one (`note_text()`)."""
if notes is not None:
notes.append(Note(tier, tuple(keys), text))
notes.append(Note(tier, tuple(keys), text, nl))
def note_text(note, lang: str = "en") -> str:
"""The note's sentence in the page's language, English when it has no
Dutch twin."""
if lang == "nl":
return getattr(note, "nl", "") or messages.NOTE_NL.get(
note.text, note.text)
return note.text
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
@@ -628,6 +639,11 @@ def S(key: str, where: str = "") -> str:
return messages.setting(key, where.strip().strip("()"))
def SN(key: str, where: str = "") -> str:
"""`S()` with the Dutch label, for the Dutch twin of a note."""
return messages.setting(key, where.strip().strip("()"), "nl")
def _labels(keys, joiner: str = "and") -> str:
"""Several settings, each with its key: the config panel files a refusal
under every key it names (`configui.problems_by_field()`)."""
@@ -1216,7 +1232,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
integ_w, max_w)
_advise(notes, "warn", ("integrator_max_w",),
f"{S('integrator_max_w')} is above {S('max_w')}, which only "
"adds overshoot, so set it to 0.")
"adds overshoot, so set it to 0.",
nl=f"{SN('integrator_max_w')} is hoger dan {SN('max_w')}, wat "
"alleen doorschieten geeft, dus zet het op 0.")
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
@@ -1244,7 +1262,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
_advise(notes, "warn", ("maintenance_discharge_w",),
f"{S('maintenance_discharge_w')} is above "
f"{S('max_w', maint_where)}, so the battery empties at "
f"{messages.watts(maint_cap)} and the cycle takes longer.")
f"{messages.watts(maint_cap)} and the cycle takes longer.",
nl=f"{SN('maintenance_discharge_w')} is hoger dan "
f"{SN('max_w', maint_where)}, dus de batterij loopt leeg met "
f"{messages.watts(maint_cap)} en de cyclus duurt langer.")
_LOG.debug(
"Not refused: the drain phase sets the setpoint, the arbiter "
"clamps it to %.0f W and names the clamp, and the drain phase has "
@@ -1473,7 +1494,9 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
TAXES_PROFILE_OPTION, negative)
_advise(notes, "warn", (TAXES_PROFILE_OPTION,),
f"{S(TAXES_PROFILE_OPTION)} has negative figures, which "
"lower the price, and that is usually a sign error.")
"lower the price, and that is usually a sign error.",
nl=f"{SN(TAXES_PROFILE_OPTION)} heeft negatieve bedragen, "
"die de prijs verlagen, en dat is meestal een tekenfout.")
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00.
@@ -1532,7 +1555,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
_advise(notes, "warn", ("price_formula", "price_provider"),
f"{S('price_formula')} is not used, because "
f"{S('price_provider')} is not 'custom_formula', so clear "
"it or choose that source.")
"it or choose that source.",
nl=f"{SN('price_formula')} wordt niet gebruikt, omdat "
f"{SN('price_provider')} niet 'custom_formula' is, dus wis "
"ze of kies die bron.")
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1547,7 +1573,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
# cannot start.
_LOG.warning("%s", aged)
_advise(notes, "warn", ("price_formula",),
_card_note("price_formula", spec))
_card_note("price_formula", spec),
nl=_card_note("price_formula", spec, "nl"))
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
#
@@ -1630,7 +1657,10 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
xspec.offset_eur_kwh)
_advise(notes, "info", (EXPORT_FORMULA_OPTION,),
f"{S(EXPORT_FORMULA_OPTION)} has no card block, so "
"only the shape of its numbers is checked.")
"only the shape of its numbers is checked.",
nl=f"{SN(EXPORT_FORMULA_OPTION)} heeft geen kaartblok, "
"dus alleen de vorm van de getallen wordt "
"gecontroleerd.")
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
@@ -1643,7 +1673,8 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
if xaged:
_LOG.warning("%s", xaged)
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
_card_note(EXPORT_FORMULA_OPTION, xspec))
_card_note(EXPORT_FORMULA_OPTION, xspec),
nl=_card_note(EXPORT_FORMULA_OPTION, xspec, "nl"))
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
# site with no price data falls back to the static profile; a dynamic site
@@ -1657,13 +1688,16 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
"fallback.", pid)
_advise(notes, "warn", ("static_profile",),
f"{S('static_profile')} is empty, so the site has no tariff "
"at all while the price feed is down.")
"at all while the price feed is down.",
nl=f"{SN('static_profile')} is leeg, dus de installatie heeft "
"helemaal geen tarief zolang de prijsfeed uitvalt.")
# PRICE-01: not a fault, so an info note and no log line here (the add-on
# logs it once at start). With `price_service` the fixed tariff is only the
# plan's last fallback; the cheap window and the gate do not read it.
if pid == "price_service" and str(opts.get("static_profile", "") or "").strip():
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE)
_advise(notes, "info", ("static_profile",), messages.FIXED_TARIFF_NOTE,
nl=messages.FIXED_TARIFF_NOTE_NL)
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
# rather than logged, because a strategy that is armed in the UI and cannot
@@ -1735,7 +1769,11 @@ def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
return problems
def _card_note(option: str, spec) -> str:
def _card_note(option: str, spec, lang: str = "en") -> str:
if lang == "nl":
return (f"{SN(option)} gebruikt een tariefkaart van "
f"{spec.card_month}, dus neem de nieuwe cijfers over als uw "
"contract veranderde.")
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
"the new figures if your contract changed.")
@@ -1810,7 +1848,10 @@ def config_notes(opts: dict, *, entities: dict | None = None,
if month_peak_w is not None and cap is not None and cap < month_peak_w:
_advise(notes, "warn", ("peak_cap_w",),
f"This month's peak is already {messages.watts(month_peak_w)}, "
f"so a lower {S('peak_cap_w')} saves nothing until next month.")
f"so a lower {S('peak_cap_w')} saves nothing until next month.",
nl=f"De piek van deze maand is al "
f"{messages.watts(month_peak_w)}, dus een lagere "
f"{SN('peak_cap_w')} bespaart niets tot volgende maand.")
return notes
@@ -1823,31 +1864,43 @@ def _entity_notes(notes, opts: dict, entities: dict, bad=()) -> None:
if row is None:
_advise(notes, "bad" if key in bad else "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
f"{S(key, where)} names.",
nl=f"Home Assistant heeft geen entiteit {entity}, terwijl "
f"{SN(key, where)} die noemt.")
continue
unit = str(row.get("unit") or "")
if key in WATT_ENTITY_KEYS and unit in pv.WATTS_PER and unit != "W":
_advise(notes, "warn", (key,),
f"{entity} reads in {unit}, but {S(key, where)} is "
"taken as watts, so pick a sensor in W.")
"taken as watts, so pick a sensor in W.",
nl=f"{entity} meldt in {unit}, maar {SN(key, where)} wordt "
"als watt gelezen, dus kies een sensor in W.")
if key == "peak_forecast_entity" and unit not in pv.WATTS_PER:
_advise(notes, "warn", (key,),
messages.peak_forecast_unit(unit) + " Pick a sensor in "
"W or kW.")
"W or kW.",
nl=messages.peak_forecast_unit(unit, "nl")
+ " Kies een sensor in W of kW.")
if key in FORECAST_KEYS and unit not in solarfc.KWH_PER:
_advise(notes, "warn", (key,),
f"{entity} does not read in kWh, so pick a solar "
"forecast sensor in kWh.")
"forecast sensor in kWh.",
nl=f"{entity} meldt niet in kWh, dus kies een sensor voor "
"de zonneprognose in kWh.")
today = solarfc.entities(opts)
tomorrow = solarfc.entities(opts, tomorrow=True)
if (today or tomorrow) and \
opts.get("battery_modes_enabled") is not True:
_advise(notes, "info", ("pv_forecast_entity",),
"The solar forecast is used only with battery modes on.")
"The solar forecast is used only with battery modes on.",
nl="De zonneprognose wordt alleen gebruikt met batterijmodi "
"aan.")
if tomorrow and not today:
_advise(notes, "warn", ("pv_forecast_tomorrow_entity",),
"Tomorrow's forecast is used only with today's, so set "
f"{S('pv_forecast_entity')} too.")
f"{S('pv_forecast_entity')} too.",
nl="De prognose voor morgen wordt alleen met die van vandaag "
f"gebruikt, dus stel ook {SN('pv_forecast_entity')} in.")
# --------------------------------------------------------------------------- #
@@ -1926,7 +1979,9 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
if entities is None:
# Red: only the list can tell a missing power request entity (SAFETY-77).
_advise(notes, "bad", ("setpoint_entity",), "Home Assistant is not "
"answering, so press Check again once it is back.")
"answering, so press Check again once it is back.",
nl="Home Assistant antwoordt niet, dus druk op Opnieuw "
"nakijken zodra het terug is.")
else:
# A missing meter entity is red too (UX-15): with the kettle test
# skipped, nothing else on the way to the save would catch it.
@@ -1948,13 +2003,16 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
solar.append(None)
_advise(notes, "warn", ("pv_invert_entities",),
f"{entity} reads below zero now, so turn on its "
"“Reads negative” switch in the Solar step.")
"“Reads negative” switch in the Solar step.",
nl=f"{entity} meet nu onder nul, dus zet de schakelaar "
"“Meet negatief” aan bij de stap Zonnepanelen.")
else:
w = -w if entity in flipped else w
solar.append(w)
_advise(notes, "ok", ("pv_entity",),
f"{entity} reads {_signed(w)} of solar production "
"now.")
"now.",
nl=f"{entity} meet nu {_signed(w)} zonneproductie.")
# A battery sensor that reads the wrong way while giving power makes the
# house use less than nothing. Only with every term known: a missing
# solar reading could be the difference.
@@ -1964,7 +2022,10 @@ def setup_checks(opts: dict, *, entities: dict | None, running=None,
_advise(notes, "warn", ("batt_invert",),
f"The readings add up to {_signed(sum(known))} of house "
"use, so check each "
"battery's “Battery power sign is reversed” switch in step 2.")
"battery's “Battery power sign is reversed” switch in step 2.",
nl=f"De metingen tellen op tot {_signed(sum(known))} "
"huisverbruik, dus controleer bij elke batterij de schakelaar "
"“Teken batterijvermogen omkeren” in stap 2.")
_price_check(notes, opts, running, prices_known)
return notes
@@ -1988,7 +2049,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
return float(meter_now_w)
_advise(notes, "info", ("meter_source",), "The add-on reads "
"this meter itself, so Home shows its reading after "
"the restart.")
"the restart.",
nl="De add-on leest deze meter zelf, dus Start toont de "
"meting na de herstart.")
return None
if entities is None:
return None
@@ -2001,7 +2064,9 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
if w is None:
_advise(notes, "warn", ("meter_source",),
f"{entity} reads {text} now, so check it in Home "
"Assistant before you start.")
"Assistant before you start.",
nl=f"{entity} meldt nu {text}, dus controleer ze in Home "
"Assistant voor u start.")
return None
total += sign * w
if source == "off" and opts.get("meter_invert"):
@@ -2012,12 +2077,14 @@ def _meter_check(notes, opts, entities, running, meter_now_w):
def _meter_line(notes, w) -> None:
if abs(w) < 1.0:
text = "The meter reads 0 W now."
text, nl = "The meter reads 0 W now.", "De meter meldt nu 0 W."
elif w > 0:
text = f"The meter reads {messages.watts(w)} taken from the grid now."
nl = f"De meter meldt nu {messages.watts(w)} afname van het net."
else:
text = f"The meter reads {messages.watts(w)} fed into the grid now."
_advise(notes, "ok", ("meter_source",), text)
nl = f"De meter meldt nu {messages.watts(w)} injectie op het net."
_advise(notes, "ok", ("meter_source",), text, nl=nl)
def _card_checks(notes, who, entry, entities):
@@ -2025,40 +2092,55 @@ def _card_checks(notes, who, entry, entities):
positive while giving power, or None when it is not known."""
if entities is None:
return None
wie = who.replace(" for ", " voor ", 1)
setpoint = str(entry.get("setpoint_entity") or "").strip()
if setpoint and setpoint in entities:
_advise(notes, "ok", ("setpoint_entity",),
f"Home Assistant has {setpoint}, the power request "
f"entity{who}.")
f"entity{who}.",
nl=f"Home Assistant heeft {setpoint}, de entiteit voor de "
f"vermogensvraag{wie}.")
soc = str(entry.get("soc_entity") or "").strip()
if not soc:
_advise(notes, "warn", ("soc_entity",), f"There is no battery "
f"level entity{who}, so pick one in step 2 or the add-on "
"holds 0 W.")
"holds 0 W.",
nl=f"Er is geen entiteit voor het laadniveau{wie}, dus kies er "
"een in stap 2 of de add-on houdt 0 W aan.")
elif soc in entities:
level, _unit = _reading(entities, soc)
if level is None or not 0.0 <= level <= 100.0:
_advise(notes, "warn", ("soc_entity",), f"The battery "
f"level{who} reads nothing now, so check {soc}.")
f"level{who} reads nothing now, so check {soc}.",
nl=f"Het laadniveau{wie} meldt niets, dus controleer "
f"{soc}.")
else:
_advise(notes, "ok", ("soc_entity",),
f"The battery level{who} reads {level:.0f} %.")
f"The battery level{who} reads {level:.0f} %.",
nl=f"Het laadniveau{wie} meldt {level:.0f} %.")
batt = str(entry.get("batt_entity") or "").strip()
if batt and batt in entities:
w, _unit = _reading(entities, batt)
if w is None:
_advise(notes, "warn", ("batt_entity",), f"The battery "
f"power{who} reads nothing now, so check {batt}.")
f"power{who} reads nothing now, so check {batt}.",
nl=f"Het batterijvermogen{wie} meldt niets, dus controleer "
f"{batt}.")
return None
w = -w if entry.get("batt_invert") else w
if abs(w) < IDLE_W:
_advise(notes, "info", ("batt_entity",), f"The battery{who} is "
"idle now, so check its direction on Home later.")
"idle now, so check its direction on Home later.",
nl=f"De batterij{wie} staat nu stil, dus controleer later "
"op Start de richting.")
else:
doing = "giving power" if w > 0 else "charging"
doet = "levert" if w > 0 else "laadt"
_advise(notes, "info", ("batt_invert",),
f"The battery{who} reads {messages.watts(w)} {doing} "
"now, so check the inverter shows the same.")
"now, so check the inverter shows the same.",
nl=f"De batterij{wie} {doet} nu {messages.watts(w)}, dus "
"controleer of de omvormer hetzelfde toont.")
return w
return None
@@ -2070,20 +2152,28 @@ def _price_check(notes, opts, running, prices_known) -> None:
if profile:
_advise(notes, "ok", ("static_profile",),
"Your prices come from your contract, so they are "
"always known.")
"always known.",
nl="Uw prijzen komen uit uw contract, dus ze zijn altijd "
"bekend.")
else:
_advise(notes, "info", ("price_provider",),
"No prices are set, so the batteries do not charge "
"on price.")
"on price.",
nl="Er zijn geen prijzen ingesteld, dus de batterijen "
"laden niet op prijs.")
return
same = (isinstance(running, dict)
and str(running.get("price_provider") or "") == provider)
if same and prices_known:
_advise(notes, "ok", ("price_provider",),
f"Prices for today are in, for {prices_known} of 24 hours.")
f"Prices for today are in, for {prices_known} of 24 hours.",
nl=f"De prijzen van vandaag zijn binnen, voor {prices_known} "
"van 24 uur.")
else:
_advise(notes, "info", ("price_provider",),
"Prices are fetched after the restart, and Home shows them.")
"Prices are fetched after the restart, and Home shows them.",
nl="De prijzen worden na de herstart opgehaald, en Start toont "
"ze.")
def _margin_problems(opts: dict) -> list[str]:
+14 -6
View File
@@ -51,7 +51,7 @@ from aiohttp import web
from . import messages, price, selfconfig, uipage, uitext
from .haentities import EntityCache
from .homeview import priced_hours, region_of
from .validate import config_notes, setup_checks
from .validate import config_notes, note_text, setup_checks
from .config_page import CONFIG_PAGE
from .configui import (changed_keys, field_model, hide_secrets, keep_secrets,
problems_by_field, secret_keys)
@@ -105,6 +105,9 @@ def page() -> str:
DEVICE_NOTE = (
"%s did not report its power range, so two checks were skipped, here and "
"at start. If they fail later, the add-on will not start then.")
DEVICE_NOTE_NL = (
"%s meldde geen vermogensbereik, dus twee controles zijn overgeslagen, "
"hier en bij het starten. Falen ze later, dan start de add-on dan niet.")
def build_app(controller, selfcfg=None) -> web.Application:
@@ -301,22 +304,27 @@ def build_app(controller, selfcfg=None) -> web.Application:
# UX-13: warnings and info beside the refusals. Never block a save.
# The entity checks use the picker's cached list and never wait on HA.
notes += config_notes(options, entities=entities.peek())
# UX-46: one language choice for everything below that has words.
lang = uitext.lang_of(_body.get("lang"))
payload = {"problems": problems, "by_key": by_key,
"unanchored": unanchored,
"notes": [n._asdict() for n in notes]}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
payload["device_note"] = (DEVICE_NOTE_NL if lang == "nl"
else DEVICE_NOTE) % ", ".join(
sorted(set(unread)))
if model is not None:
payload["messages"] = (
uipage.derive_messages(errors, problems, notes, model,
_keys_of(options),
uitext.lang_of(_body.get("lang")))
_keys_of(options), lang)
if errors
else uipage.anchors(problems, notes, model, _keys_of(options),
uitext.lang_of(_body.get("lang"))))
lang))
payload["steps"] = uipage.step_problems(payload["messages"], model)
if _body.get("setup"):
payload["setup"] = [n._asdict() for n in await _setup_checks(options)]
payload["setup"] = [
dict(n._asdict(), text=note_text(n, lang))
for n in await _setup_checks(options)]
return web.json_response(payload)
async def _setup_checks(options):
+162 -92
View File
@@ -9,11 +9,12 @@ It crossed its rail for one or two cycles, the loop's ask beat the one-sided
limit, so the cycle counted as the loop's own and raised
`goodwe_integrator_bound`. The battery gave what it was told. Now the loop's
bound does not count until the plant's settle (SHORT_CYCLES cycles and
SHORT_SPAN_S) has passed since the hand-back. Round 3: across all
hand-backs the bound is hidden on at most ROLLING_BUDGET[cycle length] cycles
(10 up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond) in any
ROLLING_S seconds, so a bound that stays warns when the guard and the charge
keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md.
SHORT_SPAN_S) has passed since the hand-back. SAFETY-121: the settle is
HANDBACK_CYCLES (4) cycles and SHORT_SPAN_S (22 s), and across all
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES (11) cycles in
any ROLLING_S (900 s), at every cycle length.
DECISIONS/SAFETY-119-bound-after-hand-back.md,
DECISIONS/SAFETY-121-bound-every-quarter.md.
1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house
800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no
@@ -25,12 +26,15 @@ keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md.
3. With a usable forecast: the same commands and the same alarms as without
the fix.
4. Repeated hand-backs (N=2, the guard and the charge take turns), 2, 5, 10
and 15 s meters: it warns (every quarter at 2, 5 and 10 s, every other
quarter at least at 15 s); the quarter-edge case stays silent at 2, 5, 10
and 15 s, including the 2 s seed where a fixed budget of 8 warns; a start
or a blind cycle is not a hand-back; a slow meter delays by cycles.
5. The rule at its boundaries (`Controller._settling`): the settle, and the
rolling budget.
and 15 s meters: it warns in every quarter, both with the meter a cycle
behind the battery reading and in the rig's shape (SAFETY-121, F-1: the
meter read in the same cycle, 1 to 5 bound cycles a quarter), where
SAFETY-119's settle of 12 cycles hid every quarter but one; the
quarter-edge case stays silent at 2, 5, 10 and 15 s; the budget's margins
(8 warns at the quarter edge, 14 misses quarters); a start or a blind
cycle is not a hand-back; the delay after one hand-back.
5. The rule at its boundaries (`Controller._settling`): the settle, the
rolling budget, and a stop that does not give the budget back.
6. test_safety84's and test_safety87's re-pinned digests: the same commands
and claims; only OUTPUT_CLAMP log rows differ.
@@ -252,7 +256,7 @@ _first = notes(_hb[1])[:1]
print(f" hand-back to a loop at its rail at 600 s: first notification "
f"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the "
f"end {_hb[0][-1][1]}")
MB = 10 # the budget at a 1 s cycle
MB = 11 # the budget (at 1 s it ends the settle)
check(f"⭐ the loop at its rail after a hand-back still warns, once "
f"{MB} of its bound cycles are hidden (1 s meter: {MB} s)",
_first and 600.0 + MB <= _first[0] <= 600.0 + MB + 4.0
@@ -317,10 +321,18 @@ def round1(self, owns, now):
# over the 5000 W rating from 300 s: the loop never held the wire for more
# than 10 cycles. Round 1: no notification in 2 h; round 2: none at 10 s or
# 15 s meters.
def never(tick, hw=5050.0, fix=True):
def never(tick, hw=5050.0, fix=True, seed=0, delay=1):
"""`seed` 0: a constant house; else 800 W +- 30 W, `hw` +- 30 W from
300 s. `delay` 0 is the rig's shape: the meter read in the same cycle
as the battery (SAFETY-121)."""
rig.TICK = tick
if seed:
load = (lambda t, h=house(seed):
h(t) + (hw - 800.0 if t >= 300.0 else 0.0))
else:
load = house(1, step=(300.0, hw))
try:
f = lambda: run(house(1, step=(300.0, hw)), n=2)
f = lambda: run(load, n=2, delay=delay)
return f() if fix else no_fix(f)
finally:
rig.TICK = 1.0
@@ -331,6 +343,10 @@ def per_quarter(c):
for q in range(8)]
def every_quarter(c):
return all(n >= 1 for n in per_quarter(c)[1:])
_nv = {t: never(t) for t in (2.0, 5.0, 10.0, 15.0)}
_nv_old = {t: never(t, fix=False) for t in (2.0, 5.0, 10.0, 15.0)}
_nv_r1 = patched(Controller, "_settling", round1)(lambda: never(5.0))
@@ -341,16 +357,9 @@ for _t in _nv:
f"{len(notes(_nv_old[_t][1]))}, highest quarter "
f"{max(quarters(_nv[_t][1], _t)):.0f} W")
print(f" round 1's rule at 5 s: {len(notes(_nv_r1[1]))}")
check("⭐ 2 s and 5 s meters: it warns in every quarter after the first",
all(per_quarter(_nv[t][1])[q] >= 1 for q in range(1, 8)
for t in (2.0, 5.0)))
check("⭐ 10 s meter: it warns in every quarter after the first (round 2 "
"gave none)",
all(per_quarter(_nv[10.0][1])[q] >= 1 for q in range(1, 8)))
check("⭐ 15 s meter: it warns at least every other quarter after the first "
"(round 2 gave none)",
not any(a == 0 and b == 0 for a, b in zip(
per_quarter(_nv[15.0][1])[1:], per_quarter(_nv[15.0][1])[2:])))
check("⭐ 2, 5, 10 and 15 s meters: it warns in every quarter after the "
"first (round 2 gave none at 10 and 15 s)",
all(every_quarter(_nv[t][1]) for t in _nv))
check("non-vacuous: round 1's rule (a window per hand-back) never warned "
"here, and the fix still hides some of what was raised without it",
notes(_nv_r1[1]) == [] and all(
@@ -363,15 +372,46 @@ check("...and at 5 s the cap held",
len(quarters(_nv[5.0][1], 5.0)) == 8
and max(quarters(_nv[5.0][1], 5.0)) < 5000.0)
# SAFETY-121, the rig's shape (TEST-23, F-1): the meter is read in the same
# cycle as the battery, so the guard's charge headroom is right after it
# hands back and the guard and the charge do not chase each other. The loop
# then holds the wire once a quarter, at its start, and is at its bound on
# 1 to 5 cycles (5 s), not 20. House 5100 W +- 30 W (5300 W at 15 s, where
# 5100 W is over the rating in one quarter only, without the fix too).
RIG = {1.0: 5100.0, 2.0: 5100.0, 5.0: 5100.0, 10.0: 5100.0, 15.0: 5300.0}
_rg = {t: never(t, hw, seed=1, delay=0) for t, hw in RIG.items()}
_rg_old = {t: never(t, hw, fix=False, seed=1, delay=0)
for t, hw in RIG.items()}
# SAFETY-119 as merged (bee7431) at 5 s: a settle of 12 cycles, budget 8.
_rg_119 = patched(app_main, "HANDBACK_CYCLES", app_main.SHORT_CYCLES)(
lambda: patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
lambda: never(5.0, 5100.0, seed=1, delay=0)))
for _t in _rg:
print(f" rig shape, {_t:.0f} s meter, house {RIG[_t]:.0f} W: per "
f"quarter {per_quarter(_rg[_t][1])}, without the fix "
f"{per_quarter(_rg_old[_t][1])}, highest quarter "
f"{max(quarters(_rg[_t][1], _t)):.0f} W")
print(f" rig shape, 5 s, SAFETY-119's settle and budget: per quarter "
f"{per_quarter(_rg_119[1])}")
check("⭐ the rig's shape at 1, 2, 5, 10 and 15 s meters: it warns in every "
"quarter after the first, as without the fix",
all(every_quarter(_rg[t][1]) and every_quarter(_rg_old[t][1])
for t in _rg))
check("non-vacuous (F-1): with SAFETY-119's settle of 12 cycles and budget "
"of 8, the rig's shape at 5 s warns in at most one quarter",
sum(1 for n in per_quarter(_rg_119[1])[1:] if n) <= 1)
check("...every command the same as without the fix, and the cap held at "
"1 to 10 s",
all(commands(_rg[t][0]) == commands(_rg_old[t][0]) for t in _rg)
and all(max(quarters(_rg[t][1], t)) < 5000.0
for t in (1.0, 2.0, 5.0, 10.0)))
# The quarter-edge case (part 1) at the slower meters: still no warning.
# The meter's own lag limits the cap there, the same with and without the fix.
_qe = {}
# Seeds 1 to 3 are the brief's fixed set; 2 s seed 25 is the one where the
# sweep's fixed budget of 8 warned (DECISIONS), kept here so that stays seen.
for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
rig.TICK = _t
for _seed, _dl in ((1, 1), (2, 1), (3, 2)) + (
((25, 2),) if _t == 2.0 else ()):
for _seed, _dl in ((1, 1), (2, 1), (3, 2)):
_qe[_t, _seed] = (run(house(_seed), delay=_dl),
no_fix(lambda: run(house(_seed), delay=_dl)))
rig.TICK = 1.0
@@ -379,8 +419,8 @@ for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
f"{[len(notes(r[0][1])) for k, r in _qe.items() if k[0] == _t]} "
f"(without the fix "
f"{[len(notes(r[1][1])) for k, r in _qe.items() if k[0] == _t]})")
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3 "
"and (2 s) 25: no notification",
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3: "
"no notification",
all(notes(r[0][1]) == [] and r[0][1].clamp_wd.bound == 0
for r in _qe.values()))
check("...every command the same as without the fix; without it the false "
@@ -388,20 +428,30 @@ check("...every command the same as without the fix; without it the false "
all(commands(r[0][0]) == commands(r[1][0]) for r in _qe.values())
and all(any(len(notes(_qe[_t, k][1][1])) >= 1 for k in (1, 2, 3))
for _t in (2.0, 5.0, 10.0, 15.0)))
# The budget's margins (DECISIONS/SAFETY-121): 9 to 13 met both cases in the
# sweep. 8 warns at the quarter edge (2 s, seed 25); 14 misses quarters in
# the rig's shape (1 s, house 5050 W, seed 1).
rig.TICK = 2.0
_q25_8 = patched(app_main, "ROLLING_BUDGET", ((math.inf, 8),))(
lambda: run(house(25), delay=2))
_q25 = (run(house(25), delay=2),
patched(app_main, "ROLLING_BOUND_CYCLES", 8)(
lambda: run(house(25), delay=2)))
rig.TICK = 1.0
print(f" 2 s meter, seed 25, a fixed budget of 8: notifications "
f"{notes(_q25_8[1])}")
check("...2 s seed 25 is the sweep's known case: a fixed budget of 8 warns "
"there (why the budget follows the cycle length)",
len(notes(_q25_8[1])) >= 1)
check("the budget is the one the sweep chose. Changing it means re-running "
"the sweep in DECISIONS/SAFETY-119-bound-after-hand-back.md",
app_main.ROLLING_BUDGET == ((3.5, 10), (11.0, 8), (16.0, 6),
(math.inf, 4))
and app_main.ROLLING_BOUND_CYCLES == 8 and app_main.ROLLING_S == 900.0)
_m14 = patched(app_main, "ROLLING_BOUND_CYCLES", 14)(
lambda: never(1.0, 5050.0, seed=1, delay=0))
_m11 = never(1.0, 5050.0, seed=1, delay=0)
print(f" 2 s, quarter edge, seed 25: {notes(_q25[0][1])}, budget 8 "
f"{notes(_q25[1][1])}; rig shape 1 s 5050 W per quarter "
f"{per_quarter(_m11[1])}, budget 14 {per_quarter(_m14[1])}")
check("...the budget's margins: 11 is silent at 2 s seed 25 and warns every "
"quarter in the rig's shape at 1 s and 5050 W; 8 warns there, 14 "
"misses a quarter there",
notes(_q25[0][1]) == [] and len(notes(_q25[1][1])) >= 1
and every_quarter(_m11[1]) and not every_quarter(_m14[1]))
check("the rule is the one the sweep chose. Changing it means re-running "
"the sweep in DECISIONS/SAFETY-121-bound-every-quarter.md",
app_main.HANDBACK_CYCLES == 4 and app_main.ROLLING_BOUND_CYCLES == 11
and app_main.ROLLING_S == 900.0 and app_main.SHORT_SPAN_S == 22.0)
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
# 20 s of not running warns at once (R2: a restart counted as a hand-back).
@@ -415,19 +465,22 @@ check("⭐ a start after a blind spell is not a hand-back: it warns at the "
notes(_bl[1])[:1] and 20.0 <= notes(_bl[1])[0] <= 26.0
and notes(_bl[1]) == notes(_bl_old[1]))
# The budget is cycles, not seconds (R4): at a 5 s meter the loop at its
# rail after a hand-back warns 8 cycles (40 s) later, not 8 s.
MB = 8 # the budget at a 5 s cycle
rig.TICK = 5.0
_sl = run(house(1, step=(600.0, -6000.0)), dur=900.0,
# One hand-back to a loop at its rail at 600 s: the first warning comes when
# the settle ends, 22 s and 4 cycles after it (SAFETY-119: 12 cycles, 40 s at
# 5 s and 80 s at 10 s).
_sl = {}
for _t in (5.0, 10.0):
rig.TICK = _t
_sl[_t] = run(house(1, step=(600.0, -6000.0)), dur=900.0,
extra={"peak_cap_w": 20000})
rig.TICK = 1.0
_sl1 = [t for t in notes(_sl[1]) if t >= 600.0][:1]
print(f" 5 s meter, hand-back to a loop at its rail at 600 s: first "
f"notification {_sl1}")
check(f"⭐ at a 5 s meter the budget is cycles, not seconds: the first "
f"warning comes {MB} cycles ({MB * 5} s) after the hand-back",
_sl1 and 600.0 + MB * 5.0 <= _sl1[0] <= 600.0 + MB * 5.0 + 10.0)
_sl1 = {t: [x for x in notes(r[1]) if x >= 600.0][:1] for t, r in _sl.items()}
print(f" hand-back to a loop at its rail at 600 s, first notification: "
f"5 s {_sl1[5.0]}, 10 s {_sl1[10.0]}")
check("⭐ one hand-back (the loop has the wire from the cycle after the "
"step): the first warning comes 25 s after it at a 5 s meter (the "
"22 s span), 30 s after it at 10 s (4 cycles)",
_sl1[5.0] == [630.0] and _sl1[10.0] == [640.0])
# ============================================================================ #
print("\n5. the rule at its boundaries")
@@ -438,7 +491,6 @@ class _C:
def __init__(self):
self._wire_lost, self._handback, self._hidden = False, None, ()
self._settle_t, self._settle_dt = None, None
self.loop_clamped = (False, "", False)
@@ -451,21 +503,30 @@ def seq(c, steps):
return out
N, SPAN = app_main.SHORT_CYCLES, app_main.SHORT_SPAN_S
M, ROLL = 8, 900.0 # the chosen budget, written out on purpose
SPAN = app_main.SHORT_SPAN_S
K, M, ROLL = 4, 11, 900.0 # the chosen rule, written out on purpose
_c = _C()
check("from the start, owned every cycle: never settling (no hand-back)",
seq(_c, [(True, float(i)) for i in range(30)]) == [False] * 30)
_c = _C()
_out = seq(_c, [(False, 0.0)] + [(True, 2.0 * i) for i in range(1, N + 3)])
check(f"after another owner: settling for {N - 1} owned cycles, then not "
f"(12 cycles at 2 s span {SPAN:.0f} s)",
_out == [False] + [True] * (N - 1) + [False] * 3)
_out = seq(_c, [(False, 0.0)] + [(True, 2.0 * i) for i in range(1, 15)])
check(f"after another owner at 2 s: settling for 11 owned cycles, then not "
f"(the span, {SPAN:.0f} s, decides)",
_out == [False] + [True] * 11 + [False] * 3)
_c = _C()
_out = seq(_c, [(False, 0.0)] + [(True, 0.5 * i) for i in range(1, 60)])
_end = _out.index(False, 1) if False in _out[1:] else -1
check(f"at a 0.5 s meter the span decides: settling until {SPAN:.0f} s",
_end == 1 + int(SPAN / 0.5) and all(_out[1:_end]))
_set = {}
for _tick in (10.0, 30.0, 60.0):
_c = _C()
_set[_tick] = seq(_c, [(False, 0.0)]
+ [(True, _tick * i) for i in range(1, K + 2)])
check(f"at 10, 30 and 60 s the cycles decide: settling for {K - 1} owned "
f"cycles, then not (SAFETY-119: 11)",
all(o == [False] + [True] * (K - 1) + [False] * 2
for o in _set.values()))
_c = _C()
_out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0),
(True, 8.0), (True, 10.0)])
@@ -474,12 +535,19 @@ check("losing the wire again restarts the settle",
and _c._handback == (8.0, 2))
_c = _C()
check("no monotonic clock: the cycle count alone decides",
seq(_c, [(False, None)] + [(True, None)] * (N + 1))
== [False] + [True] * (N - 1) + [False] * 2)
seq(_c, [(False, None)] + [(True, None)] * (K + 1))
== [False] + [True] * (K - 1) + [False] * 2)
# The rolling budget: the loop's bound is hidden on at most M cycles in any
# ROLL seconds, across all hand-backs. T = the loop's bound that cycle.
assert M < N - 1, "the budget is meant to run out inside one settle"
# One hand-back, the loop's bound on every cycle: the settle (5 s) or the
# budget (1 s) ends the hiding.
_c = _C()
_o5 = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, 9)])
_c = _C()
_o1 = seq(_c, [(False, 0.0)] + [(True, 1.0 * i, True) for i in range(1, 21)])
check("one hand-back, the bound on every cycle: 5 hidden at 5 s (22 s), "
f"then counted; {M} hidden at 1 s (the budget), then counted",
_o5 == [False] + [True] * 5 + [False] * 3
and _o1 == [False] + [True] * M + [False] * (20 - M))
def turns(c, tick, lost, periods, first=0.0, bound=True):
@@ -499,11 +567,8 @@ def turns(c, tick, lost, periods, first=0.0, bound=True):
return out, t
_c = _C()
_o = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, N)])
check(f"one hand-back at 5 s, the bound on every cycle: {M} are hidden, "
f"then it counts for the rest of the settle ({N - 1} cycles)",
_o == [False] + [True] * M + [False] * (N - 1 - M))
# The rolling budget: the loop's bound is hidden on at most M cycles in any
# ROLL seconds, across all hand-backs.
_c = _C()
_o, _ = turns(_c, 5.0, 3, 60)
_hid = [t for t, h in _o if h]
@@ -528,7 +593,7 @@ check(f"the budget refills {ROLL:.0f} s after the oldest hidden cycle: "
_a is False and _b is True)
_c = _C()
_o1, _t = turns(_c, 2.0, 3, 20)
for _ in range(N): # the wire away a whole settle
for _ in range(12): # the wire away a whole settle
_c._settling(False, _t)
_t += 2.0
_o2, _ = turns(_c, 2.0, 3, 5, first=_t)
@@ -539,7 +604,7 @@ check("the budget is not per hand-back or per run: after a whole settle "
and not any(h for _t2, h in _o2))
_c = _C()
_o1, _t = turns(_c, 2.0, 3, 20)
_ = [_c._settling(True, _t + 2.0 * i) for i in range(N + 1)]
_ = [_c._settling(True, _t + 2.0 * i) for i in range(13)]
_c._settling(False, _t + 40.0)
_c.loop_clamped = (True, "", False)
check("the loop holding the wire a whole settle ends the window: the next "
@@ -558,24 +623,20 @@ _c.loop_clamped = (True, "", False)
check("an over-rating does not use up the budget",
_c._settling(True, _t + 5.0) is True and len(_c._hidden) == 1)
# The budget follows the cycle length: bound cycles hidden before the first
# one counts, one hand-back per turn.
# The budget is one number at every cycle length (SAFETY-119 scaled it).
_bud = {}
for _tick in (1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0):
for _tick in (1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0):
_c = _C()
_o, _ = turns(_c, _tick, 1, min(14, int(880.0 // (2 * _tick))))
_o, _ = turns(_c, _tick, 1, 14)
_bud[_tick] = sum(1 for _t2, h in _o if h)
print(f" hidden before the first counts, by cycle length: {_bud}")
check("the budget by cycle length: 10 at 1-3 s, 8 at 5-10 s, 6 at 15 s, 4 "
"at 20-60 s",
_bud == {1.0: 10, 2.0: 10, 3.0: 10, 5.0: 8, 10.0: 8, 15.0: 6,
20.0: 4, 30.0: 4, 60.0: 4})
_c = _C()
_o, _t = turns(_c, 2.0, 3, 8)
_o2, _ = turns(_c, 121.0, 1, 3, first=_t + 119.0)
check("a gap over two minutes is not a cycle length: the 2 s budget (10) "
"stays",
[h for _t2, h in _o + _o2] == [True] * 10 + [False])
_o60, _ = turns(_c, 60.0, 1, 14)
print(f" hidden before the first counts, one bound cycle per turn: {_bud}; "
f"at 60 s, turns of 2 cycles: {sum(1 for _t2, h in _o60 if h)} of 14 "
"hidden (the budget never runs out: DECISIONS limit)")
check(f"the budget does not follow the cycle length: {M} hidden at 1 to "
"30 s, then counted",
set(_bud.values()) == {M})
# No monotonic clock: the budget cannot refill, so a bound that stays is
# counted after the budget however often the wire changes hands.
@@ -586,8 +647,8 @@ for _ in range(40):
_c._settling(False, None)
_c.loop_clamped = (True, "", False)
_nc.append(_c._settling(True, None))
check("no monotonic clock, repeated hand-backs: the budget holds (8 hidden, "
"then every bound counts)", _nc == [True] * M + [False] * (40 - M))
check(f"no monotonic clock, repeated hand-backs: the budget holds ({M} "
"hidden, then every bound counts)", _nc == [True] * M + [False] * (40 - M))
# An over-rating (a command over `max_w`) is a bug, never settled away.
_ctl = _hb[1]
@@ -613,13 +674,22 @@ _ctl._loop_alarm_input(_Res())
check("...while the integrator bound on the next cycle is not counted",
_ctl.loop_clamped[0] is False)
# A stop ends the window but does not give the budget back (review F2, X3).
_now = _ctl._now_mono
_ctl._hidden = (_now,) * M
_ctl._wire_lost, _ctl._handback = True, (1.0, 3)
_ctl._loop_decision = None # stopped or blind
_ctl._loop_alarm_input(_Res())
check("a stop clears it: the next start is not a hand-back, and the stop "
"is not a cycle length",
(_ctl._wire_lost, _ctl._handback, _ctl._settle_t)
== (False, None, None))
_st, _kept = (_ctl._wire_lost, _ctl._handback), _ctl._hidden
_ctl._settling(False, _now + 1.0)
_ctl.loop_clamped = (True, "", False)
_after = _ctl._settling(True, _now + 2.0)
check("a stop clears the window (the next start is not a hand-back) and "
f"keeps the budget: with {M} hidden, the bound after the next "
"hand-back counts",
_st == (False, None) and _kept == (_now,) * M and _after is False)
print(f" after the stop: hidden kept {len(_kept)}, the next hand-back's "
f"bound hidden {_after}")
# ============================================================================ #
print("\n6. the pinned digests of test_safety84 and test_safety87: only the "
+9 -8
View File
@@ -474,20 +474,21 @@ print("\n8. the N=1 and N=2 digests")
# pin lifts (t=1800) and B no longer jumps 1390 W, and after the surplus step.
# SAFETY-119 re-pinned all six: only the log. OUTPUT_CLAMP rows from the
# loop's bound just after the guard hands back are gone; every command and
# claim is the same (test_safety119.py part 6).
# claim is the same (test_safety119.py part 6). SAFETY-121 re-pinned all six
# again: only OUTPUT_CLAMP rows (test_safety119.py part 6).
PINNED = {
"n1_charge":
"19781f90b9751b10994dedeaafb38e42d48ad2551640792e601336b7f9b5bb4f",
"0ed25c856456b97acb07a39299a8cb7b1a762c4d0005b88a0823c03057a80be5",
"n1_charge_dear":
"eeeb0fa19a6fdc04d44a0838bb2bb911f506a936b0f93d8a6f4d87159053ec74",
"c25340b56060d4581f7204e200b07e7c8618711be2baca3b1667b7ebeb600dc3",
"n1_charge_banked":
"a51f125a8c1c98fdbb009fce72b6781d20d384bdf0f5ce7bbfe367d86e5477db",
"2619acdd06b04e56c000b168d64e1d044346c6ba3f929c94258d6ad8189ec609",
"n2_charge":
"e37e98390baa5dad9128991fabf8b170894395fe026d8a33c8a51de7ed0789cc",
"b16d692128f14bcce3d87b142d290e0d6b13724289680e44a05215c9eaa24a7c",
"n2_charge_dear":
"7727372de423f62d77b4426a7226eae31a30f3bb89a3cf70272cc7c1823d7263",
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
"n2_charge_banked":
"c77e26006c026b953e9c3dcc9a02f3993589f885cb52c7cbceac6bd59b600551",
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f",
}
_now = s84.digests()
for k in PINNED:
@@ -495,7 +496,7 @@ for k in PINNED:
f"{_now[k][:16]}")
check("⭐ at N=1 a charge in and out of a cheap window and under a banked "
"month peak is byte-identical to 9d5b7d1 (commands, reasons and every "
"claim; the log as re-pinned by SAFETY-119), and N=2 outside a cheap "
"claim; the log as re-pinned by SAFETY-121), and N=2 outside a cheap "
"window matches its SAFETY-85 pin",
all(_now[k] == PINNED[k] for k in ("n1_charge", "n1_charge_dear",
"n1_charge_banked",
+6 -5
View File
@@ -521,18 +521,19 @@ print("\n9. N=1 is byte-identical to release/1.0 b8ddfd7")
# SAFETY-119 re-pinned n1_crossing, n1_above and n1_charge_low: only the
# log. The loop's bound just after a hand-back is not counted, so OUTPUT_CLAMP
# rows are gone or count fewer; every command and claim is the same
# (test_safety119.py part 6).
# (test_safety119.py part 6). SAFETY-121 re-pinned the same three again:
# only OUTPUT_CLAMP rows (test_safety119.py part 6).
PINNED = { # tools/safety87_identity.py on b8ddfd7
"n1_under":
"d2395ef8d74fb23607ad864d5a4bf62c03044bbd9367e873675d7ad10e8c169f",
"n1_crossing":
"20f8e542c1ee3eb3a58c8fd6ba3b3e5b6b1486800cd63b675abc68bc222a172b",
"97f7321017d9d34ee17a40dfd4abf73d915f25b9faef086a483d2b5cd124b198",
"n1_above":
"3a79f1394b2b49fa88f3c054da1d9da450153ad5f0d2a2c2f960d02a7abe2208",
"9229b0f0e8addd1bd114461cc36c7f02e9734012b3b7e154d7747bd4cc69b773",
"n1_under_banked":
"a2876c7cc873bc4c974d2b4bd98e23bba2d007b84545587d2fe0b713b0a6249b",
"n1_charge_low":
"2e1ee6db0d4f9f18836c4cbbe406fddd496656b54b97f329da513a1591f0ce6c",
"91336e3c0314985a3b00f314c79b5ffb5a740d218de3ad44f88a4db6e4235b32",
"n1_hold":
"0a1df347754c69166b20d04c15ee878bbcefb02abb824b9b452334f68f03e46d",
}
@@ -540,7 +541,7 @@ _now = s87.digests()
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'}")
check("⭐ every N=1 digest (claims, commands, the guard's decision and the "
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-119), under, "
"log) equals b8ddfd7's (the log as re-pinned by SAFETY-121), under, "
"crossing and above the reserve, in a charge and a HOLD, and under a "
"banked peak",
_now == PINNED)
+583
View File
@@ -0,0 +1,583 @@
"""UX-46: the Dutch pages show Dutch status rows, notes and wizard lines.
`python3 -B test_ux46.py`
Before this, Home's status rows, the settings page's notes and the setup
wizard's checks were built in English and sent as they were, so the NL pages
showed English under Dutch titles (SAFETY-118 I-1, UX-44 I-2, UX-45 I-3).
1. Rows: every row in `messages.status_samples()` carries a Dutch twin
(`messages.Bi`), the Dutch follows STYLE.md, and the Home page's status
payload gives it for lang=nl and the English text for everything else.
2. Notes: every `_advise()` call in app/validate.py gives a Dutch twin, the
notes the validator really builds carry it, and /config/validate sends the
page's language, for the settings notes and for the wizard's check list.
3. The setting labels the Dutch notes quote are the labels in nl.yaml, and the
peak forecast label has no "(W)", since any power unit is read.
4. The rules above catch spoiled input.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import ast
import asyncio
import copy
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
from aiohttp import ClientSession, web as aioweb
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "tools"))
logging.disable(logging.WARNING)
import translations_lint as TL # noqa: E402
import ux13_demo as D # noqa: E402
from style_rules import sentences, text_problems, words # noqa: E402
from app import messages, uimodel as U, uitext, web # noqa: E402
from app.main import Controller # noqa: E402
from named_absence import field, nth, present # noqa: E402
from app.validate import (Note, note_text, setup_checks, # noqa: E402
config_notes)
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# STYLE.md for a Dutch row or note: the same length and shape rules, plus the
# Dutch words that test_ui_text.py bans for the same things. A quoted setting
# label may keep its own words ("Setpoint-entiteit" is the label in nl.yaml).
JARGON_NL = re.compile(
r"\b(?:setpoints?|setpunt\w*|SoC|laadtoestand|BMS|PV|failsafe|regelaar|"
r"regellus|controller|latch|ontlad\w*|payload|endpoint|JSON|API|"
r"time-?out|boolean|config|kwartuur\w*|commando\w*|geparkeerd|ESP32|"
r"master)\b", re.I)
# English function words that never belong in a Dutch sentence.
ENGLISH = re.compile(r"\b(?:the|not|and|for|with|from|reads|check|so|no|set|"
r"keeps|stays|has|have|ran|ago|seconds?|minutes?)\b", re.I)
QUOTED = re.compile(r"“[^”]*”")
def dutch_problems(text):
bare = re.sub(r"\b[a-z_]+\.[a-z0-9_]+\b", "X", QUOTED.sub("X", text))
out = []
if JARGON_NL.search(bare):
out.append("jargon: " + JARGON_NL.search(bare).group(0))
names = re.sub(r"\binverter-[a-z0-9]+\b|\b[a-z_]+\.[a-z0-9_]+\b", "X",
bare)
eng = ENGLISH.search(names)
if eng:
out.append("English word: " + eng.group(0))
return out
def row_problems_nl(colour, text):
n_s, n_w = sentences(text), len(words(text))
out = []
if colour == messages.RED:
if n_s > 3 or n_w > 30:
out.append(f"red row over 3 sentences / 30 words ({n_s}/{n_w})")
elif n_s > 1 or n_w > 15:
out.append(f"row over 1 sentence / 15 words ({n_s}/{n_w})")
if ";" in text:
out.append("semicolon")
return out + text_problems(text, keys=False, entities=False) \
+ dutch_problems(text)
# --------------------------------------------------------------------------- #
print("1. every status row has a Dutch twin, and the page picks one")
# --------------------------------------------------------------------------- #
ROWS = [(fn, c, t) for fn, c, t in messages.status_samples() if c is not None]
check(f"there are rows to grade ({len(ROWS)})", len(ROWS) > 80)
_none = [(fn, str(t)) for fn, c, t in ROWS
if not (isinstance(t, messages.Bi) and t.nl)]
check(f"every row has a Dutch text ({_none})", not _none)
_same = [(fn, str(t)) for fn, c, t in ROWS
if isinstance(t, messages.Bi) and t.nl == str(t)]
check(f"no Dutch row is the English one ({_same})", not _same)
_bad = [(fn, p) for fn, c, t in ROWS if isinstance(t, messages.Bi) and t.nl
for p in row_problems_nl(c, t.nl)]
for x in _bad:
print(f" STYLE {x}")
check(f"every Dutch row follows STYLE.md ({len(_bad)} problems)", not _bad)
BANNERS = [(fn, t) for fn, c, t in messages.status_samples() if c is None]
_nob = [(fn, str(t)) for fn, t in BANNERS
if not (isinstance(t, messages.Bi) and t.nl and t.nl != str(t))]
check(f"every banner has a Dutch text ({_nob})", BANNERS and not _nob)
_bb = [(fn, t.nl) for fn, t in BANNERS if isinstance(t, messages.Bi) and t.nl
and (len(words(t.nl)) > 12 or ";" in t.nl or dutch_problems(t.nl)
or text_problems(t.nl, keys=False, entities=False))]
check(f"every Dutch banner is at most 12 words and follows STYLE.md ({_bb})",
not _bb)
_cat = {k for k, v in vars(messages).items()
if callable(v) and k.startswith("row_")}
check("the samples cover every row_ function, so none can skip the check",
_cat <= {fn for fn, _c, _t in ROWS})
_lat = {"row_direction_trip": "herstart heft dit niet op",
"row_site_direction_trip": "regeling starten heft dit op",
"row_circulating_trip": "regeling starten heft dit op",
"row_not_delivering": "stoppen en starten heft dit op"}
_unclear = [fn for fn, _c, t in ROWS if fn in _lat and _lat[fn] not in t.nl]
check(f"a Dutch latch row says how it clears too ({_unclear})", not _unclear)
check("pick() gives the English for en, the Dutch for nl, and leaves other "
"text alone",
messages.pick(messages.row_estop_fitted(), "en") == "Emergency stop: fitted"
and type(messages.pick(messages.row_estop_fitted(), "en")) is str
and messages.pick(messages.row_estop_fitted(), "nl") == "Noodstop: geplaatst"
and messages.pick("Plain", "nl") == "Plain"
and messages.pick(messages.Bi("A"), "nl") == "A")
check("a Bi survives copy and pickle, and JSON",
copy.deepcopy(messages.row_estop_fitted()).nl == "Noodstop: geplaatst"
and json.dumps(messages.row_estop_fitted()) == '"Emergency stop: fitted"')
# --- the page: a real Controller -------------------------------------------- #
class _Store:
def __init__(self):
self.data = {}
def set(self, k, v):
self.data[k] = v
def get(self, k, d=None):
return self.data.get(k, d)
def set_time(self, k, v):
self.data[k] = v.isoformat()
def get_time(self, k):
v = self.data.get(k)
return None if v is None else datetime.fromisoformat(v)
class _Pub:
enabled = False
def publish(self, _v):
pass
def close(self):
pass
class _Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def number(self, _e, invert=False):
return None
async def state(self, _e):
return None
async def set_number(self, _e, _v):
return True
async def call(self, *_a):
return True
async def states(self):
return []
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
"maintenance_enabled": False, "peak_forecast_entity": "sensor.fc"}
TWO = dict(ONE, inverters=[
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500},
{"name": "Battery B", "setpoint_entity": "", "soc_entity":
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500}])
def make(opts, soc=(62.0, 35.0), batt=(-2500.0, -300.0)):
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
for i, inv in enumerate(c.inverters):
inv.soc, inv.batt = soc[i], batt[i]
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.grid = 10.0
return c
def attention(payload, rid):
return next((a for a in payload["attention"] if a["id"] == rid), None)
c = make(ONE, soc=(62.0,), batt=(960.0,))
c._peak_fc_fault = "kWh"
s_en, s_nl = c.status_payload(), c.status_payload(lang="nl")
def fc_row(payload):
return nth([r for r in payload["checks"] if r.get("id") == "peak_forecast"])
_en, _nl = fc_row(s_en), fc_row(s_nl)
check("Home, English: the peak forecast row is the English sentence",
present(_en) and field(_en, "text") == messages.peak_forecast_unit("kWh")
and attention(s_en, "peak_forecast")["body"]
== messages.peak_forecast_unit("kWh"))
check("Home, Dutch: the row, the Needs-attention entry and the title are "
"Dutch",
present(_nl) and field(_nl, "text") == messages.peak_forecast_unit("kWh", "nl")
and attention(s_nl, "peak_forecast")["body"]
== messages.peak_forecast_unit("kWh", "nl")
and attention(s_nl, "peak_forecast")["title"]
== uitext.words("nl")["home_words"]["attn"]["peak_forecast"])
c._peak_fc_fault = ""
_nl0 = fc_row(c.status_payload(lang="nl"))
check("...and with no unit at all, the Dutch is the 'no unit' sentence",
present(_nl0) and field(_nl0, "text") == messages.peak_forecast_unit("", "nl")
and messages.peak_forecast_unit("", "nl")
!= messages.peak_forecast_unit("kWh", "nl"))
check("a language that is not Dutch gets the English, and the default call "
"is unchanged",
c.status_payload(lang="fr")["checks"] == c.status_payload()["checks"]
and all(type(r["text"]) is str for r in c.status_payload()["checks"]))
# A site with faults of many kinds: every row it shows is Dutch on the NL page.
SCEN = {}
SCEN["two, one without a setpoint, meter stale"] = make(TWO)
SCEN["one, nothing read"] = make(ONE, soc=(None,), batt=(None,))
SCEN["one, no forecast set"] = make(dict(ONE, peak_forecast_entity=""))
SCEN["one, shadow, estop fitted"] = make(dict(ONE, shadow_mode="on",
estop_fitted=True))
SCEN["one, solar sources"] = make(dict(ONE, pv_entity="sensor.pv"))
SCEN["two, solar list"] = make(dict(TWO, pv_entities=["sensor.a", "sensor.b"]))
SCEN["one, peak forecast unit fault"] = c
SCEN["one, control stopped"] = make(ONE)
SCEN["one, control stopped"].auto = False
_English, _rows_seen = [], 0
for name, ctl in SCEN.items():
raw = ctl.checks()
en = ctl.status_payload()["checks"]
nl = ctl.status_payload(lang="nl")["checks"]
check(f"[{name}] {len(raw)} rows: same rows in both languages",
len(raw) == len(en) == len(nl))
for r, e, n in zip(raw, en, nl):
_rows_seen += 1
if not (isinstance(r["text"], messages.Bi) and r["text"].nl):
_English.append((name, r.get("id"), str(r["text"])))
elif e["text"] != str(r["text"]) or n["text"] != r["text"].nl:
_English.append((name, r.get("id"), "page did not pick"))
_banners = {n: (ctl.status_payload()["banner"],
ctl.status_payload(lang="nl")["banner"])
for n, ctl in SCEN.items()}
print(f" banners: {_banners}")
check("the banner at the top of Home is Dutch on the Dutch page, English on "
"the English one", all(e != n and not dutch_problems(n)
for e, n in _banners.values()))
check(f"{_rows_seen} rows over {len(SCEN)} sites: none shows English on the "
f"Dutch page when it has no Dutch text ({_English})", not _English)
check("the row's details list in /status is picked the same way",
all(isinstance(v, str) and not isinstance(v, messages.Bi)
for ctl in SCEN.values()
for _k, v in ctl.status_payload(lang="nl")["rows"]))
# --------------------------------------------------------------------------- #
print("\n2. every settings note and wizard line has a Dutch twin")
# --------------------------------------------------------------------------- #
tree = ast.parse((HERE / "app" / "validate.py").read_text(encoding="utf-8"))
_calls = [n for n in ast.walk(tree) if isinstance(n, ast.Call)
and ast.unparse(n.func) == "_advise"]
_bare = [n.lineno for n in _calls if not any(k.arg == "nl" for k in n.keywords)]
check(f"every _advise() call in validate.py gives `nl` ({len(_calls)} calls, "
f"without: {_bare})", len(_calls) >= 30 and not _bare)
OPTS = dict(D.defaults(D.CONFIG), setpoint_entity="number.sp",
meter_entity="sensor.grid", soc_entity="sensor.soc",
batt_entity="sensor.batt", pv_entity="sensor.pv",
peak_forecast_entity="sensor.fc")
def ent(state, unit=None):
return {"state": state, "unit": unit}
GOOD = {"number.sp": ent("0"), "sensor.grid": ent("402", "W"),
"sensor.soc": ent("62", "%"), "sensor.batt": ent("-800", "W"),
"sensor.pv": ent("-1200", "W"), "sensor.fc": ent("820", "kWh")}
NOTES = []
NOTES += setup_checks(OPTS, entities=GOOD, running=None)
NOTES += setup_checks(OPTS, entities=None)
NOTES += setup_checks(dict(OPTS, meter_invert=True, pv_invert_entities=["sensor.pv"]),
entities=dict(GOOD, **{"sensor.batt": ent("0", "W")}))
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.x", batt_entity="sensor.y",
setpoint_entity="number.typo"),
entities=GOOD)
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local"),
entities=GOOD, running=None)
NOTES += setup_checks(dict(OPTS, meter_source="homewizard_local",
price_provider="generic_dayahead"),
entities=GOOD, running=dict(OPTS, meter_source="homewizard_local",
price_provider="generic_dayahead"),
meter_now_w=-350.0, prices_known=18)
NOTES += setup_checks(dict(OPTS, price_provider="generic_dayahead"), entities=GOOD,
running=None)
NOTES += setup_checks(dict(OPTS, price_provider="off", static_profile=""),
entities=GOOD)
NOTES += setup_checks(dict(OPTS, price_provider="off",
static_profile='{"periods":[]}'), entities=GOOD)
NOTES += setup_checks(dict(OPTS, batt_entity="sensor.batt2"),
entities=dict(GOOD, **{"sensor.batt2": ent("unavailable")}))
NOTES += setup_checks(dict(OPTS, soc_entity="sensor.soc2"),
entities=dict(GOOD, **{"sensor.soc2": ent("unavailable")}))
NOTES += setup_checks(dict(OPTS, meter_entity="sensor.m2"),
entities=dict(GOOD, **{"sensor.m2": ent("unavailable")}))
_TWO = dict(OPTS, inverters=[
{"name": "a", "setpoint_entity": "number.sp", "soc_entity": "sensor.soc",
"batt_entity": "sensor.batt", "max_w": 2000, "bms_soc_floor": 10},
{"name": "b", "setpoint_entity": "number.b", "max_w": 2000,
"bms_soc_floor": 10}])
NOTES += setup_checks(_TWO, entities=dict(GOOD, **{"sensor.batt": ent("-0.5", "kW")}))
NOTES += setup_checks(dict(OPTS, meter_source="ha_signed",
p1_net_entity="sensor.net"),
entities=dict(GOOD, **{"sensor.net": ent("-3.1", "kW")}))
NOTES += config_notes(dict(OPTS, integrator_max_w=9000, max_w=2000,
peak_cap_w=2000), entities=GOOD, month_peak_w=3000)
NOTES += config_notes(dict(OPTS, pv_forecast_entity="sensor.f1",
pv_forecast_tomorrow_entity="sensor.f2",
peak_forecast_entity="sensor.fc2"),
entities=dict(GOOD, **{"sensor.f1": ent("5", "W"),
"sensor.f2": ent("6", "kWh"),
"sensor.fc2": ent("5")}))
NOTES += config_notes(dict(OPTS, meter_entity="sensor.gone"), entities=GOOD)
NOTES += config_notes(dict(OPTS, pv_forecast_tomorrow_entity="sensor.f2"),
entities=dict(GOOD, **{"sensor.f2": ent("6", "kWh")}))
NOTES += config_notes(dict(OPTS, batt_entity="sensor.kw"),
entities=dict(GOOD, **{"sensor.kw": ent("1", "kW")}))
print(f" {len(NOTES)} notes built")
_nonl = [n.text for n in NOTES if not n.nl]
check(f"every note the validator builds has a Dutch twin ({_nonl})",
NOTES and not _nonl)
_same = [n.text for n in NOTES if n.nl == n.text]
check(f"...which is not the English ({_same})", not _same)
_bad = []
for n in NOTES:
nw, ns = len(words(n.nl)), sentences(n.nl)
p = text_problems(n.nl, keys=True, entities=True) + dutch_problems(n.nl)
if nw > 25 and not n.text.startswith(("The 15-minute", "Home Assistant")):
p.append(f"over 25 words ({nw})")
if ns > 2:
p.append(f"over 2 sentences ({ns})")
if ";" in n.nl:
p.append("semicolon")
if len(words(n.nl)) > len(words(n.text)) + 6:
p.append("much longer than the English")
_bad += [(n.nl, x) for x in p]
for x in _bad:
print(f" STYLE {x}")
check(f"every Dutch note follows STYLE.md ({len(_bad)} problems)", not _bad)
_l = {t: [n for n in NOTES if n.text.startswith(t)] for t in (
"Prices for today are in", "The 15-minute demand forecast")}
check("the wizard's price line, and the peak forecast unit note, are among "
"them", all(_l.values()))
check("note_text() picks: Dutch for nl, English for en, the old twin table "
"for a note with no `nl`",
note_text(_l["Prices for today are in"][0], "nl")
== "De prijzen van vandaag zijn binnen, voor 18 van 24 uur."
and note_text(_l["Prices for today are in"][0], "en")
== "Prices for today are in, for 18 of 24 hours."
and note_text(Note("info", (), messages.FIXED_TARIFF_NOTE), "nl")
== messages.FIXED_TARIFF_NOTE_NL
and note_text(Note("info", (), "Other"), "nl") == "Other")
# --- over HTTP: the settings page and the wizard, in both languages --------- #
SCHEMA = D.schema_ui(D.CONFIG)
TRANSLATIONS = {l: TL.parse_translation_file(HERE / "translations" / f"{l}.yaml")
for l in ("en", "nl")}
STATES = [{"entity_id": e, "state": v["state"],
"attributes": ({"unit_of_measurement": v["unit"]} if v["unit"]
else {})} for e, v in GOOD.items()]
class Hass:
session = None
token = ""
async def limits(self, _e):
return (-5000.0, 5000.0, 1.0)
async def states(self):
return copy.deepcopy(STATES)
async def state(self, entity):
return next((copy.deepcopy(s) for s in STATES
if s["entity_id"] == entity), None)
class Supervisor:
def __init__(self, options):
self.options = copy.deepcopy(options)
async def info(self):
return {"options": copy.deepcopy(self.options), "schema": SCHEMA,
"translations": TRANSLATIONS}
async def write_options(self, options):
pass
async def restart(self, confirm):
pass
class Ctl:
def __init__(self, opts):
self.o, self.hass, self.inverters = dict(opts), Hass(), []
self.store, self.auto, self.grid = None, True, 402.0
def log_event(self, text, hint=None):
pass
def status_payload(self, lang="en"):
return {"banner": "", "level": "ok", "rows": [], "checks": [],
"plan": {"prices": [{"label": f"{h:02d}:00", "eur_kwh": 0.2}
for h in range(20)]
+ [{"label": f"{h:02d}:00", "eur_kwh": None}
for h in range(20, 24)]}}
async def serve(opts, steps):
runner = aioweb.AppRunner(web.build_app(Ctl(opts), Supervisor(opts)))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
root = f"http://127.0.0.1:{site._server.sockets[0].getsockname()[1]}/"
try:
async with ClientSession() as s:
return await steps(s, root)
finally:
await runner.cleanup()
async def post(s, url, body):
async with s.post(url, json=body) as r:
return await r.json()
RUN = dict(OPTS, meter_source="homewizard_local", p1_host="192.0.2.50",
price_provider="generic_dayahead")
async def pages(s, root):
out = {}
# The settings notes read the entity list the picker has cached, so the
# first call only warms it.
await post(s, root + "config/validate",
{"model": U.options_to_ui(RUN), "setup": True})
for lang in ("en", "nl"):
async with s.get(root + f"config/model?lang={lang}") as r:
m = await r.json()
model = U.options_to_ui(RUN)
out[lang] = await post(s, root + "config/validate",
{"model": model, "setup": True, "lang": lang})
out["none"] = await post(s, root + "config/validate", {
"model": U.options_to_ui(RUN), "setup": True})
return out
R = asyncio.run(serve(RUN, pages))
_texts = {l: [m["text"] for m in R[l].get("messages", [])] for l in ("en", "nl")}
_setup = {l: [m["text"] for m in R[l].get("setup", [])] for l in ("en", "nl")}
FC_EN = messages.peak_forecast_unit("kWh") + " Pick a sensor in W or kW."
FC_NL = messages.peak_forecast_unit("kWh", "nl") + " Kies een sensor in W of kW."
print(f" settings notes en: {_texts['en']}")
print(f" settings notes nl: {_texts['nl']}")
print(f" wizard lines nl: {_setup['nl']}")
check("settings page, English: the peak forecast unit note is English",
FC_EN in _texts["en"])
check("settings page, Dutch: it is the Dutch sentence, and no English "
"sentence of it is left", FC_NL in _texts["nl"]
and FC_EN not in _texts["nl"])
check("wizard, English: the price line and the checks are English",
"Prices for today are in, for 20 of 24 hours." in _setup["en"])
check("wizard, Dutch: the price line is Dutch",
"De prijzen van vandaag zijn binnen, voor 20 van 24 uur."
in _setup["nl"] and "Prices for today are in, for 20 of 24 hours."
not in _setup["nl"])
_left = [t for t in _setup["nl"] + _texts["nl"]
if ENGLISH.search(QUOTED.sub("X", t))
and not t.startswith("Home Assistant has no entity")]
check(f"no wizard line or settings note on the Dutch page has English words "
f"left ({_left})", not _left)
check("a request with no language gets English (an old page keeps working)",
[m["text"] for m in R["none"].get("setup", [])]
== [m["text"] for m in R["en"].get("setup", [])])
check("the raw `notes` list keeps the English `text` and adds `nl`",
R["nl"]["notes"] and all(n["text"] != n["nl"] for n in R["nl"]["notes"]))
# --------------------------------------------------------------------------- #
print("\n3. labels")
# --------------------------------------------------------------------------- #
for lang in ("en", "nl"):
name = TRANSLATIONS[lang]["configuration"]["peak_forecast_entity"]["name"]
check(f"[{lang}] the peak forecast label has no unit: {name!r}",
"(W)" not in name and "(kW)" not in name
and name.split("·")[-1].strip() == (
"15-minute demand forecast entity" if lang == "en"
else "Entiteit kwartierprognose"))
check("DOCS.md shows the same label, so the table cannot drift",
"| Limits | 15-minute demand forecast entity | `peak_forecast_entity` |"
in (HERE / "DOCS.md").read_text(encoding="utf-8"))
def stems(key):
tr = TRANSLATIONS["nl"]["configuration"]
out = []
for c in (tr, tr["inverters"]["fields"]):
if isinstance(c.get(key), dict):
n = c[key]["name"].split("·")[-1].strip()
while re.search(r"\s*\([^()]*\)$", n):
n = re.sub(r"\s*\([^()]*\)$", "", n)
out.append(n)
return out
_drift = sorted(k for k, v in messages.LABELS_NL.items() if v not in stems(k))
check(f"every Dutch label a note quotes is the setting's name in nl.yaml "
f"(drifted: {_drift})", not _drift
and set(messages.LABELS_NL) == set(messages.LABELS))
_unit = sorted(k for k, v in messages.UNIT_LABELS_NL.items() if v not in stems(k))
check(f"...and so are the two the inverter list names differently ({_unit})",
not _unit)
check("setting() in Dutch keeps the key, so the config panel can find it",
messages.setting("max_w", "", "nl") == "“Vermogen van de omvormer” (max_w)"
and messages.setting("max_w", "inverter-b", "nl")
== "“Vermogen van deze omvormer” voor inverter-b (inverters.max_w)"
and messages.setting("max_w") == "“Inverter rating” (max_w)")
# --------------------------------------------------------------------------- #
print("\n4. the rules catch spoiled input")
# --------------------------------------------------------------------------- #
check("a Dutch row left in English is caught",
row_problems_nl(messages.AMBER, "Control: last ran 2 seconds ago"))
check("an over-long Dutch row is caught",
row_problems_nl(messages.AMBER, " ".join(["woord"] * 16) + "."))
check("Dutch jargon is caught", dutch_problems("Het commando is geweigerd"))
check("a row with no Dutch is caught",
not isinstance(messages.pick("x", "nl"), messages.Bi)
and not getattr(messages.Bi("x"), "nl"))
check("the untranslated-call scan sees a bare _advise",
any(isinstance(n, ast.Call) and not any(k.arg == "nl" for k in n.keywords)
for n in ast.walk(ast.parse('_advise(notes, "warn", (), "x")'))))
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all passed")
+1 -1
View File
@@ -164,7 +164,7 @@ configuration:
name: "Limits · Margin below the peak limit (W)"
description: "The add-on aims this much below the peak limit, so a 15-minute period that ends a little high still stays under it."
peak_forecast_entity:
name: "Limits · 15-minute demand forecast entity (W)"
name: "Limits · 15-minute demand forecast entity"
description: "Optional. A forecast of this 15-minute period's average power, in W or kW. With no power unit it is not used. Leave it empty and there is no peak limit."
peak_soc_floor:
name: "Limits · Peak reserve (%)"
+1 -1
View File
@@ -145,7 +145,7 @@ configuration:
name: "Grenzen · Marge onder de piekgrens (W)"
description: "De add-on mikt zoveel onder de piekgrens, zodat een kwartier dat iets hoger uitvalt er toch onder blijft."
peak_forecast_entity:
name: "Grenzen · Entiteit kwartierprognose (W)"
name: "Grenzen · Entiteit kwartierprognose"
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier, in W of kW. Zonder vermogenseenheid wordt ze niet gebruikt. Laat u dit leeg, dan is er geen piekgrens."
peak_soc_floor:
name: "Grenzen · Piekreserve (%)"