Compare commits

...
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 06aa09e28c SAFETY-73 review: a refusal needs 22 s of evidence, and the cut-out warning is for giving only
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 10m11s
- REFUSED_SPAN_S: the 12 cycles of evidence must also span (SETTLE_CYCLES-1) *
  LOOP_TICK_S = 22 s. At a 1 s meter, lagged healthy readings refused both
  units 10-16 times an hour after load steps (site held 0 W). 2 s and slower
  ticks are unchanged.
- REFUSES_ABOVE_CUT_OUT only for a refusal to give power: a full battery that
  stops taking charge is always above its cut-out level.
- test_delivery: its wedged fixture (0.2 s cycles) no longer reaches a refusal;
  the release/1.0 checks are restored and a span check added.
- test_refusal_units: lagged-reading and full-battery plants, two new checks,
  U14/U15 in the mutation table. Stale "> 500 W" wording fixed. DOCS/DECISIONS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 02:16:24 +02:00
glenn schrooyenandClaude Opus 5.5 1a9c7523c9 Merge release/1.0 (a36dca6) into SAFETY-73-per-unit-refusal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:41:54 +02:00
admin a36dca60dd Merge pull request 'UX-13 P1: page shell and Home screen on a structured /status' (#144) from UX-13-p1-home into release/1.0
infra-pin / fresh (push) Successful in 1s
UX-13 P1: page shell and Home screen on a structured /status (#144)
2026-09-28 01:29:30 +02:00
adminandClaude Opus 5.5 fad73d4012 UX-13 P1 review: Home draws only what the cycle would do
checks / unit (pull_request) Successful in 9m36s
infra-pin / fresh (pull_request) Successful in 1s
- Trade blocks: none on a battery with no room, no sale at a negative price
  or while the emptiest battery is at arbitrage's floor, nothing when a
  level is unknown. A give after a charge may use what the charge put in.
- The refusal line names the real reason: prices not published vs gap.
- The plan's peak line uses the guard's ceiling (this month's peak once
  above the cap), as the peak card does.
- House below zero: 0 W inside the idle band, no figure beyond it.
- Price bars are "Low price" / "High price": the add-on trades in one hour.
- Control toggle: one request in flight at a time.
- test_home: the new cases; the house-sign mutation now goes red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:18:33 +02:00
glenn schrooyenandClaude Opus 5.5 5a8d832eba Merge release/1.0 (afd6af0) into SAFETY-73-per-unit-refusal
checks / unit (pull_request) Successful in 9m58s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 01:16:54 +02:00
Claude f8d63cc614 UX-13 P1: page shell and Home screen on a structured /status
checks / unit (pull_request) Successful in 9m37s
infra-pin / fresh (pull_request) Successful in 2s
- app/static/{index.html,app.css,app.js}: tabs (Home, Batteries, Settings,
  Setup), hash routes, phone bottom tabs, light/dark. Served inlined at "/";
  the route list is unchanged, and every URL is relative.
- app/homeview.py: /status gains state, now, batteries, plan, attention,
  month_peak and control beside the old fields. The plan is what the add-on
  does today: the arbitrage verdict (the same plan.arbitrage() call as the
  cycle), the monthly cycle, the peak limit, "follows the house".
- The Home plan chart has hover, tap and keyboard-focus tooltips: price bar =
  hour and price; block = what, when, why, kWh and EUR.
- Region preset and year: in config/model (for P2) and on the Home price line.
- test_home.py; test_messages part 8 scans every Home sentence; STYLE plan line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-28 00:45:01 +02:00
glenn schrooyenandClaude Opus 5.5 016ed5a5a3 SAFETY-73: a battery that refuses, per inverter, with two or more
checks / unit (pull_request) Successful in 10m1s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R).
A unit that gives < 100 W for 12 cycles in one direction while asked
> 200 W, or trips its stale-reading gate on a ~0 W reading, gets a
P_SAFETY `refused` unit limit closing that direction, so the allocator
hands its share to the others within their own limits. Probe: a +-200 W
pin every 15 min, one unit at a time, only while the site asks 200 W that
way. All units refusing: the site law holds 0 W. Exits reseed the site
target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above
the configured cut-out; SAFETY-42/43 no longer report a refusing unit.
N=1 unchanged.

test_refusal_units.py: cases a/b/c on flat and jitter registers against
the old behaviour, probes, recovery, exits; mutations U1-U13 all red.
TEST-19 no-fault campaign: 0 engagements in 5280 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:13:26 +02:00
admin afd6af068d UX-13 P0: layout table, page<->options derivation, warning tier, entity list (#142)
infra-pin / fresh (push) Successful in 2s
2026-09-27 23:49:32 +02:00
adminandClaude Opus 5.5 badea3cb2d UX-13 P0 review: keep unread keys, back off a failed entity read
checks / unit (pull_request) Successful in 9m30s
infra-pin / fresh (pull_request) Successful in 1s
- uimodel: editing a listed inverter keeps the stored flat keys, and a
  region of "own" keeps a stored price_taxes_profile (both were dropped).
- haentities: a failed read also waits CACHE_S, so polls while Home
  Assistant is down do not queue one 10 s read each.
- ui_text: the Flanders preset names its DSO, and the help says
  Wallonia and Brussels have no figures yet. REGION_PRESETS cites the card.
- tests: decode of a reversed solar row, both kept keys, the backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 23:39:43 +02:00
glenn schrooyenandClaude Opus 5.5 15627c38a6 UX-13 P0: layout table, page<->options derivation, warning tier, entity list
checks / unit (pull_request) Successful in 9m32s
infra-pin / fresh (pull_request) Successful in 1s
The foundation for the new settings page. No visible UI change.

- app/uilayout.py: screen -> group -> part -> field for every option in
  config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display
  scale, step, entity kind, show conditions, Simple/wizard placement and
  "applies after a restart". Ranges stay in the schema, words in
  translations and the new app/ui_text/en.json.
- app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one
  inverter vs the list, solar rows, static_profile from simple prices, a
  year-stamped region preset for the levies figure, and an OPT-11 hook for
  arbitrage_enabled that changes nothing yet). Unchanged derivations write
  the stored values back, so the round trip is exact.
- validate.py: validate.Note, a warn/info tier. Each logged warning is also
  a note when a list is passed; config_notes() adds checks that need live
  data (missing entity, a kW sensor read as W, a limit below this month's
  peak). /config/validate returns them as "notes". No refusal changed: 261
  recorded validator calls, 0 differ (tools/ux13_validator_calls.py).
- GET ha/entities: Home Assistant's entity list for the picker, read through
  hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200
  with the old list marked old, or an empty list that says why.
- DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI
  code": keys may appear in uilayout.py and uimodel.py only, both tested.
- Tests: test_uilayout, test_uimodel (round trip over the recorded calls,
  the TEST-23 option files and config.yaml defaults), test_haentities, a
  warning-tier part in test_validate; test_messages scans notes and
  ui_text; test_pv and test_selfconfig part 7 widened on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
2026-09-27 23:11:31 +02:00
admin 652097abde Merge pull request 'SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min' (#141) from SAFETY-52-refused-hold into release/1.0
infra-pin / fresh (push) Successful in 1s
2026-09-27 18:21:21 +02:00
adminandClaude Opus 5.5 3c0f2b9669 SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
checks / unit (pull_request) Successful in 9m22s
infra-pin / fresh (pull_request) Successful in 1s
- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's
  own 100 W answer, not below saturation_w: a battery derated to 100-499 W
  was held at 0 W ~93 % of the time.
- A reading of 100 W or more in the held direction ends the refusal, probe
  or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy
  reading's lag, and that held a healthy step at 0 W until the next probe.
- test_refusal part 5b and a seed test; DOCS states the N>1 limit; the
  DECISIONS record carries the traced cause of the replay's extra trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:11:42 +02:00
glenn schrooyenandClaude Opus 5.5 1d33265aac test_price: grade the Why row of the same render, not a second read of why_words
checks / unit (pull_request) Successful in 9m20s
infra-pin / fresh (pull_request) Successful in 1s
Since UX-10 the waiting sentence counts its seconds when it is read, so the
second read could cross a second boundary on a loaded runner ("1 second" in
the row, "2 seconds" in the check). CI run 953 on this PR failed that way;
test_price.py passes locally on the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:04:55 +02:00
glenn schrooyenandClaude Opus 5.5 1f97c7652f SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Failing after 9m32s
At the BMS cut-out the saturation freeze held the last request for hours
(+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid
error changed sign. compute() now carries a refused direction: after
SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction
at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go
on charge, an answered probe, stop/start or another writer. A stale-reading
trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's
~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A).

test_refusal.py drives the real Controller and gate; tools/replay_refusal.py
replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined
where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what
SAFETY-58/59 must take into account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:43:27 +02:00
admin d1f7e3336e Merge pull request 'UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG' (#140) from UX-11-short-safety-lines into release/1.0
infra-pin / fresh (push) Successful in 2s
Merge UX-11: short safety log lines, figures to DEBUG and events (#140)
2026-09-27 14:31:05 +02:00
46 changed files with 6595 additions and 135 deletions
+1
View File
@@ -7,6 +7,7 @@ This applies to everything a home owner or installer reads: notifications, phone
- **Body**: **one sentence** of at most 25 words, saying what happened and what to do. A **red alarm** may use up to **3 sentences** (70 words, each sentence at most 30 words), so that no action a person must take is lost.
- **Details**: the last line links to the add-on documentation and names the section with the rest: `Details: add-on documentation, section "<title>" under Notifications.` Background, figures, reason codes and history go there or into the log, never into the body.
- **Status row**: one sentence of at most 15 words. A **red** row may use up to 3 sentences (30 words), like a red alarm. It shows the live numbers the reader acts on and never an HA entity id. **Banner**: at most 12 words. **Refusal**: at most 30 words, naming the setting by its label and then its key, as `messages.setting()` writes it: “Inverter rating” (max_w), or “Inverter rating” for inverter-b (inverters.max_w) for one inverter in the list. **Option name**: at most 6 words, not counting the group prefix. **Option description**: at most 30 words. Where a value comes from belongs in DOCS.md.
- **Plan line** (UX-13 Home screen: the plan, tooltips, a battery's "now" line): at most 2 sentences, each at most 12 words. Words from `app/homeview.py`, checked by `test_messages.py` part 8.
- **Log line** (WARNING/ERROR): one line of at most 25 words **as logged**, with any built text filled in. Log lines stay in English and are not translated. A line that reports a safety reason gives one plain sentence and the reason's code (`STALE_READING`, `OUTPUT_CLAMP`), the evidence an installer searches for. The reason itself, with its figures and limits, goes on a DEBUG line right after it and into the action log, never onto the WARNING/ERROR line. It keeps the shape of a red alarm: at most 3 sentences of at most 30 words. The line as logged has no semicolon, banned word, capitals, "1 times" or "(s)". `test_messages.py` part 6 builds every such text with the real code and checks it, and lists the few non-safety families still over 25 words. A line that repeats every cycle is DEBUG. INFO lines carry no section numbers, ISO timestamps or capitals.
- **Refusal length is counted as shown**: a setting placeholder counts as its label and key, and the long cases are driven through the validator (`test_messages.py` part 3).
- **Event line** under the buttons: the plain words of `messages.hint()`, like a green row. The add-on's own action log stays technical and is not shown on the page. Installers read it as `events` in the `status` JSON.
@@ -0,0 +1,110 @@
# SAFETY-52 - a battery that refuses is asked for 0 W
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
questions answered yes.**
## The defect
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
inside the interval a limit can produce. The request stood at +5000 W for
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
moved again only when the grid error changed sign. SAFETY-48's lead cap only
changes where `i_w` parks (one slew step ahead instead of at the rail). When
the request moved after the reading went flat, the stale-reading gate won
instead and ran SAFETY-59's ~70 s trip cycle.
## What was decided
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
below `REFUSED_BELOW_W` (100 W, the probe's own answer) against an ask beyond
`saturation_w`. The drop to 0 skips
the slew limit (it only removes an ask nobody meets). The direction is
carried like `sat_count` (`refused=`, `Decision.refused`).
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
`PROBE_S`; twice `BATT_OBLIGED_S`). A reading of at least 100 W in the held
direction ends the refusal, during a probe or not. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
cycle. The log keeps SAFETY-56's two-explanation wording.
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
Other exits: the loop wants the other direction (charge is accepted at the
floor), control stopped or started, another writer on the wire (the arbiter
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
what seeds it.
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
battery's discharge end and moves its share. There, a one-unit gate trip must
not refuse the site. `compute()` is passed `refused=None` there, and that path
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
`test_control.py` still holds).
## Evidence
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
shapes, recovery with no sign change, the probe cadence, every exit).
`tools/replay_refusal.py` replays the 16-day capture, same model as
`replay_recorded_loop.py`, and should be read for deltas:
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|---|---|---|---|---|---|---|
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
| fitted | new | 130 (133 after review) | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
**Traced in review (#141).** The +22 W worst quarter is the old law's worst
quarter too. There the old law stands at +2060 W on a pack giving 0 W, and the
new law asks 0 W, which the fitted plant answers with its -22 W offset: 22 W
charged from the grid. The fit above the floor reads `batt = cmd - 28 W`, so a
real inverter asked 0 W may do the same. Of the 71 new-law trips with no
old-law trip within 2 min, 59 come 85-97 s after an answered probe. The offset has lifted the modelled pack
just above its floor, the probe is answered, control ramps, the pack empties
again mid-ramp, and the gate trips, which re-seeds the hold. The other 12
follow an other-direction exit the same way. On a site this is one
`STALE_READING` ERROR line per answered probe at most, and only on a battery
that answers a probe and then cuts out again. Catching the re-cut before the
gate needs a faster engage than `SETTLE_CYCLES`, which accuses a healthy slow
battery, so it is left to SAFETY-58/59. After the review changes below the
fitted-plant row reads 133 trips (the delivery exit now also catches answers
that arrive after the probe window); the other figures are unchanged. Rejected alternatives and their figures are in
the phase A plan: bounding the integrator by demand made the worst quarter
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
configured floor to be right and needs SoC.
## Review changes (#141)
- **"Gives nothing" is below 100 W, not below 500 W.** At `saturation_w` a
battery derated to 100-499 W (cold, a tapering battery) was held at 0 W for
~93 % of the time: 300 W derated gave 86 Wh in 4 h against 1 200 Wh on the
old law. The capture has 0.11 h in that band, so the replay does not move.
- **Any delivery ends the hold, not only an answered probe.** With
`meter_poll_s: 1` the 12 cycles last 12 s, less than a healthy battery's
reading lag (up to `BATT_OBLIGED_S`, 17 s). That engaged on a healthy step and
held it for 15 min. Now the late reading ends it within seconds (2 s held in
`test_refusal.py` part 5b).
- **N>1 is stated as a limit in DOCS.** The pack limit there covers an empty
battery only when its cut-out level is configured right.
## Not covered
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
genuine limit (SAFETY-13 case a) and still holds its request.
## What SAFETY-58 and SAFETY-59 must take into account
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
the first verdict, the seed arrives later. The 09-19 shape then needs the
compute-side engagement (12 cycles) to win, which it does only while the
request stands still. Keep `test_refusal.py` part 4 green.
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
change a refusing battery no longer produces the pattern at one inverter. The
DOCS text should say so, and that the `battery refuses` reason is the
discriminator the add-on now has. At N>1 the old pattern can still occur.
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
`test_refusal.py` part 2 checks the inequality.
@@ -0,0 +1,104 @@
# SAFETY-73 - a battery that refuses, with two or more inverters
**Decided 2026-09-27 by the owner, on the phase A plan (option R). All four
new questions answered yes. SAFETY-52's four decisions carry over.**
## The defect
SAFETY-52 held a refusing battery at 0 W at one inverter only. At N>1
(`main.py` passed `refused=None`) nothing closed a unit that gives ~0 W while
its state of charge reads above its configured cut-out, so the allocator kept
handing it its weighted half. Measured on the real `Controller`, two units,
house 3000 W, A cut out:
| register | law | B gives | grid | A's gate trips / h |
|---|---|---|---|---|
| flat -4 W | old | 533 W | +2471 W | 46 (each stops the whole site) |
| flat -4 W | new | 2000 W (its rating) | +1004 W | 1 |
| jitter | old | 1910 W | +1094 W | 0 |
| jitter | new | 2000 W | +1004 W | 0 |
House 1200 W, which B alone carries: old +671 W (flat) and +284 W (jitter)
standing import, new -16 W. Both cut out: old keeps asking two empty batteries
(+1910 W each on the jitter shape), new holds the site at 0 W.
## What was decided
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
claim, source `refused`. It engages when the unit gives under
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least
`REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W`
(200 W), or when its stale-reading gate
trips on a held reading under 100 W. The allocator then hands its share to
the others within their own limits (owner decision 1). It is a hard unit
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
arbitrage and maintenance are placed around it like the loop.
3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
unit: its share is 0 W, and `account()` treats it as parked.
4. **All units refusing the same way** pass that sign to `compute()`, so the
site holds 0 W with SAFETY-52's law (decision 4). Probes go one unit at a
time.
5. **Probe:** every 15 min for 34 s the claim becomes a pin at +-200 W and the
others carry the rest. The pin stands only while the resolved site target
asks at least 200 W its way; otherwise it would make a sibling take the
opposite direction, so it falls back to the closed limit for that cycle.
6. **Exits:** a reading of 100 W or more in the held direction (probe or not);
100 W or more taken the other way for 12 cycles (charge at the floor; one
cycle of the loop overshooting through zero is not enough); stop or start.
On every exit the site target and integrator restart from the watts on the
wire, or the reopened unit is handed a parked remainder at once.
**Why 200 W and not `saturation_w`.** A unit's share is often small: a 400 W
night load split two ways asks 200 W of each. With 500 W the empty battery
kept a phantom share that SAFETY-43's relative arm then reported as not
delivering, the ERROR decision 3 replaces. Shares of 200 W or less that give
under 100 W are "too small to call" in `account()`: the sibling covers the
meter and nothing is asked of the empty battery that matters.
**Why one direction throughout.** On TEST-19's flapping family a command that
swaps sign each cycle reads ~0 through the plant's lag: 42 engagements in 21
no-fault runs without the rule, 0 with it.
**N=1 is unchanged.** Every SAFETY-73 path is gated on `len(inverters) > 1`.
`test_refusal_units.py` part 6 runs SAFETY-52's whole suite with every
SAFETY-73 method made to raise.
## Evidence
- `test_refusal_units.py`: cases a, b and c on both register shapes against
the old behaviour, probes and their spacing, one-at-a-time probing, the
recovery reseed, each exit, the small-ask and small-site-target cases, the
WARNING, the status row and shadow mode. Mutations U1-U13 in its docstring.
- **No false engagements.** TEST-19's no-fault campaign (admin/goodwe
`sim/delivery_window.py` families, 2640 runs at N=2 and 2640 at N=1, every
unit executing its share) graded with the engage clause: 0 engagements, the
longest qualifying streak 5 cycles against 12. The grader is
`goodwe-session/scratch_s73/s73_nofault.py`.
- **Recovery.** Case a, A recovering into a house that fell below the parked
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
reseed and the site hold.
## Review changes
- **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12
cycles were 11 s, shorter than a healthy reading's lag (dead time plus a
10-17 s publish). The reviewer's harness (real `Controller`, both units with
6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then
refused both healthy units 10-16 times an hour, and each time the site held
0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without
the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is
`(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged.
- **The cut-out WARNING is for a refusal to give power only.** A full battery
that stops taking charge is always above its cut-out level, and the WARNING
told the installer to check a setting that has nothing to do with it.
## Not covered
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
N=1. SAFETY-42/43 still report it.
- `pack_permissions` stays SoC-based, so the peak guard may size a shave with a
refusing battery counted. The shortfall shows as `unmet_w`.
- A wedged unit (HW-01) reading ~0 W is now shown as a refusal, not reported as
not delivering. Its share still moves, which is the gain SAFETY-42 measured.
@@ -0,0 +1,69 @@
# UX-13 — one layout table, and why UI code may now name an option key
**Status: DECIDED 2026-09-27 (owner approved the UX-13 design and mockup).**
Card 6ab979852f3eca02fb40f69e. Amends [[UX-02]] §3.1. Design note and
mockup: `goodwe-session/UX-13-design.md`, `UX-13-mockup.html`.
## 1. What UX-02 said, and why it no longer fits
UX-02 built the configuration panel from the Supervisor's schema, so
`app/configui.py` contains "no option key, no label and no rule", and
test_selfconfig.py part 5 checks that the page script names no key. That kept
a second copy of the option surface from drifting, and it is why the panel is
a flat list of fields in schema order.
UX-13 needs things a schema cannot say: groups and sub-groups, a slider in kW
for a value stored in W, a picker for an entity, a field shown only for one
meter source, locked measured values, a Simple view and a setup wizard. It
also needs page fields that are not options at all: one inverter card that is
stored as six flat keys, solar rows that become `pv_entity` or `pv_entities`,
a contract and two prices that become `static_profile` JSON, and a region
that becomes the levies figure. None of that can be read off the schema, so
some code has to name option keys.
## 2. The decision
Option keys may appear in UI code in exactly two files:
- **`app/uilayout.py`**, the layout table: screen → group → part → field, with
control, unit, display scale, step, entity kind, when it is shown, whether it
is on the Simple view or a wizard step, and whether it applies now or after a
restart. Every option in `config.yaml` is a field, `DERIVED` or `HIDDEN`,
exactly once.
- **`app/uimodel.py`**, the derivations the table names: `options_to_ui()` and
`ui_to_options()`. It may name only the keys it writes.
What UX-02 made authoritative stays authoritative:
- **Ranges** come from the schema (`uilayout.range_of()`); the table writes
none. A unit holds no digit.
- **Labels and help** come from `translations/*.yaml` by key. Page-only fields,
group titles and choices are in `app/ui_text/*.json`, held to STYLE by
test_messages.py.
- **Rules** stay in `validate.py`. It gains a warning tier (`validate.Note`,
"warn" or "info") for the page. A note never blocks a save, and passing the
notes list does not change the refusals returned.
- **The stored blob** is still the one truth. The page model carries the stored
values of every derived key (`keep`). A derivation whose page fields are
unchanged writes those values back untouched, so a blob the page cannot
express survives a save of another field.
## 3. How it is held
- test_uilayout.py fails when `config.yaml` gains an option, or an inverter
field, that the table does not place. It also checks that uimodel.py names
only the keys it writes and that web.py and haentities.py name none.
- test_uimodel.py round-trips options → page model → JSON → options for every
validator call the suite made at 652097a (`scenarios/ux13/`), the TEST-23
option files and config.yaml's defaults. All must be identical.
- `tools/ux13_validator_calls.py compare` runs those calls through the old and
new validator. At P0: 261 calls, 158 refused, no refusal changed.
- test_selfconfig.py part 5 still checks the old page script; P1 moves the page
into `app/static/` and the same check applies there.
## 4. What this costs
The table is a second place that knows the option surface, which UX-02 set
out to avoid. The cost is bounded by the coverage test: an option added to
config.yaml and not placed here fails CI, rather than silently missing from
the page. Changing a key's type or range needs no change here.
+31 -2
View File
@@ -295,7 +295,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported, and neither is one that gives under 100 W (SAFETY-73: it is a battery that refuses). Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode).
- **Control gain** (`gain`): measured on real hardware and already at the limit.
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
@@ -526,7 +526,7 @@ accumulator is doing:
| `Loop integrator lead` | W | that value minus **the command it was computed against**, i.e. the command the previous cycle left standing |
| `Loop reason` | — | the law's own word for this cycle |
`Loop reason` takes one of eight values:
`Loop reason` takes one of ten values:
| value | what happened |
|---|---|
@@ -538,6 +538,35 @@ accumulator is doing:
| `saturated-freeze` | the inverter is not following, so the command's magnitude may fall but not rise |
| `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again |
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) |
| `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again |
**When the battery refuses.** At its cut-out level the battery gives nothing,
whatever it is asked. The add-on then asks 0 W instead of repeating its last
request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every
15 minutes it asks 200 W for about half a minute. Control resumes as soon as
the battery gives 100 W or more, when the grid needs charging instead, and when
control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request.
**With two or more inverters** the same rule works per inverter. An inverter
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
asked for more than 200 W is asked 0 W, and the other inverters take over its share, each up to
its own limits. The status page names it: *inverter-a: the battery refuses to
give power, the others take over*. It is tested at 200 W every 15 minutes, one
inverter at a time, and only while the site asks at least 200 W that way.
It still accepts charge, and 12 cycles of charging end the refusal. When every
inverter refuses the same way, the whole site asks 0 W as with one inverter.
If the battery refuses to give power while its level reads above its “Battery
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
A battery that refuses is not reported as *not delivering*.
*For installers:* SAFETY-52 and SAFETY-73. The event log reads
`BATTERY REFUSES …` and `BATTERY REFUSES ENDED …` (with the inverter's name at
N>1), and the INFO line ends in `BATTERY_REFUSES`.
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures and
`DECISIONS/SAFETY-73-refusal-per-inverter.md` the two-inverter ones.
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
+6
View File
@@ -226,6 +226,12 @@ CLAIM_SCOPE = {
# reaches the allocator, so a unit-scoped claim is the shape that sentence
# asks for rather than an evasion of it.
"pack": SCOPE_UNIT,
# main.py: `Claim.limit("refused", P_SAFETY, ...)` and, during a probe,
# `Claim.set("refused", P_SAFETY, +-PROBE_W)`. SAFETY-73: THIS battery gives
# nothing in one direction, measured on THIS unit's own reading. A hard unit
# limit that always intersects, so the share goes to the siblings. Filed at
# N>1 only; at N=1 SAFETY-52's law in compute() does the same job.
"refused": SCOPE_UNIT,
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
+58 -1
View File
@@ -265,6 +265,34 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
# `slew_limit_w()` and therefore with the rail.
SLEW_LEAD_STEPS = 1
# ---------------------------------------------------------------------------
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
#
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
# saturation freeze below holds the command where it stood (the recorded
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
# carried between cycles like `sat_count`, and left again when the loop wants
# the other direction, when a small probe is answered, or when the caller
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
#
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
# test_refusal.py pins the inequality. The cadence and length are the owner's
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
# (17 s is how long a healthy reading needs to show a command).
REFUSED = "battery refuses"
PROBING = "battery probe"
PROBE_W = 200.0
PROBE_ANSWER_W = PROBE_W / 2
# "Gives nothing" is below the probe's own answer, not below `saturation_w`: a
# pack derated to 100-499 W is a limit, and at 500 W it kept ~7 % of its power
# (review of #141). The capture's refusals read -4 W.
REFUSED_BELOW_W = PROBE_ANSWER_W
PROBE_EVERY_S = 900.0
PROBE_S = 34.0
@dataclass(frozen=True)
class Tuning:
@@ -346,6 +374,9 @@ class Decision:
i_bound_w: float = 0.0
requested_w: float = 0.0
clamped_w: float = 0.0
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
# charge, 0 none), or None when the caller does not carry it.
refused: int | None = None
def slew_limit_w(tuning: Tuning) -> float:
@@ -442,6 +473,8 @@ def compute(
tuning: Tuning,
sat_count: int = 0,
i_w: float | None = None,
refused: int | None = None,
probe: bool = False,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -449,6 +482,9 @@ def compute(
`grid_w` net grid power, + = importing
`actual_w` what the inverter reports it is doing, + = discharging
`i_w` the carried integrator, or None to seed it from `prev_w`
`refused` SAFETY-52's carried refused direction, or None to leave it out
(the `i_w=None` equivalence path does)
`probe` the caller's clock says a refused direction may try PROBE_W now
"""
reason = "tracking"
@@ -739,6 +775,27 @@ def compute(
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
reason = "saturated-freeze"
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
# delivers ~nothing: a reading below REFUSED_BELOW_W against an ask beyond
# `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew
# limit because it only removes an ask the battery is not meeting.
if refused is not None:
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w) < REFUSED_BELOW_W):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * actual_w >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
wants = refused * i_w > 0
i_w = 0.0
target = refused * PROBE_W if probe and wants else 0.0
reason = PROBING if probe and wants else REFUSED
# --- quantise ----------------------------------------------------------
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
@@ -749,7 +806,7 @@ def compute(
return Decision(float(target), sat_count, frozen, reason, float(i_w),
i_clamped, clamped, float(i_bound_w), float(want),
float(clamped_w))
float(clamped_w), refused)
def maintenance_charge_floor(
+91
View File
@@ -0,0 +1,91 @@
"""UX-13: Home Assistant's entities, for the settings page's entity picker.
A read of `GET /api/states` through the add-on's own session (hass.states()),
cached for 30 s and trimmed to what a picker shows. Nothing here writes, so
UX-02 §6 holds. When Home Assistant does not answer, the last good list is
served with its age, or an empty one that says why: the route never fails.
"""
import asyncio
import time
CACHE_S = 30.0
DOWN_TEXT = "Home Assistant is not answering, so the entity list is empty."
STALE_TEXT = "Home Assistant is not answering, so this list may be old."
_POWER_UNITS = ("mW", "W", "kW", "MW", "GW", "TW")
_ENERGY_UNITS = ("Wh", "kWh", "MWh", "GWh")
def row(state: dict) -> dict | None:
"""One state object -> {entity_id, name, state, unit, device_class}."""
if not isinstance(state, dict) or not isinstance(state.get("entity_id"), str):
return None
attrs = state.get("attributes") if isinstance(state.get("attributes"), dict) else {}
return {"entity_id": state["entity_id"],
"name": str(attrs.get("friendly_name") or state["entity_id"]),
"state": state.get("state"),
"unit": attrs.get("unit_of_measurement"),
"device_class": attrs.get("device_class")}
def matches(r: dict, kind: str) -> bool:
"""Is `r` what a picker of this kind offers first? `kind` is one of
uilayout.ENTITY_KINDS; "" or "any" offers everything."""
unit, cls = r.get("unit"), r.get("device_class")
if kind in ("power", "power_w"):
return cls == "power" or unit in _POWER_UNITS
if kind == "energy":
return cls == "energy" or unit in _ENERGY_UNITS
if kind == "pct":
return unit == "%"
if kind == "number":
return r["entity_id"].startswith(("number.", "input_number."))
return True
class EntityCache:
"""The last entity list read from Home Assistant, at most CACHE_S old."""
def __init__(self, hass, clock=time.monotonic):
self.hass = hass
self.clock = clock
self.rows: dict | None = None
self.read_at: float | None = None
self.tried_at: float | None = None
self._lock = asyncio.Lock()
async def get(self) -> tuple[dict | None, float | None]:
"""(rows by entity id, age in s), or (None, None) if never read."""
async with self._lock: # one HA read however many pickers ask
now = self.clock()
# A failed read also waits CACHE_S, so polls while HA is down do
# not queue one 10 s read each behind this lock.
if self.tried_at is None or now - self.tried_at >= CACHE_S:
self.tried_at = now
states = await self.hass.states()
if states is not None:
rows = (row(s) for s in states)
self.rows = {r["entity_id"]: r for r in rows if r}
self.read_at = now
if self.read_at is None:
return None, None
return self.rows, now - self.read_at
def peek(self) -> dict | None:
"""The cached rows if they are fresh, without asking Home Assistant."""
if self.read_at is None or self.clock() - self.read_at >= CACHE_S:
return None
return self.rows
async def payload(self, kind: str = "") -> dict:
"""The route's JSON: matching entities first, then the rest."""
rows, age = await self.get()
if rows is None:
return {"available": False, "text": DOWN_TEXT, "entities": []}
ordered = sorted(rows.values(), key=lambda r: r["entity_id"])
first = [dict(r, match=True) for r in ordered if matches(r, kind)]
rest = [dict(r, match=False) for r in ordered if not matches(r, kind)]
out = {"available": True, "age_s": round(age, 1), "entities": first + rest}
if age >= CACHE_S:
out["text"] = STALE_TEXT
return out
+27
View File
@@ -18,6 +18,7 @@ SUPERVISOR_API = "http://supervisor"
BAD_STATES = ("unknown", "unavailable", "none", "")
# What the Supervisor's proxy answers while Home Assistant restarts.
UNAVAILABLE = (502, 503, 504)
STATES_TIMEOUT_S = 10 # UX-13's entity list, like every other read here
class Outage:
@@ -113,6 +114,32 @@ class HomeAssistant:
return None
return obj
async def states(self) -> list | None:
"""Every entity's state object (`GET /api/states`), or None when Home
Assistant does not answer. A read only (UX-13 entity picker)."""
try:
async with self.session.get(
f"{CORE_API}/states", headers=self._headers,
timeout=STATES_TIMEOUT_S
) as resp:
if resp.status in UNAVAILABLE:
OUTAGE.down("api", f"error {resp.status}",
f"read all states: {resp.status}")
return None
OUTAGE.up("api")
resp.raise_for_status()
obj = await resp.json()
except aiohttp.ClientConnectionError as err:
OUTAGE.down("api", "no connection", f"read all states: {err}")
return None
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
self._moan("read:*", "Reading the entity list failed: %s", err)
return None
if not isinstance(obj, list):
self._moan("shape:*", "The entity list is not a list: %.80r", obj)
return None
return obj
async def number(self, entity_id: str, invert: bool = False) -> float | None:
"""Numeric state, or None. Never substitutes a default."""
obj = await self.state(entity_id)
+985
View File
@@ -0,0 +1,985 @@
"""UX-13 P1: the Home screen's part of `/status`, built from what the add-on
already knows and already plans. Reads only; commands nothing.
`build(controller)` returns `now`, `batteries`, `plan`, `attention`,
`month_peak`, `control` and `state`. The old fields (`banner`, `rows`,
`checks`, ...) stay beside them for the installer details and for anything
that reads the JSON. See DECISIONS/UX-13-one-layout-table.md and the design
note, section 3.3.
The plan shows only what the add-on does today: trading on price (the
arbitrage verdict, the same call the control cycle makes), the monthly battery
cycle, the peak limit and "follows the house". Battery modes (OPT-11) are not
built, so nothing here invents a mode plan. Every sentence is in the words
section at the bottom and is held to STYLE.md by test_messages.py.
"""
from datetime import datetime, timedelta, timezone
from . import arbitrage, messages, peakguard
from .maintenance import CHARGE as M_CHARGE, DRAIN as M_DRAIN, HOLD as M_HOLD
from .maintenance import IDLE as M_IDLE
from .price import Absence, Uncertainty, brussels
from .static_tariff import static_plan
from .tariff import BILLING_TZ
from .uimodel import REGION_PRESETS, options_to_ui
# A battery at or under this many watts is resting, not charging or giving.
IDLE_W = 50.0
# Display only: the lowest third of today's price range is "cheap", the top
# third "expensive". A price range under this is flat, and nothing is marked.
FLAT_EUR_KWH = 0.01
# Severity words the page shows; the same four as STYLE.md section 3.
ALARM, WARNING, INFO = messages.ALARM, messages.WARNING, messages.INFO
# --------------------------------------------------------------------------- #
# Time: today's local day, on the billing calendar
# --------------------------------------------------------------------------- #
def local_day(now: datetime):
"""`(midnight_utc, next_midnight_utc)` of the local day holding `now`."""
here = now.astimezone(BILLING_TZ)
start = datetime(here.year, here.month, here.day, tzinfo=BILLING_TZ)
nxt = start.date() + timedelta(days=1)
end = datetime(nxt.year, nxt.month, nxt.day, tzinfo=BILLING_TZ)
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
def _hours(t: datetime, day0: datetime) -> float:
return (t - day0).total_seconds() / 3600.0
def hhmm(t: datetime) -> str:
return f"{brussels(t):%H:%M}"
def _kw(w) -> str:
return f"{abs(float(w)) / 1000:.1f} kW"
def _eur(v) -> str:
return f"€{float(v):.2f}"
def _kwh(v) -> str:
return f"{float(v):.1f} kWh"
# --------------------------------------------------------------------------- #
# Now
# --------------------------------------------------------------------------- #
def flow(w, *, shadow=False):
"""`charging` / `giving` / `resting` / None for one battery power."""
if w is None:
return None
if shadow:
return "resting"
if w > IDLE_W:
return "giving"
if w < -IDLE_W:
return "charging"
return "resting"
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow):
"""The four tiles. `house_w` needs all three readings, so without a solar
reading it is None rather than a number that leaves the sun out."""
house, disagree = None, False
if has_solar and None not in (grid_w, pv_w, batt_w):
house = float(grid_w) + float(pv_w) + float(batt_w)
# The three are read at slightly different moments, so the sum can dip
# under zero. Inside the idle band that is 0 W; beyond it the readings
# disagree and no house figure is shown.
if house < -IDLE_W:
house, disagree = None, True
elif house < 0.0:
house = 0.0
return {
"solar_w": None if pv_w is None else float(pv_w),
"house_w": house,
"grid_w": None if grid_w is None else float(grid_w),
"batt_w": None if batt_w is None else float(batt_w),
"has_solar": bool(has_solar),
"lines": {
"solar": tile_solar(pv_w, has_solar),
"house": tile_house(house, has_solar, disagree),
"grid": tile_grid(grid_w),
"batt": tile_batt(batt_w, shadow),
},
}
# --------------------------------------------------------------------------- #
# Prices for today's chart
# --------------------------------------------------------------------------- #
def _points(plan):
if plan is None:
return ()
return plan.read.match(series=lambda r: tuple(r.points),
no_data=lambda _r: ())
def _spans(points):
"""`[(point, end)]`: each point runs until the next one starts, and the
last one for the series' own spacing (15 minutes or an hour)."""
pts = sorted(points, key=lambda p: p.start)
gaps = [b.start - a.start for a, b in zip(pts, pts[1:])]
step = min([g for g in gaps if g > timedelta(0)] or [timedelta(hours=1)])
step = min(step, timedelta(hours=1))
out = []
for i, p in enumerate(pts):
nxt = pts[i + 1].start if i + 1 < len(pts) else None
end = nxt if nxt is not None and nxt - p.start <= timedelta(hours=1) \
else p.start + step
out.append((p, end))
return out
def _day_prices(c):
cache = getattr(c, "home_prices", None)
if cache is None:
cache = c.home_prices = DayPrices()
return cache
class DayPrices:
"""Today's prices as seen so far. The refresh keeps only the hours ahead,
so the hours already past are remembered here for the chart. Display only:
nothing reads this back into a decision."""
def __init__(self):
self.day = None
self.points = {}
def see(self, points, day0, day1):
if self.day != day0:
self.day, self.points = day0, {}
for p in points:
if day0 <= p.start < day1:
self.points[p.start] = p
return sorted(self.points.values(), key=lambda p: p.start)
def hour_bars(points, day0, day1):
"""One bar per local hour: the mean of its prices, or None when unknown.
`kind` is cheap / dear / mid by thirds of today's range (display only).
The page calls these "low price" and "high price", not cheap hours: the
add-on trades in the one cheapest and one dearest hour, not in a third."""
n = int(round(_hours(day1, day0)))
bars = []
for k in range(n):
a, b = day0 + timedelta(hours=k), day0 + timedelta(hours=k + 1)
inside = [p for p in points if a <= p.start < b]
price = (sum(p.eur_per_kwh for p in inside) / len(inside)
if inside else None)
bars.append({"h": k, "start": a.isoformat(), "label": hhmm(a),
"end_label": hhmm(b),
"eur_kwh": None if price is None else round(price, 4),
"estimate": bool(inside) and any(p.provisional
for p in inside),
"kind": None})
known = [b["eur_kwh"] for b in bars if b["eur_kwh"] is not None]
if known and max(known) - min(known) >= FLAT_EUR_KWH:
lo, hi = min(known), max(known)
third = (hi - lo) / 3.0
for b in bars:
p = b["eur_kwh"]
if p is None:
continue
b["kind"] = ("cheap" if p <= lo + third else
"dear" if p >= hi - third else "mid")
elif known:
for b in bars:
if b["eur_kwh"] is not None:
b["kind"] = "mid"
return bars
# --------------------------------------------------------------------------- #
# The plan, per battery
# --------------------------------------------------------------------------- #
def _run(i, kind, start, end, day0, day1, what, why, amount, *, kwh=None,
eur=None, price=None):
a = max(0.0, _hours(start, day0))
z = min(_hours(day1, day0), _hours(end, day0))
return {"battery": i, "kind": kind, "start": start.isoformat(),
"end": end.isoformat(), "from": hhmm(start),
"to": "24:00" if end == day1 else hhmm(end),
"start_h": round(a, 3), "end_h": round(z, 3),
"what": what, "why": why, "amount": amount,
"kwh": None if kwh is None else round(kwh, 2),
"eur": None if eur is None else round(eur, 2),
"eur_kwh": None if price is None else round(price, 4)}
def trade_verdict(c, plan):
"""The arbitrage verdict the control cycle would reach on this plan, or a
reason there is none. The SAME call and inputs as `Controller.cycle()`:
`plan.arbitrage()` with the peak guard's state, the cycle cost and the
no-weather uncertainty. Returns `(verdict_or_None, why_key)`."""
if not arbitrage.enabled(c.o):
return None, "off"
guard = arbitrage.guard_state(c.peak_guard)
if guard is None:
return None, "not_covered"
if plan is None:
return None, "no_prices"
verdict = plan.arbitrage(peak_guard_active=guard,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}))
return verdict.match(
allowed=lambda v: (v, "allowed"),
refused=lambda _v: (None, refusal_key(plan, guard)))
def refusal_key(plan, guard):
"""Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order:
the peak guard, no prices, under two published points, then the gap."""
if guard:
return "peak"
if not plan.has_prices:
return "no_prices"
if sum(1 for p in _points(plan) if not p.provisional) < 2:
return "not_firm"
return "not_worth"
def _point_end(plan, point):
for p, end in _spans(_points(plan)):
if p.start == point.start:
return end
return point.start + timedelta(hours=1)
# Under this many kWh a block is not drawn: the cycle would not move it.
MIN_KWH = 0.05
def trade_runs(c, plan, verdict, day0, day1, now, shadow):
"""A charge block and a give block on each battery, where the verdict put
them. The site trades as one; each battery's share is capped by its own
rating and by the room its level leaves (arbitrage's own floor/ceiling).
Only what `arbitrage.decide()` would still do is drawn: no block on a
battery with no room, no sale at a negative price, and no sale while the
emptiest battery is at the floor (the cycle's `soc` is the emptiest).
With a level unknown the cycle does not trade, so nothing is drawn."""
floor, ceiling = arbitrage.soc_bounds(c.o)
if any(inv.soc is None for inv in c.inverters):
return [], [], "no_level"
level = [float(inv.soc) for inv in c.inverters]
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
key=lambda kp: kp[1].start)
runs, later = [], []
for kind, pt in legs:
end = _point_end(plan, pt)
if end <= now:
continue
if not (day0 <= pt.start < day1):
later.append((kind, pt))
continue
if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor):
continue
hrs = (end - pt.start).total_seconds() / 3600.0
for i, inv in enumerate(c.inverters):
kwh = inv.max_w / 1000.0 * hrs
cap = inv.capacity_kwh
if cap:
room = (ceiling - level[i] if kind == "grid"
else level[i] - floor)
kwh = min(kwh, max(0.0, room) / 100.0 * cap)
# A later give may use what this charge put in: an upper
# bound, like every amount here ("up to").
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
if kwh < MIN_KWH:
continue
eur = kwh * pt.eur_per_kwh
if kind == "grid":
what, why, amount = (run_grid_what(kwh, shadow),
run_grid_why(pt.eur_per_kwh),
run_grid_amount(eur))
else:
what, why, amount = (run_give_what(kwh, shadow),
run_give_why(pt.eur_per_kwh),
run_give_amount(eur))
runs.append(_run(i, kind, pt.start, end, day0, day1, what, why,
amount, kwh=kwh, eur=eur, price=pt.eur_per_kwh))
return runs, later, None
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."""
runs, lines = [], []
waiting = []
for i, (inv, m) in enumerate(zip(c.inverters, c.maints)):
cfg = m.cfg
if not cfg.enabled:
continue
cap = inv.capacity_kwh
if m.phase != M_IDLE:
hours = _cycle_hours(m.phase, inv.soc, cap, cfg)
end = now + timedelta(hours=hours or 1.0)
runs.append(_run(i, "cycle", now, end, day0, day1,
run_cycle_what(m.phase, shadow),
run_cycle_why(shadow),
run_cycle_amount(hours)))
continue
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)
due_at = m.next_due(now)
if due_at is not None and due_at > start:
continue
if not (day0 <= start < day1):
continue
waiting.append((m.waited_days(now), i, start))
# One battery cycles at a time (maintenance.arbitrate); the one waiting
# longest goes first, and none starts while another is under way.
busy = any(m.phase != M_IDLE for m in c.maints)
waiting.sort(key=lambda t: (-t[0], t[1]))
for n, (_w, i, start) in enumerate(waiting):
inv, m = c.inverters[i], c.maints[i]
hours = _cycle_hours(M_DRAIN, inv.soc, inv.capacity_kwh, m.cfg)
if n == 0 and not busy:
end = start + timedelta(hours=hours or 1.0)
runs.append(_run(i, "cycle", start, end, day0, day1,
run_cycle_what(M_IDLE, shadow),
run_cycle_why(shadow),
run_cycle_amount(hours)))
else:
lines.append(line_cycle_waits(inv.name))
return runs, lines
def _cycle_hours(phase, soc, cap_kwh, cfg):
"""Hours left in a cycle, or None when the capacity or level is unknown."""
if phase == M_HOLD:
return cfg.hold_min / 60.0
if not cap_kwh or soc is None:
return None
hours = cfg.hold_min / 60.0
fill_from = soc if phase == M_CHARGE else cfg.soc_floor
hours += (max(0.0, cfg.soc_target - fill_from) / 100.0 * cap_kwh
/ max(cfg.charge_w / 1000.0, 0.1))
if phase not in (M_CHARGE,):
hours += (max(0.0, soc - cfg.soc_floor) / 100.0 * cap_kwh
/ max(cfg.discharge_w / 1000.0, 0.1))
return hours
def peak_run(c, day0, day1, now, shadow, limit_w):
"""While the batteries hold the grid under the peak limit, one block for
the current 15-minute period on every battery."""
pg = c.peak_guard
if pg is None or not pg.engaged:
return []
start = now - timedelta(minutes=now.minute % 15, seconds=now.second,
microseconds=now.microsecond)
end = start + timedelta(minutes=15)
return [_run(i, "peak", start, end, day0, day1,
run_peak_what(pg.ceiling_w or limit_w, shadow),
run_peak_why(), None)
for i in range(len(c.inverters))]
def house_runs(n, day0, day1, now, shadow):
""""Follows the house" for the rest of the day, drawn under the others."""
return [_run(i, "house", max(now, day0), day1, day0, day1,
run_house_what(shadow), run_house_why(shadow),
run_house_amount())
for i in range(n)]
def region_of(opts):
"""The levies preset and its year, as the page model has it (UX-13 P0)."""
try:
ui = options_to_ui(opts)
except Exception: # noqa: BLE001
return {"id": None, "year": None}
rid = ui.get("ui.region")
return {"id": rid, "year": ui.get("region_year")
if rid in REGION_PRESETS else None}
def build_plan(c, now, shadow, running, held=False):
day0, day1 = local_day(now)
fresh = c.fresh_tariff_plan(now)
plan = fresh
if plan is None or plan.origin == "static":
# A static profile is pure arithmetic: price the whole day from it.
try:
static = static_plan(c.o, day0, day1, now=now)
if static.has_prices:
plan = static
except Exception: # noqa: BLE001
pass
seen = _day_prices(c).see(_points(plan), day0, day1)
bars = hour_bars(seen, day0, day1)
limit_w = c.peak.cap_w # the limit the controller parsed at start
runs, lines = [], []
# The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh)
if running:
runs += house_runs(len(c.inverters), day0, day1, now, shadow)
if verdict is not None:
trade, later, no_trade = trade_runs(c, fresh, verdict, day0, day1,
now, shadow)
runs += trade
if no_trade:
lines.append(line_no_trade(no_trade))
for kind, pt in later:
lines.append(line_trade_tomorrow(kind, hhmm(pt.start),
pt.eur_per_kwh, shadow))
else:
lines.append(line_no_trade(trade_why))
cyc, waits = maintenance_runs(c, day0, day1, now, shadow)
runs += cyc
lines += waits
runs += peak_run(c, day0, day1, now, shadow, limit_w)
if c.p1_enabled:
# The guard's ceiling: the cap, or this month's peak once above it.
pg = c.peak_guard
lines.append(line_peak_limit(
(pg.ceiling_w if pg is not None else None) or limit_w, shadow))
lines = [x for x in lines if x]
kinds = {r["kind"] for r in runs}
if held:
verdict_key = "held"
elif not running:
verdict_key = "stopped"
elif "cycle" in kinds:
verdict_key = "cycle"
elif "grid" in kinds or "give" in kinds:
verdict_key = "trade"
else:
verdict_key = "house"
known = [b for b in bars if b["eur_kwh"] is not None]
price_now = None
try:
if plan is not None:
price_now = plan.price_at(now)
except Absence:
price_now = None
feed = fresh is not None and fresh.from_feed
tomorrow = any(not p.provisional and p.start >= day1
for p in _points(fresh))
region = region_of(c.o)
return {
"day": brussels(now).date().isoformat(),
"now_h": round(_hours(now, day0), 3),
"hours": int(round(_hours(day1, day0))),
"verdict": VERDICT[verdict_key],
"verdict_key": verdict_key,
"head": plan_head(verdict_key, shadow),
"prices": bars,
"runs": runs,
"lines": lines,
"why": c.why_words,
"price_line": line_prices(
price_now,
min(known, key=lambda b: b["eur_kwh"]) if known else None,
max(known, key=lambda b: b["eur_kwh"]) if known else None),
"tomorrow_line": line_tomorrow(tomorrow) if feed else "",
"region": region,
"region_line": (line_region(region["id"], region["year"])
if feed else ""),
"has_prices": bool(known),
}
# --------------------------------------------------------------------------- #
# Batteries, attention, month peak, control
# --------------------------------------------------------------------------- #
def battery_rows(c, shadow, running, held=False):
shares = {}
alloc = getattr(c, "allocation", None)
if shadow and alloc is not None and getattr(alloc, "shares", None):
shares = dict(alloc.shares)
rows = []
for i, inv in enumerate(c.inverters):
m = c.maints[i] if i < len(c.maints) else None
phase = m.phase if m is not None else M_IDLE
cap = inv.capacity_kwh
rows.append({
"name": inv.name,
"level_pct": None if inv.soc is None else float(inv.soc),
"power_w": None if inv.batt is None else float(inv.batt),
"capacity_kwh": cap,
"stored_kwh": (None if cap is None or inv.soc is None
else round(cap * float(inv.soc) / 100.0, 2)),
"state": flow(inv.batt, shadow=shadow),
"mode": MODE_HOUSE,
"maintenance": phase,
"now": battery_now(inv.soc, inv.batt, phase=phase, shadow=shadow,
running=running, held=held,
would_w=shares.get(inv.name)),
# Per-battery reasons arrive with battery modes (OPT-11).
"why": None,
})
return rows
# Row id -> the title on Home. The row's own sentence is the body.
ATTN_TITLES = {
"controller": "Control is not running",
"meter": "Meter reading",
"meter_frozen": messages.T_METER_FROZEN,
"freeze_check": "Stuck meter check",
"month_peak": "This month's peak",
"peak_guard": "Peak limit",
"peak_forecast": "Peak limit forecast",
"sensor": "Battery readings",
"control": "Inverter control",
"power_too_high": messages.T_OUTPUT_CLAMP,
"not_keeping_up": messages.T_LOOP_BOUND,
"write": "Power request not accepted",
"direction": "Power went the wrong way",
"site_direction": "Power went the wrong way",
"circulating": messages.T_CIRCULATING,
"delivery": messages.S_NOT_DELIVERING,
"estop": "Emergency stop",
"pv": "Solar reading",
None: "Inverter connection",
}
# Row id -> the DOCS.md heading under Notifications, where one exists.
ATTN_DOCS = {
"meter_frozen": messages.T_METER_FROZEN,
"power_too_high": messages.T_OUTPUT_CLAMP,
"not_keeping_up": messages.T_LOOP_BOUND,
"circulating": messages.T_CIRCULATING,
"delivery": messages.S_NOT_DELIVERING,
}
def attention(checks):
"""Every row that is not plain green: red rows as alarms, amber rows as
warnings, and green-but-amber rows (a state still settling) as info.
Most urgent first; within a level, the page's own row order."""
rank = {ALARM: 0, WARNING: 1, INFO: 2}
out = []
for row in checks:
ok, warn = bool(row.get("ok")), bool(row.get("warn"))
if ok and not warn:
continue
sev = INFO if ok else (WARNING if warn else ALARM)
rid = row.get("id")
out.append({"severity": sev, "id": rid,
"title": ATTN_TITLES.get(rid, ATTN_TITLES[None]),
"body": row.get("text", ""),
"docs": ATTN_DOCS.get(rid)})
out.sort(key=lambda a: rank[a["severity"]])
return out
def month_peak(c, now):
"""This month's peak against the limit, and the 15-minute period so far."""
limit_w = c.peak.cap_w # the limit the controller parsed at start
local = brussels(now)
out = {"month": f"{local:%B}", "limit_w": limit_w, "peak_w": None,
"pct": None, "period_w": None, "state": "not_covered",
"line": ""}
if not c.p1_enabled:
out["line"] = line_peak_not_covered()
return out
reading = c.peak.peak(now)
if reading.is_unreadable:
out["state"] = "unreadable"
elif reading.is_observed:
out["state"] = "observed"
out["peak_w"] = float(reading.peak_w)
out["pct"] = round(100.0 * float(reading.peak_w) / limit_w, 1) \
if limit_w > 0 else None
else:
out["state"] = "none"
pg = c.peak_guard
ceiling = limit_w
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)
return out
def state_of(c, level, shadow):
"""One word for the pill and the banner's colour: running, shadow,
stopped, alarm or warning."""
if shadow:
return "shadow"
if level == "bad":
return "alarm"
if not c.auto:
return "stopped"
if level == "warn":
return "warning"
return "running"
def build(c, *, level: str, checks: list, now: datetime | None = None) -> dict:
"""The structured half of `/status`. Pure reads of the controller."""
now = now or datetime.now(timezone.utc)
shadow = bool(c.shadow)
kind = state_of(c, level, shadow)
# A red banner means every inverter is at 0 W (the status cascade), so
# nothing is planned until it clears. Shadow mode still plans: it is
# what the add-on would do.
held = level == "bad" and not shadow
running = (bool(c.auto) or shadow) and not held
ctl = next((r for r in checks if r.get("id") == "controller"), None)
return {
"state": {"kind": kind, "pill": pill(kind),
"ran": ctl.get("text", "") if ctl else "",
"sub": banner_sub(kind)},
"now": now_block(grid_w=c.grid, pv_w=c.pv_w, batt_w=c.site_batt_w,
has_solar=bool(c.pv_roster), shadow=shadow),
"batteries": battery_rows(c, shadow, running, held),
"plan": build_plan(c, now, shadow, running, held=held),
"attention": ([{"severity": INFO, "id": "shadow",
"title": ATTN_SHADOW, "body": attn_shadow_body(),
"docs": None}] if shadow else []) + attention(checks),
"month_peak": month_peak(c, now),
"control": {"running": bool(c.auto), "shadow": shadow,
"text": control_text(bool(c.auto), shadow),
"maintenance": c.maint.phase,
"docs_url": _docs_url()},
}
def _docs_url():
slug = messages.addon_slug()
return None if slug is None else messages.docs_url(slug,
messages._site["ha"])
# =========================================================================== #
# The words. STYLE.md: plain, short, present tense. A plan line is at most
# two sentences of at most 12 words each (UX-13 design, section 4); a title at
# most 5 words; everything else one sentence of at most 25 words. In shadow
# mode a text says what the add-on WOULD do. test_messages.py renders every
# function below through `text_samples()` and checks it.
# =========================================================================== #
MODE_HOUSE = "Follows the house"
ATTN_SHADOW = "Shadow mode is on"
def attn_shadow_body() -> str:
return "The add-on decides everything, but every inverter stays at 0 W."
VERDICT = {
"held": "Held at 0 W",
"stopped": "Stopped",
"cycle": "Monthly cycle today",
"trade": "Trading on price",
"house": "Follows the house",
}
def _w(shadow, live, would):
return would if shadow else live
def pill(kind) -> str:
return {"running": "Running", "shadow": "Shadow mode",
"stopped": "Stopped", "alarm": "Needs you now",
"warning": "Running, with a warning"}[kind]
def banner_sub(kind) -> str:
return {
"running": "The batteries follow the plan below.",
"shadow": "The plans below say what the add-on would do.",
"stopped": "Start control below when you are ready.",
"alarm": "See Needs attention below for what to do.",
"warning": "See Needs attention below.",
}[kind]
def tile_solar(pv_w, has_solar) -> str:
if not has_solar:
return "Not set up"
if pv_w is None:
return "No reading"
return "Producing" if pv_w > IDLE_W else "Not producing"
def tile_house(house_w, has_solar, disagree=False) -> str:
if not has_solar:
return "Needs a solar reading"
if disagree:
return "Readings do not add up"
return "No reading" if house_w is None else "Using"
def tile_grid(grid_w) -> str:
if grid_w is None:
return "No reading"
if grid_w > IDLE_W:
return "Taking from the grid"
if grid_w < -IDLE_W:
return "Feeding in"
return "Balanced"
def tile_batt(batt_w, shadow) -> str:
if shadow:
return "Held at 0 W"
return {"giving": "Giving power", "charging": "Charging",
"resting": "Resting", None: "No reading"}[flow(batt_w)]
def battery_now(soc, batt_w, *, phase, shadow, running, held=False,
would_w=None) -> str:
if soc is None or batt_w is None:
return "No reading from this battery."
if shadow:
if would_w is None or abs(would_w) <= IDLE_W:
return "Held at 0 W. Would rest now."
verb = "give" if would_w > 0 else "charge"
return f"Held at 0 W. Would {verb} {_kw(would_w)} now."
if held:
return "Held at 0 W until the alarm clears."
if not running:
return "Held at 0 W while control is stopped."
if phase != M_IDLE:
return {M_DRAIN: "Monthly cycle: emptying.",
M_CHARGE: "Monthly cycle: refilling.",
M_HOLD: "Monthly cycle: holding full."}.get(
phase, "Monthly cycle under way.")
st = flow(batt_w)
if st == "giving":
return f"Giving {_kw(batt_w)} now."
if st == "charging":
return f"Charging {_kw(batt_w)} now."
return "Resting now."
def plan_head(key, shadow) -> str:
if key == "held":
return "Every inverter is at 0 W until the alarm clears."
if key == "stopped":
return "Control is stopped, so nothing is planned."
if not shadow:
return {
"cycle": "A battery runs its monthly cycle today.",
"trade": "Charges in the cheapest hour, gives power in the dearest.",
"house": "Charges from spare solar and covers the house.",
}[key]
return "Shadow mode: this is what the add-on would do. " + {
"cycle": "A battery would run its monthly cycle today.",
"trade": "Would charge when cheapest and give power when dearest.",
"house": "Would charge from spare solar and cover the house.",
}[key]
def run_grid_what(kwh, shadow) -> str:
return _w(shadow, "Charges", "Would charge") + \
f" up to {_kwh(kwh)} from the grid."
def run_grid_why(price) -> str:
return f"Cheapest price in the list, {_eur(price)} per kWh."
def run_grid_amount(eur) -> str:
return f"Costs up to {_eur(eur)}."
def run_give_what(kwh, shadow) -> str:
return _w(shadow, "Gives", "Would give") + \
f" up to {_kwh(kwh)}, to the house first."
def run_give_why(price) -> str:
return f"Dearest price in the list, {_eur(price)} per kWh."
def run_give_amount(eur) -> str:
return f"Worth up to {_eur(eur)} at that price."
def run_cycle_what(phase, shadow) -> str:
if phase == M_IDLE:
return _w(shadow, "Starts", "Would start") + \
" the monthly battery cycle."
return _w(shadow, "Runs", "Would run") + " the monthly battery cycle now."
def run_cycle_why(shadow=False) -> str:
return _w(shadow, "Empties, refills and holds full",
"Would empty, refill and hold full") + \
", so the level reads right."
def run_cycle_amount(hours) -> str:
if hours is None:
return "How long depends on the battery."
return f"Takes about {max(1, round(hours))} hours."
def run_peak_what(limit_w, shadow) -> str:
return _w(shadow, "Gives", "Would give") + \
f" power to stay under {_kw(limit_w)}."
def run_peak_why() -> str:
return "The peak limit comes before everything else."
def run_house_what(shadow) -> str:
return _w(shadow, "Follows", "Would follow") + " the house."
def run_house_why(shadow=False) -> str:
return _w(shadow, "Charges from spare solar, gives power",
"Would charge from spare solar, give power") + \
" when the house needs it."
def run_house_amount() -> str:
return "Amounts depend on the sun and the house."
def line_no_trade(key) -> str:
return {
"off": "",
"not_covered": "No trading on price: nothing measures the 15-minute "
"peak.",
"no_prices": "No trading on price yet: no prices have arrived.",
"peak": "No trading on price now: the peak limit comes first.",
"not_firm": "No trading on price yet: the prices are not published.",
"not_worth": "No trading on price today. The price gap is too small.",
"no_level": "No trading on price now: a battery level is unknown.",
}[key]
def line_trade_tomorrow(kind, at, price, shadow) -> str:
verb = {"grid": _w(shadow, "Charges", "Would charge"),
"give": _w(shadow, "Gives power", "Would give power")}[kind]
return f"Tomorrow: {verb.lower()} at {at}, {_eur(price)} per kWh."
def line_cycle_waits(name) -> str:
return f"{name} waits its turn for the monthly cycle."
def line_peak_limit(limit_w, shadow) -> str:
return _w(shadow, "The batteries step in", "The batteries would step in") \
+ f" above {_kw(limit_w)}."
def line_prices(now_eur, lo, hi) -> str:
parts = []
if now_eur is not None:
parts.append(f"Price now {_eur(now_eur)} per kWh, all in.")
if lo is not None and hi is not None:
parts.append(f"Today: cheapest {_eur(lo['eur_kwh'])} at "
f"{lo['label']}, dearest {_eur(hi['eur_kwh'])} at "
f"{hi['label']}.")
return " ".join(parts) or "No prices for today."
def line_tomorrow(known) -> str:
return ("Tomorrow's prices are in." if known else
"Tomorrow's prices usually arrive after 13:00.")
def line_region(rid, year) -> str:
if rid in REGION_PRESETS and year:
return f"Levies and VAT: {REGION_WORDS.get(rid, rid)}, {year} figures."
return "Levies and VAT: your own figure."
REGION_WORDS = {"flanders": "Flanders, Fluvius Antwerpen"}
def line_peak_not_covered() -> str:
return "No peak limit: this meter does not measure 15-minute periods."
def line_period(period_w, limit_w, shadow) -> str:
step = _w(shadow, "The batteries step in", "The batteries would step in")
if period_w is None:
return f"{step} above {_kw(limit_w)}."
return (f"This 15-minute period is heading for {_kw(period_w)}. "
f"{step} above {_kw(limit_w)}.")
def control_text(running, shadow) -> str:
if shadow:
return "Shadow mode: every inverter stays at 0 W."
if running:
return "Control is running."
return "Control is stopped. Every inverter is at 0 W."
def text_samples() -> list:
"""`(kind, text)` for every sentence above, rendered with real values, so
test_messages.py can hold each to its STYLE limit. Kinds: title, plan
(2 sentences of 12 words), line (1 sentence of 25 words)."""
out = [("title", t) for t in VERDICT.values()]
out += [("title", t) for t in ATTN_TITLES.values()]
out += [("title", MODE_HOUSE), ("title", ATTN_SHADOW),
("line", attn_shadow_body())]
for k in ("running", "shadow", "stopped", "alarm", "warning"):
out += [("title", pill(k)), ("line", banner_sub(k))]
for sh in (False, True):
out += [("title", tile_solar(v, s)) for v, s in
((None, False), (None, True), (3900, True), (0, True))]
out += [("title", tile_house(v, s)) for v, s in
((None, False), (None, True), (1100, True))]
out.append(("title", tile_house(None, True, True)))
out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)]
out += [("title", tile_batt(v, sh)) for v in (None, 2500, -2500, 0)]
for phase in (M_IDLE, M_DRAIN, M_CHARGE, M_HOLD):
for w in (2500, -2500, 0):
out.append(("plan", battery_now(62, w, phase=phase,
shadow=sh, running=True,
would_w=-2400)))
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=sh,
running=False)))
out.append(("plan", battery_now(62, 0, phase=M_IDLE, shadow=False,
running=False, held=True)))
out.append(("plan", battery_now(None, None, phase=M_IDLE, shadow=sh,
running=True)))
for k in VERDICT:
out.append(("plan", plan_head(k, sh)))
out += [("plan", run_grid_what(12.34, sh)),
("plan", run_give_what(9.6, sh)),
("plan", run_cycle_what(M_IDLE, sh)),
("plan", run_cycle_what(M_DRAIN, sh)),
("plan", run_peak_what(3500, sh)),
("plan", run_house_what(sh)),
("plan", line_trade_tomorrow("grid", "02:00", 0.1234, sh)),
("plan", line_trade_tomorrow("give", "19:00", 0.3612, sh)),
("plan", line_peak_limit(3500, sh)),
("plan", line_period(1240, 3500, sh)),
("plan", line_period(None, 3500, sh)),
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
("plan", run_give_why(0.3612)), ("plan", run_give_amount(3.47)),
("plan", run_cycle_why()), ("plan", run_cycle_why(True)),
("plan", run_cycle_amount(None)),
("plan", run_cycle_amount(5.4)), ("plan", run_peak_why()),
("plan", run_house_why()), ("plan", run_house_why(True)),
("plan", run_house_amount()),
("plan", line_cycle_waits("Battery B")),
("plan", line_prices(0.16, {"eur_kwh": 0.14, "label": "13:00"},
{"eur_kwh": 0.36, "label": "19:00"})),
("plan", line_prices(None, None, None)),
("plan", line_tomorrow(True)), ("plan", line_tomorrow(False)),
("plan", line_region("flanders", 2026)),
("plan", line_region("own", None)),
("plan", line_peak_not_covered())]
out += [("plan", line_no_trade(k)) for k in
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
"no_level")]
return out
+19
View File
@@ -317,6 +317,18 @@ class Inverter:
tripped=bool(data.get(self.key("delivery_tripped"), False)),
reason=str(data.get(self.key("delivery_reason"), "") or ""))
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
# take, 0 none), when that refusal or its last probe started, the
# current probe's start, and the cycles of evidence so far and when
# they began. Memory only, like SAFETY-52's: stop and start end it.
# Used at N>1 only.
self.refused = 0
self.refused_at = None
self.probe_from = None
self.ref_count = 0
self.ref_since = None
self.ref_sign = 0
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
@@ -411,6 +423,13 @@ class Inverter:
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
else "")
def refusal_note(self) -> str:
"""SAFETY-73's status row while this unit's battery refuses."""
if not self.refused:
return ""
return messages.row_battery_refuses(self.name, self.refused,
self.probe_from is not None)
def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say.
+317 -6
View File
@@ -28,6 +28,7 @@ import contextlib
import functools
import json
import logging
import math
import signal
import time
from datetime import datetime, timedelta, timezone
@@ -39,6 +40,7 @@ from .arbiter import (
P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK, P_SAFETY,
Claim, Resolution, actuator_for, resolve,
)
from . import control as _ctl
from .control import Tuning, compute, maintenance_charge_floor, peak_at_risk
from .delivery import (
DELIVERY_OPTION_DEFAULTS, DeliveryConfig, UnitDelivery,
@@ -53,6 +55,7 @@ from .clamp import (
clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note,
)
from . import freshness
from . import homeview
from . import messages
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
@@ -233,6 +236,20 @@ def pack_energy_of(inv):
inv.capacity_kwh, inv.max_w)
# SAFETY-73 review: the evidence must also span this long, not only
# SETTLE_CYCLES cycles. SETTLE_CYCLES was derived at the 2 s meter tick (22 s
# from its first to its twelfth cycle). A 1 s meter makes 12 cycles only 11 s,
# shorter than a healthy battery reading's lag (BATT_OBLIGED_S, 17 s, plus the
# plant's dead time), and both inverters of a site were then refused after
# ordinary load steps. At 2 s and slower this changes nothing.
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
def _refused_interval(sign: int) -> tuple:
"""SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way."""
return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf)
def why_key(winner: str, claims) -> str:
"""The key of `messages.WHY_WINNER` for a resolution's winner.
@@ -241,6 +258,10 @@ def why_key(winner: str, claims) -> str:
disagreement"). A failsafe with no claim of its own is the arbiter's "no
claim was made", or main.py's refusal to split the power ("unsplit").
"""
if winner == "loop":
# SAFETY-52: a loop holding a refused direction says so.
return next((c.reason for c in claims if c.source == "loop"
and c.reason in (_ctl.REFUSED, _ctl.PROBING)), winner)
if winner in ("safety", "failsafe"):
return next((c.reason for c in claims
if c.source == winner and c.kind == "set"),
@@ -925,6 +946,12 @@ class Controller:
self.target = 0.0
self.sat_count = 0
self.i_w = 0.0 # the loop's integrator, carried between cycles
# SAFETY-52. The direction the battery refuses (+1 / -1 / 0), carried
# like `i_w`, and when the refusal (or its last probe) started.
self.batt_refused = 0
self.batt_refused_at = None
self._now_mono = None
self._refusal_auto = False
# TEL-10. What the last cycle's loop did, kept for publication. Seeded
# to the SAME values the "stopped or blind" branch of `cycle()` writes,
# because before the first cycle the loop has not run - which is the
@@ -1036,6 +1063,9 @@ class Controller:
# because `self.peak_guard` is a different cycle's decision by the time
# the status page renders.
self.arb_headroom_w = None
# UX-13 P1: today's prices as the Home chart has seen them. Display
# only; nothing reads it back into a decision.
self.home_prices = homeview.DayPrices()
# TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at`
# is the exact, derived statement of "the latest cycle did not get as
# far as the arbitrage step", which is the fact the *Arbitrage* row
@@ -1058,6 +1088,192 @@ class Controller:
self.loop_pass_mono = None
# -- helpers -------------------------------------------------------------
# --- SAFETY-52: a battery that refuses is asked for 0 W ------------------
def _refusal_probe(self) -> bool:
"""True while a refused direction may try `PROBE_W` (every 15 min)."""
if (not self.batt_refused or self.batt_refused_at is None
or self._now_mono is None):
return False
held = self._now_mono - self.batt_refused_at
if held >= _ctl.PROBE_EVERY_S + _ctl.PROBE_S:
self.batt_refused_at = self._now_mono
return False
return held >= _ctl.PROBE_EVERY_S
def _refusal_start(self, sign: int, evidence: str) -> None:
self.batt_refused = sign
self.batt_refused_at = self._now_mono
self.log_event(f"BATTERY REFUSES {evidence}",
hint=messages.hint("battery_refuses"))
_LOG.info("The battery refuses to give power, so the add-on asks 0 W "
"and tries %.0f W every %.0f minutes: BATTERY_REFUSES.",
_ctl.PROBE_W, _ctl.PROBE_EVERY_S / 60)
_LOG.debug("Evidence: BATTERY_REFUSES: %s.", evidence)
def _refusal_end(self, why: str) -> None:
if not self.batt_refused:
return
self.batt_refused, self.batt_refused_at = 0, None
self.log_event(f"BATTERY REFUSES ENDED: {why}",
hint=messages.hint("battery_gives"))
_LOG.info("The battery refusal ends, control asks for power again: %s.",
why)
def _refusal_update(self, decision, asked_w, batt_w, probe) -> None:
"""Follow `compute()`'s carried refused direction, and say so."""
now = decision.refused or 0
if now and not self.batt_refused:
self._refusal_start(now, f"asked {asked_w:+.0f} W, the battery "
f"gave {batt_w:+.0f} W for "
f"{decision.sat_count} cycles")
elif self.batt_refused and not now:
gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
self._refusal_end("the probe was answered" if gave and probe
else "the battery gives power" if gave
else "the grid now asks the other direction")
def _refusal_from_stale(self, inv, now_mono) -> None:
"""A stale-reading stop while the loop is frozen on ~0 W is a refusal.
Only at one inverter: with several, one empty battery must not stop the
site (OPT-09's pack limit moves its share to the others).
"""
t = self.tuning
standing = inv.standing_w()
if len(self.inverters) > 1:
# SAFETY-73: at N>1 the same trip refuses THIS unit only.
if ((self.auto or self.shadow) and not inv.refused
and inv.ref_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(standing) > _ctl.PROBE_W):
self._now_mono = now_mono
self._unit_refusal_start(
inv, 1 if standing > 0 else -1,
f"stale reading {inv.batt_fresh.held:+.0f} W while asked "
f"{standing:+.0f} W")
return
if (len(self.inverters) == 1 and (self.auto or self.shadow)
and not self.batt_refused
and self.sat_count >= t.saturation_cycles
and inv.batt_fresh.held is not None
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
and abs(standing) > t.saturation_w):
self._now_mono = now_mono
self._refusal_start(1 if standing > 0 else -1,
f"stale reading {inv.batt_fresh.held:+.0f} W "
f"while asked {standing:+.0f} W")
# --- SAFETY-73: the same refusal, per inverter, at N>1 -------------------
# SAFETY-52's decisions applied to one unit of several: a unit that gives
# < REFUSED_BELOW_W for SETTLE_CYCLES while asked > PROBE_W is closed in
# that direction by a P_SAFETY unit limit, so the allocator hands its share
# to the others; it is probed at PROBE_W every PROBE_EVERY_S, one unit at a
# time. The ask is PROBE_W, not `saturation_w`: a unit's share is often
# small (a 400 W night load split two ways) and SAFETY-43 would otherwise
# name the empty battery as not delivering. DECISIONS/SAFETY-73.
def _unit_refusal_start(self, inv, sign: int, evidence: str) -> None:
inv.refused, inv.refused_at = sign, self._now_mono
inv.probe_from, inv.ref_count = None, 0
self.log_event(f"BATTERY REFUSES {inv.name}: {evidence}",
hint=messages.hint("unit_refuses", unit=inv.name))
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
"other inverters take over: BATTERY_REFUSES.", inv.name)
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
# Only a refusal to GIVE: a full battery that stops taking charge is
# above its cut-out level by definition and says nothing about it.
if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None
and float(inv.soc) > float(inv.bms_soc_floor)):
# Owner decision 3: a refusal above the configured cut-out level
# may mean that level is set wrong. One line, no safety stop.
_LOG.warning("%s refuses power above its battery cut-out level, "
"check its cut-out level: REFUSES_ABOVE_CUT_OUT.",
inv.name)
_LOG.debug("Evidence for %s: REFUSES_ABOVE_CUT_OUT: battery level "
"%.0f %%, cut-out level %.0f %%.", inv.name,
float(inv.soc), float(inv.bms_soc_floor))
def _unit_refusal_end(self, inv, why: str) -> None:
if not inv.refused:
return
inv.refused, inv.refused_at, inv.probe_from = 0, None, None
inv.ref_count = 0
self.log_event(f"BATTERY REFUSES ENDED {inv.name}: {why}",
hint=messages.hint("unit_gives", unit=inv.name))
_LOG.info("%s: the battery refusal ends, it is asked for power again: "
"%s.", inv.name, why)
# The site target may stand above what was placed (the refused share
# was unmet). Restart it from the watts on the wire, or the reopened
# unit is handed the parked remainder at once (phase A: -1059 W).
placed = float(sum(i.standing_w() for i in self.inverters))
self.target = self.i_w = placed
self.sat_count = 0
def _unit_probe(self, inv) -> bool:
"""True while `inv` may try PROBE_W. One unit probes at a time."""
now = self._now_mono
if not inv.refused or inv.refused_at is None or now is None:
return False
if inv.probe_from is not None:
if now - inv.probe_from < _ctl.PROBE_S:
return True
inv.probe_from, inv.refused_at = None, now
return False
if now - inv.refused_at < _ctl.PROBE_EVERY_S or any(
i.probe_from is not None for i in self.inverters):
return False
inv.probe_from = now
return True
def _unit_refusals(self) -> None:
"""Engage and end each unit's refusal from its own reading."""
for inv in self.inverters:
b = inv.batt
if inv.refused:
if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W:
self._unit_refusal_end(
inv, "the probe was answered"
if inv.probe_from is not None
else "the battery gives power")
continue
# The other direction: the battery takes PROBE_ANSWER_W or more
# the other way (charge at the floor) for SETTLE_CYCLES. Counted
# on its own reading, so one cycle of the loop overshooting
# through zero does not hand it its share back.
inv.ref_count = (inv.ref_count + 1 if b is not None and
-inv.refused * b >= _ctl.PROBE_ANSWER_W else 0)
if inv.ref_count >= _ctl.SETTLE_CYCLES:
self._unit_refusal_end(
inv, "the battery takes power the other way")
continue
if b is None:
continue # a gap keeps the count: the gate's trip seeds
standing = inv.standing_w()
sign = 1 if standing > 0 else -1
# One direction throughout: a command that swaps sign every cycle
# (a flapping load) reads ~0 through the plant's own lag, and that
# is a battery following, not refusing (TEST-19 flapping family).
if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W:
if sign != inv.ref_sign or not inv.ref_count:
inv.ref_count, inv.ref_since = 0, self._now_mono
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
else:
inv.ref_count = 0
inv.ref_sign = sign
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
inv.ref_since is None or self._now_mono is None
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
self._unit_refusal_start(
inv, sign,
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
f"for {_ctl.SETTLE_CYCLES} cycles")
def _site_refused(self):
"""Owner decision 4: every unit refuses the same way, so the site law
holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law."""
signs = {inv.refused for inv in self.inverters}
return signs.pop() if len(signs) == 1 and 0 not in signs else None
def log_event(self, msg: str, hint: str | None = None) -> None:
"""`msg` goes on the action log, the add-on's own technical record.
`hint` is `messages.hint()`'s plain words for the same event, and is
@@ -1840,6 +2056,7 @@ class Controller:
"""
o = self.o
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
self._now_mono = now_mono
if self.p1_enabled:
# ⚠️ P1 is the only authoritative measurement of what the utility
# sees (§5.1). When it is stale this is None, which falls into the
@@ -2003,6 +2220,7 @@ class Controller:
if inv.batt_fresh.stale and not was_stale:
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
hint=messages.hint("battery_stuck", unit=inv.name))
self._refusal_from_stale(inv, now_mono)
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
# ELSE IN IT. The escape clause used to read "It clears itself the
# moment the register reports a different value", full stop - and
@@ -2440,7 +2658,8 @@ class Controller:
now = datetime.now(timezone.utc)
return [n for n in
[x for inv in self.inverters
for x in (inv.link_note(now), inv.stale_note())]
for x in (inv.link_note(now), inv.stale_note(),
inv.refusal_note())]
if n]
async def cycle(self) -> None:
@@ -2734,7 +2953,13 @@ class Controller:
# what part 6 grades instead is that WIDENING the window
# changes the answer.
live=inv.is_live(now)[0],
parked=inv.sign_wd.tripped,
# SAFETY-73: a refusing unit is asked 0 W on purpose, and
# one asked under PROBE_W that gives under 100 W is a
# refusal too small to call (its sibling covers the meter).
parked=inv.sign_wd.tripped or bool(inv.refused) or (
n_units > 1 and inv.batt is not None
and abs(float(standing[inv.name])) <= _ctl.PROBE_W
and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W),
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
# the site's `peak_soc_floor` either - that one bounds a
# money-saving discharge only, and grading a pack against it
@@ -3340,6 +3565,22 @@ class Controller:
# loop should be. It is safe because the loop's `prev_w` is
# `self.target`, which the end of this method re-seeds to what is
# actually standing on the wire.
# SAFETY-52: a refusal learned while control ran does not survive a
# stop or a start (shadow mode keeps the loop running when stopped).
if bool(self.auto) != self._refusal_auto:
self._refusal_auto = bool(self.auto)
self._refusal_end("control started" if self.auto
else "control stopped")
for inv in (self.inverters if len(self.inverters) > 1 else ()):
self._unit_refusal_end(inv, "control started" if self.auto
else "control stopped")
# SAFETY-73: each unit's own refusal, engaged and ended on its own
# reading, before the loop reads `self.target` (an end reseeds it).
_probing = {}
if len(self.inverters) > 1 and self.inputs_ok:
self._unit_refusals()
_probing = {inv.name: self._unit_probe(inv)
for inv in self.inverters if inv.refused}
if (self.auto or self.shadow) and self.inputs_ok:
# ⚠️ prev is the ARBITER's last output, not the loop's own last
# wish. If something outranked the loop, that is what the hardware
@@ -3357,6 +3598,15 @@ class Controller:
# stale period as power.
if self.loop_w is None or self.target != self.loop_w:
self.i_w = self.target
# SAFETY-52: another writer overrode the loop, so a refusal learned
# against the loop's own ask no longer describes the wire. A gap
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
# is what seeds it, and dropping it there restarts the ~70 s cycle.
if self.loop_w is not None and self.target != self.loop_w:
self._refusal_end("another writer")
probe = self._refusal_probe()
if len(self.inverters) > 1:
probe = any(_probing.values())
# ⚠️ `actual_w` IS THE SITE SUM AND THAT IS DEV-02's OTHER HALF.
# The anti-windup test inside compute() is
# `abs(prev_w - actual_w) > saturation_w`, and `prev_w` is the SITE
@@ -3385,6 +3635,13 @@ class Controller:
tuning=self.tuning,
sat_count=self.sat_count,
i_w=self.i_w,
# SAFETY-52 at one inverter only: with several, OPT-09's pack
# limit already closes an empty battery's discharge end.
# SAFETY-73: at N>1 the site law holds 0 W only while every
# unit refuses the same way. Otherwise None, the old law.
refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(),
probe=probe,
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
@@ -3393,6 +3650,8 @@ class Controller:
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
if len(self.inverters) == 1:
self._refusal_update(decision, loop_prev, site_batt, probe)
# --- TEL-10 ------------------------------------------------------
# ⚠️ THE INTEGRATOR HAS NEVER LEFT THIS PROCESS, AND THAT IS WHAT
# THE CARD IS. `self.i_w` is carried between cycles and published
@@ -3491,8 +3750,44 @@ class Controller:
# ability to DELIVER rests on this argument - a weight cannot stop a
# unit being commanded and cannot move a failing unit's load to its
# sibling. The `pack` claim filed above is the half that constrains.
resolution = resolve(claims, units=self.units, live=self.live_units(),
weights=self.pack_weights, tick=self.alloc_tick)
# --- SAFETY-73: each refusing unit's own P_SAFETY claim ---------------
# A hard unit limit that always intersects (OPT-11's P_MODE layer sits
# inside it): closed in the refused direction, so the allocator hands
# the share to the others within their own limits. During its probe it
# is a pin at PROBE_W and the others carry the rest.
for inv in self.inverters:
if not inv.refused:
continue
if _probing.get(inv.name):
claims.append(Claim.set("refused", P_SAFETY,
inv.refused * _ctl.PROBE_W,
_ctl.PROBING,
actuator=self.actuator_of("refused",
inv)))
else:
claims.append(Claim.limit("refused", P_SAFETY,
*_refused_interval(inv.refused),
_ctl.REFUSED,
actuator=self.actuator_of("refused",
inv)))
_live = self.live_units()
while True:
resolution = resolve(claims, units=self.units, live=_live,
weights=self.pack_weights, tick=self.alloc_tick)
# SAFETY-73: a probe pin stands only while the resolved site target
# asks at least PROBE_W its way. Below that a sibling would carry
# the opposite direction (circulation), so the pin goes back to the
# closed limit and the split is resolved once more.
_void = [k for k, c in enumerate(claims)
if c.source == "refused" and c.kind == "set"
and c.value * resolution.target_w < _ctl.PROBE_W ** 2]
if not _void:
break
for k in _void:
claims[k] = Claim.limit(
"refused", P_SAFETY,
*_refused_interval(1 if claims[k].value > 0 else -1),
_ctl.REFUSED, actuator=claims[k].actuator)
self.allocation = resolution.allocation
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
@@ -5020,7 +5315,8 @@ class Controller:
_now = datetime.now(timezone.utc)
for inv in self.inverters:
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
(inv.stale_note(), False)):
(inv.stale_note(), False),
(inv.refusal_note(), False)):
if note:
out.append({"ok": False, "warn": not red, "text": note})
@@ -5414,7 +5710,7 @@ class Controller:
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.why_words)),
]
return {
out = {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.hint,
# The action log itself, newest first. The page does not show it
@@ -5423,6 +5719,21 @@ class Controller:
# peak guard's projection) are written nowhere else.
"events": list(self.events),
}
# UX-13 P1: the Home screen's structured half. Read-only, and it may
# never cost the page its rows: a fault here is logged and the old
# fields are served alone (the page then shows the installer details).
try:
out.update(homeview.build(self, level=level, checks=checks))
except Exception: # noqa: BLE001
# Once per process at ERROR: the page polls every 3 s.
if not getattr(self, "_home_failed", False):
self._home_failed = True
_LOG.exception("Home view could not be built, so the page "
"shows the plain status rows only")
else:
_LOG.debug("Home view failed again", exc_info=True)
out["home_error"] = True
return out
async def handle_action(self, what: str):
now = datetime.now(timezone.utc)
+36
View File
@@ -786,6 +786,14 @@ def row_battery_stuck(unit) -> str:
f"for now")
def row_battery_refuses(unit, sign, probing) -> str:
"""SAFETY-73. One inverter's battery refuses, the others take its share."""
way = "give" if sign > 0 else "take"
if probing:
return f"{unit}: testing if the battery will {way} power again"
return f"{unit}: the battery refuses to {way} power, the others take over"
# The top of the page: why the battery does what it does (the "Why" line).
WHY_WINNER = {
"loop": "Keeping the grid near zero",
@@ -802,6 +810,11 @@ WHY_WINNER = {
# failsafe winner. test_messages.py part 7 finds every one in the code.
"sign disagreement": "Safety stop, power went the wrong way",
"unsplit": "The power could not be split, all at 0 W",
# SAFETY-52: the loop's two words for a battery that gives nothing.
"battery refuses": "The battery refuses to give power",
"battery probe": "Testing if the battery gives power again",
# SAFETY-73: a refusing inverter's own claim, closed or probing.
"refused": "A battery refuses power, the others take over",
}
WHY_LIMIT = {
"peak(quarter peak)": "the peak limit",
@@ -812,6 +825,7 @@ WHY_LIMIT = {
"pack": "the battery level",
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
"refused": "a battery that refuses power",
}
@@ -852,6 +866,10 @@ HINTS = {
"stop_restored": "{unit}: an earlier safety stop is back, it stays at 0 W",
"battery_stuck": "{unit}: battery power reading stopped changing",
"battery_back": "{unit}: battery power reading is used again",
"battery_refuses": "The battery refuses to give power, the add-on asks 0 W",
"battery_gives": "The battery refusal ended, control asks for power again",
"unit_refuses": "{unit}: the battery refuses power, the others take over",
"unit_gives": "{unit}: the battery refusal ended, it is asked for power again",
"bad_value": "The add-on made an invalid power value, inverters set to 0 W",
"write_blocked": "{unit}: a power request was blocked, it is set to 0 W",
"all_blocked": "A power request was blocked, inverters set to 0 W",
@@ -1022,6 +1040,12 @@ def status_samples() -> list:
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
shadow=True)),
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
False)),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", -1,
False)),
("row_battery_refuses", AMBER, row_battery_refuses("inverter-b", 1,
True)),
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
]
@@ -1094,6 +1118,18 @@ LABELS = {
"unit_sign_disagree_w": "Per-inverter wrong-direction threshold",
"slew_w": "Largest change per cycle",
"shadow_mode": "Shadow mode",
# UX-13: named by the settings page's warnings (validate.Note).
"integrator_max_w": "Catch-up limit",
"maintenance_discharge_w": "Draw-down rate",
"peak_cap_w": "Peak limit",
"peak_forecast_entity": "15-minute demand forecast entity",
"meter_entity": "Grid power entity",
"soc_entity": "Battery level entity",
"batt_entity": "Battery power entity",
"pv_entity": "Solar production entity",
"pv_entities": "Solar production entities, per inverter",
"p1_phase_import_entities": "Import entities per phase",
"p1_phase_export_entities": "Export entities per phase",
}
+6 -3
View File
@@ -288,7 +288,7 @@ async def problems_for(opts: dict, hass=None) -> list:
return problems
async def problems_and_ranges(opts: dict, hass=None):
async def problems_and_ranges(opts: dict, hass=None, notes=None):
"""`problems_for()`, plus WHICH setpoint entities did not report a range.
⚠️ THE SECOND HALF EXISTS SO THE PAGE CANNOT LOOK GREEN FOR A REASON THE
@@ -303,6 +303,8 @@ async def problems_and_ranges(opts: dict, hass=None):
has just typed a new `setpoint_entity` must be told about THAT entity, and
a route that asked the running controller instead would report on the old
one. That was the first shape of this and it disagreed with itself.
UX-13: a list passed as `notes` collects the validator's warnings too.
"""
flat = str(opts.get("setpoint_entity", "") or "").strip()
@@ -329,7 +331,7 @@ async def problems_and_ranges(opts: dict, hass=None):
# disagreement is itself the finding - so it is reported rather than
# swallowed. Returning `[]` here would turn a crash into a silent
# acceptance, which is worse than the 500 this replaced.
found = list(validate_startup_config(opts))
found = list(validate_startup_config(opts, notes=notes))
if not found:
found = [f"the configuration could not be composed ({type(exc).__name__}: "
f"{exc}), and the validator did not name a reason. This is a "
@@ -347,7 +349,8 @@ async def problems_and_ranges(opts: dict, hass=None):
if flat and unit.setpoint_entity.strip().lower() == flat.lower():
dev_min, dev_max = unit.dev_min, unit.dev_max
break
problems = list(validate_startup_config(opts, dev_min, dev_max))
problems = list(validate_startup_config(opts, dev_min, dev_max,
notes=notes))
problems += validate_unit_ranges(units, skip_entity=flat)
unread = [unit.setpoint_entity for unit in units
if unit.setpoint_entity and unit.dev_min is None
+137
View File
@@ -0,0 +1,137 @@
/* UX-13 page styles. Colours follow the approved mockup (UX-13-mockup.html,
version 2) and prefers-color-scheme. No web fonts, no external files. */
:root{
--bg:#f3f4f6;--card:#ffffff;--ink:#1b1d21;--muted:#5b626c;--faint:#8a919b;--line:#e2e5e9;
--accent:#1c64d9;--accent-soft:#e6efff;--on-accent:#ffffff;
--ok:#17773a;--ok-bg:#e6f4eb;--warn:#955300;--warn-bg:#fcefd9;--bad:#c02626;--bad-bg:#fce8e8;--info:#5b626c;--info-bg:#eceef1;
--solar:#c98a00;--house:#6c47c2;--grid:#56657a;--batt:#17773a;
--cheap:#8fcca4;--dear:#f0ad72;--mid:#cfd5dc;--track:#dde1e6;--shadow:0 1px 2px rgba(0,0,0,.05);
}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){
--bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038;
--accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220;
--ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d;
--solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98;
--cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none;
}}
:root[data-theme="dark"]{
--bg:#111317;--card:#1a1d23;--ink:#e7e9ec;--muted:#a2a9b3;--faint:#7c848f;--line:#2b3038;
--accent:#6ea2ff;--accent-soft:#1d2a44;--on-accent:#0b1220;
--ok:#7ccf98;--ok-bg:#15291d;--warn:#f3c16b;--warn-bg:#2e2412;--bad:#f28b82;--bad-bg:#3a1a19;--info:#a2a9b3;--info-bg:#22262d;
--solar:#f0c04a;--house:#b39cf0;--grid:#9fb0c6;--batt:#7ccf98;
--cheap:#3f7d56;--dear:#9a5a26;--mid:#3a414b;--track:#343a44;--shadow:none;
}
*{box-sizing:border-box}
[hidden]{display:none!important}
html{-webkit-text-size-adjust:100%}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
button,input,select{font:inherit;color:inherit}
a{color:var(--accent)}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.top{display:flex;align-items:center;gap:12px;padding:12px 16px 8px;max-width:820px;margin:0 auto}
.logo{width:36px;height:36px;border-radius:10px;background:var(--accent-soft);display:grid;place-items:center;color:var(--accent);flex:none}
.app{font-weight:700;font-size:17px;line-height:1.2}
.pill{display:flex;flex-wrap:wrap;align-items:center;gap:0 6px;font-size:13px;color:var(--muted)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--ok);flex:none}
.dot.warn{background:var(--warn)}.dot.bad{background:var(--bad)}
.tabs{display:flex;gap:4px;max-width:820px;margin:0 auto;padding:0 16px;border-bottom:1px solid var(--line)}
.tab{border:0;background:none;padding:10px 12px;border-bottom:2px solid transparent;color:var(--muted);cursor:pointer;display:flex;align-items:center;gap:6px;min-height:44px;position:relative}
.tab[aria-selected="true"]{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
.tab svg{width:20px;height:20px}
.badge{font-size:11px;font-weight:700;background:var(--warn);color:var(--card);border-radius:9px;padding:0 6px;line-height:18px}
.badge.bad{background:var(--bad)}
@media (max-width:640px){
.tabs{position:fixed;bottom:0;left:0;right:0;z-index:20;background:var(--card);border-top:1px solid var(--line);border-bottom:0;padding:0 4px env(safe-area-inset-bottom);justify-content:space-around;gap:0}
.tab{flex-direction:column;gap:0;font-size:11px;padding:6px 4px;border-bottom:0;border-top:2px solid transparent;flex:1;min-width:0}
.tab[aria-selected="true"]{border-top-color:var(--accent)}
.tab .badge{position:absolute;top:2px;left:calc(50% + 6px)}
.wrap{padding-bottom:96px!important}
}
.wrap{max-width:820px;margin:0 auto;padding:16px 16px 40px}
h2{font-size:15px;margin:0 0 10px;font-weight:650}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px;box-shadow:var(--shadow)}
.muted{color:var(--muted)} .small{font-size:13px} .tiny{font-size:12px}
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.spread{justify-content:space-between}
.grow{flex:1 1 auto;min-width:0}
.banner{border-radius:12px;padding:12px 14px;margin-bottom:12px;display:flex;gap:10px;align-items:flex-start;font-weight:600}
.banner .ic{flex:none;margin-top:2px}
.b-ok{background:var(--ok-bg);color:var(--ok)} .b-warn{background:var(--warn-bg);color:var(--warn)} .b-bad{background:var(--bad-bg);color:var(--bad)}
.banner small{display:block;font-weight:400;color:var(--ink);margin-top:2px}
.tiles{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px}
@media (max-width:640px){.tiles{grid-template-columns:repeat(2,minmax(0,1fr))}}
.tile{border:1px solid var(--line);border-radius:10px;padding:10px;min-width:0}
.tile .lab{font-size:13px;color:var(--muted);display:flex;align-items:center;gap:6px}
.tile .val{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;line-height:1.25;margin-top:2px}
.tile .val small{font-size:13px;font-weight:500;color:var(--muted)}
.tile .sub{font-size:12px;color:var(--muted)}
.sw-solar{color:var(--solar)} .sw-house{color:var(--house)} .sw-grid{color:var(--grid)} .sw-batt{color:var(--batt)}
.brow{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 10px;padding:10px 0;border-top:1px solid var(--line)}
.brow:first-child{border-top:0}
.lvl{height:10px;background:var(--track);border-radius:6px;overflow:hidden;grid-column:1/-1;margin:4px 0 2px}
.lvl>i{display:block;height:100%;background:var(--batt);border-radius:6px}
.lvl.none>i{background:var(--faint)}
/* the plan chart */
.chart{position:relative;margin:18px 0 4px}
.bars{display:flex;align-items:flex-end;gap:2px;height:72px}
.bars i{flex:1;border-radius:3px 3px 0 0;background:var(--mid);min-width:0}
.bars i.cheap{background:var(--cheap)}
/* not by colour alone: expensive hours are striped, estimates are faded */
.bars i.dear{background:repeating-linear-gradient(135deg,var(--dear) 0 4px,color-mix(in srgb,var(--dear) 60%,var(--card)) 4px 7px)}
.bars i.est{opacity:.55}
.bars i.none{background:none;border-bottom:2px dotted var(--faint);height:2px!important}
.lane{position:relative;height:18px;margin-top:4px;background:var(--bg);border-radius:4px}
.lane b{position:absolute;top:2px;bottom:2px;border-radius:3px;min-width:3px}
.lane .house{background:repeating-linear-gradient(90deg,var(--mid) 0 6px,transparent 6px 9px);top:6px;bottom:6px}
.lane .grid{background:var(--accent)}
.lane .give{background:repeating-linear-gradient(135deg,var(--house) 0 4px,color-mix(in srgb,var(--house) 55%,var(--card)) 4px 7px)}
.lane .cycle{background:repeating-linear-gradient(90deg,var(--warn) 0 3px,var(--warn-bg) 3px 6px)}
.lane .peak{background:var(--bad)}
.lanelab{font-size:12px;color:var(--muted);margin-top:6px}
.nowline{position:absolute;top:-4px;bottom:0;width:2px;background:var(--ink);opacity:.7;pointer-events:none}
.nowline span{position:absolute;top:-15px;left:-10px;font-size:10px}
.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--faint);margin-top:4px}
.legend{display:flex;flex-wrap:wrap;gap:4px 12px;font-size:12px;color:var(--muted);margin-top:8px}
.legend span{display:inline-flex;align-items:center;gap:5px}
.legend i{width:14px;height:10px;border-radius:2px;display:inline-block}
.legend i.house{background:repeating-linear-gradient(90deg,var(--mid) 0 4px,transparent 4px 6px)}
ul.plain{list-style:none;margin:8px 0 0;padding:0}
ul.plain li{padding:6px 0;border-top:1px solid var(--line)}
ul.plain li:first-child{border-top:0}
.meter{height:12px;background:var(--track);border-radius:6px;overflow:hidden;margin:8px 0 4px}
.meter i{display:block;height:100%;background:var(--accent)}
.meter i.warn{background:var(--warn)} .meter i.bad{background:var(--bad)}
.alert{display:flex;gap:10px;padding:10px 0;border-top:1px solid var(--line)}
.alert:first-child{border-top:0}
.sev{flex:none;width:24px;height:24px;border-radius:50%;display:grid;place-items:center;font-size:13px;font-weight:800}
.sev.warning{background:var(--warn-bg);color:var(--warn)} .sev.alarm{background:var(--bad-bg);color:var(--bad)} .sev.info{background:var(--info-bg);color:var(--info)} .sev.ok{background:var(--ok-bg);color:var(--ok)}
.alert .t{font-weight:600}
.btn{border:1px solid var(--line);background:var(--card);padding:9px 14px;border-radius:10px;cursor:pointer;min-height:44px}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}
.btn.danger{color:var(--bad);border-color:var(--bad)}
.btn:disabled{opacity:.5;cursor:not-allowed}
a.btn{display:inline-flex;align-items:center;text-decoration:none;color:var(--ink)}
details.more{margin-top:12px;border-top:1px solid var(--line)}
details.more>summary{cursor:pointer;padding:10px 0;color:var(--accent);font-size:14px;min-height:44px;display:flex;align-items:center}
table.rows{width:100%;border-collapse:collapse;font-size:13px}
table.rows td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
table.rows td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600;padding-left:12px;overflow-wrap:anywhere}
table.rows tr:last-child td{border-bottom:0}
ul.checks{margin:6px 0 0;padding-left:0;list-style:none;font-size:13px}
ul.checks li{margin:3px 0}
.c-ok{color:var(--ok)} .c-warn{color:var(--warn)} .c-bad{color:var(--bad)}
.kv{display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 12px;font-size:14px;margin-top:10px}
.kv dt{color:var(--muted)} .kv dd{margin:0}
#toast{position:fixed;left:50%;transform:translateX(-50%);bottom:24px;z-index:30;background:var(--ink);color:var(--bg);padding:10px 14px;border-radius:10px;font-size:14px;max-width:calc(100% - 32px);opacity:0;pointer-events:none;transition:opacity .2s}
#toast.show{opacity:1}
@media (max-width:640px){#toast{bottom:84px}}
dialog{border:1px solid var(--line);border-radius:14px;background:var(--card);color:var(--ink);max-width:min(420px,calc(100% - 32px));padding:18px}
dialog::backdrop{background:rgba(0,0,0,.45)}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
.tip{position:fixed;z-index:40;background:var(--ink);color:var(--bg);font-size:12px;line-height:1.35;padding:6px 8px;border-radius:8px;pointer-events:none;max-width:240px;opacity:0;transition:opacity .1s;box-shadow:0 2px 8px rgba(0,0,0,.25);left:0;top:0}
.tip.show{opacity:1}
.tip b{display:block;font-weight:700}
.tip span{display:block}
.chart [data-tip]{cursor:default}
.chart [data-tip]:hover,.chart [data-tip]:focus,.chart .hot{outline:2px solid var(--ink);outline-offset:-1px}
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+374
View File
@@ -0,0 +1,374 @@
"use strict";
/* UX-13 P1: the page shell and the Home screen. Plain JS, no framework, no
external files. Every URL is relative, because ingress serves the add-on
under a generated path prefix. Every word comes from app/ui_text (fixed
labels) or from /status (sentences built by app/homeview.py), so STYLE.md
is checked in Python. Data is always set as text, never as HTML. */
(function () {
const T = JSON.parse(document.getElementById("ui-text").textContent).home;
const $ = (s) => document.querySelector(s);
const t = (path) => path.split(".").reduce((o, k) => (o == null ? o : o[k]), T);
function h(tag, attrs, ...kids) {
const e = document.createElement(tag);
for (const k in attrs || {}) {
const v = attrs[k];
if (v === false || v == null) continue;
if (k === "class") e.className = v;
else if (k === "style") e.style.cssText = v;
else if (k.startsWith("on")) e.addEventListener(k.slice(2), v);
else e.setAttribute(k, v === true ? "" : v);
}
for (const c of kids.flat()) {
if (c == null || c === false) continue;
e.append(c.nodeType ? c : document.createTextNode(String(c)));
}
return e;
}
const kw = (w) => (Math.abs(Number(w)) / 1000).toFixed(1);
const eur = (v) => "€" + Number(v).toFixed(2);
// Fixed labels from ui_text.
document.querySelectorAll("[data-t]").forEach((e) => { e.textContent = t(e.dataset.t) || ""; });
document.querySelectorAll("[data-t-label]").forEach((e) => { e.setAttribute("aria-label", t(e.dataset.tLabel) || ""); });
let S = null; // the last /status
let busy = false;
/* ---------------- toast and confirm dialog ---------------- */
function toast(text) {
const e = $("#toast"); e.textContent = text; e.classList.add("show");
clearTimeout(toast.timer); toast.timer = setTimeout(() => e.classList.remove("show"), 2600);
}
let onYes = null;
function ask(key, fn) {
$("#dlg-t").textContent = t("dialog." + key + ".name");
$("#dlg-b").textContent = t("dialog." + key + ".description");
$("#dlg-yes").textContent = t("dialog." + key + ".yes");
onYes = fn;
const d = $("#dlg");
if (typeof d.showModal === "function") d.showModal(); else if (window.confirm($("#dlg-t").textContent)) fn();
}
$("#dlg-no").addEventListener("click", () => $("#dlg").close());
$("#dlg-yes").addEventListener("click", () => { $("#dlg").close(); if (onYes) onYes(); });
/* ---------------- data ---------------- */
async function getStatus() {
const r = await fetch("status", { cache: "no-store" });
if (!r.ok) throw new Error("status " + r.status);
return r.json();
}
async function act(what) {
const r = await fetch("action", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: what }) });
if (!r.ok) throw new Error("action " + r.status);
return r.json();
}
async function refresh() {
if (busy) return;
busy = true;
try { S = await getStatus(); render(); }
catch (e) { offline(); }
finally { busy = false; }
}
/* ---------------- shell ---------------- */
function banner(el, level, title, sub, role) {
el.replaceChildren(h("div", { class: "banner " + ({ ok: "b-ok", bad: "b-bad" }[level] || "b-warn"), role: role || null },
h("span", { class: "ic", "aria-hidden": "true" }, { ok: "✓", bad: "×" }[level] || "!"),
h("span", {}, title, sub ? h("small", {}, sub) : null)));
}
function offline() {
banner($("#home-banner"), "bad", t("offline.name"), t("offline.description"), "alert");
$("#pill").replaceChildren(h("span", { class: "dot bad" }), h("span", {}, t("offline.name")));
}
function renderPill() {
const st = S.state || null;
const dot = st ? ({ running: "", shadow: " warn", stopped: " warn", warning: " warn", alarm: " bad" }[st.kind] ?? " warn")
: (S.level === "ok" ? "" : S.level === "bad" ? " bad" : " warn");
const words = st ? [st.pill, st.ran].filter(Boolean).join(". ") : S.banner;
$("#pill").replaceChildren(h("span", { class: "dot" + dot, "aria-hidden": "true" }), h("span", {}, words));
}
function render() {
renderPill();
const structured = S.now && S.plan && !S.home_error;
banner($("#home-banner"), S.level, S.banner, structured ? S.state.sub : "", S.level === "bad" ? "alert" : null);
["#c-now", "#c-plan", "#c-peak", "#c-attn"].forEach((id) => { $(id).hidden = !structured; });
if (structured) { renderNow(); renderPlan(); renderPeak(); renderAttention(); }
renderControl(structured);
renderDetails(!structured);
renderBatteries();
renderSettings();
}
/* ---------------- Home: now ---------------- */
function val(id, w, show) {
const e = $(id);
if (w == null || !show) { e.textContent = "–"; return; }
e.replaceChildren(kw(w) + " ", h("small", {}, "kW"));
}
function levelBar(pct, label) {
const known = pct != null;
return h("div", { class: "lvl" + (known ? "" : " none"), role: "img",
"aria-label": label + " " + (known ? Math.round(pct) + " %" : "–") },
h("i", { style: "width:" + (known ? Math.max(0, Math.min(100, pct)) : 0) + "%" }));
}
function renderNow() {
const n = S.now;
val("#v-solar", n.solar_w, n.has_solar); $("#sub-solar").textContent = n.lines.solar;
val("#v-house", n.house_w, true); $("#sub-house").textContent = n.lines.house;
val("#v-grid", n.grid_w, true); $("#sub-grid").textContent = n.lines.grid;
val("#v-batt", n.batt_w, true); $("#sub-batt").textContent = n.lines.batt;
$("#home-batts").replaceChildren(...S.batteries.map((b) => h("div", { class: "brow" },
h("div", {}, h("strong", {}, b.name), h("span", { class: "muted small" }, " · " + b.mode)),
h("div", { class: "small", style: "font-variant-numeric:tabular-nums" }, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
levelBar(b.level_pct, b.name),
h("div", { class: "small muted", style: "grid-column:1/-1" }, b.now))));
}
/* ---------------- Home: today's plan ---------------- */
const LEGEND = [["cheap", "background:var(--cheap)"], ["dear", "background:repeating-linear-gradient(135deg,var(--dear) 0 3px,transparent 3px 5px)"],
["grid", "background:var(--accent)"], ["give", "background:repeating-linear-gradient(135deg,var(--house) 0 3px,transparent 3px 5px)"],
["cycle", "background:repeating-linear-gradient(90deg,var(--warn) 0 2px,transparent 2px 4px)"],
["peak", "background:var(--bad)"], ["house", null]];
function tipText(lines) { return lines.filter(Boolean).join(" "); }
function chartItem(tag, cls, style, title, lines) {
return h(tag, { class: cls, style: style, tabindex: "-1", role: "img", "aria-label": tipText([title + "."].concat(lines)),
"data-tip": JSON.stringify([title].concat(lines.filter(Boolean))) });
}
function roving(group) {
const items = [...group.querySelectorAll("[data-tip]")];
items.forEach((el, i) => {
el.tabIndex = i === 0 ? 0 : -1;
el.addEventListener("keydown", (e) => {
const d = { ArrowRight: 1, ArrowLeft: -1, Home: -1e9, End: 1e9 }[e.key];
if (d === undefined) return;
e.preventDefault();
const j = Math.max(0, Math.min(items.length - 1, i + d));
items[i].tabIndex = -1; items[j].tabIndex = 0; items[j].focus();
});
});
}
function renderPlan() {
const p = S.plan;
$("#verdict").textContent = p.verdict;
$("#plan-head").textContent = p.head;
const hours = p.hours || 24;
const bars = $("#bars");
const known = p.prices.filter((b) => b.eur_kwh != null);
const mx = Math.max(0.01, ...known.map((b) => Math.abs(b.eur_kwh)));
bars.setAttribute("aria-label", t("plan.name"));
bars.replaceChildren(...p.prices.map((b) => {
const title = b.label + " " + t("plan.time_to") + " " + b.end_label;
if (b.eur_kwh == null) return chartItem("i", "none", "", title, [t("plan.none_tip") + "."]);
const kind = b.kind === "cheap" ? ", " + t("plan.cheap_tip") : b.kind === "dear" ? ", " + t("plan.dear_tip") : "";
const est = b.estimate ? ", " + t("plan.estimate_tip") : "";
return chartItem("i", (b.kind || "mid") + (b.estimate ? " est" : ""),
"height:" + Math.max(4, Math.round(Math.abs(b.eur_kwh) / mx * 100)) + "%",
title, [eur(b.eur_kwh) + " " + t("plan.per_kwh") + kind + est + "."]);
}));
roving(bars);
$("#no-prices").hidden = known.length > 0;
const lanes = $("#lanes");
lanes.replaceChildren();
S.batteries.forEach((b, i) => {
const lane = h("div", { class: "lane", role: "group", "aria-label": b.name });
const runs = p.runs.filter((r) => r.battery === i).sort((a, z) => (a.kind === "house" ? -1 : 0) - (z.kind === "house" ? -1 : 0));
for (const r of runs) {
if (r.end_h <= r.start_h) continue;
lane.append(chartItem("b", r.kind, "left:" + (r.start_h / hours * 100) + "%;width:" + ((r.end_h - r.start_h) / hours * 100) + "%",
b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to, [r.what, r.why, r.amount]));
}
lanes.append(h("div", { class: "lanelab", "aria-hidden": "true" }, b.name), lane);
roving(lane);
});
const nl = $("#nowline");
nl.style.left = Math.max(0, Math.min(100, p.now_h / hours * 100)) + "%";
const used = new Set(p.runs.map((r) => r.kind));
known.forEach((b) => used.add(b.kind));
$("#legend").replaceChildren(...LEGEND.filter(([k]) => used.has(k)).map(([k, css]) =>
h("span", {}, h("i", { class: k === "house" ? "house" : null, style: css, "aria-hidden": "true" }), t("plan." + k))));
const list = [];
S.batteries.forEach((b, i) => {
for (const r of p.runs.filter((x) => x.battery === i && x.kind !== "house")) {
list.push(h("li", {}, h("strong", {}, b.name + ", " + r.from + " " + t("plan.time_to") + " " + r.to + ": "), tipText([r.what, r.why, r.amount])));
}
const house = p.runs.find((x) => x.battery === i && x.kind === "house");
if (house) list.push(h("li", {}, h("strong", {}, b.name + ": "), tipText([house.what, house.why])));
});
for (const line of p.lines) list.push(h("li", {}, line));
$("#plan-list").replaceChildren(...list);
$("#price-line").textContent = [p.price_line, p.tomorrow_line, p.region_line].filter(Boolean).join(" ");
}
/* ---------------- tooltips: hover, tap and keyboard focus ---------------- */
(function () {
const tip = $("#tip"); let hot = null;
const hide = () => { tip.classList.remove("show"); if (hot) hot.classList.remove("hot"); hot = null; };
function show(el, x, y) {
if (hot && hot !== el) hot.classList.remove("hot");
hot = el; el.classList.add("hot");
const lines = JSON.parse(el.dataset.tip);
tip.replaceChildren(h("b", {}, lines[0]), ...lines.slice(1).map((l) => h("span", {}, l)));
tip.classList.add("show");
const r = tip.getBoundingClientRect();
let L = x + 12, T2 = y - r.height - 12;
if (L + r.width > innerWidth - 8) L = x - r.width - 12;
if (L < 8) L = 8;
if (T2 < 8) T2 = y + 16;
tip.style.left = L + "px"; tip.style.top = T2 + "px";
}
const target = (e) => e.target.closest && e.target.closest(".chart [data-tip]");
document.addEventListener("pointermove", (e) => {
if (e.pointerType === "touch") return;
const el = target(e);
if (el) show(el, e.clientX, e.clientY); else if (hot && document.activeElement !== hot) hide();
});
document.addEventListener("pointerdown", (e) => {
const el = target(e);
if (el && e.pointerType === "touch") { if (hot === el) hide(); else show(el, e.clientX, e.clientY); }
else if (!el) hide();
});
document.addEventListener("focusin", (e) => {
const el = target(e);
if (el) { const r = el.getBoundingClientRect(); show(el, r.left + r.width / 2, r.top); }
});
document.addEventListener("focusout", (e) => { if (target(e)) hide(); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
addEventListener("scroll", hide, { passive: true });
})();
/* ---------------- Home: peak, attention, control, details ---------------- */
function renderPeak() {
const m = S.month_peak;
$("#peak-month").textContent = m.month;
const covered = m.state !== "not_covered";
$("#peak-figures").hidden = !covered; $("#peak-meter").hidden = !covered;
$("#peak-now").textContent = m.peak_w != null ? kw(m.peak_w) + " kW"
: m.state === "unreadable" ? t("peak.unreadable") : t("peak.none");
$("#peak-limit").textContent = kw(m.limit_w) + " kW";
const pct = m.pct == null ? 0 : Math.max(0, Math.min(100, m.pct));
const bar = $("#peak-meter i");
bar.style.width = pct + "%";
bar.className = pct >= 100 ? "bad" : pct >= 80 ? "warn" : "";
$("#peak-meter").setAttribute("aria-label", t("peak.this_month") + " " + (m.pct == null ? "–" : Math.round(m.pct) + " %"));
$("#peak-line").textContent = m.line;
}
function renderAttention() {
const list = S.attention;
const icon = { alarm: "×", warning: "!", info: "i" };
$("#alerts").replaceChildren(...list.map((a) => h("div", { class: "alert" },
h("span", { class: "sev " + a.severity, "aria-hidden": "true" }, icon[a.severity] || "i"),
h("div", {}, h("div", { class: "t" }, h("span", { class: "sr" }, t("attention." + a.severity) + ": "), a.title),
h("div", { class: "small" }, a.body),
a.docs && S.control.docs_url ? h("a", { class: "tiny", href: S.control.docs_url, target: "_top", rel: "noopener" }, t("attention.details")) : null))));
const n = list.filter((a) => a.severity !== "info").length;
$("#attn-count").textContent = n ? n + " " + t("attention.to_look") : t("attention.nothing");
if (!list.length) $("#alerts").replaceChildren(h("div", { class: "small muted" }, t("attention.nothing")));
const badge = $("#attn-badge");
badge.textContent = n; badge.hidden = !n;
badge.classList.toggle("bad", list.some((a) => a.severity === "alarm"));
}
function renderControl(structured) {
const running = structured ? S.control.running : null;
const btn = $("#btn-ctl");
btn.textContent = running === false ? t("control.start") : t("control.stop");
btn.className = "btn" + (running === false ? " primary" : "");
$("#ctl-text").textContent = structured ? S.control.text : "";
$("#hint").textContent = S.hint || "";
const docs = $("#docs-link");
if (structured && S.control.docs_url) { docs.href = S.control.docs_url; docs.hidden = false; } else docs.hidden = true;
}
function renderDetails(open) {
$("#rows").replaceChildren(...(S.rows || []).map(([k, v]) => h("tr", {}, h("td", { class: "muted" }, k), h("td", {}, v))));
$("#checks").replaceChildren(...(S.checks || []).map((c) => {
const cls = c.ok ? (c.warn ? "c-warn" : "c-ok") : (c.warn ? "c-warn" : "c-bad");
const mark = c.ok ? (c.warn ? "!" : "✓") : (c.warn ? "!" : "✗");
return h("li", { class: cls }, mark + " " + c.text);
}));
if (open) $("#c-details").open = true;
}
/* ---------------- Batteries and Settings (placeholders until P2/P3) ---------------- */
function renderBatteries() {
if (!S.batteries) { $("#bcards").replaceChildren(); return; }
$("#bcards").replaceChildren(...S.batteries.map((b) => h("div", { class: "card" },
h("div", { class: "row spread" }, h("strong", {}, b.name), h("span", { class: "small muted" }, b.capacity_kwh ? b.capacity_kwh + " kWh" : "")),
levelBar(b.level_pct, b.name),
h("dl", { class: "kv" },
h("dt", {}, t("batteries.level")), h("dd", {}, b.level_pct == null ? "–" : Math.round(b.level_pct) + " %"),
b.stored_kwh != null ? [h("dt", {}, t("batteries.stored")), h("dd", {}, b.stored_kwh.toFixed(1) + " kWh")] : null,
h("dt", {}, t("batteries.now")), h("dd", {}, b.now),
h("dt", {}, t("batteries.mode")), h("dd", {}, b.mode)))));
}
function renderSettings() {
const line = S.plan && S.plan.region_line;
$("#set-region").textContent = line || ""; $("#set-region").hidden = !line;
}
/* ---------------- control buttons ---------------- */
// The action is a toggle, so the page reads the live state first and sends
// nothing when control is already where the person asked it to be.
// One request at a time: a second confirm while the first is in flight
// would read the same state and toggle it back.
let toggling = false;
async function setControl(want) {
if (toggling) return;
toggling = true;
try {
const now = await getStatus();
if (!now.control || now.control.running !== want) await act("auto_toggle");
toast(want ? t("toast.started") : t("toast.stopped"));
} catch (e) { toast(t("toast.failed")); }
finally { toggling = false; }
refresh();
}
$("#btn-ctl").addEventListener("click", () => {
const running = S && S.control ? S.control.running : true;
if (running) ask("stop", () => setControl(false)); else ask("start", () => setControl(true));
});
async function simple(what) {
try { await act(what); toast(t("toast.done")); } catch (e) { toast(t("toast.failed")); }
refresh();
}
$("#btn-maint").addEventListener("click", () => ask("maint_start", () => simple("maint_start")));
$("#btn-abort").addEventListener("click", () => ask("maint_abort", () => simple("maint_abort")));
/* ---------------- tabs: hash routes, arrow keys ---------------- */
const TABS = [...document.querySelectorAll(".tab")];
function select(tab, focus) {
TABS.forEach((x) => {
const on = x === tab;
x.setAttribute("aria-selected", String(on)); x.tabIndex = on ? 0 : -1;
document.getElementById(x.getAttribute("aria-controls")).hidden = !on;
});
if (focus) tab.focus();
if (location.hash !== "#" + tab.dataset.hash) history.replaceState(null, "", "#" + tab.dataset.hash);
schedule();
}
TABS.forEach((x, i) => {
x.addEventListener("click", () => select(x, false));
x.addEventListener("keydown", (e) => {
const j = { ArrowRight: i + 1, ArrowLeft: i - 1 + TABS.length, Home: 0, End: TABS.length - 1 }[e.key];
if (j === undefined) return;
e.preventDefault(); select(TABS[j % TABS.length], true);
});
});
function fromHash() {
const want = (location.hash || "#home").slice(1);
select(TABS.find((x) => x.dataset.hash === want) || TABS[0], false);
}
addEventListener("hashchange", fromHash);
/* ---------------- polling: every 3 s while Home or Batteries shows ---------------- */
let timer = null;
function schedule() {
const live = !document.hidden && ($("#t-home").getAttribute("aria-selected") === "true" ||
$("#t-batt").getAttribute("aria-selected") === "true");
if (live && !timer) { refresh(); timer = setInterval(refresh, 3000); }
if (!live && timer) { clearInterval(timer); timer = null; }
}
document.addEventListener("visibilitychange", schedule);
fromHash();
refresh();
})();
+135
View File
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Battery add-on</title>
<style>/*APP_CSS*/</style>
</head>
<body>
<header class="top">
<div class="logo" aria-hidden="true"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2M8 10l-2 3h4l-2 3"/></svg></div>
<div class="grow">
<div class="app" data-t="app.name"></div>
<div class="pill" id="pill" aria-live="polite"><span class="dot warn"></span><span>…</span></div>
</div>
</header>
<nav class="tabs" role="tablist" id="tabs" data-t-label="tabs.label">
<button class="tab" role="tab" id="t-home" aria-controls="s-home" aria-selected="true" data-hash="home"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z"/></svg><span data-t="tabs.home"></span><span class="badge" id="attn-badge" hidden></span></button>
<button class="tab" role="tab" id="t-batt" aria-controls="s-batt" aria-selected="false" data-hash="batteries" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="7" width="16" height="10" rx="2"/><path d="M21 11v2"/><rect x="5.5" y="9.5" width="7" height="5" fill="currentColor" stroke="none"/></svg><span data-t="tabs.batteries"></span></button>
<button class="tab" role="tab" id="t-set" aria-controls="s-set" aria-selected="false" data-hash="settings" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/></svg><span data-t="tabs.settings"></span></button>
<button class="tab" role="tab" id="t-wiz" aria-controls="s-wiz" aria-selected="false" data-hash="setup" tabindex="-1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 7l3 3M5 19l9.5-9.5 3 3L8 22H5z"/><path d="M19 2v4M17 4h4"/></svg><span data-t="tabs.setup"></span></button>
</nav>
<main class="wrap">
<section class="screen" id="s-home" role="tabpanel" aria-labelledby="t-home">
<div id="home-banner"></div>
<div class="card" id="c-now">
<div class="row spread"><h2 data-t="now.name"></h2><span class="tiny muted" data-t="now.description"></span></div>
<div class="tiles">
<div class="tile"><div class="lab"><span class="sw-solar" aria-hidden="true">&#9728;</span><span data-t="now.solar"></span></div><div class="val" id="v-solar">–</div><div class="sub" id="sub-solar"></div></div>
<div class="tile"><div class="lab"><span class="sw-house" aria-hidden="true">&#8962;</span><span data-t="now.house"></span></div><div class="val" id="v-house">–</div><div class="sub" id="sub-house"></div></div>
<div class="tile"><div class="lab"><span class="sw-grid" aria-hidden="true">&#8645;</span><span data-t="now.grid"></span></div><div class="val" id="v-grid">–</div><div class="sub" id="sub-grid"></div></div>
<div class="tile"><div class="lab"><span class="sw-batt" aria-hidden="true">&#9646;</span><span data-t="now.batteries"></span></div><div class="val" id="v-batt">–</div><div class="sub" id="sub-batt"></div></div>
</div>
<div id="home-batts" style="margin-top:6px"></div>
</div>
<div class="card" id="c-plan">
<div class="row spread"><h2 data-t="plan.name"></h2><span class="chip tiny muted" id="verdict"></span></div>
<div class="small" id="plan-head"></div>
<div class="chart" id="chart">
<div class="bars" id="bars" role="group"></div>
<div id="lanes"></div>
<div class="nowline" id="nowline" aria-hidden="true"><span data-t="plan.now"></span></div>
<div class="axis" aria-hidden="true"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span></div>
</div>
<p class="tiny muted" id="no-prices" hidden data-t="plan.no_prices.description"></p>
<div class="legend" id="legend"></div>
<p class="tiny muted" style="margin:6px 0 0" data-t="plan.description"></p>
<h3 class="sr" data-t="plan.list"></h3>
<ul class="plain small" id="plan-list"></ul>
<p class="tiny muted" style="margin:8px 0 0" id="price-line"></p>
</div>
<div class="card" id="c-peak">
<div class="row spread"><h2 data-t="peak.name"></h2><span class="tiny muted" id="peak-month"></span></div>
<div class="row spread small" id="peak-figures"><span><span data-t="peak.this_month"></span>: <strong id="peak-now"></strong></span><span class="muted"><span data-t="peak.limit"></span> <span id="peak-limit"></span></span></div>
<div class="meter" id="peak-meter" role="img"><i style="width:0%"></i></div>
<div class="tiny muted" id="peak-line"></div>
</div>
<div class="card" id="c-attn">
<div class="row spread"><h2 data-t="attention.name"></h2><span class="tiny muted" id="attn-count"></span></div>
<div id="alerts"></div>
</div>
<div class="card" id="c-ctl">
<h2 data-t="control.name"></h2>
<div class="small" id="ctl-text"></div>
<div class="row" style="margin-top:10px">
<button class="btn" id="btn-ctl" type="button"></button>
<a class="small" id="docs-link" hidden target="_top" rel="noopener" data-t="control.docs"></a>
</div>
<div class="tiny muted" id="hint" aria-live="polite" style="margin-top:8px"></div>
<details class="more">
<summary data-t="control.more"></summary>
<div class="row" style="padding-bottom:6px">
<button class="btn" id="btn-maint" type="button" data-t="control.maint_start"></button>
<button class="btn danger" id="btn-abort" type="button" data-t="control.maint_abort"></button>
</div>
</details>
</div>
<details class="card more" id="c-details">
<summary data-t="details.name"></summary>
<table class="rows" id="rows"></table>
<h2 style="margin-top:12px" data-t="details.checks"></h2>
<ul class="checks" id="checks"></ul>
</details>
</section>
<section class="screen" id="s-batt" role="tabpanel" aria-labelledby="t-batt" hidden>
<div id="bcards"></div>
<div class="card">
<p class="small muted" style="margin:0 0 10px" data-t="batteries.description"></p>
<a class="btn" href="config" data-t="batteries.settings"></a>
</div>
</section>
<section class="screen" id="s-set" role="tabpanel" aria-labelledby="t-set" hidden>
<div class="card">
<h2 data-t="settings.name"></h2>
<p class="small muted" data-t="settings.description"></p>
<p class="small" id="set-region" hidden></p>
<a class="btn primary" href="config" data-t="settings.open"></a>
</div>
</section>
<section class="screen" id="s-wiz" role="tabpanel" aria-labelledby="t-wiz" hidden>
<div class="card">
<h2 data-t="setup.name"></h2>
<p class="small muted" data-t="setup.description"></p>
<a class="btn" href="config" data-t="settings.open"></a>
</div>
</section>
</main>
<dialog id="dlg" aria-labelledby="dlg-t" aria-describedby="dlg-b">
<h2 id="dlg-t"></h2>
<p class="small" id="dlg-b"></p>
<div class="row" style="justify-content:flex-end">
<button class="btn" id="dlg-no" type="button" data-t="dialog.cancel"></button>
<button class="btn primary" id="dlg-yes" type="button"></button>
</div>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
<div id="tip" class="tip" aria-hidden="true"></div>
<script type="application/json" id="ui-text">/*UI_TEXT*/</script>
<script>/*APP_JS*/</script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
{
"groups": {
"meter": {"name": "Meter", "description": "Where the grid reading comes from."},
"inverters": {"name": "Inverters and batteries", "description": "The inverters the add-on controls, and your solar."},
"tariff": {"name": "Tariff and prices", "description": "What you pay and are paid per kWh."},
"safety": {"name": "Safety limits", "description": "The peak limit, starting and stopping, and the safety stops."},
"maintenance": {"name": "Maintenance", "description": "The monthly battery cycle and the log."},
"tuning": {"name": "Control tuning", "description": "For support only, because these were measured on real hardware."}
},
"parts": {
"meter.timing": {"name": "Timing"},
"inverters.solar": {"name": "Solar"},
"tariff.selling": {"name": "Charging and selling on price"},
"tariff.feed": {"name": "Price feed details"},
"safety.peak": {"name": "Peak limit"},
"safety.start": {"name": "Starting and stopping"},
"safety.stops": {"name": "Safety stops", "description": "Measured on real hardware, so leave them unchanged."},
"maintenance.support": {"name": "Support"}
},
"fields": {
"solar": {"name": "Solar inverters", "description": "Each solar inverter's power sensor, which the add-on records but does not use for control."},
"contract": {"name": "Your contract", "description": "How your supplier prices each kWh you take from the grid."},
"region": {"name": "Region", "description": "Fills in grid costs, levies and VAT for the year shown, but not yet for Wallonia or Brussels, so check your bill."},
"fallback": {"name": "Price when the feed is down", "description": "Used when the price source does not answer."},
"fixed_price": {"name": "Price per kWh, all in", "description": "Energy, supplier costs, grid costs, levies and VAT together."},
"day_price": {"name": "Day price, weekdays 07:00 to 22:00", "description": "All in, as on your bill."},
"night_price": {"name": "Night and weekend price", "description": "All in, as on your bill."}
},
"choices": {
"contract": {"none": "No prices", "fixed": "Fixed price", "daynight": "Day and night", "dynamic": "Dynamic, hourly", "custom": "Other time windows"},
"fallback": {"none": "None", "fixed": "One price", "daynight": "Day and night", "custom": "Other time windows"},
"region": {"flanders": "Flanders, Fluvius Antwerpen", "own": "I enter my own figure"}
},
"applies": {
"restart": {"name": "Applies after a restart"},
"now": {"name": "Applies right away"}
},
"home": {
"app": {"name": "Battery add-on"},
"tabs": {"home": "Home", "batteries": "Batteries", "settings": "Settings", "setup": "Setup", "label": "Screens"},
"now": {"name": "Now", "description": "Updated every 3 seconds.", "solar": "Solar", "house": "House", "grid": "Grid", "batteries": "Batteries"},
"plan": {
"name": "Today's plan",
"description": "Point at a bar or a block for details, or tap it on a phone.",
"cheap": "Low price",
"dear": "High price",
"estimate": "Estimate",
"grid": "Charges from the grid",
"give": "Gives power",
"cycle": "Monthly battery cycle",
"peak": "Peak limit",
"house": "Follows the house",
"now": "now",
"time_to": "to",
"per_kwh": "per kWh",
"cheap_tip": "low for today",
"dear_tip": "high for today",
"estimate_tip": "estimate from yesterday",
"none_tip": "No price known",
"no_prices": {"description": "No prices for today, so the chart shows only the plan."},
"list": "The plan in words"
},
"peak": {"name": "Peak limit", "this_month": "This month's peak", "limit": "Limit", "none": "None recorded yet", "unreadable": "Cannot be read now"},
"attention": {"name": "Needs attention", "nothing": "Nothing to look at", "to_look": "to look at", "details": "Details", "alarm": "Alarm", "warning": "Warning", "info": "Info"},
"control": {"name": "Control", "stop": "Stop control", "start": "Start control", "maint_start": "Start a battery cycle now", "maint_abort": "Stop the battery cycle", "docs": "Add-on documentation", "more": "More actions"},
"details": {"name": "Details for installers", "rows": "Status rows", "checks": "Checks", "event": "Last event"},
"dialog": {
"cancel": "Cancel",
"stop": {"name": "Stop control?", "description": "Every inverter is set to 0 W until you start control again.", "yes": "Stop control"},
"start": {"name": "Start control?", "description": "The add-on controls the batteries again and clears any safety stop.", "yes": "Start control"},
"maint_start": {"name": "Start a battery cycle?", "description": "The battery empties, refills and holds full, which takes a few hours.", "yes": "Start the cycle"},
"maint_abort": {"name": "Stop the battery cycle?", "description": "Every inverter is set to 0 W for a moment, then control carries on.", "yes": "Stop the cycle"}
},
"toast": {"started": "Control started.", "stopped": "Control stopped.", "done": "Done.", "failed": "That did not work. Try again."},
"offline": {"name": "Cannot reach the add-on", "description": "The page tries again every 3 seconds."},
"batteries": {"name": "Batteries", "description": "Battery modes and on and off switches arrive in a later update.", "level": "Level", "now": "Now", "stored": "Stored", "mode": "Mode", "settings": "Change inverter settings"},
"settings": {"name": "Settings", "description": "The new settings screens arrive in a later update, so use the settings page for now.", "open": "Open the settings page"},
"setup": {"name": "Setup", "description": "The setup steps arrive in a later update, so use the settings page for now."}
}
}
+295
View File
@@ -0,0 +1,295 @@
"""UX-13: the new page's layout, written once - screen, group, field.
UI code names option keys only here and in uimodel.py, which holds the
derivations this table lists (DECISIONS/UX-13-one-layout-table.md, amending
UX-02 §3). Everything else stays where UX-02 put it: ranges in config.yaml's
schema, rules in validate.py. test_uilayout.py fails CI when an option in
config.yaml has no place here.
A field's `key` is an option key, `inverters.<field>` for one inverter in the
list, or `ui.<name>` for a page-only control that uimodel.py turns into options.
The key is also its text key: label and help are translations/*.yaml's entry
for an option, and app/ui_text's `fields.<name>` for a `ui.` field.
"""
from dataclasses import dataclass
RESTART = "restart" # stored options apply after a restart (UX-02 §5)
NOW = "now" # runtime state (OPT-11's battery switches), none yet
# Controls are names, not string literals, so a key is never followed by a
# constant (test_defaults_pin.py reads `f("key", <constant>)` as a fallback).
CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER = (
"choice", "entity", "entity_list", "toggle", "segmented", "slider")
NUMBER, TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST = (
"number", "text", "select", "locked", "inverter_list", "solar_list")
CONTROLS = (CHOICE, ENTITY, ENTITY_LIST, TOGGLE, SEGMENTED, SLIDER, NUMBER,
TEXT, SELECT, LOCKED, INVERTER_LIST, SOLAR_LIST)
# What an entity picker offers first. `power_w` is a power sensor the add-on
# reads as watts with no unit conversion, so validate.py warns on a kW sensor.
ENTITY_KINDS = ("power_w", "power", "energy", "pct", "number", "any")
SHOW_OPS = ("==", "!=", "in", ">", "<")
@dataclass(frozen=True)
class Field:
key: str
control: str
unit: str = ""
scale: int = 1 # shown = stored / scale (the peak limit in kW)
step: float = 0 # slider or number step, stored units; 0 = 1
kind: str = "" # entity picker filter, one of ENTITY_KINDS
show: tuple = () # ((name, op, value), ...), all must hold
applies: str = RESTART
simple: bool = False # also on Settings, Simple
wizard: int = 0 # setup wizard step 1-6, 0 = not in the wizard
@property
def text(self) -> str:
"""Where the label and help live: `translations` or `ui_text`."""
return "ui_text" if self.key.startswith("ui.") else "translations"
@dataclass(frozen=True)
class Part:
id: str # "" is the group's main part
fields: tuple
collapsed: bool = False
@dataclass(frozen=True)
class Group:
id: str
parts: tuple
support_only: bool = False # collapsed, with a "for support" warning
def _src(*values):
return (("meter_source", "in", values),)
_DYNAMIC = (("ui.contract", "==", "dynamic"),)
_MAINT = (("maintenance_enabled", "==", True),)
_ONE = (("inverter_count", "==", 1),)
_SEVERAL = (("inverter_count", ">", 1),)
_PHASES = (("meter_phases", ">", 1),)
# The per-inverter card. Its keys are `inverters.<field>` in config.yaml.
ROSTER_FIELDS = (
Field("inverters.name", TEXT, wizard=2),
Field("inverters.setpoint_entity", ENTITY, kind="number", wizard=2),
Field("inverters.max_w", SLIDER, "W", step=100, wizard=2),
Field("inverters.bms_soc_floor", SLIDER, "%", wizard=2),
Field("inverters.soc_entity", ENTITY, kind="pct", wizard=2),
Field("inverters.batt_entity", ENTITY, kind="power_w", wizard=2),
Field("inverters.batt_invert", TOGGLE, wizard=2),
Field("inverters.capacity_kwh", NUMBER, "kWh", step=0.1, wizard=2),
)
GROUPS = (
Group("meter", (
Part("", (
Field("meter_source", CHOICE, wizard=1),
Field("meter_entity", ENTITY, kind="power_w", show=_src("off"),
wizard=1),
Field("meter_invert", TOGGLE, show=_src("off"), wizard=1),
Field("p1_net_entity", ENTITY, kind="power_w",
show=_src("ha_signed"), wizard=1),
Field("p1_import_entity", ENTITY, kind="power_w",
show=_src("ha_dsmr"), wizard=1),
Field("p1_export_entity", ENTITY, kind="power_w",
show=_src("ha_dsmr"), wizard=1),
Field("p1_host", TEXT, show=_src("homewizard_local"), wizard=1),
Field("meter_mqtt_topic", TEXT, show=_src("mqtt_p1"), wizard=1),
Field("meter_phases", SEGMENTED, wizard=1),
Field("p1_phase_net_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_signed") + _PHASES, wizard=1),
Field("p1_phase_import_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_dsmr") + _PHASES, wizard=1),
Field("p1_phase_export_entities", ENTITY_LIST, kind="power_w",
show=_src("ha_dsmr") + _PHASES, wizard=1),
)),
Part("timing", (
Field("meter_max_age_s", SLIDER, "s", step=5),
Field("meter_poll_s", SLIDER, "s",
show=_src("homewizard_local")),
), collapsed=True),
)),
Group("inverters", (
Part("", (Field("inverters", INVERTER_LIST, wizard=2),)),
# pv.py reads each entity's own unit, so a kW sensor is fine here.
Part("solar", (Field("ui.solar", SOLAR_LIST, kind="power",
wizard=3),)),
)),
Group("tariff", (
Part("", (
Field("ui.contract", SEGMENTED, wizard=4),
Field("price_provider", SELECT, show=_DYNAMIC, wizard=4),
Field("price_formula", TEXT,
show=_DYNAMIC + (("price_provider", "==", "custom_formula"),),
wizard=4),
Field("price_csv_path", TEXT,
show=_DYNAMIC + (("price_provider", "==", "manual_csv"),),
wizard=4),
Field("ui.region", SELECT, wizard=4),
Field("price_taxes_eur_kwh", NUMBER, "€/kWh", step=0.00001,
wizard=4),
Field("price_markup_eur_kwh", NUMBER, "€/kWh", step=0.001,
show=_DYNAMIC, wizard=4),
Field("export_price_eur_kwh", NUMBER, "€/kWh", step=0.001,
wizard=4),
Field("price_export_formula", TEXT),
Field("ui.fallback", SEGMENTED, show=_DYNAMIC, wizard=4),
Field("ui.fixed_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "fixed"),), wizard=4),
Field("ui.day_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
Field("ui.night_price", NUMBER, "€/kWh", step=0.01,
show=(("ui.fallback", "==", "daynight"),), wizard=4),
)),
Part("selling", (
# OPT-11: derived from the batteries' selling switches once battery
# modes exist (uimodel.arbitrage_from_batteries). A toggle until then.
Field("arbitrage_enabled", TOGGLE),
Field("cycle_cost_threshold_eur", NUMBER, "€/kWh", step=0.01),
Field("arbitrage_soc_floor", SLIDER, "%"),
Field("arbitrage_soc_ceiling", SLIDER, "%"),
)),
Part("feed", (
Field("price_feed_url", TEXT, show=_DYNAMIC),
Field("price_horizon_h", SLIDER, "h", show=_DYNAMIC),
Field("price_timeout_s", SLIDER, "s", show=_DYNAMIC),
Field("price_now_entity", ENTITY, kind="any"),
Field("price_avg_entity", ENTITY, kind="any"),
), collapsed=True),
)),
Group("safety", (
Part("peak", (
Field("peak_cap_w", SLIDER, "kW", scale=1000, step=100,
simple=True, wizard=5),
Field("peak_forecast_entity", ENTITY, kind="power_w", wizard=5),
Field("peak_soc_floor", LOCKED, "%"),
)),
Part("start", (
Field("shadow_mode", SEGMENTED, simple=True, wizard=6),
Field("auto_start", TOGGLE),
Field("estop_fitted", TOGGLE),
Field("stale_input_s", SLIDER, "s"),
Field("meter_freeze_s", SLIDER, "s", step=30,
show=_src("homewizard_local")),
)),
Part("stops", (
Field("sign_disagree_w", LOCKED, "W", step=10, show=_ONE),
Field("sign_disagree_s", LOCKED, "s", step=5, show=_ONE),
Field("unit_sign_disagree_w", LOCKED, "W", step=10,
show=_SEVERAL),
Field("unit_sign_disagree_s", LOCKED, "s", step=5,
show=_SEVERAL),
Field("site_sign_disagree_w", LOCKED, "W", step=10,
show=_SEVERAL),
Field("site_sign_disagree_s", LOCKED, "s", step=5,
show=_SEVERAL),
Field("circulation_w", LOCKED, "W", step=10, show=_SEVERAL),
Field("circulation_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
Field("delivery_hold_s", LOCKED, "s", step=5, show=_SEVERAL),
), collapsed=True),
)),
Group("maintenance", (
Part("", (
Field("maintenance_enabled", TOGGLE),
Field("maintenance_interval_days", SLIDER, "days", show=_MAINT),
Field("maintenance_start_hour", SELECT, show=_MAINT),
Field("maintenance_discharge_w", SLIDER, "W", step=100,
show=_MAINT),
Field("maintenance_charge_w", SLIDER, "W", step=100,
show=_MAINT),
Field("maintenance_soc_floor", SLIDER, "%", show=_MAINT),
Field("maintenance_soc_target", SLIDER, "%", show=_MAINT),
Field("maintenance_hold_min", SLIDER, "min", step=5,
show=_MAINT),
)),
Part("support", (Field("log_level", SELECT),)),
)),
Group("tuning", (
Part("", (
Field("gain", LOCKED, step=0.05),
Field("slew_w", LOCKED, "W", step=50),
Field("deadband_w", LOCKED, "W"),
Field("target_grid_w", LOCKED, "W"),
Field("step_w", LOCKED, "W"),
Field("saturation_w", LOCKED, "W", step=10),
Field("saturation_cycles", LOCKED),
Field("integrator_max_w", LOCKED, "W", step=100),
Field("heartbeat_s", LOCKED, "s"),
Field("cycle_floor_s", LOCKED, "s"),
)),
), support_only=True),
)
# Option keys the page never shows. uimodel.py writes each from page fields.
DERIVED = {
"setpoint_entity": "inverters", # one inverter, stored flat
"soc_entity": "inverters",
"batt_entity": "inverters",
"batt_invert": "inverters",
"max_w": "inverters",
"bms_soc_floor": "inverters",
"pv_entity": "ui.solar", # one solar row, stored flat
"pv_entities": "ui.solar",
"pv_invert_entities": "ui.solar", # the rows' "reads negative" toggles
"static_profile": "ui.fallback", # built from the price fields
"price_taxes_profile": "ui.region", # a preset writes it, "own" keeps it
}
# Option keys the page never shows and never writes: passed through as stored.
HIDDEN: dict = {}
def fields():
"""Every field once, in page order, roster fields after `inverters`."""
for group in GROUPS:
for part in group.parts:
for field in part.fields:
yield group, part, field
if field.control == INVERTER_LIST:
for sub in ROSTER_FIELDS:
yield group, part, sub
def field(key: str) -> Field | None:
return next((f for _g, _p, f in fields() if f.key == key), None)
def visible(fld: Field, model: dict) -> bool:
"""Whether `fld` is shown for this page model (uimodel.options_to_ui)."""
for name, op, value in fld.show:
have = model.get(name)
if op == "==" and have != value:
return False
if op == "!=" and have == value:
return False
if op == "in" and have not in value:
return False
try:
if op == ">" and not float(have) > value:
return False
if op == "<" and not float(have) < value:
return False
except (TypeError, ValueError):
return False
return True
def range_of(key: str, schema_ui: list):
"""(min, max) from the Supervisor's schema (`info["schema"]`), or None.
Never typed here: config.yaml is the one place a range is written. A range
arrives as lengthMin/lengthMax (tools/supervisor_selfapi_probe.py)."""
path = key.split(".")
nodes = schema_ui or []
for step in path[:-1]:
parent = next((n for n in nodes if n.get("name") == step), None)
nodes = (parent or {}).get("schema") or []
node = next((n for n in nodes if n.get("name") == path[-1]), None)
if not node or "lengthMin" not in node or "lengthMax" not in node:
return None
return node["lengthMin"], node["lengthMax"]
+331
View File
@@ -0,0 +1,331 @@
"""UX-13: stored options <-> the page model, for the keys the page derives.
`options_to_ui()` turns the stored blob into what the page edits: always a list
of inverter cards, solar rows with a "reads negative" toggle, a contract and
simple prices instead of `static_profile` JSON, and a region preset instead of
the levies figure. `ui_to_options()` turns it back, so the validator and the
Supervisor always see the real blob.
The rule that makes the round trip exact: the model carries the stored values
of the derived keys (`keep`). A derivation whose page fields still read what
`keep` decodes to writes `keep` back untouched, and only an edited one is
re-encoded. So a blob the page cannot express (a `static_profile` with odd
windows, a malformed roster) survives a save of some other field unchanged.
See DECISIONS/UX-13-one-layout-table.md.
"""
import copy
import json
from .price import FLEMISH_TAXES_COMPONENTS_EUR_KWH
from .static_tariff import ProfileInvalid, parse_static_profile
__all__ = ["options_to_ui", "ui_to_options", "REGION_PRESETS",
"arbitrage_from_batteries", "DeriveError"]
class DeriveError(ValueError):
"""The page model asks for something no options blob can say."""
# --------------------------------------------------------------------------- #
# Region presets for "Grid costs, levies and VAT". Year-stamped, updated each
# January (owner, 2026-09-27). Only presets with a sourced figure are listed:
# Flanders is app/price.py's Fluvius Antwerpen sum (TARIFF-18: Eneco
# residential card, Flanders, September 2026, VAT included). Wallonia and
# Brussels have a day and a night band (config.yaml) and join with sourced
# figures; until then those sites choose "own".
# --------------------------------------------------------------------------- #
REGION_PRESETS = {
"flanders": {
"year": 2026,
"taxes_eur_kwh": round(sum(v for _n, v in
FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5),
"taxes_profile": "",
},
}
OWN = "own"
_ROSTER_FLAT = ("setpoint_entity", "soc_entity", "batt_entity", "batt_invert",
"max_w", "bms_soc_floor")
_TOL = 1e-9
def _blank(v) -> bool:
return v is None or (isinstance(v, str) and not v.strip())
def _num(v):
try:
return None if _blank(v) or isinstance(v, bool) else float(v)
except (TypeError, ValueError):
return None
# --- 1. one inverter (flat keys) or the `inverters` list -------------------- #
def _roster_decode(o):
entries = o.get("inverters")
if entries is None or entries == "" or entries == []:
card = {k: o.get(k) for k in _ROSTER_FLAT}
card.update(name="", capacity_kwh=None)
return {"inverter_shape": "flat", "inverters": [card]}
if isinstance(entries, list) and all(isinstance(e, dict) for e in entries):
return {"inverter_shape": "list",
"inverters": [dict(e) for e in entries]}
# Not something a card can show: edited in the Configuration tab only.
return {"inverter_shape": "raw", "inverters": None}
def _roster_encode(m, keep):
out = {}
shape, cards = m.get("inverter_shape"), m.get("inverters")
if shape == "raw":
raise DeriveError("the inverter list is edited in the Configuration tab")
if not cards:
raise DeriveError("at least one inverter is needed")
if shape == "flat" and len(cards) == 1:
card = cards[0]
if not _blank(card.get("name")) or not _blank(card.get("capacity_kwh")):
raise DeriveError("one inverter stored flat has no name or capacity")
for k in _ROSTER_FLAT:
if card.get(k) is not None or k in keep:
out[k] = card.get(k)
out["inverters"] = []
return out
out["inverters"] = [{k: v for k, v in c.items() if v is not None}
for c in cards]
# The flat keys beside a list are not read: they stay as stored.
out.update({k: copy.deepcopy(keep[k]) for k in _ROSTER_FLAT if k in keep})
if shape == "flat":
# CFG-04 rule 9e: a flat power request entity beside a list is refused.
out["setpoint_entity"] = ""
return out
# --- 2. solar rows: pv_entity / pv_entities / pv_invert_entities ------------ #
def _strs(v):
return [str(x).strip() for x in (v if isinstance(v, list) else [])
if not _blank(x)]
def _solar_rows(o):
listed = _strs(o.get("pv_entities"))
if listed:
return listed, True
one = "" if _blank(o.get("pv_entity")) else str(o.get("pv_entity")).strip()
return ([one] if one else []), False
def _solar_decode(o):
rows, _listed = _solar_rows(o)
flipped = set(_strs(o.get("pv_invert_entities")))
return {"ui.solar": [{"entity": e, "reversed": e in flipped}
for e in rows]}
def _solar_encode(m, keep):
out = {}
rows = [r for r in (m.get("ui.solar") or [])
if not _blank(r.get("entity"))]
entities = [str(r["entity"]).strip() for r in rows]
old_rows, was_list = _solar_rows(keep)
if len(entities) <= 1 and not was_list:
out["pv_entity"] = entities[0] if entities else ""
if "pv_entities" in keep:
out["pv_entities"] = []
else:
out["pv_entities"] = entities
if "pv_entity" in keep:
out["pv_entity"] = "" # TEL-12: the list replaces it
# Flips of entities that were never a row stay as stored (pv.stray_inverts
# reports them). Flips of removed rows go with the row.
extras = [x for x in _strs(keep.get("pv_invert_entities"))
if x not in old_rows and x not in entities]
flips = [e for e, r in zip(entities, rows) if r.get("reversed")] + extras
if flips or "pv_invert_entities" in keep:
out["pv_invert_entities"] = flips
return out
# --- 3. contract and static_profile ----------------------------------------- #
def _fixed_json(price) -> str:
return json.dumps({"periods": [
{"name": "all", "from": "00:00", "to": "24:00", "days": "mon-sun",
"eur_per_kwh": price}]})
def _daynight_json(day, night) -> str:
return json.dumps({"periods": [
{"name": "day", "from": "07:00", "to": "22:00", "days": "mon-fri",
"eur_per_kwh": day},
{"name": "night", "from": "22:00", "to": "07:00", "days": "mon-fri",
"eur_per_kwh": night},
{"name": "night", "from": "00:00", "to": "24:00", "days": "sat,sun",
"eur_per_kwh": night}]})
def _shape(text):
"""The periods of a profile without their names, for comparing, or None."""
try:
prof = parse_static_profile(text)
except ProfileInvalid:
return None
if prof is None:
return ()
return tuple(sorted((p.start_min, p.end_min, tuple(sorted(p.days)),
p.eur_per_kwh) for p in prof.periods))
def _profile_decode(raw):
"""("none"|"fixed"|"daynight"|"custom", fixed, day, night)."""
if isinstance(raw, str) and _blank(raw):
return "none", None, None, None
if not isinstance(raw, str):
return "custom", None, None, None
got = _shape(raw)
if not got:
return ("none" if got == () else "custom"), None, None, None
if len(got) == 1 and got == _shape(_fixed_json(got[0][3])):
return "fixed", got[0][3], None, None
day = next((p[3] for p in got if p[:2] == (420, 1320)), None)
night = next((p[3] for p in got if p[:2] == (1320, 420)), None)
if (day is not None and night is not None
and got == _shape(_daynight_json(day, night))):
return "daynight", None, day, night
return "custom", None, None, None
def _tariff_decode(o):
kind, fixed, day, night = _profile_decode(o.get("static_profile", ""))
provider = o.get("price_provider")
dynamic = not _blank(provider) and provider != "off"
return {"ui.contract": "dynamic" if dynamic else kind,
"price_provider": provider, "ui.fallback": kind,
"ui.fixed_price": fixed, "ui.day_price": day,
"ui.night_price": night}
def _tariff_encode(m, keep):
out = {}
contract = m.get("ui.contract")
if contract == "dynamic":
if _blank(m.get("price_provider")) or m.get("price_provider") == "off":
raise DeriveError("a dynamic contract needs a price source")
out["price_provider"] = m["price_provider"]
kind = m.get("ui.fallback")
else:
out["price_provider"] = "off"
kind = contract
if kind == "none":
out["static_profile"] = ""
elif kind == "fixed":
out["static_profile"] = _fixed_json(_price_of(m, "ui.fixed_price"))
elif kind == "daynight":
out["static_profile"] = _daynight_json(_price_of(m, "ui.day_price"),
_price_of(m, "ui.night_price"))
elif kind == "custom":
if "static_profile" not in keep:
raise DeriveError("odd tariff windows are edited in the "
"Configuration tab")
out["static_profile"] = keep["static_profile"]
else:
raise DeriveError(f"unknown contract {kind!r}")
return out
def _price_of(m, name):
value = _num(m.get(name))
if value is None:
raise DeriveError(f"{name} needs a price")
return value
# --- 4. region preset -> price_taxes_eur_kwh / price_taxes_profile ---------- #
def _region_decode(o):
taxes, profile = o.get("price_taxes_eur_kwh"), o.get("price_taxes_profile", "")
region = OWN
for rid, p in REGION_PRESETS.items():
same = _num(taxes) is not None and abs(_num(taxes) - p["taxes_eur_kwh"]) < _TOL
if same and str(profile or "").strip() == p["taxes_profile"]:
region = rid
return {"ui.region": region, "price_taxes_eur_kwh": taxes}
def _region_encode(m, keep):
region = m.get("ui.region")
if region == OWN:
out = {"price_taxes_eur_kwh": m.get("price_taxes_eur_kwh")}
if "price_taxes_profile" in keep: # "own" keeps the stored table
out["price_taxes_profile"] = keep["price_taxes_profile"]
return out
if region not in REGION_PRESETS:
raise DeriveError(f"unknown region {region!r}")
p = REGION_PRESETS[region]
out = {"price_taxes_eur_kwh": p["taxes_eur_kwh"]}
if p["taxes_profile"] or "price_taxes_profile" in keep:
out["price_taxes_profile"] = p["taxes_profile"]
return out
# --- 5. arbitrage_enabled: the OPT-11 hook ---------------------------------- #
def arbitrage_from_batteries(m):
"""OPT-11 hook. With battery modes on, `arbitrage_enabled` is true when any
battery may sell. Battery modes are not built yet, so this returns None and
the toggle is stored as the page has it: no behaviour change."""
return None
def _arbitrage_decode(o):
return {"arbitrage_enabled": o.get("arbitrage_enabled")}
def _arbitrage_encode(m, keep):
derived = arbitrage_from_batteries(m)
value = m.get("arbitrage_enabled") if derived is None else derived
return {} if value is None and "arbitrage_enabled" not in keep \
else {"arbitrage_enabled": value}
# (option keys it writes, decode, encode). Each is independent of the others.
DERIVATIONS = (
(("inverters",) + _ROSTER_FLAT, _roster_decode, _roster_encode),
(("pv_entity", "pv_entities", "pv_invert_entities"), _solar_decode,
_solar_encode),
(("price_provider", "static_profile"), _tariff_decode, _tariff_encode),
(("price_taxes_eur_kwh", "price_taxes_profile"), _region_decode,
_region_encode),
(("arbitrage_enabled",), _arbitrage_decode, _arbitrage_encode),
)
_OWNED = {k for keys, _d, _e in DERIVATIONS for k in keys}
_MODEL_ONLY = ("keep", "inverter_shape", "inverter_count", "region_year")
def options_to_ui(options: dict) -> dict:
"""The page model for a stored blob. Pure: reads nothing else."""
model = {k: copy.deepcopy(v) for k, v in options.items()
if k not in _OWNED}
for _keys, decode, _encode in DERIVATIONS:
model.update(decode(options))
model["keep"] = {k: copy.deepcopy(options[k]) for k in _OWNED
if k in options}
cards = model.get("inverters")
model["inverter_count"] = len(cards) if isinstance(cards, list) else None
preset = REGION_PRESETS.get(model["ui.region"])
model["region_year"] = preset["year"] if preset else None
return model
def ui_to_options(model: dict) -> dict:
"""The options blob for a page model. Raises DeriveError on a model no blob
can express, so the page says so instead of saving something else."""
keep = model.get("keep") or {}
out = {k: copy.deepcopy(v) for k, v in model.items()
if k not in _MODEL_ONLY and k not in _OWNED
and not k.startswith("ui.")}
for keys, decode, encode in DERIVATIONS:
was = decode(keep)
if all(model.get(name) == value for name, value in was.items()):
out.update({k: copy.deepcopy(keep[k]) for k in keys if k in keep})
else:
out.update(encode(model, keep))
return out
+130 -5
View File
@@ -321,8 +321,9 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
"""
import logging
from typing import NamedTuple
from . import messages
from . import messages, pv
from .arbitrage import enabled as arbitrage_on
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
from .custom_formula import (
@@ -330,7 +331,8 @@ from .custom_formula import (
parse_export_formula, parse_price_formula, stale_card)
from .inverter import roster_entries
from .maintenance import MAINT_OPTION_DEFAULTS
from .p1 import is_enabled, validate_meter_options
from .p1 import (
SOURCE_HA, SOURCE_HA_SIGNED, is_enabled, validate_meter_options)
from .peakguard import PEAK_OPTION_DEFAULTS
from .price import (
PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS,
@@ -367,6 +369,22 @@ class _WarnOnce(logging.Filter):
WARN_ONCE = _WarnOnce()
_LOG.addFilter(WARN_ONCE)
class Note(NamedTuple):
"""UX-13: a warning or info for the settings page. Never a refusal: the
add-on starts. `keys` are the options it is about, so the page can show it
beside them."""
tier: str # "warn" or "info"
keys: tuple
text: str
def _advise(notes, tier: str, keys, text: str) -> None:
"""Add a Note when the caller collects them (the settings page)."""
if notes is not None:
notes.append(Note(tier, tuple(keys), text))
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
# threshold is a compile-time YAML substitution baked into the ESP32 image
# (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at
@@ -789,7 +807,8 @@ class ConfigRefused(Exception):
super().__init__(problems[0] if problems else "configuration refused")
def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]:
def validate_startup_config(opts: dict, dev_min=None, dev_max=None,
notes: list | None = None) -> list[str]:
"""Return the list of reasons this configuration must not be started.
Empty list means go. `dev_min`/`dev_max` are the setpoint device's own
@@ -799,6 +818,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
Pure and side-effect free apart from two warnings, so it can be called from
a test or from sim/inject.py without a Home Assistant anywhere near it.
UX-13: pass a list as `notes` to also get each logged warning as a `Note`
for the settings page. The refusals returned do not depend on it.
"""
problems: list[str] = []
@@ -1127,7 +1149,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
f"site. Set it to 0 or to at most {bound:.0f} W.")
# --- 7. two price sources on one site ------------------------------------
problems.extend(_price_problems(opts))
problems.extend(_price_problems(opts, notes))
# --- 8. an EMS energy floor the BMS does not already enforce -------------
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
@@ -1173,6 +1195,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
"integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
"but it only adds overshoot, so 0 is recommended.",
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.")
# ⚠️ 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
@@ -1197,6 +1222,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
"maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
_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.")
_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 "
@@ -1205,7 +1234,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
return problems
def _price_problems(opts: dict) -> list[str]:
def _price_problems(opts: dict, notes: list | None = None) -> list[str]:
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
@@ -1423,6 +1452,9 @@ def _price_problems(opts: dict) -> list[str]:
"%s has negative levies %s EUR/kWh, which are subtracted from "
"the price. This is allowed, but it is usually a sign error.",
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.")
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00.
@@ -1478,6 +1510,10 @@ def _price_problems(opts: dict) -> list[str]:
"price_formula is filled in, but price_provider is %r, so the "
"formula is not used. Set price_provider to 'custom_formula' "
"or clear price_formula.", pid)
_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.")
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1491,6 +1527,8 @@ def _price_problems(opts: dict) -> list[str]:
# exactly right and a site refused over one is a site that
# cannot start.
_LOG.warning("%s", aged)
_advise(notes, "warn", ("price_formula",),
_card_note("price_formula", spec))
# ⚠️ TARIFF-16: THE INJECTION LEG, REFUSED AT STARTUP ON THE SAME RULE.
#
@@ -1571,6 +1609,9 @@ def _price_problems(opts: dict) -> list[str]:
"%.4g, offset %+.5f EUR/kWh).",
EXPORT_FORMULA_OPTION, xspec.passthrough,
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.")
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
@@ -1582,6 +1623,8 @@ def _price_problems(opts: dict) -> list[str]:
xaged = stale_card(xspec)
if xaged:
_LOG.warning("%s", xaged)
_advise(notes, "warn", (EXPORT_FORMULA_OPTION,),
_card_note(EXPORT_FORMULA_OPTION, xspec))
# ⚠️ 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
@@ -1593,6 +1636,9 @@ def _price_problems(opts: dict) -> list[str]:
"price_provider is %r but static_profile is empty, so a price "
"feed outage leaves no tariff at all. Fill in static_profile as a "
"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.")
# ⚠️ 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
@@ -1644,6 +1690,85 @@ def _price_problems(opts: dict) -> list[str]:
return problems
def _card_note(option: str, spec) -> str:
return (f"{S(option)} uses a tariff card dated {spec.card_month}, so copy "
"the new figures if your contract changed.")
# UX-13: entity options whose number the add-on takes as watts, with no unit
# conversion. pv.py converts each solar entity's own unit, so those are not here.
WATT_ENTITY_KEYS = (
"meter_entity", "p1_net_entity", "p1_import_entity", "p1_export_entity",
"p1_phase_net_entities", "p1_phase_import_entities",
"p1_phase_export_entities", "batt_entity", "peak_forecast_entity")
def _entities_read(opts: dict) -> list[tuple[str, str, str]]:
"""(key, inverter name or "", entity id) for each entity the add-on reads
with these options. Keys the chosen meter source or roster shape ignores
are left out, so a stale default is not reported."""
out = []
def add(key, value, where=""):
for e in (value if isinstance(value, list) else [value]):
if isinstance(e, str) and e.strip():
out.append((key, where, e.strip()))
source = str(opts.get("meter_source") or "off").strip()
by_source = {"off": ("meter_entity",),
SOURCE_HA_SIGNED: ("p1_net_entity", "p1_phase_net_entities"),
SOURCE_HA: ("p1_import_entity", "p1_export_entity",
"p1_phase_import_entities",
"p1_phase_export_entities")}
for key in by_source.get(source, ()):
add(key, opts.get(key))
entries = roster_entries(opts)
if not entries:
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
add(key, opts.get(key))
for i, entry in enumerate(entries):
if isinstance(entry, dict):
where = _unit_label(opts, i).strip().strip("()")
for key in ("setpoint_entity", "soc_entity", "batt_entity"):
add(key, entry.get(key), where)
solar = [e for e in (opts.get("pv_entities") or []) if str(e or "").strip()]
add("pv_entities" if solar else "pv_entity", list(pv.entities(opts)))
for key in ("peak_forecast_entity", "price_now_entity", "price_avg_entity"):
add(key, opts.get(key))
return out
def config_notes(opts: dict, *, entities: dict | None = None,
month_peak_w=None) -> list[Note]:
"""UX-13: warnings that need live data, for the settings page. Never a
refusal, and nothing here changes what `validate_startup_config()` says.
`entities` is Home Assistant's entity list by id (app/haentities.py), or
None when it could not be read, in which case those checks are skipped.
`month_peak_w` is this month's peak so far, or None.
"""
notes: list[Note] = []
if entities is not None:
for key, where, entity in _entities_read(opts):
row = entities.get(entity)
if row is None:
_advise(notes, "warn", (key,),
f"Home Assistant has no entity {entity}, which "
f"{S(key, where)} names.")
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.")
cap = _number(opts, "peak_cap_w", 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.")
return notes
def _number(opts: dict, key, default):
raw = opts.get(key, default)
if raw is None or raw == "":
+53 -79
View File
@@ -10,7 +10,9 @@ a cellar, by someone who did not write it. Numbers alone are not enough - it
says what is wrong in words.
⚠️ SINCE UX-02 THERE ARE TWO PAGES HERE, AND ONLY ONE OF THEM IS THIS FILE'S.
`/` is the status and control surface and is unchanged. `/config` is the
`/` is the status and control surface; since UX-13 P1 its files are in
app/static/ and its Home screen reads the structured half of `/status`
(app/homeview.py). `/config` is the
configuration surface: its markup lives in app/config_page.py, its field model
in app/configui.py and its Supervisor conversation in app/selfconfig.py. What
stays HERE is the routing, so that the question a reviewer has to answer - does
@@ -24,97 +26,52 @@ routes below read `GET /addons/self/info`, run the SHIPPED validator, write
mode or reaches `controller.handle_action()`. The set of things an operator can
do to the inverter through this panel is exactly what `/action` offered before
this card - test_selfconfig.py part 7 asserts that by listing the routes and the
actions and comparing them with the shipped set.
actions and comparing them with the shipped set. UX-13's `ha/entities` is a GET
of Home Assistant's entity list (app/haentities.py) and writes nothing.
"""
import io
import json
import logging
import os
from aiohttp import web
from . import messages, selfconfig
from .haentities import EntityCache
from .homeview import region_of
from .validate import config_notes
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
_LOG = logging.getLogger("goodwe.web")
PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoodWe RS485 Controller</title>
<style>
:root{--bg:#f5f6f8;--card:#fff;--ink:#1c1e21;--muted:#6b7280;--line:#e5e7eb;
--ok:#137333;--warn:#b25e02;--bad:#c5221f}
@media(prefers-color-scheme:dark){:root{--bg:#111317;--card:#1b1e24;--ink:#e8eaed;
--muted:#9aa0a6;--line:#2c3038;--ok:#81c995;--warn:#fdd663;--bad:#f28b82}}
*{box-sizing:border-box}
body{margin:0;padding:16px;background:var(--bg);color:var(--ink);
font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
h1{font-size:18px;margin:0 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px;margin-bottom:12px}
.banner{font-weight:600;padding:12px 14px;border-radius:10px;margin-bottom:12px}
.b-ok{background:rgba(19,115,51,.12);color:var(--ok)}
.b-warn{background:rgba(178,94,2,.12);color:var(--warn)}
.b-bad{background:rgba(197,34,31,.12);color:var(--bad)}
table{width:100%;border-collapse:collapse}
td{padding:6px 0;border-bottom:1px solid var(--line);vertical-align:top}
td:last-child{text-align:right;font-variant-numeric:tabular-nums;font-weight:600}
tr:last-child td{border-bottom:0}
.k{color:var(--muted);font-weight:400}
ul{margin:6px 0 0;padding-left:18px}
li{margin:3px 0}
.ok{color:var(--ok)} .bad{color:var(--bad)} .warn{color:var(--warn)}
button{font:inherit;padding:9px 14px;border-radius:8px;border:1px solid var(--line);
background:var(--card);color:var(--ink);cursor:pointer;margin:0 6px 6px 0}
button.primary{background:#1a73e8;border-color:#1a73e8;color:#fff}
button.danger{background:var(--bad);border-color:var(--bad);color:#fff}
.muted{color:var(--muted);font-size:13px}
</style></head><body>
<h1>GoodWe RS485 Controller</h1>
<p class="muted"><a href="config">Configuration &rarr;</a></p>
<div id="banner" class="banner b-warn">loading…</div>
# UX-13 P1: the page lives in app/static/ as three files - index.html,
# app.css, app.js - plus the page words from app/ui_text/en.json. They are
# joined into ONE response here, once, so the page needs no second request
# and no route of its own: the route list below is unchanged, and every URL
# the page uses stays relative (ingress). No build step, no external file.
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
UI_TEXT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ui_text",
"en.json")
<div class="card">
<table id="live"></table>
</div>
<div class="card">
<div class="k">Commissioning</div>
<ul id="checks"></ul>
</div>
def _read(path):
with io.open(path, encoding="utf-8") as f:
return f.read()
<div class="card">
<button class="primary" onclick="act('auto_toggle')">Start / stop control</button>
<button onclick="act('maint_start')">Force maintenance cycle</button>
<button class="danger" onclick="act('maint_abort')">Abort maintenance</button>
<div class="muted" id="hint"></div>
</div>
<script>
async function refresh(){
try{
const r = await fetch('status', {cache:'no-store'});
const s = await r.json();
const b = document.getElementById('banner');
b.textContent = s.banner;
b.className = 'banner ' + (s.level==='ok'?'b-ok':s.level==='bad'?'b-bad':'b-warn');
document.getElementById('live').innerHTML = s.rows
.map(([k,v]) => `<tr><td class="k">${k}</td><td>${v}</td></tr>`).join('');
document.getElementById('checks').innerHTML = s.checks
.map(c => `<li class="${c.ok?'ok':(c.warn?'warn':'bad')}">${c.ok?'✓':(c.warn?'!':'✗')} ${c.text}</li>`)
.join('');
document.getElementById('hint').textContent = s.hint || '';
}catch(e){
document.getElementById('banner').textContent = 'cannot reach the add-on';
}
}
async function act(what){
await fetch('action', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({action: what})});
refresh();
}
refresh(); setInterval(refresh, 3000);
</script></body></html>
"""
def page() -> str:
"""The assembled index page. `</` is escaped inside the two inline
scripts, so no text in them can close the tag early."""
ui_text = json.dumps(json.loads(_read(UI_TEXT)), ensure_ascii=False)
script = _read(os.path.join(STATIC, "app.js"))
if "</script" in script.lower():
raise ValueError("app.js may not contain a closing script tag")
return (_read(os.path.join(STATIC, "index.html"))
.replace("/*APP_CSS*/", _read(os.path.join(STATIC, "app.css")))
.replace("/*UI_TEXT*/", ui_text.replace("</", "<\\/"))
.replace("/*APP_JS*/", script))
# ⚠️ WHAT THE PANEL SAYS WHEN IT COULD NOT READ A DEVICE RANGE. Two of
@@ -140,9 +97,11 @@ def build_app(controller, selfcfg=None) -> web.Application:
if selfcfg is None:
selfcfg = selfconfig.SelfConfig(controller.hass.session,
controller.hass.token)
entities = EntityCache(controller.hass)
index_page = page()
async def index(_request):
return web.Response(text=PAGE, content_type="text/html")
return web.Response(text=index_page, content_type="text/html")
async def status(_request):
return web.json_response(controller.status_payload())
@@ -199,6 +158,9 @@ def build_app(controller, selfcfg=None) -> web.Application:
"running": controller.o,
"running_differs": changed_keys(controller.o, options),
"lang": lang,
# UX-13 P1: the levies preset and its year, for the P2 Settings
# screens, which show the year beside the region choice.
"region": region_of(options),
})
return web.json_response(model)
@@ -216,11 +178,16 @@ def build_app(controller, selfcfg=None) -> web.Application:
be a second opinion about a rule it does not own.
"""
_body, options = await _proposed(request)
notes = []
problems, unread = await selfconfig.problems_and_ranges(
options, controller.hass)
options, controller.hass, notes=notes)
by_key, unanchored = problems_by_field(problems, _keys_of(options))
# 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())
payload = {"problems": problems, "by_key": by_key,
"unanchored": unanchored}
"unanchored": unanchored,
"notes": [n._asdict() for n in notes]}
if unread:
payload["device_note"] = DEVICE_NOTE % ", ".join(sorted(set(unread)))
return web.json_response(payload)
@@ -290,6 +257,12 @@ def build_app(controller, selfcfg=None) -> web.Application:
hint=messages.hint("restart_requested"))
return web.json_response({"ok": True})
async def ha_entities(request):
"""UX-13: Home Assistant's entities for the picker. A read only, and
never an error: when Home Assistant is down the list says so."""
kind = str(request.query.get("kind", "") or "")[:16]
return web.json_response(await entities.payload(kind))
app = web.Application()
app.add_routes([
web.get("/", index),
@@ -300,6 +273,7 @@ def build_app(controller, selfcfg=None) -> web.Application:
web.post("/config/validate", config_validate),
web.post("/config/save", config_save),
web.post("/config/restart", config_restart),
web.get("/ha/entities", ha_entities),
])
return app
@@ -0,0 +1,73 @@
{
"arbitrage_enabled": true,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": false,
"batt_entity": "sensor.test23_battery_power",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "ha_signed",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "sensor.test23_p1_meter_active_power",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 3500,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "generic_dayahead",
"price_taxes_eur_kwh": 0.06,
"price_timeout_s": 10,
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "number.test23_setpoint_w",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "sensor.test23_soc",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
@@ -0,0 +1,73 @@
{
"arbitrage_enabled": false,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": false,
"batt_entity": "",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "off",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 3500,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "off",
"price_taxes_eur_kwh": 0.06,
"price_timeout_s": 10,
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
@@ -0,0 +1,98 @@
{
"arbitrage_enabled": false,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": false,
"batt_entity": "sensor.test23_battery_power",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [
{
"batt_entity": "sensor.goodwe_a_battery_power",
"bms_soc_floor": 10,
"capacity_kwh": 15.36,
"max_w": 5000,
"name": "inverter-a",
"setpoint_entity": "number.goodwe_a_setpoint_w",
"soc_entity": "sensor.goodwe_a_soc"
},
{
"batt_entity": "sensor.goodwe_b_battery_power",
"bms_soc_floor": 10,
"capacity_kwh": 20.48,
"max_w": 4600,
"name": "inverter-b",
"setpoint_entity": "number.goodwe_b_setpoint_w",
"soc_entity": "sensor.goodwe_b_soc"
}
],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "ha_signed",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "sensor.test23_p1_meter_active_power",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 3500,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_export_formula": "",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_formula": "",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "generic_dayahead",
"price_taxes_eur_kwh": 0.06,
"price_taxes_profile": "",
"price_timeout_s": 10,
"pv_entities": [],
"pv_entity": "",
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "",
"shadow_mode": "auto",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "sensor.test23_soc",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
@@ -0,0 +1,73 @@
{
"arbitrage_enabled": false,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": false,
"batt_entity": "sensor.test23_battery_power",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "ha_signed",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "sensor.test23_p1_meter_active_power",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 3500,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "https://api.energy-charts.info/price?bzn=BE",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "generic_dayahead",
"price_taxes_eur_kwh": 0.06,
"price_timeout_s": 10,
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "number.test23_setpoint_w",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "sensor.test23_soc",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
@@ -0,0 +1,73 @@
{
"arbitrage_enabled": true,
"arbitrage_soc_ceiling": 95,
"arbitrage_soc_floor": 20,
"auto_start": true,
"batt_entity": "sensor.test23_battery_power",
"batt_invert": false,
"bms_soc_floor": 10,
"circulation_hold_s": 75,
"circulation_w": 700,
"cycle_cost_threshold_eur": 0.1,
"cycle_floor_s": 15,
"deadband_w": 15,
"delivery_hold_s": 185,
"estop_fitted": false,
"export_price_eur_kwh": 0.04,
"gain": 0.6,
"heartbeat_s": 10,
"integrator_max_w": 0,
"inverters": [],
"log_level": "info",
"maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000,
"maintenance_enabled": false,
"maintenance_hold_min": 120,
"maintenance_interval_days": 28,
"maintenance_soc_floor": 11,
"maintenance_soc_target": 99,
"maintenance_start_hour": 10,
"max_w": 2000,
"meter_entity": "sensor.p1_meter_active_power",
"meter_freeze_s": 300,
"meter_invert": false,
"meter_max_age_s": 30,
"meter_mqtt_topic": "",
"meter_phases": 1,
"meter_poll_s": 5,
"meter_source": "ha_signed",
"p1_export_entity": "",
"p1_host": "",
"p1_import_entity": "",
"p1_net_entity": "sensor.test23_p1_meter_active_power",
"p1_phase_export_entities": [],
"p1_phase_import_entities": [],
"p1_phase_net_entities": [],
"peak_cap_w": 15000,
"peak_forecast_entity": "",
"peak_soc_floor": 20,
"price_avg_entity": "",
"price_csv_path": "/share/goodwe_prices.csv",
"price_feed_url": "",
"price_horizon_h": 48,
"price_markup_eur_kwh": 0.03,
"price_now_entity": "",
"price_provider": "manual_csv",
"price_taxes_eur_kwh": 0.06,
"price_timeout_s": 10,
"saturation_cycles": 3,
"saturation_w": 500,
"setpoint_entity": "number.test23_setpoint_w",
"sign_disagree_s": 180,
"sign_disagree_w": 300,
"site_sign_disagree_s": 325,
"site_sign_disagree_w": 300,
"slew_w": 0,
"soc_entity": "sensor.test23_soc",
"stale_input_s": 15,
"static_profile": "",
"step_w": 10,
"target_grid_w": -10,
"unit_sign_disagree_s": 85,
"unit_sign_disagree_w": 250
}
+8 -4
View File
@@ -149,7 +149,7 @@ check("...and `source` is still the first positional argument, which is what "
Claim.limit("device", P_SAFETY, -1, 1).source == "device")
print()
print("ARB-03 2. the split: eleven sources, each classified where it is BUILT")
print("ARB-03 2. the split: twelve sources, each classified where it is BUILT")
# The classification is graded against what each claim is ABOUT, restated here
# so that the table in arbiter.py and the expectation in a test are not the same
# sentence read twice. test_peakguard.py part 10f pins this set against main.py's
@@ -189,16 +189,20 @@ UNIT_SOURCES = {
"is added, `peak_soc_floor` is not consulted, the SITE target "
"is untouched, and main.py does not file it at all at a "
"roster of one",
# SAFETY-73.
"refused": "THIS battery's own reading gave nothing against THIS unit's "
"own standing command, so that direction is closed on THIS "
"unit and its share goes to the others. Filed at N>1 only",
}
for _src, _why in sorted(SITE_SOURCES.items()):
print(f" site {_src:<11} {_why}")
for _src, _why in sorted(UNIT_SOURCES.items()):
print(f" unit {_src:<11} {_why}")
check("all ELEVEN claim sources are classified, and only those eleven",
check("all TWELVE claim sources are classified, and only those twelve",
set(CLAIM_SCOPE) == set(SITE_SOURCES) | set(UNIT_SOURCES))
check("the six that are about the GRID CONNECTION are site claims",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE} == set(SITE_SOURCES))
check("the five that are about a PACK, a DEVICE or one write are per-inverter",
check("the six that are about a PACK, a DEVICE or one write are per-inverter",
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT} == set(UNIT_SOURCES))
check("a source nobody classified cannot be routed at all - actuator_for() "
"raises rather than defaulting to the site, because defaulting to the "
@@ -216,7 +220,7 @@ check("with one inverter every source routes to the site, per-inverter ones "
all(actuator_for(s, "inverter-a", ONE) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("...and with none configured at all, likewise",
all(actuator_for(s, "inverter-a", ()) == ACTUATOR_SITE for s in CLAIM_SCOPE))
check("with TWO the identity breaks and the split becomes real: the five "
check("with TWO the identity breaks and the split becomes real: the six "
"per-inverter sources route to the unit this controller drives, the six "
"site sources stay on the grid connection",
{s: actuator_for(s, "inverter-a", TWO) for s in CLAIM_SCOPE}
+12 -3
View File
@@ -497,8 +497,13 @@ check(f"AC 1: NOT ONE of the {len(emitted)} values that reached the device "
# largest emitted value is what it is and not 5000 W. The clamp is what makes it
# bounded; the freeze is what makes it small. A check that read
# `max(emitted) == max_w` would have been asserting the freeze, not the clamp.
# SAFETY-52 re-baseline: an inverter reporting 0 W for SETTLE_CYCLES is a
# battery that refuses, so the command then drops to 0 W. The freeze still
# engaged first, and the event log says so.
check(f"...and the saturation freeze is what pins it at {max(emitted):,.0f} W "
f"rather than the rail", ctl_r.sat_count >= ctl_r.tuning.saturation_cycles)
f"rather than the rail",
any("saturation freeze" in e for e in ctl_r.events)
and max(emitted) < LIVE_MAX_W)
notes_r = clamp_notes(hass_r)
print(f" the loop clamped on {ctl_r.clamp_wd.engagements} of {60} cycles, "
@@ -580,8 +585,12 @@ check("UX-07 item 1: during a bound episode an earlier over-rating count is "
# ⚠️ NOT A TRIP. 11.2's action is "clamp and alarm"; the loop keeps running and
# keeps writing the clamped value. If a later cut changes that it must come here.
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W, not 0 W",
ctl_r.target != 0.0 and ctl_r.target == hass_r.writes[-1])
# SAFETY-52: the loop itself now asks 0 W of an inverter that gives nothing,
# with its own reason. That is the loop's decision, not a stop.
check(f"it is NOT a trip - the command stands at {ctl_r.target:+.0f} W because "
f"the loop says '{ctl_r.loop_reason}', not because of a stop",
ctl_r.loop_reason == "battery refuses"
and ctl_r.target == hass_r.writes[-1])
check("...and the controller still reports itself running, not failsafe",
ctl_r.auto and not ctl_r.sign_wd.tripped)
+18 -4
View File
@@ -33,7 +33,7 @@ from datetime import datetime, timedelta, timezone
from named_absence import given, present
from app import messages
from app import control, messages
from app.control import Tuning
from app.delivery import (
DELIVERY_OPTION_DEFAULTS, DELIVERY_SHARE_FRACTION, REASON_CODE,
@@ -593,6 +593,15 @@ check("...and the SHARES now differ, which is OPT-09 and not a weakening of the
"grades the ALARM, and neither can do the other's half",
_pw != _pf
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
# SAFETY-73. A unit that gives < 100 W while asked > 200 W is refused after
# SETTLE_CYCLES cycles that also span REFUSED_SPAN_S (22 s). This fixture's 14
# cycles are 0.2 s apart, so the wedged B meets the count and not the span, and
# SAFETY-42's report stands alone here. test_refusal_units.py grades the
# refusal. On a site the 185 s hold puts the refusal first.
check("SAFETY-73: the wedged arm's B is not refused in this fixture: 12 cycles "
"of evidence in under 3 s are not the 22 s a refusal needs",
_cw.inverters[1].refused == 0
and _cw.inverters[1].ref_count >= control.SETTLE_CYCLES)
check("...and the state of charge is still what tells them apart",
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
@@ -1147,8 +1156,10 @@ async def _one():
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "max_w": 2000, "gain": 0.6,
# ⚠️ THE SAME 1 s AS THE TWO-INVERTER FIXTURE, AND THAT IS WHAT
# MAKES THIS SECTION NON-VACUOUS. The run below is 2 s of wall clock
# against a 1 s window, so a hold is not what stops this firing -
# MAKES THIS SECTION NON-VACUOUS. The run below is 1.1 s of wall
# clock against a 1 s window (SAFETY-52 shortened it from 2 s, and
# "the window was never even opened" below is what still makes it
# non-vacuous), so a hold is not what stops this firing -
# the reduction is. With a shipped-length hold "nothing was
# reported" would be true of a fixture that simply ran out of time.
"bms_soc_floor": _FLOOR_PCT, "delivery_hold_s": 1,
@@ -1158,7 +1169,10 @@ async def _one():
c.inverters[0].dev_min, c.inverters[0].dev_max = -2000.0, 2000.0
c.auto = True
hass.ctrl = c
await _drive(c, n=20, dt=0.1)
# SAFETY-52: from SETTLE_CYCLES of delivering nothing the loop asks 0 W
# (the battery refuses), so the run stops just before that. 11 cycles is
# still 1.1 s against the 1 s hold.
await _drive(c, n=11, dt=0.1)
return c, hass, store
+227
View File
@@ -0,0 +1,227 @@
"""UX-13 P0: the entity list for the picker. `python3 test_haentities.py`
`GET ha/entities` reads Home Assistant's states through the add-on's own
session (hass.states()) and must never fail the page: part 1 drives the real
HomeAssistant client against a local server that answers 502, garbage, the
wrong shape, nothing at all, or too slowly. Part 2 checks the 30 s cache and
what is served while Home Assistant is down. Part 3 serves the real route.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import json
import sys
import time
from aiohttp import ClientSession, web as aioweb
from app import hass as H, haentities as E, web
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
STATES = [
{"entity_id": "sensor.grid", "state": "812",
"attributes": {"friendly_name": "Grid power", "unit_of_measurement": "W",
"device_class": "power"}},
{"entity_id": "sensor.soc", "state": "54",
"attributes": {"friendly_name": "Battery level", "unit_of_measurement": "%",
"device_class": "battery"}},
{"entity_id": "number.setpoint", "state": "0", "attributes": {}},
{"entity_id": "sensor.pv_kw", "state": "1.2",
"attributes": {"unit_of_measurement": "kW"}},
{"no_entity_id": True},
]
async def serve(handler):
app = aioweb.Application()
app.router.add_get("/api/states", handler)
runner = aioweb.AppRunner(app)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
return runner, site._server.sockets[0].getsockname()[1]
async def read_with(handler, timeout_s=10):
runner, port = await serve(handler)
H.CORE_API, H.STATES_TIMEOUT_S = f"http://127.0.0.1:{port}/api", timeout_s
try:
async with ClientSession() as session:
t0 = time.monotonic()
got = await H.HomeAssistant(session, "token").states()
read_with.took = time.monotonic() - t0
return got
finally:
await runner.cleanup()
async def ok(_r):
return aioweb.json_response(STATES)
async def bad_gateway(_r):
return aioweb.Response(status=502)
async def garbage(_r):
return aioweb.Response(text="<html>", content_type="application/json")
async def not_a_list(_r):
return aioweb.json_response({"message": "hi"})
async def slow(_r):
await asyncio.sleep(2)
return aioweb.json_response(STATES)
async def closed_port():
H.CORE_API = "http://127.0.0.1:9/api"
async with ClientSession() as session:
return await H.HomeAssistant(session, "token").states()
print("1. hass.states() answers None, never an exception")
check("a normal answer is the state list", asyncio.run(read_with(ok)) == STATES)
for name, handler in (("502 while HA restarts", bad_gateway),
("a body that is not JSON", garbage),
("JSON that is not a list", not_a_list)):
check(f"{name} -> None", asyncio.run(read_with(handler)) is None)
check("an answer slower than the timeout -> None, at the timeout",
asyncio.run(read_with(slow, timeout_s=0.3)) is None
and read_with.took < 1.0)
check("no server at all -> None", asyncio.run(closed_port()) is None)
check("the timeout for the list is the same 10 s as every other read",
"STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read())
class FakeHass:
def __init__(self):
self.answer, self.reads = STATES, 0
async def states(self):
self.reads += 1
await asyncio.sleep(0.01)
return self.answer
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
print("\n2. the cache, and Home Assistant being down")
_h, _c = FakeHass(), Clock()
_cache = E.EntityCache(_h, clock=_c)
async def burst():
return await asyncio.gather(*(_cache.payload("power") for _ in range(5)))
_p = asyncio.run(burst())
check("five pickers opening at once cost one read of Home Assistant",
_h.reads == 1 and all(x == _p[0] for x in _p))
_ids = [r["entity_id"] for r in _p[0]["entities"]]
check("power entities come first, then the rest, and a broken state is left "
f"out ({_ids})", _ids == ["sensor.grid", "sensor.pv_kw",
"number.setpoint", "sensor.soc"]
and [r["match"] for r in _p[0]["entities"]] == [True, True, False, False])
check("each row carries id, name, state, unit and device class",
_p[0]["entities"][0] == {"entity_id": "sensor.grid", "name": "Grid power",
"state": "812", "unit": "W",
"device_class": "power", "match": True})
check("the kinds filter as the pickers need",
[r["entity_id"] for r in _p[0]["entities"]
if E.matches(r, "pct")] == ["sensor.soc"]
and [r["entity_id"] for r in _p[0]["entities"]
if E.matches(r, "number")] == ["number.setpoint"])
_c.t += 10
asyncio.run(_cache.payload())
check("within 30 s the cached list is served", _h.reads == 1)
check("peek() hands the validator the fresh list without a read",
set(_cache.peek()) == set(_ids) and _h.reads == 1)
_h.answer = None
_c.t += 30
_down = asyncio.run(_cache.payload())
check("Home Assistant down after a good read: the old list, marked old",
_h.reads == 2 and _down["available"] and _down["text"] == E.STALE_TEXT
and len(_down["entities"]) == 4 and _down["age_s"] >= 30)
check("...and peek() then gives nothing, so no warning rests on an old list",
_cache.peek() is None)
async def down_windows():
"""One loop throughout: a contended asyncio.Lock binds to its loop."""
hass, clock = FakeHass(), Clock()
hass.answer = None
cache = E.EntityCache(hass, clock=clock)
counts = []
for step in (0, 1, 30):
clock.t += step
await asyncio.gather(*(cache.payload() for _ in range(5)))
counts.append(hass.reads)
return counts
check("five polls while Home Assistant is down cost one read, the polls just "
"after wait for the next window, and that window tries once",
asyncio.run(down_windows()) == [1, 1, 2])
_h2 = FakeHass()
_h2.answer = None
_empty = asyncio.run(E.EntityCache(_h2).payload("power"))
check("Home Assistant down from the start: an empty list that says why",
_empty == {"available": False, "text": E.DOWN_TEXT, "entities": []})
print("\n3. the route")
class FakeController:
def __init__(self, hass):
self.hass, self.o = hass, {}
async def route(hass, query=""):
app = web.build_app(FakeController(hass), selfcfg=object())
runner = aioweb.AppRunner(app)
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
try:
async with ClientSession() as s:
async with s.get(f"http://127.0.0.1:{port}/ha/entities{query}") as r:
return r.status, await r.json()
finally:
await runner.cleanup()
_st, _body = asyncio.run(route(FakeHass(), "?kind=pct"))
check("GET ha/entities?kind=pct answers 200 with the matching entity first",
_st == 200 and _body["entities"][0]["entity_id"] == "sensor.soc")
_st, _body = asyncio.run(route(_h2))
check("with Home Assistant down it still answers 200, with the reason",
_st == 200 and _body["available"] is False and _body["entities"] == [])
check("the words shown are short and plain (STYLE: one sentence)",
all(len(t.split()) <= 12 and t.count(".") == 1
for t in (E.DOWN_TEXT, E.STALE_TEXT)))
print(json.dumps(_body))
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+583
View File
@@ -0,0 +1,583 @@
"""UX-13 P1: the Home screen's `/status` and the page shell. `python3 -B test_home.py`
1. `/status` keeps every old field (`banner`, `level`, `rows`, `checks`,
`hint`, `events`) AND carries the structured half the Home screen reads:
`state`, `now`, `batteries`, `plan`, `attention`, `month_peak`, `control`.
Both are pinned here, by name and shape.
2. The plan shows only what the add-on does today. Trading on price is the
verdict the control cycle would reach (the same `plan.arbitrage()` call),
drawn where the cheapest and dearest prices are, with kWh and € per block.
The monthly cycle, the peak limit and "follows the house" are the rest.
3. Shadow mode says "would", a stopped site plans nothing, and a red row is an
alarm under "Needs attention".
4. A fault in the Home view never costs the page its old rows.
5. The page: served at `/` from app/static, no external file, relative URLs
only, every label resolves in app/ui_text, and the region year reaches
`config/model` for the P2 Settings screens.
The STYLE scan of every Home sentence is test_messages.py part 8.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import asyncio
import io
import json
import os
import re
import sys
from datetime import datetime, timedelta, timezone
from aiohttp import ClientSession, web as aioweb
from app import homeview, web
from app.main import Controller
from app.peakguard import PeakDecision
from app.price import PricePoint, PriceProduct, PriceRead
from app.static_tariff import TariffPlan
from app.uimodel import REGION_PRESETS
from named_absence import field, nth, present
FLANDERS = REGION_PRESETS["flanders"]["taxes_eur_kwh"]
HERE = os.path.dirname(os.path.abspath(__file__))
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
# --------------------------------------------------------------------------- #
# Fixtures: a real Controller, with its readings set directly.
# --------------------------------------------------------------------------- #
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}
TWO = dict(ONE, inverters=[
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
"capacity_kwh": 9.6},
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
"capacity_kwh": 5.4}])
# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day.
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc)
MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
.16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
auto=True):
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
c.auto = auto
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 = grid
return c
def provider_plan(prices=MOCK, day0=DAY0, days=2, provisional_from=None):
pts = []
for d in range(days):
for k, p in enumerate(prices):
start = day0 + timedelta(days=d, hours=k)
pts.append(PricePoint(start, p, provisional_from is not None
and start >= provisional_from))
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
"generic_dayahead", NOW), None,
"provider")
def quiet_guard(projected_w=1240.0):
return PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
ceiling_from="cap", projected_w=projected_w)
def status(c, now=NOW):
s = c.status_payload()
# The status route builds at wall-clock time; rebuild the structured half
# at the fixture's instant so the day and the hours are the mockup's.
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=now))
return s
# --------------------------------------------------------------------------- #
print("1. /status: the old fields stay, the structured half is added")
# --------------------------------------------------------------------------- #
c1 = make(ONE, soc=(62.0,), batt=(960.0,), grid=800.0)
s1 = c1.status_payload()
OLD = {"banner", "level", "rows", "checks", "hint", "events"}
NEW = {"state", "now", "batteries", "plan", "attention", "month_peak",
"control"}
check(f"every old field is still there, with its old type ({sorted(OLD)})",
OLD <= set(s1) and isinstance(s1["rows"], list)
and all(len(r) == 2 for r in s1["rows"])
and all({"ok", "warn", "text"} <= set(r) for r in s1["checks"])
and isinstance(s1["banner"], str) and s1["level"] in ("ok", "warn", "bad")
and isinstance(s1["events"], list))
check(f"...and the structured half is added beside them ({sorted(NEW)})",
NEW <= set(s1) and "home_error" not in s1)
check("...and it is JSON, as the route serves it", json.loads(json.dumps(s1))
== json.loads(json.dumps(s1)))
check("`state` names the kind, the pill word, the control row and a sub-line",
set(s1["state"]) == {"kind", "pill", "ran", "sub"}
and s1["state"]["kind"] == "running")
check("`now` carries the four tiles in W and a plain line for each",
{"solar_w", "house_w", "grid_w", "batt_w", "has_solar", "lines"}
<= set(s1["now"])
and set(s1["now"]["lines"]) == {"solar", "house", "grid", "batt"}
and s1["now"]["grid_w"] == 800.0 and s1["now"]["batt_w"] == 960.0)
_nh = homeview.now_block(grid_w=-3000.0, pv_w=1000.0, batt_w=1500.0,
has_solar=True, shadow=False)
_nz = homeview.now_block(grid_w=-1030.0, pv_w=1000.0, batt_w=0.0,
has_solar=True, shadow=False)
_np = homeview.now_block(grid_w=800.0, pv_w=1000.0, batt_w=-300.0,
has_solar=True, shadow=False)
check("House = grid (+ import) + solar + battery (+ giving): 800 + 1000 - 300 "
"= 1500 W",
_np["house_w"] == 1500.0 and _np["lines"]["house"] == "Using")
check("...a sum below zero is not shown as a positive figure: 0 W inside "
"the idle band, and no figure beyond it",
_nz["house_w"] == 0.0 and _nh["house_w"] is None
and _nh["lines"]["house"] == homeview.tile_house(None, True, True))
check("...and with no solar reading the house figure is NOT invented: it is "
"None and the tile says why",
s1["now"]["house_w"] is None and s1["now"]["has_solar"] is False
and s1["now"]["lines"]["house"] == homeview.tile_house(None, False))
_b = s1["batteries"][0]
check("`batteries[]` has level, power, state, mode, the now line and why",
{"name", "level_pct", "power_w", "state", "mode", "now", "why",
"capacity_kwh", "stored_kwh", "maintenance"} <= set(_b)
and _b["level_pct"] == 62.0 and _b["state"] == "giving"
and _b["now"] == "Giving 1.0 kW now.")
_p = s1["plan"]
check("`plan` carries the verdict, the prices, the runs, the lines and "
"the region",
{"verdict", "head", "prices", "runs", "lines", "now_h", "hours",
"price_line", "tomorrow_line", "region", "region_line"} <= set(_p)
and len(_p["prices"]) in (23, 24, 25))
check("`attention[]` has severity, title, body and docs; `month_peak` and "
"`control` are there",
all({"severity", "title", "body", "docs"} <= set(a)
for a in s1["attention"])
and {"month", "limit_w", "peak_w", "pct", "period_w", "state", "line"}
<= set(s1["month_peak"])
and {"running", "shadow", "text", "maintenance", "docs_url"}
<= set(s1["control"]))
# --------------------------------------------------------------------------- #
print("\n2. the plan is what the add-on actually does")
# --------------------------------------------------------------------------- #
s_house = status(make(TWO))
check("with trading off and no cycle due, each battery follows the house "
"from now to the end of the day, and nothing else is drawn",
[r["kind"] for r in s_house["plan"]["runs"]] == ["house", "house"]
and s_house["plan"]["verdict_key"] == "house"
and all(r["end_h"] == 24.0 and abs(r["start_h"] - 11 - 40 / 60) < 0.01
for r in s_house["plan"]["runs"])
and s_house["plan"]["runs"][0]["to"] == "24:00")
ARB = dict(TWO, arbitrage_enabled=True, cycle_cost_threshold_eur=0.05)
c_arb = make(ARB)
c_arb.tariff_plan, c_arb.tariff_at = provider_plan(), NOW
c_arb.peak_guard = quiet_guard()
s_arb = status(c_arb)
_runs = s_arb["plan"]["runs"]
_grid = [r for r in _runs if r["kind"] == "grid"]
_give = [r for r in _runs if r["kind"] == "give"]
check("trading on price: a charge block at the cheapest hour (13:00) and a "
"give block at the dearest (19:00), on each battery",
[(r["battery"], r["from"], r["to"]) for r in _grid]
== [(0, "13:00", "14:00"), (1, "13:00", "14:00")]
and [(r["battery"], r["from"], r["to"]) for r in _give]
== [(0, "19:00", "20:00"), (1, "19:00", "20:00")]
and s_arb["plan"]["verdict_key"] == "trade")
check("...placed where the control cycle's own verdict puts them: the same "
"plan.arbitrage() call on the same plan",
c_arb.fresh_tariff_plan(NOW).arbitrage(
peak_guard_active=False, min_spread_eur_kwh=0.05,
uncertainty=frozenset({__import__("app.price", fromlist=["x"])
.Uncertainty.NO_WEATHER}))
.match(allowed=lambda v: (v.cheapest.start, v.dearest.start),
refused=lambda v: None)
== (DAY0 + timedelta(hours=13), DAY0 + timedelta(hours=19)))
# Battery A: 2.5 kW for 1 h = 2.5 kWh; room to the 95 % ceiling is 33 % of
# 9.6 kWh = 3.17 kWh, so 2.5. Battery B: 60 % of 5.4 = 3.24, so 2.5.
check("each block carries kWh and €: charging is capped by the battery's own "
"rating and the room its level leaves, € = kWh x that hour's price",
[r["kwh"] for r in _grid] == [2.5, 2.5]
and [r["eur"] for r in _grid] == [0.35, 0.35]
and [r["eur_kwh"] for r in _grid] == [0.14, 0.14]
and "2.5 kWh" in _grid[0]["what"] and "€0.14" in _grid[0]["why"]
and "€0.35" in _grid[0]["amount"])
# Giving at 19:00 comes after the 13:00 charge, so each battery may give what
# it will hold then: B is at 35 % + 2.5 kWh of 5.4 = 81 %, and 81 - 20 = 61 %
# of 5.4 = 3.3 kWh, so 2.5 (an upper bound, like every amount here).
check("...and giving is capped by what each battery holds above arbitrage's "
"floor after the charge before it",
[r["kwh"] for r in _give] == [2.5, 2.5]
and [r["eur"] for r in _give] == [0.9, 0.9])
def trade(soc, prices=MOCK, provisional_from=None, now=NOW, opts=ARB):
c = make(opts, soc=soc)
c.tariff_plan = provider_plan(prices, provisional_from=provisional_from)
c.tariff_at, c.peak_guard = now, quiet_guard()
s = status(c, now=now)
return ([(r["kind"], r["battery"], r["kwh"]) for r in s["plan"]["runs"]
if r["kind"] in ("grid", "give")], s["plan"]["lines"])
# Review of #144: only what arbitrage.decide() would still do is drawn.
_t, _l = trade((96.0, 35.0))
check("a battery at arbitrage's ceiling gets no charge block, not "
"'Charges up to 0.0 kWh'", ("grid", 0, 0.0) not in _t
and [k for k in _t if k[0] == "grid"] == [("grid", 1, 2.5)])
# 20:00 local: the dearest (19:00) is over; tomorrow's charge is a line. Put
# the dearest later today instead, with the emptiest pack at the floor.
_late = MOCK[:22] + [.40, .24]
_t, _l = trade((62.0, 20.0), prices=_late, now=NOW + timedelta(hours=8))
_t2, _l2 = trade((62.0, 35.0), prices=_late, now=NOW + timedelta(hours=8))
check("no give block while the emptiest battery is at arbitrage's floor: "
"the cycle's sale is graded on the emptiest pack (at 35 % both give)",
not [k for k in _t if k[0] == "give"]
and [k[:2] for k in _t2] == [("give", 0), ("give", 1)])
_t, _l = trade((62.0, 35.0), prices=[x - 0.5 for x in MOCK])
check("no give block at a negative price (the cycle refuses that sale)",
not [k for k in _t if k[0] == "give"]
and [k for k in _t if k[0] == "grid"])
# Running, a missing level is a red row and the site is held. In shadow mode
# the plan is still drawn, so this is where the level gate shows.
_t, _l = trade((None, 35.0), opts=dict(ARB, shadow_mode="on"))
check("with a battery level unknown the cycle does not trade, so nothing is "
"drawn and a line says why (shadow mode, which still plans)",
_t == [] and homeview.line_no_trade("no_level") in _l)
_t, _l = trade((62.0, 35.0), provisional_from=DAY0)
check("a plan with no published prices yet says so, not 'the price gap is "
"too small'",
_t == [] and homeview.line_no_trade("not_firm") in _l
and homeview.line_no_trade("not_worth") not in _l)
check("the price bars are today's 24 hours, from the real series, with the "
"cheap and expensive thirds marked",
[b["eur_kwh"] for b in s_arb["plan"]["prices"]] == MOCK
and s_arb["plan"]["prices"][13]["kind"] == "cheap"
and s_arb["plan"]["prices"][19]["kind"] == "dear"
and s_arb["plan"]["prices"][0]["kind"] == "mid"
and s_arb["plan"]["prices"][13]["label"] == "13:00")
check("the price line names the price now, today's cheapest and dearest, and "
"that tomorrow's prices are in",
s_arb["plan"]["price_line"] == ("Price now €0.16 per kWh, all in. "
"Today: cheapest €0.14 at 13:00, "
"dearest €0.36 at 19:00.")
and s_arb["plan"]["tomorrow_line"] == "Tomorrow's prices are in.")
c_late = make(ARB)
c_late.tariff_plan, c_late.tariff_at = provider_plan(), NOW
c_late.peak_guard = quiet_guard()
s_late = status(c_late, now=NOW + timedelta(hours=9)) # 20:40 local
check("a block whose hour is over is not drawn: at 20:40 no trade is left "
"today",
not [r for r in s_late["plan"]["runs"] if r["kind"] in ("grid", "give")])
c_peak = make(ARB)
c_peak.tariff_plan, c_peak.tariff_at = provider_plan(), NOW
c_peak.peak_guard = PeakDecision(engaged=True, floor_w=800.0,
ceiling_w=3500.0, ceiling_from="cap",
projected_w=3900.0)
s_peak = status(c_peak)
check("while the peak limit holds, the verdict refuses as the cycle does: no "
"trade block, a plain line, and a peak block for this 15-minute period",
not [r for r in s_peak["plan"]["runs"] if r["kind"] in ("grid", "give")]
and homeview.line_no_trade("peak") in s_peak["plan"]["lines"]
and [(r["from"], r["to"]) for r in s_peak["plan"]["runs"]
if r["kind"] == "peak"] == [("11:30", "11:45"), ("11:30", "11:45")])
c_pk = make(ARB)
c_pk.p1_enabled = True # the peak line is shown where P1 runs
c_pk.tariff_plan, c_pk.tariff_at = provider_plan(), NOW
c_pk.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=4200.0,
ceiling_from="month_peak", projected_w=1240.0)
s_pk = status(c_pk)
check("the plan's peak line names the guard's ceiling, which is this month's "
"peak once it is above the limit",
homeview.line_peak_limit(4200.0, False) in s_pk["plan"]["lines"]
and s_pk["month_peak"]["line"].endswith("above 4.2 kW."))
c_blind = make(ARB)
c_blind.tariff_plan, c_blind.tariff_at = provider_plan(), NOW
s_blind = status(c_blind) # meter_source off: the guard never runs
check("with nothing measuring the 15-minute peak, trading is NOT drawn, as "
"the cycle refuses it (guard state None)",
not [r for r in s_blind["plan"]["runs"] if r["kind"] in ("grid", "give")]
and homeview.line_no_trade("not_covered") in s_blind["plan"]["lines"])
c_flat = make(ARB)
c_flat.tariff_plan, c_flat.tariff_at = provider_plan(prices=[.2] * 24), NOW
c_flat.peak_guard = quiet_guard()
s_flat = status(c_flat)
check("a flat day: no trade, no cheap or expensive hour marked",
not [r for r in s_flat["plan"]["runs"] if r["kind"] in ("grid", "give")]
and {b["kind"] for b in s_flat["plan"]["prices"]} == {"mid"}
and homeview.line_no_trade("not_worth") in s_flat["plan"]["lines"])
c_old = make(ARB)
c_old.tariff_plan = provider_plan()
c_old.tariff_at = NOW - timedelta(hours=3) # past its max age
c_old.peak_guard = quiet_guard()
s_old = status(c_old)
check("a stale plan is not traded on by the cycle, so it is not drawn",
not [r for r in s_old["plan"]["runs"] if r["kind"] in ("grid", "give")])
MAINT = dict(TWO, maintenance_enabled=True, maintenance_start_hour=12)
c_m = make(MAINT)
s_m = status(c_m)
_cyc = [r for r in s_m["plan"]["runs"] if r["kind"] == "cycle"]
check("a monthly cycle due today is drawn on ONE battery, at the start hour "
"the cycle uses (12 UTC is 14:00 here), and the other one waits",
len(_cyc) == 1 and field(nth(_cyc), "from") == "14:00"
and s_m["plan"]["verdict_key"] == "cycle"
and any("waits its turn" in x for x in s_m["plan"]["lines"]))
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)
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 "
"so",
[r["from"] for r in s_md["plan"]["runs"] if r["kind"] == "cycle"]
== ["11:40"]
and s_md["batteries"][0]["now"] == "Monthly cycle: emptying."
and any("Battery B waits its turn" in x for x in s_md["plan"]["lines"]))
# --------------------------------------------------------------------------- #
print("\n3. shadow mode, a stopped site, an alarm")
# --------------------------------------------------------------------------- #
c_sh = make(dict(ARB, shadow_mode="on"))
c_sh.tariff_plan, c_sh.tariff_at = provider_plan(), NOW
c_sh.peak_guard = quiet_guard()
s_sh = status(c_sh)
_texts = [r["what"] for r in s_sh["plan"]["runs"]] + [s_sh["plan"]["head"]]
check("shadow: the state is shadow, the battery tile is held at 0 W, and "
"every block and the head say 'would'",
s_sh["state"]["kind"] == "shadow"
and s_sh["now"]["lines"]["batt"] == "Held at 0 W"
and all("ould" in x for x in _texts)
and s_sh["plan"]["head"].startswith("Shadow mode:"))
check("...and 'Shadow mode is on' is the first item under Needs attention",
s_sh["attention"][0]["title"] == homeview.ATTN_SHADOW
and s_sh["attention"][0]["severity"] == "info")
check("...and each battery's now line says it is held at 0 W",
all(b["now"].startswith("Held at 0 W.") for b in s_sh["batteries"]))
s_stop = status(make(TWO, auto=False))
check("stopped: nothing is planned and the head says so",
s_stop["plan"]["runs"] == [] and s_stop["state"]["kind"] == "stopped"
and s_stop["plan"]["verdict_key"] == "stopped"
and s_stop["control"]["running"] is False
and all(b["now"] == "Held at 0 W while control is stopped."
for b in s_stop["batteries"]))
c_al = make(ONE, soc=(62.0,), batt=(0.0,))
c_al.inverters[0].sign_wd.tripped = True
s_al = c_al.status_payload()
_red = [a for a in s_al["attention"] if a["severity"] == "alarm"]
check("an alarm: level bad, state alarm, and the red row is the first item "
"under Needs attention",
s_al["level"] == "bad" and s_al["state"]["kind"] == "alarm"
and _red and s_al["attention"][0] is _red[0]
and _red[0]["title"] == homeview.ATTN_TITLES["direction"])
check("...and nothing is planned while every inverter is held at 0 W",
s_al["plan"]["runs"] == [] and s_al["plan"]["verdict_key"] == "held")
_rows = [r for r in s_al["checks"] if not r["ok"] and not r["warn"]]
check("...and every red, amber and settling row reaches Needs attention with "
"its own sentence as the body, and no green row does",
[a["body"] for a in homeview.attention(s_al["checks"])]
== [r["text"] for r in s_al["checks"] if not r["ok"] and not r["warn"]]
+ [r["text"] for r in s_al["checks"] if not r["ok"] and r["warn"]]
+ [r["text"] for r in s_al["checks"] if r["ok"] and r["warn"]])
# --------------------------------------------------------------------------- #
print("\n4. a fault in the Home view never costs the page its rows")
# --------------------------------------------------------------------------- #
_real = homeview.build
try:
homeview.build = lambda *a, **k: 1 / 0
s_err = make(ONE, soc=(50.0,), batt=(0.0,)).status_payload()
finally:
homeview.build = _real
check("the old fields are served and `home_error` says the rest is missing",
OLD <= set(s_err) and s_err.get("home_error") is True
and not (NEW & set(s_err)))
# --------------------------------------------------------------------------- #
print("\n5. the page")
# --------------------------------------------------------------------------- #
PAGE = web.page()
JS = io.open(os.path.join(HERE, "app", "static", "app.js"),
encoding="utf-8").read()
UI = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))["home"]
def resolve(path):
node = UI
for k in path.split("."):
if not isinstance(node, dict) or k not in node:
return None
node = node[k]
return node
check("the page is assembled: no placeholder is left and the words parse",
"/*APP_CSS*/" not in PAGE and "/*APP_JS*/" not in PAGE
and "/*UI_TEXT*/" not in PAGE
and json.loads(PAGE.split('id="ui-text">', 1)[1]
.split("</script>", 1)[0].replace("<\\/", "</"))["home"]
== UI)
check("four tabs with the four hash routes, in a keyboard tablist",
re.findall(r'data-hash="(\w+)"', PAGE) == ["home", "batteries",
"settings", "setup"]
and 'role="tablist"' in PAGE and PAGE.count('role="tabpanel"') == 4
and "ArrowRight" in JS and "ArrowLeft" in JS)
_ext = re.findall(r"""(?:https?:)?//[A-Za-z0-9.-]+\.[a-z]{2,}""", PAGE)
check(f"no external file and no web font: no URL leaves the add-on "
f"({_ext})", not _ext and "@import" not in PAGE
and "<link" not in PAGE and "src=" not in PAGE)
_abs = re.findall(r"""(?:fetch\(|href=)["'](/[^"']*)["']""", PAGE)
check(f"every fetch and link is relative, for ingress ({_abs})",
not _abs and 'fetch("status"' in JS and 'fetch("action"' in JS)
_keys = set(re.findall(r'data-t(?:-label)?="([\w.]+)"', PAGE)) | set(
re.findall(r'''\bt\("([\w.]+)"\)''', JS))
_missing = sorted(k for k in _keys if not isinstance(resolve(k), str))
check(f"every label the page asks for is in app/ui_text ({len(_keys)} "
f"keys; missing: {_missing})", len(_keys) > 30 and not _missing)
check("data is set as text, never as HTML, so no status sentence can inject "
"markup", "innerHTML" not in JS and "insertAdjacentHTML" not in JS)
check("every function of the old page is still on it: start/stop, the "
"maintenance start and abort, the status rows, the checks and the "
"event line",
"auto_toggle" in JS and '"maint_start"' in JS and '"maint_abort"' in JS
and 'id="rows"' in PAGE and 'id="checks"' in PAGE
and 'id="hint"' in PAGE and 'href="config"' in PAGE)
check("stopping, starting and both maintenance actions ask first",
all(f'ask("{k}"' in JS for k in ("stop", "start", "maint_start",
"maint_abort")))
check("tooltips open on hover, on a tap and on keyboard focus",
"pointermove" in JS and "pointerdown" in JS and "focusin" in JS)
check("the chart is not colour alone: expensive hours and blocks are "
"patterned, and every item has a text label",
"repeating-linear-gradient" in PAGE and '"aria-label": tipText' in JS)
check("the phone layout puts the tabs at the bottom under 640 px, and dark "
"mode follows the system",
"@media (max-width:640px)" in PAGE and "position:fixed;bottom:0" in PAGE
and "prefers-color-scheme:dark" in PAGE)
async def _serve():
class _Sup:
async def info(self):
return {"options": {"price_taxes_eur_kwh": FLANDERS,
"price_taxes_profile": ""},
"schema": [], "translations": {}}
c = make(ONE, soc=(50.0,), batt=(0.0,))
runner = aioweb.AppRunner(web.build_app(c, _Sup()))
await runner.setup()
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
out = {}
try:
async with ClientSession() as s:
async with s.get(f"http://127.0.0.1:{port}/") as r:
out["page"] = (r.status, r.content_type, await r.text())
async with s.get(f"http://127.0.0.1:{port}/status") as r:
out["status"] = await r.json()
async with s.get(f"http://127.0.0.1:{port}/config/model") as r:
out["model"] = await r.json()
finally:
await runner.cleanup()
return out
E = asyncio.run(_serve())
check("GET / serves the assembled page as HTML", E["page"][0] == 200
and E["page"][1] == "text/html" and E["page"][2] == PAGE)
check("GET status over HTTP carries both halves",
OLD <= set(E["status"]) and NEW <= set(E["status"]))
check("the levies preset and its year reach config/model for the P2 "
"Settings screens (P0 review follow-up)",
E["model"].get("region") == {"id": "flanders", "year": 2026})
c_reg = make(dict(ARB, price_taxes_eur_kwh=FLANDERS))
c_reg.tariff_plan, c_reg.tariff_at = provider_plan(), NOW
s_reg = status(c_reg)
check("...and Home names the preset with its year wherever prices come from "
"a feed, and says 'your own figure' otherwise",
s_reg["plan"]["region"] == {"id": "flanders", "year": 2026}
and s_reg["plan"]["region_line"]
== "Levies and VAT: Flanders, Fluvius Antwerpen, 2026 figures."
and s_arb["plan"]["region_line"] == "Levies and VAT: your own figure."
and s_house["plan"]["region_line"] == "")
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+89
View File
@@ -26,6 +26,7 @@ Run `python3 -B test_messages.py --violations` to print what part 3 sees.
import ast
import hashlib
import json
import os
import re
import sys
@@ -388,6 +389,9 @@ LIMITS = { # kind: (words, keys ok, entity ids ok)
"log": (25, True, True),
"option-name": (6, False, False),
"option-desc": (30, True, True),
"note": (25, True, True), # UX-13: a settings-page warning
"ui-name": (6, False, False), # UX-13: app/ui_text, a name or choice
"ui-desc": (25, False, False), # UX-13: app/ui_text, one-line help
}
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
@@ -453,6 +457,10 @@ def collect():
if arg is not None:
for n, t in strings_in(arg):
out.append(("log", f"{f.name}:{n.lineno}", t))
elif name.endswith("_advise") and node.args:
for n, t in strings_in(node.args[-1], expand=True):
if len(words(t)) > 1:
out.append(("note", f"{f.name}:{n.lineno}", t))
elif re.search(r"problems\.(append|extend)$", name):
for a in node.args:
for n, t in strings_in(a, expand=True):
@@ -475,6 +483,16 @@ def collect():
if isinstance(v.get("fields"), dict):
walk(v["fields"], f"{path}{key}.")
walk(tr.get("configuration"), "")
# UX-13: the new page's own words, held to the option texts' rule.
for f in sorted((APP / "ui_text").glob("*.json")):
def walk_ui(node, path):
for key, v in node.items():
if isinstance(v, dict):
walk_ui(v, f"{path}{key}.")
elif isinstance(v, str):
out.append(("ui-desc" if key == "description" else
"ui-name", f"{f.name}:{path}{key}", v))
walk_ui(json.loads(f.read_text(encoding="utf-8")), "")
return out
@@ -485,6 +503,8 @@ def problems_of(kind, text):
p.append(f"over {lim} words ({len(words(text))})")
if ";" in text:
p.append("semicolon")
if kind in ("note", "ui-desc") and sentences(text) > 1:
p.append(f"over 1 sentence ({sentences(text)})")
return p + text_problems(text, keys=keys, entities=ents)
@@ -1399,6 +1419,7 @@ import logging # noqa: E402
from app import hass as _hass, p1 as _p1, validate as _validate, web as _web # noqa: E402
from app.arbiter import Claim # noqa: E402
from app.main import Controller, why_key # noqa: E402
from app import control as _control # noqa: E402
from app import main as _main # noqa: E402
@@ -1585,6 +1606,11 @@ for _f in sorted(APP.glob("*.py")):
_keys = {why_key(s, [Claim.set(s, 0, 0.0, r)]) for s, r in _SOURCES}
_keys |= {why_key("failsafe", []),
why_key("failsafe", [Claim.set("loop", 0, 1.0, "tracking")])}
# SAFETY-52: the loop's claim reason is computed, so the scan cannot see it.
_keys |= {why_key("loop", [Claim.set("loop", 0, 0.0, _r)])
for _r in (_control.REFUSED, _control.PROBING)}
check("...including the loop's two battery-refuses reasons, which get words of "
"their own", {_control.REFUSED, _control.PROBING} <= _keys)
check(f"the scan found the claims ({len(_SOURCES)}), including the latched "
f"per-inverter stop the 2d test saw as 'Other'",
("failsafe", "sign disagreement") in _SOURCES and len(_SOURCES) >= 8)
@@ -1791,6 +1817,69 @@ check("the action log counts 'engagements' with a real plural and the "
and messages.count(25, "engagement") == "25 engagements"
and messages.count(1, "engagement") == "1 engagement")
# ============================================================================== #
print("\n8. the Home screen's sentences (UX-13 P1)")
# app/homeview.py builds every sentence the Home screen shows. Each is rendered
# with real values by `text_samples()` and held to STYLE.md: a title at most 5
# words, a plan line at most 2 sentences of at most 12 words each (the design
# note's new row type), any other line 1 sentence of at most 25 words.
from app import homeview # noqa: E402
PLAN_SENTENCE_WORDS, PLAN_SENTENCES = 12, 2
def home_problems(kind, text):
if kind == "title":
return title_problems(text)
out = text_problems(text, keys=False, entities=False)
if ";" in text:
out.append("semicolon")
if kind == "line":
if sentences(text) > 1:
out.append(f"over 1 sentence ({sentences(text)})")
if len(words(text)) > 25:
out.append(f"over 25 words ({len(words(text))})")
return out
if sentences(text) > PLAN_SENTENCES:
out.append(f"plan line over {PLAN_SENTENCES} sentences")
long = [x for x in re.split(r"(?<=[.!?])\s+", text)
if len(words(x)) > PLAN_SENTENCE_WORDS]
if long:
out.append(f"plan sentence over {PLAN_SENTENCE_WORDS} words "
f"({len(words(long[0]))}): {long[0]!r}")
return out
_home = homeview.text_samples()
_home_bad = [(k, t, p) for k, t in _home for p in [home_problems(k, t)] if p]
for k, t, p in _home_bad:
print(f" {k}: {t!r}: {p}")
check(f"every Home sentence obeys its STYLE limit ({len(_home)} rendered, "
f"{len(_home_bad)} over)", len(_home) > 80 and not _home_bad)
_shadowed = [t for k, t in homeview.text_samples() if "ould" in t]
check("in shadow mode the Home plan says what the add-on WOULD do: every "
"shadow variant of a plan text carries 'would'",
all("ould" in f(True) for f in (
lambda sh: homeview.run_grid_what(2.5, sh),
lambda sh: homeview.run_give_what(2.5, sh),
lambda sh: homeview.run_cycle_what("idle", sh),
lambda sh: homeview.run_cycle_why(sh),
lambda sh: homeview.run_peak_what(3500, sh),
lambda sh: homeview.run_house_what(sh),
lambda sh: homeview.run_house_why(sh),
lambda sh: homeview.plan_head("house", sh),
lambda sh: homeview.plan_head("trade", sh),
lambda sh: homeview.line_peak_limit(3500, sh)))
and not any("ould" in f(False) for f in (
lambda sh: homeview.run_grid_what(2.5, sh),
lambda sh: homeview.run_house_what(sh),
lambda sh: homeview.plan_head("house", sh)))
and len(_shadowed) > 10)
check("mutation: a 13-word plan sentence and a 6-word title are caught",
home_problems("plan", "One two three four five six seven eight nine "
"ten eleven twelve thirteen.")
and home_problems("title", "One two three four five six"))
print()
if fails:
print(f"{len(fails)} FAILED")
+15 -13
View File
@@ -2655,7 +2655,7 @@ check("...and so does the persistent notification, which outlives the page and "
# wants from you, not an edit.
CLAIM_SOURCES = {"device", "supervised", "failsafe", "safety", "peak",
"arbitrage", "maintenance", "loop", "circulation",
"sign_site", "pack"}
"sign_site", "pack", "refused"}
# The two appends that file a claim BUILT ELSEWHERE in the method. Named here so
# that a THIRD such indirection reads as an unrecognised append rather than as a
# silent gap in the scan.
@@ -2697,7 +2697,7 @@ check("every `claims.append(...)` in main.py's cycle is one this scan can READ "
"- a claim filed through a shape it cannot parse has to fail here rather "
"than be counted as nothing, which is how a scan like this goes quietly "
"vacuous", len(_appends) >= 10 and None not in _filed)
check("...and the sources that cycle can file are EXACTLY these ELEVEN. THIS "
check("...and the sources that cycle can file are EXACTLY these TWELVE. THIS "
"is what makes part 10 a tripwire: replay_loop() hand-builds the claim "
"list, so it cannot see a claim main.py adds - and a loop-side SoC "
"reserve is a new source. Filing one takes this line red on the line it "
@@ -2848,7 +2848,8 @@ check("...and the split is the one ARB-03 argued, with SAFETY-31's and "
{s for s, v in CLAIM_SCOPE.items() if v == SCOPE_SITE}
== {"loop", "peak", "arbitrage", "safety", "circulation", "sign_site"}
and {s for s, v in CLAIM_SCOPE.items() if v == SCOPE_UNIT}
== {"device", "supervised", "failsafe", "maintenance", "pack"})
== {"device", "supervised", "failsafe", "maintenance", "pack",
"refused"})
# The shipped roster is ONE inverter, so §6.2's identity holds and every claim a
# real cycle files is a site claim. Observed on the claims main.py's own resolve
@@ -2945,7 +2946,7 @@ check("⚠️ AND THIS IS THE BREAKING HALF. Give the same Controller a second "
"rest of the roster goes on pushing the same way - and this line goes red",
_routed == {"device": "inverter-a", "supervised": "inverter-a",
"failsafe": "inverter-a", "maintenance": "inverter-a",
"pack": "inverter-a",
"pack": "inverter-a", "refused": "inverter-a",
"loop": ACTUATOR_SITE, "peak": ACTUATOR_SITE,
"arbitrage": ACTUATOR_SITE, "safety": ACTUATOR_SITE,
"circulation": ACTUATOR_SITE, "sign_site": ACTUATOR_SITE})
@@ -3027,13 +3028,13 @@ _unit_sources_unrouted = [
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT and "," not in r[1]]
print(f" {len(_direct)} direct appends, each read at its own line: "
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
check("all ELEVEN of `Controller.cycle()`'s DIRECT appends route through "
check("all THIRTEEN of `Controller.cycle()`'s DIRECT appends route through "
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
"of EVERY one of them. An append that computed its actuator, or omitted "
"the argument, or hand-wrote a string, fails HERE rather than being "
"silently skipped - which is how a scan like this goes quietly vacuous, "
"and 10f is in this file because that already happened once",
len(_direct) == 11
len(_direct) == 13
and all(a is not None and a.startswith("self.actuator_of('")
for _s, a, _ln in _routing))
check("⚠️ AND AT EVERY ONE OF THE ELEVEN THE LITERAL IT ROUTES THROUGH IS THE "
@@ -3050,15 +3051,15 @@ check("⚠️ AND EVERY PER-INVERTER APPEND NAMES THE INVERTER IT IS ABOUT. "
"site-scoped sources and is the whole defect for a per-inverter one: a "
"`device` limit built inside the roster loop and routed without `inv` "
"files inverter B's rating on inverter A, where it binds the wrong "
"share. Six of the eleven appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two and OPT-09's `pack` - and "
"all six pass one. "
"share. Eight of the thirteen appends are SCOPE_UNIT - `device`, "
"`supervised`, `failsafe`, maintenance's two, OPT-09's `pack` and "
"SAFETY-73's two `refused` - and all eight pass one. "
"SAFETY-30's `sign_site` is deliberately NOT one of them: it is filed on "
"the site, beside `circulation`, because the number that tripped it is "
"the site target",
_unit_sources_unrouted == []
and len([r for r in _routing
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 6)
if CLAIM_SCOPE.get(r[0]) == SCOPE_UNIT]) == 8)
check("...and the only appends this pin does NOT reach are the two that file a "
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
"`actuator=` at the call site to mis-type. Their routing is pinned where "
@@ -3067,7 +3068,7 @@ check("...and the only appends this pin does NOT reach are the two that file a "
"arbitrage.claim() by test_arbitrage.py",
sorted(_indirect) == sorted(CLAIM_BUILDERS)
and len(_direct) + len(_indirect) == len(_appends))
check("...and between them the twelve appends account for exactly the eleven "
check("...and between them the fifteen appends account for exactly the twelve "
"sources 10f pins, so the routing pin and the source set are one scan "
"and neither can drift from the other",
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
@@ -3119,7 +3120,7 @@ check("⚠️ AND THE RATING LIMITS ARE FILED ONCE PER INVERTER, ON ITS OWN "
("supervised", "inverter-a"),
("supervised", "inverter-b")])
check("...and naming those three is not an accident of this fixture but the "
"reason the static pin exists - SEVEN of the eleven sources need a state "
"reason the static pin exists - EIGHT of the twelve sources need a state "
"no check in this file reaches. SAFETY-31's `circulation` is one of the "
"seven: it is filed only while the site is MEASURED to be circulating, "
"which no fixture here is. SAFETY-30's `sign_site` is another, and it "
@@ -3127,7 +3128,8 @@ check("...and naming those three is not an accident of this fixture but the "
"standing opposed to the meter for its whole window",
{c.source for c in _cl2}
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
"arbitrage", "circulation", "sign_site"})
"arbitrage", "circulation", "sign_site",
"refused"})
# ⚠️ AND NOW THE HALF THAT WAS EVIDENCE-SHAPED AND OBSERVED NOTHING. The three
# checks above grade WHICH ACTUATOR each claim lands on. They say nothing about
# WHOSE NUMBERS the bound is built from - and those are two different defects
+7 -3
View File
@@ -4412,12 +4412,16 @@ check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
f"`off (arbitrage_enabled)` for both - naming an option that is `true` "
f"in the options blob",
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
# ⚠️ THE "Why" ROW OF THE SAME RENDER, NOT A SECOND READ OF `why_words`. Since
# UX-10 the waiting sentence counts its seconds when it is read, so reading the
# property again here, after the other renders above, could cross a second
# ("1 second" in the row, "2 seconds" now) - CI run 953 on PR #141 did.
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
f"({_T11_ON_HELD[1].why_words!r}), so the operator is told which input is "
f"({_T11_ON_HELD[0]['Why']!r}), so the operator is told which input is "
f"missing rather than being sent to look at a switch that is already on. "
f"The reason comes from main.py, not from this file",
_T11_ON_HELD[1].why_words
and _T11_ON_HELD[1].why_words in _T11_ON_HELD[0]["Arbitrage"])
_T11_ON_HELD[0]["Why"].startswith("A reading is missing")
and _T11_ON_HELD[0]["Why"] in _T11_ON_HELD[0]["Arbitrage"])
check(f"...and a cycle that DID reach the step renders the strategy's own "
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
+18 -7
View File
@@ -697,13 +697,24 @@ print(f" {len(APP)} modules under app/; TEL-11 material is in: {_touch}")
check(f"PRECONDITION: the scan found the whole package, not a subset - "
f"{len(APP)} modules", len(APP) >= 25 and "control.py" in
[f.name for f in APP])
check("⭐ EXACTLY THREE MODULES CAN SEE THE MATERIAL: `pv.py` (the reader), "
"`main.py` (the read and the two operator surfaces) and `mqtt.py` (the "
"publication). `control.py`, `arbiter.py`, `allocator.py`, `peakguard.py`, "
"`arbitrage.py`, `maintenance.py`, `clamp.py`, `freeze.py`, "
"`watchdog.py`, `delivery.py` and `circulation.py` do not mention it in "
"any spelling, comments included",
_touch == ["main.py", "mqtt.py", "pv.py"])
# UX-13 adds the settings surface: labels (messages.py), a settings warning
# (validate.py) and the page's layout and derivation (uilayout.py, uimodel.py).
# None of them decides anything, so the decision modules stay excluded.
# UX-13 P1 adds homeview.py, the Home screen's read-only view: its House tile
# is grid + solar + battery, and it shows nothing without a solar reading.
_DECIDERS = ("control.py", "arbiter.py", "allocator.py", "peakguard.py",
"arbitrage.py", "maintenance.py", "clamp.py", "freeze.py",
"watchdog.py", "delivery.py", "circulation.py")
check("⭐ ONLY THE READER, THE PUBLISHER AND THE SETTINGS SURFACE SEE THE "
"MATERIAL: `pv.py` (the reader), `main.py` (the read and the two operator "
"surfaces), `homeview.py` (the Home screen's view), `mqtt.py` (the "
"publication), and `messages.py`, `validate.py`, `uilayout.py` and "
"`uimodel.py` (settings). "
f"{', '.join(_DECIDERS)} do not mention it in any spelling, comments "
"included",
_touch == ["homeview.py", "main.py", "messages.py", "mqtt.py", "pv.py",
"uilayout.py", "uimodel.py", "validate.py"]
and not set(_DECIDERS) & set(_touch))
# habit 4 in the instrument: a scanner that cannot go red is not a scanner.
check("...and the scan CAN go red - a module body with the material in it is "
"caught, so the green above is the tree speaking and not the scanner "
+475
View File
@@ -0,0 +1,475 @@
"""Runnable check for SAFETY-52. `python3 test_refusal.py`
A battery that refuses to give power (the BMS cut-out) used to leave the
command standing at its last ask for hours: +5000 W for 11.87 h on 2026-09-06,
+1940 W for 3 h 55 m on the bus on 2026-09-19. Now a refused direction is held
at 0 W, a 200 W probe asks every 15 minutes, and the refusal ends on charge, on
an answered probe, on stop/start and when another writer takes the wire.
DECISIONS/SAFETY-52 has the record and the replay figures.
Mutating the modules this file grades? Purge `__pycache__` first, or run
`tools/mutation_run.sh`, which does - see MUTATION.md.
Parts 1-5 drive the REAL `Controller` (read_inputs + cycle, the real
SAFETY-37/57 gate) against a scripted battery whose reading stands at exactly
-4 W for any request >= -4 W at the cut-out, and follows otherwise. Time is the
`now_mono` handed to `read_inputs()`, so 12 hours run in seconds. `OLD` runs the
same fixture with `refused=None` forced into `compute()` - release/1.0's law -
so every "new" figure is shown beside the one it replaces. Part 4 also runs
`compute()` alone, as the bus saw 09-19 before the gate existed.
Mutations, each on a copy of this tree purged of `__pycache__`, graded with
`python3 -B` over test_refusal, test_control, test_clamp, test_telemetry and
test_messages (MUTATION.md). Reds are failing checks, on this tree:
# mutation reds
-- ------------------------------------------------------- ---------------
R1 control.py: the engage `if` -> `if False and ...` 8 here + 1 clamp
(release/1.0's behaviour)
R2 control.py: the exit on the other direction removed 1
R3 control.py: the exit on an answered probe removed 2
R4 control.py: `PROBE_W = 300.0`, above the gate's 250 W 4
R5 control.py: the hold keeps the old target, not 0 W 4
R6 main.py: `_refusal_from_stale()` returns at once 1
R7 main.py: another writer no longer ends the refusal 1
R8 main.py: stop/start no longer ends the refusal 1
R9 main.py: the probe window never opens 4
R10 main.py: seed from a stale stop below `saturation_w` again, 1
not below REFUSED_BELOW_W (review of #141). The same mutation
in control.py's engage clause SURVIVES, and that is expected:
the delivery exit (R11) ends such a refusal in the same cycle.
R11 control.py: only an answered PROBE ends the refusal 2
(`and probe` back in the delivery exit; review of #141)
"""
import asyncio
import sys
from datetime import datetime, timedelta, timezone
from app import control, freshness
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W, REFUSED, PROBING
from app.main import Controller, why_key
from app.arbiter import Claim
from app import messages
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
LIVE = {"max_w": 5000.0, "gain": 0.6, "slew_w": 400.0, "target_grid_w": -10.0,
"deadband_w": 15, "step_w": 10}
TICK = 5.0 # the meter's measured median tick (replay_recorded_loop)
CUT_W = -4.0 # what the register reads at the cut-out, 2026-09-06
H = 3600.0
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
class _Store:
def __init__(self):
self.data = {}
def set(self, *a):
pass
def get_time(self, *a):
return None
class _Pub:
enabled = False
def publish(self, values):
pass
def close(self):
pass
class _Hass:
"""House load, and a battery that follows unless `cut(t)` says it refuses.
At the cut-out the reading is exactly CUT_W for any request >= CUT_W (a flat
register, which is what arms or does not arm the stale-reading gate). A
charge request is followed: the pack accepts charge at the floor.
`cap(t)` limits discharge while not cut (the 09-06 pack gave 4570 W at 10 %).
"""
def __init__(self, load, cut, cap=lambda t: 1e9):
self.load, self.cut, self.cap = load, cut, cap
self.t = 0.0
self.cmd = 0.0
self.writes = []
def batt(self):
if self.cut(self.t) and self.cmd >= CUT_W:
return CUT_W
return float(round(min(self.cmd, self.cap(self.t))))
async def limits(self, entity):
return (-LIVE["max_w"], LIVE["max_w"], 1.0)
async def number(self, entity, invert=False):
b = self.batt()
return {"sensor.grid": self.load(self.t) - b, "sensor.soc": 9.0,
"sensor.batt": b}.get(entity)
async def set_number(self, entity, value):
self.writes.append((self.t, float(value)))
self.cmd = float(value)
return True
async def call(self, *a):
return True
def run(load, cut, dur, cap=lambda t: 1e9, old=False, events=None):
"""Real cycles every TICK seconds of `now_mono`. `events[t] = fn(c)` pokes
the controller (stop, start, another writer) at that instant.
Returns [(t, grid, batt, setpoint, i_w, loop_reason, batt_refused)], the
number of gate trips, and the controller."""
async def go():
hass = _Hass(load, cut, cap)
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "heartbeat_s": 10, "stale_input_s": 15},
**LIVE), hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -LIVE["max_w"], LIVE["max_w"]
import app.main as m
real, real_dt = m.compute, m.datetime
clock = [0.0]
class _DT(real_dt):
"""`cycle()`'s wall clock follows `now_mono`, so the 15 s input hold
and the gate's release run in simulated seconds too."""
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
if old:
m.compute = lambda *a, **k: real(*a, **dict(k, refused=None))
rows, trips, was = [], 0, False
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
for et, fn in list((events or {}).items()):
if et <= t:
fn(c)
del events[et]
await c.read_inputs(now_mono=t)
stale = c.inverters[0].batt_fresh.stale
trips += stale and not was
was = stale
await c.cycle()
rows.append((t, hass.load(t) - hass.batt(), hass.batt(),
hass.cmd, c.i_w, c.loop_reason, c.batt_refused))
t += TICK
finally:
m.compute, m.datetime = real, real_dt
return rows, trips, c
return asyncio.run(go())
def at(rows, t):
return min(rows, key=lambda r: abs(r[0] - t))
def longest_hold_h(rows, floor_w=250.0, until=None):
"""Longest run of one identical request at or above `floor_w`, in hours."""
best = cur = 0.0
last = None
for r in rows:
if until is not None and r[0] >= until:
break
cur = cur + TICK if (r[3] == last and abs(r[3]) >= floor_w) else 0.0
last = r[3]
best = max(best, cur)
return best / H
# ==============================================================================
print("1. the 09-06 shape: a load step takes the request to 5000 W, the battery "
"cuts out, the load ends")
# ==============================================================================
def load906(t):
return 1020 if t < 60 else 4800 if t < 162 else 940 if t < 11.9 * H else -600
def cut906(t):
return t >= 132
def cap906(t):
return 4570.0
old906, otrips906, _ = run(load906, cut906, 12.2 * H, cap906, old=True)
new906, ntrips906, c906 = run(load906, cut906, 12.2 * H, cap906)
for name, rows, tr in (("old", old906, otrips906), ("new", new906, ntrips906)):
print(f" {name}: request at 2 min {at(rows, 120)[3]:+.0f} W, at 6 h "
f"{at(rows, 6 * H)[3]:+.0f} W (i_w {at(rows, 6 * H)[4]:+.0f}), longest "
f"hold {longest_hold_h(rows):.2f} h, gate trips {tr}")
check("PRECONDITION: the old law holds +5000 W for the whole night on this "
"fixture, as it did on 2026-09-06 - the fixture reproduces the card",
at(old906, 6 * H)[3] == 5000.0 and longest_hold_h(old906) > 11.5)
check("the request reaches the rail before the cut, so the new law starts from "
"the same place", at(new906, 120)[3] == 5000.0)
t0 = next((r[0] for r in new906 if r[5] == REFUSED), None)
check(f"after the cut the battery-refuses hold takes the request to 0 W within "
f"about a minute (at t={t0})", t0 is not None and 132 < t0 <= 132 + 90
and at(new906, t0)[3] == 0.0)
check("...and it stays refused overnight, with the integrator at 0, and no "
"request of 250 W or more stands for longer than one probe interval",
at(new906, 6 * H)[6] == 1 and at(new906, 6 * H)[4] == 0.0
and longest_hold_h(new906, until=11.9 * H) < PROBE_EVERY_S / H)
check(f"...and the stale-reading gate does not cycle: {ntrips906} trips in "
f"12 hours (the old law's latch also kept it quiet: {otrips906})",
ntrips906 == 0)
morning = [r for r in new906 if r[0] >= 11.9 * H + 60]
check("when solar makes the grid export, the refusal ends and the battery "
"charges - the exit on the other direction",
morning and morning[-1][6] == 0 and morning[-1][3] < -400)
# ==============================================================================
print("2. the probe: 200 W every 15 minutes, below the gate's 250 W")
# ==============================================================================
probes = []
for r in new906:
if r[3] == PROBE_W and (not probes or r[0] - probes[-1][1] > TICK):
probes.append([r[0], r[0]])
elif r[3] == PROBE_W:
probes[-1][1] = r[0]
starts = [p[0] for p in probes]
gaps = [b - a for a, b in zip(starts, starts[1:])]
print(f" {len(probes)} probes, first at {starts[0] / 60 if starts else 0:.1f} "
f"min, gaps {sorted(set(round(g) for g in gaps))} s, lengths "
f"{sorted(set(round(p[1] - p[0] + TICK) for p in probes))} s")
check("the probe asks exactly PROBE_W, which is below the stale-reading gate's "
"threshold, so a probe can never arm it",
PROBE_W < freshness.MOVE_W and all(r[3] in (0.0, PROBE_W)
for r in new906 if r[6] == 1))
check("there is a probe every 15 minutes through the night (at least 40 in "
"11.8 hours), each about 34 s long",
len(probes) >= 40 and all(PROBE_EVERY_S <= g <= PROBE_EVERY_S + PROBE_S
+ 2 * TICK for g in gaps)
and all(PROBE_S - TICK <= p[1] - p[0] + TICK <= PROBE_S + TICK
for p in probes))
check("...and a probe reads as a probe on the loop's own reason row",
any(r[5] == PROBING for r in new906))
# ==============================================================================
print("3. recovery with no sign change: the battery gives power again at 2 h")
# ==============================================================================
def cut_rec(t):
return 132 <= t < 2 * H
oldr, _, _ = run(load906, cut_rec, 2.6 * H, cap906, old=True)
newr, _, crec = run(load906, cut_rec, 2.6 * H, cap906)
after_o = [r for r in oldr if 2 * H <= r[0] < 2 * H + 600]
after_n = [r for r in newr if 2 * H <= r[0] < 2.6 * H]
exp_o = sum(max(0.0, -r[1]) * TICK for r in after_o) / 3600
exp_n = sum(max(0.0, -r[1]) * TICK for r in after_n) / 3600
print(f" old: worst grid {min(r[1] for r in after_o):+.0f} W, export "
f"{exp_o:.0f} Wh; new: worst grid {min(r[1] for r in after_n):+.0f} W, "
f"export {exp_n:.0f} Wh")
check("PRECONDITION: the old law answers a recovering battery with its "
"standing +5000 W and exports kilowatts into a 940 W house",
min(r[1] for r in after_o) < -3000)
check("the new law never exports more than the probe while it waits, and the "
"answered probe ends the refusal so control resumes",
min(r[1] for r in after_n) > -PROBE_W - 50
and after_n[-1][6] == 0 and abs(after_n[-1][1] - (-10)) < 100)
check("...and the event log says the probe was answered",
any("BATTERY REFUSES ENDED: the probe was answered" in e
for e in crec.events))
# ==============================================================================
print("4. the 09-19 shape: a stale-reading stop on a frozen ~0 W reading "
"is a refusal")
# ==============================================================================
# The request steps +1540 -> +1940 and more as the battery cuts out mid-ramp:
# the request moves after the reading went flat, so the gate trips before the
# hold's 12 cycles - the ~70 s cycle SAFETY-59 measured. The trip seeds the hold.
def load919(t):
return 1530 if t < 60 else 2400 if t < 1800 else 900 if t < 4 * H else -600
def cut919(t):
return t >= 65
def run919(old):
return run(load919, cut919, 4.2 * H, old=old)
old919, otr919, _ = run919(True)
new919, ntr919, c919 = run919(False)
print(f" old: gate trips {otr919}, longest hold {longest_hold_h(old919):.2f} h;"
f" new: gate trips {ntr919}, longest hold {longest_hold_h(new919):.2f} h")
check("PRECONDITION: on the old law the gate trips over and over all night "
"(the SAFETY-59 cycle) or the request stands for hours",
otr919 > 20 or longest_hold_h(old919) > 1.0)
check("the new law stops after at most two trips and holds 0 W, and the event "
"log says the battery refuses",
ntr919 <= 2 and at(new919, 2 * H)[3] in (0.0, PROBE_W)
and any("BATTERY REFUSES" in e for e in c919.events))
# The same shape through `compute()` alone, as the bus saw it on 2026-09-19
# before the gate existed: +1540 -> +1940, then a battery giving nothing.
T = control.Tuning(**LIVE)
res = {}
for name, carry in (("old", None), ("new", 0)):
# sat_count 2: the +1540 and +1940 cycles were already short (the plan)
prev, i_w, sat, ref, cmds = 1940.0, 2340.0, 2, carry, []
for _ in range(2000): # ~2.8 h at 5 s
d = control.compute(prev, 1950.0, CUT_W, T, sat, i_w, refused=ref)
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
cmds.append(prev)
res[name] = cmds
print(f" compute() alone: old stands at {res['old'][-1]:+.0f} W "
f"({sum(v == 1940.0 for v in res['old'])} of 2000 cycles at +1940 W), "
f"new at {res['new'][-1]:+.0f} W from cycle "
f"{res['new'].index(0.0) if 0.0 in res['new'] else None}")
check("PRECONDITION: without the gate the old law parks at +1940 W, the 7 065 "
"identical writes the bus recorded", set(res["old"]) == {1940.0})
check("...and the new law holds 0 W within SETTLE_CYCLES cycles",
res["new"].index(0.0) < control.SETTLE_CYCLES
and set(res["new"][control.SETTLE_CYCLES:]) == {0.0})
# ==============================================================================
print("5. the other exits: stop, start, another writer")
# ==============================================================================
def stop(c):
c.auto = False
def start(c):
c.auto = True
rows, _, c = run(load906, cut906, 1200, cap906, events={600.0: stop,
700.0: start})
check("stopping control ends the refusal, and starting again begins from "
"nothing refused", at(rows, 590)[6] == 1 and at(rows, 650)[6] == 0
and at(rows, 705)[6] == 0)
def other_writer(c):
c.loop_w = 1234.0 # the arbiter gave the wire something the loop did not ask
rows, _, c = run(load906, cut906, 900, cap906, events={600.0: other_writer})
check("another writer on the wire ends the refusal (the arbiter reseed)",
at(rows, 590)[6] == 1 and at(rows, 605)[6] == 0)
# ==============================================================================
print("5b. review of #141: a pack that gives SOME power, and a reading that is "
"only late, are not a refusal")
# ==============================================================================
# A pack derated to 100-499 W (cold, BMS taper) gives what it can against a
# larger ask. Engaging below `saturation_w` (500 W) held it at 0 W for ~93 % of
# the time; "gives nothing" is below the probe's own answer.
# `compute()` alone: through the Controller the stale-reading gate trips on the
# flat 300 W first, and that fixture then passes whatever this threshold is.
def derated(carry, cap_w=300.0, n=1800):
prev, i_w, sat, ref, gave, held = 0.0, 0.0, 0, carry, 0.0, 0
b = 0.0
for _ in range(n): # 2.5 h at 5 s
d = control.compute(prev, 2000.0 - b, b, T, sat, i_w, refused=ref)
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
held += bool(ref)
b = max(0.0, min(prev, cap_w)) # one tick late
gave += b
return gave / n, held
T = control.Tuning(**LIVE)
gave_old, _ = derated(None)
gave_new, held_new = derated(0)
print(f" pack derated to 300 W: old law {gave_old:.0f} W average, new "
f"{gave_new:.0f} W, held as refusing on {held_new} cycles")
check("a pack that gives a steady 300 W against a 2000 W ask is never held at "
"0 W as refusing, and keeps giving what the old law got from it",
held_new == 0 and gave_new == gave_old and gave_old > 290.0)
def lagging(tick, dead_s=6.0, publish_s=10.0, phase_s=5.0, dur=600.0):
"""`compute()` on a HEALTHY pack whose reading lags: it follows the command
of `dead_s` ago, published every `publish_s`. A 1 s meter (`meter_poll_s`
1) makes SETTLE_CYCLES only 12 s, shorter than that lag."""
prev, i_w, sat, ref, hist, reg, pub = 0.0, 0.0, 0, 0, [], 0.0, -phase_s
held = engaged = 0
t = 0.0
while t < dur:
then = [c for tc, c in hist if tc <= t - dead_s]
b = then[-1] if then else 0.0
if t - pub >= publish_s:
reg, pub = b, t
d = control.compute(prev, (3000.0 if t >= 10 else 0.0) - b, reg, T,
sat, i_w, refused=ref)
engaged += bool(d.refused) and not ref
prev, i_w, sat, ref = d.target_w, d.i_w, d.sat_count, d.refused
hist.append((t, prev))
held += tick * bool(ref)
t += tick
return engaged, held
lag1 = lagging(1.0)
print(f" 1 s meter, healthy pack with a late reading: engaged {lag1[0]} "
f"time(s), held {lag1[1]:.0f} s")
check("a healthy pack whose reading is merely late is let go as soon as the "
"reading shows it delivering - not held until the next probe",
lag1[1] <= 10.0)
async def _seed(held_w):
"""`_refusal_from_stale()` on a unit standing at +2000 W, frozen."""
c = Controller(dict(
{"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off"}, **LIVE),
_Hass(lambda t: 0.0, lambda t: False), _Store(), _Pub(),
stop=asyncio.Event())
c.auto, c.sat_count = True, c.tuning.saturation_cycles
inv = c.inverters[0]
inv.last_write, inv.batt_fresh.held = 2000.0, held_w
c._refusal_from_stale(inv, 100.0)
return c.batt_refused
check("a stale-reading stop on a register standing at 300 W is not a refusal "
"(it gives something), and one at -4 W is",
asyncio.run(_seed(300.0)) == 0 and asyncio.run(_seed(CUT_W)) == 1)
d = control.compute(0.0, 1500.0, 1400.0, T, 0, 0.0, refused=1, probe=False)
check("...i.e. a held direction that reads 100 W or more ends the refusal "
"without waiting for a probe", d.refused == 0)
# ==============================================================================
print("6. words")
# ==============================================================================
w = messages.why(why_key("loop", [Claim.set("loop", 0, 0.0, REFUSED)]), 0.0)
wp = messages.why(why_key("loop", [Claim.set("loop", 0, PROBE_W, PROBING)]),
PROBE_W)
print(f" {w!r} / {wp!r}")
check("the page's Why line says the battery refuses, not 'Keeping the grid "
"near zero: 0 W'", w == "The battery refuses to give power: 0 W")
check("...and a probe says what it is", wp.startswith("Testing if the battery"))
check("...and an ordinary loop cycle still reads as the loop",
why_key("loop", [Claim.set("loop", 0, 10.0, "tracking")]) == "loop")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+537
View File
@@ -0,0 +1,537 @@
"""Runnable check for SAFETY-73. `python3 test_refusal_units.py`
SAFETY-52 per inverter. With two or more inverters, a battery that refuses
(gives < 100 W for 12 cycles and 22 s while asked > 200 W, or trips the stale-reading
gate on a ~0 W reading) is closed in that direction by its own P_SAFETY limit,
so the allocator hands its share to the others. It is probed at 200 W every
15 minutes, one inverter at a time. When every inverter refuses the same way
the site holds 0 W. When a refusal ends the site target restarts from the watts
on the wire. DECISIONS/SAFETY-73 has the record.
Drives the REAL `Controller` (read_inputs + cycle, the real freshness gate,
allocator, OPT-09 pack claim and SAFETY-42 detector) with two inverters in
simulated time. At the cut-out a battery reads -4 W for any request >= -4 W
and accepts charge. Two register shapes: FLAT at -4 W (the 09-06 capture) and
JITTER +-3 W (the gate cannot arm). `OLD` runs the same fixture with the
per-unit refusal switched off, which is 652097a's behaviour at N>1.
Mutations (MUTATION.md, `python3 -B`, copies purged of `__pycache__`), graded
over this file / test_delivery / test_refusal. Every one is red here:
# mutation reds
--- --------------------------------------------------------- -------
U1 main.py: the per-unit engage clause never fires 7/2/0
U2 main.py: the N>1 stale-reading seed returns at once 14/0/0
U3 main.py: no `refused` claim is filed 13/2/0
U4 main.py: a probe never pins (always the closed limit) 7/0/0
U5 main.py: a probe pin stands whatever the site target 1/0/0
U6 main.py: no reseed of the site target when a refusal ends 1/0/0
U7 main.py: no site hold when every inverter refuses 2/0/0
U8 main.py: the other-direction exit removed (charge counter) 1/0/0
U9 main.py: the delivery exit removed 3/0/0
U10 main.py: probes are no longer one at a time 1/0/0
U11 main.py: the above-cut-out WARNING condition inverted 1/0/0
U12 main.py: SAFETY-42's account no longer sees a small ask 1/0/0
U13 main.py: a unit engages above `saturation_w`, not PROBE_W 1/0/0
U14 main.py: the evidence need not span REFUSED_SPAN_S (review) 1/0/0
U15 main.py: the WARNING also for a refusal to take (review) 1/0/0
On 652097a itself this file stops at the first sample (`Inverter` has no
`refused`); its OLD arms reproduce that tree's N>1 behaviour instead, and
every PRECONDITION line is graded against them.
"""
import asyncio
import logging
import sys
from datetime import datetime, timedelta, timezone
from app import control
from app.control import PROBE_EVERY_S, PROBE_S, PROBE_W
from app.main import Controller
from app.arbiter import P_LOOP, P_PEAK, P_SAFETY, Claim, resolve
import app.main as m
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
TICK = 5.0
CUT_W = -4.0
FULL_W = 40.0
H = 3600.0
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
UNITS = ("a", "b")
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
def set(self, k, v):
self.data[k] = v
def get_time(self, *a):
return None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
class _Hass:
"""A house, one meter, and two batteries that follow unless cut out.
`lag` (review): (dead s, publish s). Each battery reading then shows the
power of `dead` s before its last publish; the power itself and the meter
follow at once. `full[u](t)`: that battery takes at most FULL_W of charge.
"""
def __init__(self, load, cut, soc, jitter, lag=None, full=None):
self.load, self.cut, self.soc, self.jitter = load, cut, soc, jitter
self.lag, self.full = lag, full or {}
self.cmd = {u: 0.0 for u in UNITS}
self.hist = {u: [(-1e18, 0.0)] for u in UNITS}
self.t = 0.0
def power(self, u, t=None):
t = self.t if t is None else t
c = next((v for tt, v in reversed(self.hist[u]) if tt <= t), 0.0)
if self.cut[u](t) and c >= CUT_W:
if self.jitter:
return CUT_W + ((int(t / TICK) % 3) - 1) * 3.0
return CUT_W
if u in self.full and self.full[u](t) and c < -FULL_W:
return -FULL_W
return float(round(c))
def batt(self, u):
if self.lag is None:
return self.power(u)
dead, pub = self.lag
return self.power(u, (self.t // pub) * pub - dead)
def grid(self):
return self.load(self.t) - sum(self.power(u) for u in UNITS)
async def limits(self, e):
return (-2000.0, 2000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.grid()
u = e[-1]
if e.startswith("sensor.soc"):
v = self.soc[u]
return v(self.t) if callable(v) else v
if e.startswith("sensor.batt"):
return self.batt(u)
return None
async def set_number(self, e, v):
self.cmd[e[-1]] = float(v)
self.hist[e[-1]].append((self.t, float(v)))
return True
def _off(c):
"""652097a's N>1 behaviour: nothing refuses per unit."""
c._unit_refusals = lambda: None
c._unit_refusal_start = lambda *a, **k: None
def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
extra=None, tick=None, lag=None, full=None):
"""Real cycles every TICK of simulated time. Rows are dicts per cycle."""
async def go():
hass = _Hass(load, cut, soc or {"a": 50.0, "b": 50.0}, jitter, lag,
full)
dt = tick or TICK
inv = [{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}",
"batt_entity": f"sensor.batt_{u}",
"max_w": 2000, "bms_soc_floor": 10} for u in UNITS]
o = {"meter_entity": "sensor.grid", "meter_source": "off",
"inverters": inv, "gain": 0.6, "slew_w": 400,
"target_grid_w": -10, "deadband_w": 15, "step_w": 10,
"heartbeat_s": 10, "stale_input_s": 15, "auto_start": True}
o.update(extra or {})
c = Controller(o, hass, _Store(), _Pub(), stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -2000.0, 2000.0
c.auto = True
if old:
_off(c)
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
rows = []
trips = {u: 0 for u in UNITS}
was = {u: False for u in UNITS}
pending = dict(events or {})
try:
t = 0.0
while t < dur:
hass.t = clock[0] = t
for et in sorted(pending):
if et <= t:
pending.pop(et)(c)
await c.read_inputs(now_mono=t)
for u, i in zip(UNITS, c.inverters):
s = i.batt_fresh.stale
trips[u] += s and not was[u]
was[u] = s
await c.cycle()
rows.append(dict(
t=t, grid=hass.grid(), site=c.target, reason=c.loop_reason,
a=hass.cmd["a"], b=hass.cmd["b"],
ra=c.inverters[0].refused, rb=c.inverters[1].refused,
da=c.inverters[0].delivery_wd.tripped,
db=c.inverters[1].delivery_wd.tripped))
t += dt
finally:
m.datetime = real_dt
return rows, trips, c
return asyncio.run(go())
def after(rows, t0, t1=None):
return [r for r in rows if r["t"] >= t0 and (t1 is None or r["t"] < t1)]
def mean(rows, k):
return sum(r[k] for r in rows) / len(rows) if rows else float("nan")
def never(t):
return False
def cut_at(t0, t1=None):
return lambda t: t >= t0 and (t1 is None or t < t1)
def probes(rows, k):
"""Start times of runs where refused unit `k` is asked exactly PROBE_W."""
out, prev = [], False
for r in rows:
on = abs(r[k]) == PROBE_W and r["r" + k] != 0
if on and not prev:
out.append(r["t"])
prev = on
return out
SHAPES = (("flat", False), ("jitter", True))
# ==============================================================================
print("1. case a: house 3000 W, both give power, A cuts out at 120 s")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, H,
jitter=jit)
old, otr, _ = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, H,
jitter=jit, old=True)
tail_n, tail_o = after(new, 300), after(old, 300)
held = [r for r in tail_n if abs(r["a"]) != PROBE_W]
print(f" {shape}: old B {mean(tail_o, 'b'):.0f} W grid "
f"{mean(tail_o, 'grid'):+.0f} W trips A {otr['a']}; new B "
f"{mean(tail_n, 'b'):.0f} W grid {mean(tail_n, 'grid'):+.0f} W "
f"trips A {ntr['a']}")
check(f"{shape}: PRECONDITION: on the old law B never takes over (mean "
f"under 1950 W) while A gives nothing",
mean(tail_o, "b") < 1950 and mean(tail_o, "grid") > 1050)
check(f"{shape}: A is refused and asked 0 W outside its probes, B carries "
f"its rating, and the grid imports only what B cannot give",
cn.inverters[0].refused == 1 and all(r["a"] == 0.0 for r in held)
and min(r["b"] for r in tail_n) == 2000.0
and abs(mean(tail_n, "grid") - 1004) < 20)
check(f"{shape}: A's stale-reading gate trips at most once, the site is "
f"never stopped after the first 5 minutes, and SAFETY-42 does not "
f"report A as not delivering",
ntr["a"] <= 1
and all(r["reason"] != "not-running" for r in tail_n)
and not any(r["da"] for r in new))
# ==============================================================================
print("2. case b: house 1200 W, which B alone can carry, A cuts out")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 1200.0, {"a": cut_at(120), "b": never}, H,
jitter=jit)
old, _, _ = run(lambda t: 1200.0, {"a": cut_at(120), "b": never}, H,
jitter=jit, old=True)
tail_n, tail_o = after(new, 300), after(old, 300)
imp = sum(max(0.0, r["grid"]) for r in tail_n) * TICK / H
print(f" {shape}: old grid {mean(tail_o, 'grid'):+.0f} W, new grid "
f"{mean(tail_n, 'grid'):+.0f} W, new import {imp:.0f} Wh")
check(f"{shape}: PRECONDITION: the old law leaves a standing import",
mean(tail_o, "grid") > 250)
check(f"{shape}: B takes over the whole house and the grid sits near its "
f"target", abs(mean(tail_n, "grid") + 10) < 25 and imp < 10)
pa = probes(new, "a")
in_probe = [r for r in new
if any(p <= r["t"] < p + PROBE_S for p in pa)]
check(f"{shape}: A is probed at 200 W every 15 minutes while B carries "
f"the rest, and the two never pull opposite ways",
len(pa) >= 3
and all(abs(q - p - PROBE_EVERY_S - PROBE_S) <= 2 * TICK
for p, q in zip(pa, pa[1:]))
and all(r["b"] > 0 for r in in_probe)
and all(r["a"] * r["b"] >= 0 for r in new))
# ==============================================================================
print("3. case c: both cut out; the site holds 0 W, probes one at a time")
# ==============================================================================
for shape, jit in SHAPES:
new, ntr, cn = run(lambda t: 3000.0, {"a": cut_at(120), "b": cut_at(150)},
1.5 * H, jitter=jit)
old, _, _ = run(lambda t: 3000.0, {"a": cut_at(120), "b": cut_at(150)},
1.5 * H, jitter=jit, old=True)
tail_n, tail_o = after(new, 600), after(old, 600)
pa, pb = probes(new, "a"), probes(new, "b")
print(f" {shape}: old mean asks A/B {mean(tail_o, 'a'):.0f}/"
f"{mean(tail_o, 'b'):.0f} W; new {mean(tail_n, 'a'):.0f}/"
f"{mean(tail_n, 'b'):.0f} W, probes A {len(pa)} B {len(pb)}")
check(f"{shape}: PRECONDITION: the old law keeps asking two empty "
f"batteries", mean(tail_o, "a") > 150 and mean(tail_o, "b") > 150)
check(f"{shape}: the site holds 0 W: the loop says `battery refuses` or "
f"`battery probe` and each battery is asked 0 W or its probe",
all(r["reason"] in (control.REFUSED, control.PROBING)
for r in tail_n)
and all(r["a"] in (0.0, PROBE_W) and r["b"] in (0.0, PROBE_W)
for r in tail_n))
check(f"{shape}: both batteries are probed, never at the same time",
len(pa) >= 3 and len(pb) >= 3
and not any(r["a"] and r["b"] for r in new if r["t"] > 300))
# ==============================================================================
print("4. recovery: the reseed stops the parked site target reaching the grid")
# ==============================================================================
# Both cut, the house drops to 940 W at 1 h, A recovers at 1.5 h with no sign
# change. Phase A, with the envelope but no reseed and no site hold: -1059 W.
ld = (lambda t: 3000.0 if t < H else 940.0)
for shape, jit in SHAPES:
rows, _, c = run(ld, {"a": cut_at(120, 1.5 * H), "b": cut_at(150)},
2 * H, jitter=jit)
win = after(rows, 1.5 * H, 1.5 * H + 900)
worst = min(r["grid"] for r in win)
print(f" {shape}: worst grid after A recovers {worst:+.0f} W; A "
f"refused {c.inverters[0].refused}")
check(f"{shape}: A's recovery is found (at its next probe at the latest) "
f"and never exports more than 250 W",
c.inverters[0].refused == 0 and worst > -250
and any("BATTERY REFUSES ENDED inverter-a" in e for e in c.events))
# Case a: B at its rating, the site target parked at 3600 W while the house
# still imports; the house falls to 2500 W and A recovers. Without the reseed A
# is handed the parked 1600 W at once, 1100 W more than the house needs.
ld2 = (lambda t: 3000.0 if t < 0.5 * H else 2500.0)
rows, _, c = run(ld2, {"a": cut_at(120, 0.75 * H), "b": never}, 1.5 * H)
win = after(rows, 0.75 * H, 0.75 * H + 900)
print(f" a: worst grid when A recovers {min(r['grid'] for r in win):+.0f} W")
check("case a: a recovery hands A no parked site target (worst export under "
"250 W)", min(r["grid"] for r in win) > -250
and any("BATTERY REFUSES ENDED inverter-a" in e for e in c.events))
# ==============================================================================
print("5. exits, words and the circulation guard")
# ==============================================================================
# Solar: the house exports from 30 min, so the loop wants to charge.
rows, _, c = run(lambda t: 3000.0 if t < 0.5 * H else -2500.0,
{"a": cut_at(120), "b": never}, H)
check("the grid asking the other direction lets A charge at once (its limit "
"closes only the refused direction) and a minute of charge ends the "
"refusal",
c.inverters[0].refused == 0 and min(r["a"] for r in after(rows, 0.6 * H))
< -500 and any("the other way" in e for e in c.events))
def _stop(c):
c.auto = False
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 0.5 * H,
events={1200.0: _stop})
check("stopping control ends the refusal",
c.inverters[0].refused == 0
and any("control stopped" in e for e in c.events))
# A small house: the site asks under 200 W, so a probe may not pin A (B would
# have to charge while A gives power). The probe waits instead.
rows, _, c = run(lambda t: 3000.0 if t < 600 else 120.0,
{"a": cut_at(120), "b": never}, 1.2 * H)
check("with the site asking under 200 W no probe is sent and no inverter is "
"ever asked the opposite way to the other",
c.inverters[0].refused == 1
and probes(after(rows, 700), "a") == []
and all(r["a"] * r["b"] >= 0 for r in rows)
and not any("ARBITER" in e for e in c.events))
# The hold binds every strategy's ask: a peak-guard SET at P_PEAK on the site
# is still placed around a refusing unit's closed limit.
_res = resolve([Claim.set("peak", P_PEAK, 3000.0, "quarter peak"),
Claim.set("loop", P_LOOP, 500.0, "tracking"),
Claim.limit("supervised", P_SAFETY, -2000.0, 2000.0,
actuator="inverter-a"),
Claim.limit("supervised", P_SAFETY, -2000.0, 2000.0,
actuator="inverter-b"),
Claim.limit("refused", P_SAFETY, hi=0.0,
reason=control.REFUSED, actuator="inverter-a")],
units=("inverter-a", "inverter-b"),
live=("inverter-a", "inverter-b"))
check("a P_SAFETY refusal limit binds a higher-priority strategy too: the "
"peak guard's 3000 W goes to B, A gets 0 W",
_res.shares.get("inverter-a") == 0 and _res.shares.get("inverter-b")
== 2000)
class _Grab(logging.Handler):
def __init__(self):
super().__init__()
self.lines = []
def emit(self, record):
self.lines.append((record.levelno, record.getMessage()))
_g = _Grab()
_lg = logging.getLogger("goodwe")
_lv = _lg.level
_lg.setLevel(logging.INFO)
_lg.addHandler(_g)
run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600)
run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600,
soc={"a": 10.0, "b": 50.0})
_lg.removeHandler(_g)
_lg.setLevel(_lv)
warns = [x for lv, x in _g.lines if lv == logging.WARNING
and "REFUSES_ABOVE_CUT_OUT" in x]
infos = [x for lv, x in _g.lines if lv == logging.INFO
and "BATTERY_REFUSES" in x]
print(f" {warns} / {infos}")
check("a refusal above the configured cut-out level logs INFO and one WARNING "
"naming the inverter, and a battery at its cut-out logs neither "
"(OPT-09's limit closes it first)",
len(warns) == 1 and warns[0].startswith("inverter-a ")
and len(infos) == 1 and infos[0].startswith("inverter-a:")
and len(warns[0].split()) <= 25)
# A night load of 400 W: each unit is asked about 400 W once the loop has
# made up A's missing half, under `saturation_w`. Engaging at PROBE_W is what
# refuses A here; at 500 W it would keep a phantom share that SAFETY-43 names.
rows, _, c = run(lambda t: 400.0, {"a": cut_at(0), "b": never}, 1200)
check("a 400 W night load: A is refused at a share under 500 W, B carries the "
"house and nothing reports A as not delivering",
c.inverters[0].refused == 1 and not any(r["da"] for r in rows)
and abs(mean(after(rows, 300), "grid") + 10) < 25
and mean(after(rows, 300), "b") > 380)
# A night load of 150 W: A's share never passes PROBE_W, so it is never
# refused, and SAFETY-43 must not name it as not delivering either: a battery
# asked under 200 W that gives under 100 W is a refusal too small to call.
rows, _, c = run(lambda t: 150.0, {"a": cut_at(0), "b": never}, 1200)
check("a small share on an empty battery is neither refused nor reported as "
"not delivering, and the grid stays near its target",
c.inverters[0].refused == 0 and not any(r["da"] for r in rows)
and abs(mean(after(rows, 300), "grid") + 10) < 25)
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600)
notes = c.liveness_notes()
check("the status page names the refusing inverter",
any(n.startswith("inverter-a: the battery refuses to give power")
for n in notes))
# Shadow mode writes nothing, so nothing is asked and nothing can refuse.
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 1200,
extra={"shadow_mode": True})
check("shadow mode: no inverter is refused and both stay at 0 W",
c.inverters[0].refused == 0 and c.inverters[1].refused == 0
and all(r["a"] == 0.0 and r["b"] == 0.0 for r in rows))
# Review. A 1 s meter makes 12 cycles 11 s, shorter than a healthy battery
# reading's lag (6 s dead time plus a 13 s publish). Ordinary load steps then
# refused both inverters and dropped the site to 0 W (16 times an hour). The
# evidence must also span REFUSED_SPAN_S.
_steps = (lambda t: 300.0 if int(t // 120) % 2 == 0 else 3000.0)
rows, _, c = run(_steps, {"a": never, "b": never}, H, tick=1.0,
lag=(6.0, 13.0))
check("a 1 s meter and slow battery readings: load steps for an hour refuse "
"neither healthy inverter",
not any(r["ra"] or r["rb"] for r in rows))
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600,
tick=1.0, lag=(6.0, 13.0))
_eng = next((r["t"] for r in rows if r["ra"]), None)
check("...and the same slow readings still refuse a battery that is cut "
"out, after the span and well inside the 185 s report",
_eng is not None and 120 + m.REFUSED_SPAN_S <= _eng < 185
and not any(r["da"] for r in rows))
# Review. A full battery that stops taking charge is refused (charge), and
# it is always above its cut-out level: that says nothing about the setting,
# so it gets the INFO line and no REFUSES_ABOVE_CUT_OUT warning.
_g2 = _Grab()
_lg.setLevel(logging.INFO)
_lg.addHandler(_g2)
rows, _, c = run(lambda t: -3000.0, {"a": never, "b": never}, 600,
soc={"a": 99.0, "b": 60.0}, full={"a": lambda t: t >= 120})
_lg.removeHandler(_g2)
_lg.setLevel(_lv)
check("a full battery that stops taking charge is refused that way only, "
"with the INFO line and no warning about its cut-out level",
c.inverters[0].refused == -1
and any(lv == logging.INFO and "BATTERY_REFUSES" in x
for lv, x in _g2.lines)
and not any("REFUSES_ABOVE_CUT_OUT" in x for _lv2, x in _g2.lines))
# ==============================================================================
print("6. one inverter is untouched")
# ==============================================================================
def _boom(*a, **k):
raise AssertionError("SAFETY-73 code ran at N=1")
_saved = {k: getattr(Controller, k) for k in (
"_unit_refusals", "_unit_refusal_start", "_unit_refusal_end",
"_unit_probe", "_site_refused")}
for k in _saved:
setattr(Controller, k, _boom)
try:
import test_refusal as _t1
_one_ok = not _t1.fails
except (AssertionError, SystemExit) as e:
_one_ok = isinstance(e, SystemExit) and not e.code
finally:
for k, v in _saved.items():
setattr(Controller, k, v)
check("SAFETY-52's whole single-inverter suite passes with every SAFETY-73 "
"method made to raise, so none of it runs at N=1", _one_ok)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+20 -5
View File
@@ -636,12 +636,18 @@ _app = web.build_app(FakeController(), FakeSupervisor())
_routes = sorted({(r.method, r.get_info().get("path"))
for r in _app.router.routes() if r.method != "HEAD"})
print(" " + " ".join(f"{m} {p}" for m, p in _routes))
check("the configuration surface adds five routes and touches none of the "
"three that were there",
check("the configuration surface adds five routes and UX-13 one GET, and "
"none touches the three that were there",
_routes == [("GET", "/"), ("GET", "/config"), ("GET", "/config/model"),
("GET", "/status"), ("POST", "/action"),
("POST", "/config/restart"), ("POST", "/config/save"),
("POST", "/config/validate")])
("GET", "/ha/entities"), ("GET", "/status"),
("POST", "/action"), ("POST", "/config/restart"),
("POST", "/config/save"), ("POST", "/config/validate")])
_ents_src = io.open(os.path.join(HERE, "app", "haentities.py"),
encoding="utf-8").read()
check("UX-13's entity list only reads: haentities.py calls hass.states() and "
"no service, setter or action",
"hass.states()" in _ents_src
and not re.search(r"\.(call|set_number|handle_action|post)\(", _ents_src))
_selfconfig_src = io.open(os.path.join(HERE, "app", "selfconfig.py"),
encoding="utf-8").read()
for forbidden in ("set_number", "write_setpoint", "handle_action",
@@ -692,6 +698,10 @@ async def _e2e():
async with s.post(f"{root}/config/validate",
json={"options": base(setpoint_entity="")}) as r:
out["invalid"] = await r.json()
async with s.post(f"{root}/config/validate",
json={"options": base(integrator_max_w=3000)}
) as r:
out["warned"] = await r.json()
# ⚠️ THE COUNTS ARE SNAPSHOTTED AT EACH STAGE. Asserting
# `sup.writes == []` after the whole sequence would be asserting
# about the LAST request, not this one - the first draft did, and
@@ -749,6 +759,11 @@ check("...and it honours the browser's language per key, falling back to "
check("POST config/validate returns the validator's sentences, anchored",
E["invalid"]["problems"]
and "setpoint_entity" in E["invalid"]["by_key"])
check("UX-13: POST config/validate also returns the validator's warnings, "
"beside no refusal",
E["warned"]["problems"] == [] and [n["keys"] for n in
E["warned"]["notes"]]
== [["integrator_max_w"]] and E["warned"]["notes"][0]["tier"] == "warn")
check("a save of an invalid blob answers 422 and the Supervisor got nothing",
E["save_invalid"][0] == 422
and E["save_invalid"][1]["kind"] == "invalid"
+202
View File
@@ -0,0 +1,202 @@
"""UX-13 P0: the layout table places every option once. `python3 test_uilayout.py`
app/uilayout.py is the one place UI code names an option key
(DECISIONS/UX-13-one-layout-table.md). This file fails CI when config.yaml
gains an option the table does not place, and checks each row against the
sources UX-02 keeps authoritative: config.yaml for ranges, translations and
app/ui_text for words, validate.py for which entities are read as watts.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import io
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "tools"))
import translations_lint as TL # noqa: E402
from app import uilayout as L, uimodel, validate # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
CONFIG = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
ORDER, NESTED = TL.config_surface(CONFIG)
TYPES = dict(re.findall(r"^ ([a-z_0-9]+): *(\S.*?) *$",
CONFIG[CONFIG.index("\nschema:"):], re.M))
EN = TL.parse_translation_file(os.path.join(HERE, "translations", "en.yaml"))
UI_TEXT = json.load(io.open(os.path.join(HERE, "app", "ui_text", "en.json"),
encoding="utf-8"))
ROWS = list(L.fields())
KEYS = [f.key for _g, _p, f in ROWS]
def coverage_problems(order, nested):
"""Every way the table and config.yaml's schema disagree."""
out = []
placed = [k for k in KEYS if not k.startswith(("ui.", "inverters."))]
for k in order:
n = placed.count(k) + (k in L.DERIVED) + (k in L.HIDDEN)
if n != 1:
out.append(f"{k} is placed {n} times")
out += [f"{k} is not in config.yaml" for k in
set(placed) | set(L.DERIVED) | set(L.HIDDEN) if k not in order]
sub = [k.split(".", 1)[1] for k in KEYS if k.startswith("inverters.")]
out += [f"inverters.{k} has no card field" for k in nested.get("inverters", [])
if sub.count(k) != 1]
out += [f"inverters.{k} is not in config.yaml" for k in sub
if k not in nested.get("inverters", [])]
return out
print("1. every option has exactly one place")
_p = coverage_problems(ORDER, NESTED)
_fields = sum(1 for k in ORDER if k in KEYS)
print(f" {len(ORDER)} options: {_fields} fields, {len(L.DERIVED)} derived, "
f"{len(L.HIDDEN)} hidden. Problems: {_p}")
check(f"config.yaml's {len(ORDER)} options are each a field, derived or hidden, "
"once", not _p and len(ORDER) >= 78)
check("a new option without a place fails this check",
coverage_problems(ORDER + ["new_option_w"], NESTED)
== ["new_option_w is placed 0 times"])
check("...and so does a new inverter field",
coverage_problems(ORDER, {"inverters": NESTED["inverters"] + ["mode"]})
== ["inverters.mode has no card field"])
check("no key is both a field and derived",
not set(KEYS) & (set(L.DERIVED) | set(L.HIDDEN)))
check("each derived key names the page field that writes it",
all(any(f.key == how for _g, _p2, f in ROWS)
for how in L.DERIVED.values()))
print("\n2. each row is well formed")
check("every control, entity kind and show operator is a known one",
all(f.control in L.CONTROLS and (not f.kind or f.kind in L.ENTITY_KINDS)
and all(op in L.SHOW_OPS for _n, op, _v in f.show)
for _g, _p2, f in ROWS))
_names = set(ORDER) | set(KEYS) | {"inverter_count"}
check("every show condition reads an option, a page field or inverter_count",
all(n in _names for _g, _p2, f in ROWS for n, _o, _v in f.show))
check("every entity control has a kind, and no other control has one",
all((f.control in (L.ENTITY, L.ENTITY_LIST, L.SOLAR_LIST)) == bool(f.kind)
for _g, _p2, f in ROWS))
check("everything applies after a restart: options are read at start, and "
"OPT-11's runtime switches are not built yet",
all(f.applies == L.RESTART for _g, _p2, f in ROWS))
check("no unit states a number, so no range is written here",
all(not re.search(r"\d", f.unit) for _g, _p2, f in ROWS))
_SCHEMA = CONFIG[CONFIG.index("\nschema:"):]
_ROSTER = re.search(r"^ inverters:\n((?: {4,}.*\n)+)", _SCHEMA, re.M).group(1)
ROSTER_TYPES = dict(re.findall(r"^ {4,}(?:- )?([a-z_0-9]+): *(\S+)", _ROSTER,
re.M))
def _type(key):
if key.startswith("inverters."):
return ROSTER_TYPES.get(key.split(".", 1)[1], "")
return TYPES.get(key, "")
_ranged = [f.key for _g, _p2, f in ROWS
if f.control in (L.SLIDER, L.NUMBER, L.LOCKED)
and not f.key.startswith("ui.")]
_unranged = [k for k in _ranged if not re.match(r"(int|float)\(", _type(k))]
check(f"every slider, number and locked value has a range in config.yaml "
f"({len(_ranged)} of them, without: {_unranged})", not _unranged)
_SUFFIX = (("_eur_kwh", "€/kWh"), ("_eur", "€/kWh"), ("_kwh", "kWh"),
("_w", "W"), ("_s", "s"), ("_h", "h"), ("_min", "min"),
("_days", "days"), ("_floor", "%"), ("_ceiling", "%"),
("_target", "%"))
def _unit_problem(f):
want = next((u for suf, u in _SUFFIX if f.key.endswith(suf)), None)
if want is None or f.control not in (L.SLIDER, L.NUMBER, L.LOCKED):
return False
return f.unit != want and not (want == "W" and f.unit == "kW"
and f.scale == 1000)
_mism = [(f.key, f.unit) for _g, _p2, f in ROWS if _unit_problem(f)]
check(f"each unit matches its key's suffix (mismatched: {_mism})", not _mism)
check("the peak limit is shown in kW and stored in W",
L.field("peak_cap_w").unit == "kW" and L.field("peak_cap_w").scale == 1000)
_watt = sorted({f.key.split(".")[-1] for _g, _p2, f in ROWS
if f.kind == "power_w"})
check(f"the pickers that expect watts are exactly the entities validate.py "
f"reads as watts ({_watt})",
_watt == sorted(validate.WATT_ENTITY_KEYS))
check("range_of() reads a Supervisor-shaped schema, nested ones too",
L.range_of("max_w", [{"name": "max_w", "lengthMin": 100,
"lengthMax": 5000}]) == (100, 5000)
and L.range_of("inverters.max_w", [{"name": "inverters", "schema": [
{"name": "max_w", "lengthMin": 100, "lengthMax": 5000}]}])
== (100, 5000)
and L.range_of("p1_host", [{"name": "p1_host"}]) is None)
print("\n3. the words come from translations and app/ui_text")
_conf = EN["configuration"]
_no_tr = [k for k in KEYS if not k.startswith("ui.") and not (
k in _conf if "." not in k
else k.split(".")[1] in _conf["inverters"]["fields"])]
check(f"every option field has an en.yaml name (missing: {_no_tr})", not _no_tr)
_no_ui = [k for k in KEYS if k.startswith("ui.")
and k[3:] not in UI_TEXT["fields"]]
check(f"every page-only field has words in ui_text (missing: {_no_ui})",
not _no_ui)
_titles = [g.id for g in L.GROUPS if g.id not in UI_TEXT["groups"]] + [
f"{g.id}.{p.id}" for g in L.GROUPS for p in g.parts
if p.id and f"{g.id}.{p.id}" not in UI_TEXT["parts"]]
check(f"every group and part has a title (missing: {_titles})", not _titles)
_contract = {"none", "fixed", "daynight", "dynamic", "custom"}
check("every contract, fallback and region the model can hold has words",
set(UI_TEXT["choices"]["contract"]) == _contract
and set(UI_TEXT["choices"]["fallback"]) == _contract - {"dynamic"}
and set(UI_TEXT["choices"]["region"])
== set(uimodel.REGION_PRESETS) | {uimodel.OWN})
check("both 'applies' labels have words",
set(UI_TEXT["applies"]) == {L.RESTART, L.NOW})
print("\n4. visibility follows the page model")
_m = uimodel.options_to_ui({"meter_source": "ha_signed", "meter_phases": 3,
"inverters": [], "maintenance_enabled": False})
check("a signed meter shows its net entity, and three phases its net list",
L.visible(L.field("p1_net_entity"), _m)
and L.visible(L.field("p1_phase_net_entities"), _m)
and not L.visible(L.field("meter_entity"), _m)
and not L.visible(L.field("p1_phase_import_entities"), _m))
check("one inverter shows the one-inverter stops, not the per-inverter ones",
L.visible(L.field("sign_disagree_w"), _m)
and not L.visible(L.field("unit_sign_disagree_w"), _m))
check("maintenance details hide while maintenance is off",
not L.visible(L.field("maintenance_charge_w"), _m))
print("\n5. UI code names option keys in two files only")
_KEY = re.compile(r"""["']([a-z0-9]+(?:_[a-z0-9]+)+)["']""")
_src = io.open(os.path.join(HERE, "app", "uimodel.py"), encoding="utf-8").read()
_owned = set(L.DERIVED) | {"inverters", "price_provider",
"price_taxes_eur_kwh", "arbitrage_enabled"}
_extra = sorted({k for k in _KEY.findall(_src) if k in ORDER} - _owned)
check(f"uimodel.py names only the keys its derivations write (others: "
f"{_extra})", not _extra)
_leak = sorted({f"{name}:{k}" for name in ("haentities.py", "web.py")
for k in _KEY.findall(io.open(os.path.join(HERE, "app", name),
encoding="utf-8").read())
if k in ORDER})
check(f"haentities.py and web.py name no option key ({_leak})", not _leak)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+224
View File
@@ -0,0 +1,224 @@
"""UX-13 P0: stored options <-> page model, exactly. `python3 test_uimodel.py`
app/uimodel.py derives the keys the page does not show (the flat inverter,
the solar keys, static_profile, the levies figure, arbitrage_enabled). Part 1
round-trips every options blob the test suite hands the validator
(scenarios/ux13/, recorded by tools/ux13_validator_calls.py at 652097a), the
TEST-23 option files and config.yaml's defaults: options -> model -> JSON ->
options must be identical. Parts 2-6 edit one page field and check the blob.
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
"""
import glob
import gzip
import io
import json
import logging
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
logging.disable(logging.WARNING) # the validator's own log lines
from app import uimodel as U # noqa: E402
from app.price import (FLEMISH_TAXES_COMPONENTS_EUR_KWH, # noqa: E402
PRICE_OPTION_DEFAULTS)
from app.static_tariff import parse_static_profile # noqa: E402
from app.validate import validate_startup_config # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def trip(options):
return U.ui_to_options(json.loads(json.dumps(U.options_to_ui(options))))
def edit(options, **fields):
model = json.loads(json.dumps(U.options_to_ui(options)))
for k, v in fields.items():
model[k.replace("__", ".")] = v
return U.ui_to_options(model)
def raises(fn):
try:
fn()
except U.DeriveError:
return True
return False
def defaults():
"""config.yaml's `options:` block, read without PyYAML (not in CI)."""
text = io.open(os.path.join(HERE, "config.yaml"), encoding="utf-8").read()
block = text[text.index("\noptions:"):text.index("\nschema:")]
out = {}
for key, raw in re.findall(r"^ ([a-z_0-9]+):(.*)$", block, re.M):
raw = raw.split(" #")[0].strip()
try:
out[key] = json.loads(raw)
except ValueError:
out[key] = raw
return out
DEFAULTS = defaults()
SITE = dict(DEFAULTS, setpoint_entity="number.goodwe_setpoint",
soc_entity="sensor.soc", batt_entity="sensor.batt")
print("1. every known blob survives the round trip unchanged")
_calls = [json.loads(line) for line in gzip.open(os.path.join(
HERE, "scenarios", "ux13", "validator-calls-652097a.jsonl.gz"), "rt")]
_test23 = sorted(glob.glob(os.path.join(HERE, "scenarios", "ux13", "test23",
"*.json")))
_blobs = ([("config.yaml", DEFAULTS)]
+ [(c["source"], c["opts"]) for c in _calls
if isinstance(c["opts"], dict)]
+ [(os.path.basename(f), json.load(open(f))) for f in _test23])
_bad = []
for _src, _o in _blobs:
try:
_back = trip(_o)
except Exception as e: # noqa: BLE001
_bad.append(f"{_src}: {type(e).__name__} {e}")
continue
if json.dumps(_back, sort_keys=True) != json.dumps(_o, sort_keys=True):
_bad.append(f"{_src}: {sorted(set(_back) ^ set(_o)) or 'values'}")
print(f" {len(_calls)} validator calls, {len(_test23)} TEST-23 files, "
f"config.yaml defaults")
check(f"all {len(_blobs)} are identical after options -> model -> JSON -> "
f"options (not: {_bad[:3]})", not _bad and len(_calls) >= 200
and len(_test23) >= 5 and len(DEFAULTS) >= 78)
check("config.yaml was read whole: the defaults hold every option",
"inverters" in DEFAULTS and DEFAULTS["inverters"] == []
and DEFAULTS["price_taxes_eur_kwh"] == 0.11736)
_m = U.options_to_ui(DEFAULTS)
check("the page model never shows a derived key: it keeps them aside",
not {"setpoint_entity", "pv_entity", "pv_entities", "static_profile",
"price_taxes_profile"} & set(_m)
and set(_m["keep"]) >= {"static_profile", "pv_entity", "max_w"})
check("a blob that lacks keys gains none", trip({"max_w": 900}) == {"max_w": 900})
print("\n2. one inverter or a list")
_one = edit(SITE, inverters=[dict(U.options_to_ui(SITE)["inverters"][0],
max_w=1500)])
check("editing the one inverter writes the flat keys and keeps the list empty",
_one["max_w"] == 1500 and _one["inverters"] == []
and _one["setpoint_entity"] == "number.goodwe_setpoint")
_card = U.options_to_ui(SITE)["inverters"][0]
_two = edit(SITE, inverters=[
dict(_card, name="inverter-a", capacity_kwh=None),
dict(_card, name="inverter-b", setpoint_entity="number.goodwe_b",
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b")])
check("a second inverter writes the list and clears the flat power request "
"entity, which the validator refuses beside a list",
len(_two["inverters"]) == 2 and _two["setpoint_entity"] == ""
and "capacity_kwh" not in _two["inverters"][0]
and not validate_startup_config(_two))
_listed = dict(SITE, setpoint_entity="", inverters=[
{"name": "garage", "setpoint_entity": "number.g", "max_w": 2000,
"bms_soc_floor": 10}])
_l1 = edit(_listed, inverters=[dict(_listed["inverters"][0], max_w=1800)])
check("a list of one stays a list, so the inverter keeps its name and stored "
"state", _l1["inverters"] == [dict(_listed["inverters"][0], max_w=1800)])
check("editing a listed inverter keeps the unread flat keys as stored",
all(_l1[k] == _listed[k] for k in ("setpoint_entity", "soc_entity",
"batt_entity", "max_w")))
check("a name on the flat inverter is refused rather than dropped",
raises(lambda: edit(SITE, inverters=[dict(_card, name="garage")])))
_raw = dict(SITE, inverters="garage")
check("a roster no card can show is kept when another field is saved, and "
"cannot be edited here",
edit(_raw, gain=0.5)["inverters"] == "garage"
and raises(lambda: edit(_raw, inverters=[_card])))
print("\n3. solar rows")
check("a stored flip reads as a reversed row",
U.options_to_ui(dict(DEFAULTS, pv_entity="sensor.pv",
pv_invert_entities=["sensor.pv"]))["ui.solar"]
== [{"entity": "sensor.pv", "reversed": True}])
check("one row writes pv_entity",
edit(DEFAULTS, ui__solar=[{"entity": "sensor.pv", "reversed": False}])
["pv_entity"] == "sensor.pv")
_pv2 = edit(dict(DEFAULTS, pv_entity="sensor.a"), ui__solar=[
{"entity": "sensor.a", "reversed": False},
{"entity": "sensor.b", "reversed": True}])
check("two rows write pv_entities, clear pv_entity and flip the reversed one",
_pv2["pv_entities"] == ["sensor.a", "sensor.b"] and _pv2["pv_entity"] == ""
and _pv2["pv_invert_entities"] == ["sensor.b"])
_stray = dict(DEFAULTS, pv_entities=["sensor.a", "sensor.b"],
pv_invert_entities=["sensor.b", "sensor.old"])
_pv1 = edit(_stray, ui__solar=[{"entity": "sensor.a", "reversed": False}])
check("removing a reversed row drops its flip, and a flip that was never a row "
"stays as stored", _pv1["pv_entities"] == ["sensor.a"]
and _pv1["pv_invert_entities"] == ["sensor.old"])
print("\n4. contract and prices")
_fixed = edit(DEFAULTS, ui__contract="fixed", ui__fixed_price=0.31)
check("a fixed price writes a profile the tariff parser reads as one price",
_fixed["price_provider"] == "off"
and [p.eur_per_kwh for p in parse_static_profile(
_fixed["static_profile"]).periods] == [0.31]
and U.options_to_ui(_fixed)["ui.contract"] == "fixed")
_dn = edit(SITE, ui__contract="daynight", ui__day_price=0.34,
ui__night_price=0.26)
check("day and night write a profile that starts and reads back the same",
not validate_startup_config(_dn)
and U.options_to_ui(_dn)["ui.day_price"] == 0.34
and U.options_to_ui(_dn)["ui.night_price"] == 0.26)
_dyn = edit(_dn, ui__contract="dynamic", price_provider="generic_dayahead")
check("a dynamic contract keeps the day and night profile as its fallback",
_dyn["price_provider"] == "generic_dayahead"
and _dyn["static_profile"] == _dn["static_profile"])
check("a dynamic contract without a price source is refused here",
raises(lambda: edit(DEFAULTS, ui__contract="dynamic")))
_odd = json.dumps({"periods": [
{"from": "00:00", "to": "12:00", "days": "mon-sun", "eur_per_kwh": 0.2},
{"from": "12:00", "to": "24:00", "days": "mon-sun", "eur_per_kwh": 0.3}]})
_om = U.options_to_ui(dict(DEFAULTS, static_profile=_odd))
check("odd windows show as 'custom' and survive saving another field",
_om["ui.contract"] == "custom"
and edit(dict(DEFAULTS, static_profile=_odd), gain=0.5)
["static_profile"] == _odd)
print("\n5. region presets")
_fl = U.REGION_PRESETS["flanders"]
check("the Flanders preset is the sum of app/price.py's components, and is the "
"shipped default", _fl["taxes_eur_kwh"] == round(sum(
v for _n, v in FLEMISH_TAXES_COMPONENTS_EUR_KWH), 5)
== PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == DEFAULTS[
"price_taxes_eur_kwh"])
check("every preset carries its year", all(
isinstance(p["year"], int) and p["year"] >= 2026
for p in U.REGION_PRESETS.values()))
_own = dict(DEFAULTS, price_taxes_eur_kwh=0.06)
check("a figure that is no preset reads as 'own', and choosing Flanders "
"writes the preset", U.options_to_ui(_own)["ui.region"] == U.OWN
and edit(_own, ui__region="flanders")["price_taxes_eur_kwh"] == 0.11736)
_prof = dict(DEFAULTS, price_taxes_eur_kwh=0.12,
price_taxes_profile='{"day": 0.14, "night": 0.1}')
check("'own' keeps a stored levies table when the figure is edited",
edit(_prof, price_taxes_eur_kwh=0.13)["price_taxes_profile"]
== _prof["price_taxes_profile"])
check("'own' stores the typed figure",
edit(DEFAULTS, ui__region=U.OWN, price_taxes_eur_kwh=0.13)
["price_taxes_eur_kwh"] == 0.13)
print("\n6. arbitrage_enabled: the OPT-11 hook changes nothing yet")
check("the hook answers None while battery modes do not exist",
U.arbitrage_from_batteries(U.options_to_ui(DEFAULTS)) is None)
check("so the toggle is stored as the page has it",
edit(DEFAULTS, arbitrage_enabled=True)["arbitrage_enabled"] is True)
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all passed")
+41
View File
@@ -1808,6 +1808,47 @@ check("an UNREADABLE shadow_mode resolves to SHADOW, not to live - the "
and shadow.engaged({"shadow_mode": ""}, control_started=True) is True
and shadow.mode({"shadow_mode": "garbage"}) == "on")
# --------------------------------------------------------------------------- #
print("\nUX-13 - the warning tier: notes for the settings page, never refusals")
from app.validate import Note, WATT_ENTITY_KEYS, config_notes # noqa: E402
_warned = base(integrator_max_w=3000, maintenance_discharge_w=2500,
price_provider="generic_dayahead", static_profile="")
_notes = []
_with = validate_startup_config(_warned, -5000, 5000, notes=_notes)
check("a config with three logged warnings starts, and gives three notes, "
"each filed under the setting it names",
_with == [] and validate_startup_config(_warned, -5000, 5000) == []
and sorted(n.keys for n in _notes) == [
("integrator_max_w",), ("maintenance_discharge_w",),
("static_profile",)]
and all(n.tier == "warn" and f"({n.keys[0]})" in n.text for n in _notes))
_refused = base(setpoint_entity="", integrator_max_w=3000)
_n2 = []
check("collecting notes changes no refusal",
validate_startup_config(_refused, notes=_n2)
== validate_startup_config(_refused) != [] and len(_n2) == 1)
_rows = {"number.goodwe_setpoint": {"unit": None},
"sensor.soc": {"unit": "%"}, "sensor.batt": {"unit": "kW"}}
_cn = config_notes(base(), entities=_rows)
check("a battery power sensor in kW is warned about: the add-on takes it as "
"watts", [n.keys for n in _cn] == [("batt_entity",)]
and "kW" in _cn[0].text)
_cn = config_notes(base(meter_entity="sensor.grid"), entities={})
check("an entity Home Assistant does not have is named, and only the ones "
"this meter source and roster shape read",
sorted(n.keys[0] for n in _cn) == ["batt_entity", "meter_entity",
"setpoint_entity", "soc_entity"])
check("with no entity list (Home Assistant down) those checks are skipped",
config_notes(base(), entities=None) == [])
check("a peak limit below this month's peak is warned about",
[n.keys for n in config_notes(base(peak_cap_w=2500),
month_peak_w=2900)] == [("peak_cap_w",)]
and config_notes(base(peak_cap_w=3500), month_peak_w=2900) == [])
check("pv.py converts solar units itself, so no solar key is read as watts",
not [k for k in WATT_ENTITY_KEYS if k.startswith("pv_")]
and isinstance(_cn[0], Note))
# --------------------------------------------------------------------------- #
print()
if fails:
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""SAFETY-52's evidence: the 16-day capture replayed with and without the
battery-refuses hold. `python3 tools/replay_refusal.py`
Same disturbance, fitted plant and pack floor as `replay_recorded_loop.py`,
plus main.py's reseed, 15 s input hold and 0 W rules and the REAL SAFETY-37/57
gate on a register published every 10 s. `old` is `compute()` with
`refused=None`, which is release/1.0's law unchanged; `new` carries the refusal,
runs the probe on main.py's clock and seeds a refusal from a gate trip.
Modelled watt-hours: read the deltas, not the absolutes (see that tool).
"""
import math
import pathlib
import sys
ADDON = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ADDON))
sys.path.insert(0, str(ADDON / "tools"))
import replay_recorded_loop as R # noqa: E402
from app import freshness, control as NEW # noqa: E402
def go(which, rows, fit, publish_s=10.0, stale_input_s=15.0):
law = NEW
tun = law.Tuning(**R.LIVE)
t0, g0, b0, sp0, soc0 = rows[0]
pl = R.Plant(*fit[:4], soc0); pl.seed(t0, b0, sp0)
st = dict(target=sp0, i_w=sp0, sat=0, loop_w=sp0, ref=0, anchor=None)
fr = freshness.Freshness(freshness.BOUNDS["batt"]); reg, reg_t = None, -1e18
bad, trips, was = None, 0, False
M = dict(und=0.0, imp=0.0, exp=0.0, q={}, run=0.0, mx=0.0, last=None, refh=0.0, probes=0)
for i in range(1, len(rows)):
t, gr, ba, sp, soc = rows[i]
dt = t - rows[i - 1][0]
if dt > R.TICK_CAP_S:
pl = R.Plant(*fit[:4], soc); pl.seed(t, ba, sp)
st.update(target=sp, i_w=sp, sat=0, loop_w=sp, ref=0, anchor=None)
fr = freshness.Freshness(freshness.BOUNDS["batt"]); reg_t = -1e18; bad = None
continue
b = pl.step(t, dt, st["target"]); grid = gr + ba - b
if t - reg_t >= publish_s:
reg, reg_t = float(round(b)), t
fr.submit(reg, st["target"], t); batt = fr.reading()
if fr.stale and not was:
trips += 1
if (which == "new" and not st["ref"] and st["sat"] >= tun.saturation_cycles
and abs(reg) < NEW.REFUSED_BELOW_W and abs(st["target"]) > tun.saturation_w):
st["ref"], st["anchor"] = (1 if st["target"] > 0 else -1), t
was = fr.stale
if batt is None:
bad = t if bad is None else bad
if t - bad >= stale_input_s:
st.update(target=0.0, i_w=0.0, loop_w=None)
else:
bad = None
if st["loop_w"] is None or st["target"] != st["loop_w"]:
st["i_w"] = st["target"]
if which == "old":
d = law.compute(st["target"], grid, batt, tun, st["sat"], st["i_w"],
refused=None)
else:
probe = False
if st["ref"] and st["anchor"] is not None:
h = t - st["anchor"]
if h >= NEW.PROBE_EVERY_S + NEW.PROBE_S: st["anchor"] = t
else: probe = h >= NEW.PROBE_EVERY_S
d = law.compute(st["target"], grid, batt, tun, st["sat"], st["i_w"], refused=st["ref"], probe=probe)
if d.refused and not st["ref"]: st["anchor"] = t
if not d.refused: st["anchor"] = None
if probe and d.target_w == NEW.PROBE_W and M["last"] != NEW.PROBE_W: M["probes"] += 1
st["ref"] = d.refused
st.update(sat=d.sat_count, i_w=d.i_w, loop_w=d.target_w, target=d.target_w)
tg = st["target"]
if tg - b > 1000: M["und"] += dt / 3600
if st["ref"]: M["refh"] += dt / 3600
M["imp"] += max(0, grid) * dt / 3.6e6; M["exp"] += max(0, -grid) * dt / 3.6e6
k = math.floor(t / 900); a = M["q"].setdefault(k, [0.0, 0.0]); a[0] += grid * dt; a[1] += dt
M["run"] = M["run"] + dt if (M["last"] == tg and abs(tg) >= 250) else 0.0
M["mx"] = max(M["mx"], M["run"]); M["last"] = tg
worst = max(max(0.0, s / d) for s, d in M["q"].values() if d > 600)
return trips, M, worst
def main():
rows = R.stream()
fit = R.fit_plant(rows, stride=7)
print(f"fit {[round(x, 2) for x in fit]}, {len(rows)} meter instants")
for label, f in (("fitted plant", fit),
("plant offset 0 W", (fit[0], fit[1], fit[2], 0.0, fit[4]))):
for which in ("old", "new"):
tr, M, w = go(which, rows, f)
print(f"{label:17s} {which}: gate trips {tr:4d} | request >1 kW above "
f"delivery {M['und']:6.1f} h | longest held >=250 W "
f"{M['mx'] / 3600:5.2f} h | import {M['imp']:6.1f} kWh export "
f"{M['exp']:5.1f} kWh | worst 15-min {w:5.0f} W | refused "
f"{M['refh']:5.1f} h, probes {M['probes']}", flush=True)
if __name__ == "__main__":
main()
+131
View File
@@ -0,0 +1,131 @@
"""UX-13 P1: serve the real page and routes over a fixture controller, for a
headless browser check. `python3 tools/ux13_demo.py STATE PORT`
STATE is running, shadow or alarm. The controller is the shipped `Controller`
with its readings set by hand (two batteries, solar, today's prices in the
mockup's shape, trading on). Nothing here talks to Home Assistant or to an
inverter: `/action` changes only this fixture. Used for the P1 render check
(no JS errors, tooltips, phone width, dark mode) described in the PR.
"""
import asyncio
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from aiohttp import web as aioweb # noqa: E402
from app import homeview, web # noqa: E402
from app.main import Controller # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead # noqa: E402
from app.static_tariff import TariffPlan # noqa: E402
PRICES = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15,
.14, .16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
class Store(dict):
def set(self, k, v):
self[k] = v
def set_time(self, k, v):
self[k] = v.isoformat()
def get_time(self, k):
v = self.get(k)
return None if v is None else datetime.fromisoformat(v)
@property
def data(self):
return self
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 []
class Supervisor:
async def info(self):
return {"options": {}, "schema": [], "translations": {}}
OPTS = {"meter_entity": "sensor.grid", "meter_source": "off",
"pv_entity": "sensor.pv", "maintenance_enabled": True,
"maintenance_start_hour": 18, "arbitrage_enabled": True,
"cycle_cost_threshold_eur": 0.05,
"inverters": [
{"name": "Battery A", "setpoint_entity": "number.a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 2500, "capacity_kwh": 9.6},
{"name": "Battery B", "setpoint_entity": "number.b",
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
"max_w": 2500, "capacity_kwh": 5.4}]}
def controller(state):
opts = dict(OPTS, shadow_mode="on" if state == "shadow" else "off")
c = Controller(opts, Hass(), Store(), Pub(), stop=asyncio.Event())
c.auto = True
now = datetime.now(timezone.utc)
day0, _day1 = homeview.local_day(now)
pts = tuple(PricePoint(day0 + timedelta(days=d, hours=k), p, False)
for d in range(2) for k, p in enumerate(PRICES))
c.tariff_plan = TariffPlan(PriceRead.series(
pts, PriceProduct.DAY_AHEAD, "generic_dayahead", now), None,
"provider")
c.tariff_at = now
c.peak_guard = PeakDecision(engaged=False, floor_w=None,
ceiling_w=3500.0, ceiling_from="cap",
projected_w=1240.0)
for inv, soc, batt in zip(c.inverters, (62.0, 35.0), (-2500.0, -300.0)):
inv.soc, inv.batt = soc, (0.0 if state == "shadow" else batt)
inv.dev_min, inv.dev_max = -5000.0, 5000.0
c.grid = -10.0 if state != "shadow" else -2810.0
c.pv_w = 3900.0
if state == "alarm":
c.inverters[0].sign_wd.tripped = True
return c
async def main(state, port):
runner = aioweb.AppRunner(web.build_app(controller(state), Supervisor()))
await runner.setup()
await aioweb.TCPSite(runner, "127.0.0.1", port).start()
print(f"serving {state} on http://127.0.0.1:{port}/", flush=True)
await asyncio.Event().wait()
if __name__ == "__main__":
asyncio.run(main(sys.argv[1], int(sys.argv[2])))
@@ -0,0 +1,106 @@
"""UX-13: record every `validate_startup_config()` call the test suite makes,
and compare two validators over those calls.
python3 tools/ux13_validator_calls.py record OUT.jsonl.gz
python3 tools/ux13_validator_calls.py compare OLD_APP_PARENT OUT.jsonl.gz
`record` runs each test_*.py with the validator wrapped (in this directory, so
run it where the suite runs: the CI image). `compare` runs the recorded calls
through the validator under OLD_APP_PARENT (a directory holding an `app/`,
e.g. from `git archive <base> goodwe_controller/app`) and through this tree's,
and prints every call whose refusals differ. P0 of UX-13 used it to show that
adding the warning tier changed no refusal. scenarios/ux13/ holds the record
test_uimodel.py round-trips.
"""
import gzip
import json
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_WRAP = r"""
import json, runpy, sys
sys.path.insert(0, ".")
from app import validate
real = validate.validate_startup_config
out = open(sys.argv[1], "a")
def rec(opts, dev_min=None, dev_max=None, *a, **kw):
try:
out.write(json.dumps({"opts": opts, "dev_min": dev_min,
"dev_max": dev_max}) + "\n")
except (TypeError, ValueError):
pass
return real(opts, dev_min, dev_max, *a, **kw)
validate.validate_startup_config = rec
sys.argv = sys.argv[2:]
try:
runpy.run_path(sys.argv[0], run_name="__main__")
except SystemExit:
pass
"""
def record(out_path):
tmp = out_path + ".part"
open(tmp, "w").close()
rows, seen = [], set()
for name in sorted(f for f in os.listdir(HERE)
if f.startswith("test_") and f.endswith(".py")):
before = sum(1 for _ in open(tmp))
subprocess.run([sys.executable, "-B", "-c", _WRAP, tmp, name],
cwd=HERE, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL)
with open(tmp) as fh:
for line in list(fh)[before:]:
call = json.loads(line)
key = json.dumps(call, sort_keys=True)
if key not in seen:
seen.add(key)
rows.append(dict(call, source=name))
os.remove(tmp)
with gzip.open(out_path, "wt") as fh:
for row in rows:
fh.write(json.dumps(row, sort_keys=True) + "\n")
print(f"{len(rows)} distinct validator calls -> {out_path}")
def _run(app_parent, path):
code = r"""
import gzip, json, logging, sys
logging.disable(logging.CRITICAL)
sys.path.insert(0, sys.argv[1])
from app.validate import validate_startup_config as v
out = []
for line in gzip.open(sys.argv[2], "rt"):
c = json.loads(line)
try:
out.append(v(c["opts"], c["dev_min"], c["dev_max"]))
except Exception as e:
out.append("raised " + type(e).__name__)
print(json.dumps(out))
"""
got = subprocess.run([sys.executable, "-B", "-c", code, app_parent, path],
capture_output=True, text=True, check=True)
return json.loads(got.stdout)
def compare(old_parent, path):
old, new = _run(old_parent, path), _run(HERE, path)
differ = [i for i, (a, b) in enumerate(zip(old, new)) if a != b]
print(f"{len(old)} calls, {sum(1 for r in old if r)} refused by the old "
f"validator, {len(differ)} with different refusals")
for i in differ:
print(f" call {i}:\n old {old[i]}\n new {new[i]}")
return 1 if differ else 0
if __name__ == "__main__":
if len(sys.argv) == 3 and sys.argv[1] == "record":
record(sys.argv[2])
elif len(sys.argv) == 4 and sys.argv[1] == "compare":
sys.exit(compare(sys.argv[2], sys.argv[3]))
else:
sys.exit(__doc__)