Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
badea3cb2d | ||
|
|
15627c38a6 | ||
|
|
652097abde | ||
|
|
3c0f2b9669 | ||
|
|
1d33265aac | ||
|
|
1f97c7652f | ||
|
|
d1f7e3336e | ||
|
|
3c298af3d8 | ||
|
|
c628099e69 | ||
|
|
29837dfaa7 | ||
|
|
c4032ebc21 | ||
|
|
2f1627e287 | ||
|
|
2b14b88cb4 | ||
|
|
e0b578756f | ||
|
|
f1a660881c | ||
|
|
23a3a3f5b5 | ||
|
|
125b0702a2 | ||
|
|
b878663b2d | ||
|
|
32b9e56357 |
+1
-1
@@ -1,2 +1,2 @@
|
||||
# The admin/goodwe commit CI tests against. Bump: set to the full SHA of admin/goodwe release/1.0 HEAD (README.md, "CI and the infra pin").
|
||||
7081df4276ad3cf788355b97a3b3f40ea28d72bd
|
||||
d637d0b1b13f1b863dc47e445e2ca4cf4a82fe10
|
||||
|
||||
@@ -7,7 +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.
|
||||
- **Log line** (WARNING/ERROR): one line, at most 25 words, with the numbers that matter. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated. A reason text a line quotes (`%s`) keeps its own codes, because that is the evidence an installer searches for.
|
||||
- **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.
|
||||
- **Sentences**: one idea each, active voice, present tense, with nothing nested in brackets and no semicolons (a semicolon is a second sentence in disguise). These translate well into Dutch and French.
|
||||
|
||||
@@ -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.
|
||||
@@ -61,3 +61,32 @@ The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line
|
||||
10. **The panel's key match** no longer files `inverters.max_w` under the flat `max_w`.
|
||||
11. **The Supervisor question at start** is one INFO line with a real reason (a timeout's own text is empty, so the error type is named).
|
||||
12. **DOCS**: a glossary ("Words used on this page") explains control board, isolator and Developer Tools once. Card ids left the headings and sit on a *For installers* line under each. The links to the renamed headings were updated.
|
||||
|
||||
# UX-10: the last plain-language leftovers (after the 2d test)
|
||||
|
||||
**Unchanged:** every safety behaviour, every alarm's log level (WARNING and ERROR stay), every notification id, trigger and cadence, the banner cascade, and every row colour except the meter row right after a start (item 6).
|
||||
|
||||
1. **Why the 2d scan missed the clamp lines.** Part 3 reads a log call's own literal and counts `%s` as one word. The clamp lines are `"…software fault: %s …"`, and the text in `%s` was built elsewhere, by `loop_clamp_note()` and `ClampResult.describe()`. So the scan passed a line whose logged text had a semicolon, "BUG SIGNAL", "clamped" and "1 times". The same was true of every quoted reason. Part 6 closes the gap. It lists every dynamic part of every WARNING+ line as data (a number, a name, a path, a library's own error text) or as a built text, and a new part in neither list fails. Each built text is produced by the real code (the watchdogs, `decide()`, `loop_clamp_note()`, `MigrationReport.explain()`) or, for a family of builders, by every template of it (`PriceRead.no_data`, `Gate.block`). The text and the line as logged are then checked. A mutation shows the 2d clamp text is caught on both clamp lines.
|
||||
2. **A quoted reason gets the red-alarm shape**, 3 sentences of at most 30 words, and not the 25-word line limit. It is the evidence behind an alarm, and it keeps its code and its limits, which the owner asked to keep. The alarm shape is STYLE.md's existing rule for the texts behind a safety stop, so no new limit was made up. Rewritten: `OUTPUT_CLAMP` (both kinds), `METER_FROZEN`, `CIRCULATING_POWER`, `STALE_READING` (SAFETY-56's two explanations and "cannot tell which" kept, in order), the peak guard's reasons, the state-migration report, the price feed and price history reasons, and the maintenance and trading gate reasons. "Software fault" left the clamp reason, because the ERROR line around it already says it.
|
||||
3. **Codes are one token.** `PEAK GUARD`, `PEAK GUARD FLOOR-LIMITED` and `PEAK GUARD BLIND` read as capitals, so they are now `PEAK_GUARD`, `PEAK_GUARD_FLOOR_LIMITED` and `PEAK_GUARD_BLIND`, like `OUTPUT_CLAMP`. Nothing in admin/goodwe or in the scripts under `/home/homelab` greps them (checked). `/home/homelab/clampwatch.sh` greps `OUTPUT_CLAMP`, which stays.
|
||||
4. **Top table.** *Price history* writes "96 prices over 24 hours for 26 Sep", joined by commas. *Tariff* writes the spacing as "one every 15 minutes or every hour". "Not final yet" was dropped, because a plan is provisional exactly when it holds an estimated price (`n_carried` counts the provisional points), so the estimated count already says it.
|
||||
5. **Levels and noise.** The shadow line of every cycle is DEBUG, and "Shadow mode is on/off" is INFO: it follows a setting or a button, and the banner, hint and event are unchanged. aiohttp's access log is on only at log level debug. A settings warning is logged once per start (a filter on `goodwe.config`, WARNING only, so refusals are untouched), because the validator runs twice at start. When Home Assistant restarts, `hass.Outage` says so in one WARNING line for the REST API and the meter websocket together, puts the library's text on DEBUG, and says at INFO when both answer again. INFO lines lost "§8.3", ISO timestamps, "banked" and "keys ARE", and error texts lost "NOT_OBSERVED", "did NOT land" and "AND".
|
||||
6. **Display bugs.**
|
||||
- "Why: Other" during a latched per-inverter stop: `main.py` turns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit", main.py's own refusal to split) had no entry in `WHY_WINNER`. The mapping is now `main.why_key()`, and part 7 finds every `Claim.set()` in the code and checks that every key it can produce has words.
|
||||
- Red "Meter: no reading for 0 seconds" after a restart: until the first reading arrives, `P1Ingest.stale` is true, and the age is the time since start. The row now reads amber "waiting for the first reading" while that age is inside the limit. **Colour change, justified:** red at 0 s contradicted the limit the same row prints, and once readings exist the row is red only past that limit. Amber, not green, because control waits for the reading. Past the limit it is red as before. `stale` itself, which gates control, is unchanged.
|
||||
- "A reading is missing for 0 seconds" stuck at 0: with the meter reading gone, the grid value stops changing, so the loop runs a cycle only every `cycle_floor_s`, and the line was computed once per cycle. The age is now counted when the page asks. The hold and its 15 s escalation are unchanged.
|
||||
7. **Review: the line as logged gets the shape too.** Part 6 first checked the quoted reason alone, so a title, a 2-sentence reason and two tails still logged as 4 sentences ("Power request too high …: OUTPUT_CLAMP: … +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far."). Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. The wrappers became one sentence each, the circulation and peak guard reasons two, and `STALE_READING`'s fixed limits moved from the reason to a DEBUG line beside the ERROR (its release clause stays, pinned by test_freshness). The clamp lines for one inverter are pinned at 30 words at most: `OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W`. Several clamped inverters read as one sentence of asked/sent pairs; which limit bound each is on its own action-log row and in the notification. Totals above 30 words remain on the direction, delivery, frozen-meter and stale lines (up to 74 words). Shortening them is a follow-up: the direction and delivery reasons are also persisted in state.json, and the direction one is pinned byte-for-byte in test_watchdog.
|
||||
8. **Home Assistant 400 while it loads** now reads "Home Assistant refused number.set_value for number.x (error 400, no details). The entity may still be loading, or the value is out of range." A body with more than "400: Bad Request" is still quoted. Still WARNING, still once a minute, still a failed write.
|
||||
9. **Witness** (admin/goodwe): the resolved push says "after no answers from … to …", as the resolved card does. The YAML with every title and message stripped is identical.
|
||||
|
||||
# UX-11: safety log lines give the code, the figures go to DEBUG
|
||||
|
||||
**Unchanged:** every trigger, latch, threshold and alarm log level (the WARNING and ERROR lines stay), every notification id and cadence, every row colour and the banner cascade. No notification is added.
|
||||
|
||||
1. **One plain sentence and the code.** A WARNING/ERROR line that reports a safety reason (direction, delivery, stuck meter, stale reading, peak guard, circulation, over-rating) now ends in the reason's code, taken by `messages.reason_code()`. The reason itself, figures and limits included, is on a DEBUG line logged right after it ("Evidence for inverter-a: …"), and the action log entry carries it as before. Part 6 checks that pairing in the code. Circulation and the clamp lines were not among the five named, but they break the same 25-word rule, so they follow the same pattern. The clamp numbers stay in the notification (phase 2a item 3).
|
||||
2. **The reasons and `state.json` are unchanged.** `reason` stays the evidence text, so what is saved has the same format as before. An old state file therefore loads and displays like a new one: every saved reason has opened with its code since it was first written (checked in the git history), and that code is all the ERROR line reads. The saved text goes on the DEBUG line. A stop saved without a reason shows `REASON_NOT_SAVED`. `test_messages.py` part 7 restarts from a state file holding the old long texts. `test_watchdog.py` pins the meaning (the code first, then command, reading, time and both limits, in that order) and, since the text did not change, still the bytes.
|
||||
3. **The 25-word rule is checked as logged.** Part 6 fails any WARNING+ line over 25 words with its built text filled in. Nine non-safety families still run long (tariff plan words, price feed reasons, the state migration report, write refusals and claim scope errors that only a software fault raises, maintenance, trading gate and price history reasons). They are listed with a reason in `LONG_LINE_OK`, capped at 9, and a listed family that gets shorter fails until it is removed. Shortening them is a follow-up.
|
||||
4. **The meter row's "the add-on log shows why" is now true.** When the meter goes quiet past its limit, the add-on logs one WARNING per episode, not one per cycle, and an INFO line when readings return. A meter that flaps (on `ha_signed` a healthy meter under a flat load can go 42–97 s between changes) logs at most one such pair per 10 minutes, and the episodes in between go to DEBUG. This is a new log line, not a new alarm: it has no notification and changes no decision.
|
||||
5. **The Why line past the 15 s hold** names the missing reading ("No usable meter reading for over 15 seconds: 0 W", or the battery power or level of an inverter), instead of "A reading is missing: 0 W".
|
||||
6. **Action log:** "25 engagement(s)" is "25 engagements", and "SHADOW MODE OFF - …" is "Shadow mode off: …". "SHADOW MODE ON" was not named in the card and is left as it is.
|
||||
7. **Review: after a restart the figures go on the action log too.** `log_level` defaults to `info`, so the DEBUG evidence line is not seen by default, and `events` starts empty. Each restored stop (direction per inverter and site, delivery, circulation) now also writes its saved reason to the action log ("FAILSAFE inverter-a, saved before the restart: …"), and the page's event line gets the same plain words the live stop gives. `LONG_LINE_OK` also refuses a key whose text is a safety reason, not only a `reason_code` key.
|
||||
|
||||
@@ -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.
|
||||
+55
-24
@@ -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,27 @@ 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.
|
||||
|
||||
⚠️ **One inverter only.** With several inverters this hold is off. There, the
|
||||
battery level limit moves an empty battery's share to the others, but only if
|
||||
its “Battery cut-out level” matches where the battery really stops. A battery
|
||||
that refuses at another level, or for another reason (cold, a battery fault),
|
||||
can still hold its last request until the grid starts to export.
|
||||
|
||||
*For installers:* SAFETY-52. The event log reads `BATTERY REFUSES …` and
|
||||
`BATTERY REFUSES ENDED: …`, and the INFO line ends in `BATTERY_REFUSES`.
|
||||
`DECISIONS/SAFETY-52-a-battery-that-refuses.md` has the replay figures.
|
||||
|
||||
⚠️ **`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
|
||||
@@ -1639,7 +1660,7 @@ is the design rather than an accident: it is 1.0 at the top of the quarter and
|
||||
grows, so the guard is gentlest when its projection rests on the least evidence
|
||||
and becomes aggressive only once the quarter is nearly measured.
|
||||
|
||||
**The ceiling** is `peak_cap_w`, raised to the month's banked peak when that
|
||||
**The ceiling** is `peak_cap_w`, raised to this month's recorded peak when that
|
||||
already stands higher — once a month's bill is set at 5013 W, holding later
|
||||
quarters to 3500 W buys nothing and costs real charging. **That relaxation is
|
||||
granted only by an OBSERVED reading:** a peak store that cannot be read leaves the
|
||||
@@ -1716,7 +1737,7 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
|
||||
**nothing more**. See below for what that costs on a real evening.
|
||||
- **When it binds you are told**, once per episode, with notification
|
||||
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
|
||||
peak reserve* (`PEAK GUARD FLOOR-LIMITED` in the log): the quarter **will**
|
||||
peak reserve* (`PEAK_GUARD_FLOOR_LIMITED` in the log): the quarter **will**
|
||||
go over the ceiling, so shedding load is
|
||||
the only thing left that changes the bill. Failing to shave is never silent —
|
||||
the euro figure is the whole point of the guard.
|
||||
@@ -1758,7 +1779,7 @@ because the guard it bounds is not the thing doing the discharging.
|
||||
|
||||
**And the two can happen in the same cycle.** On the high-load block of the same
|
||||
day (15:00–17:00 UTC) with the pack below the floor, the guard reports
|
||||
`PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on 49 cycles,
|
||||
`PEAK_GUARD_FLOOR_LIMITED` — *"there is no forced discharge"* — on 49 cycles,
|
||||
and on **all 49 of them** the loop is simultaneously being granted a discharge of
|
||||
up to 2000 W by the arbiter. The status page is telling you the truth about the
|
||||
guard while the battery empties.
|
||||
@@ -1848,14 +1869,14 @@ Both are watched. A guard that watched only the emitted value would be silent on
|
||||
every standard configuration.
|
||||
|
||||
**The integrator bound is not a bug, and it is reported as what it is.** It
|
||||
reads *"the loop's integrator reached its +5000 W anti-windup bound; the loop's
|
||||
command this cycle was +2000 W"*, titled *"the control loop is at its integrator
|
||||
bound"*, logged as a `WARNING`, with an amber (not red) status row and its own
|
||||
notification (`goodwe_integrator_bound`), so it never replaces a BUG SIGNAL
|
||||
notice still on screen. Brief
|
||||
engagements after a large load or solar step are normal. If it stays engaged,
|
||||
check that the battery is following its commands. Only a command over the rating
|
||||
carries the BUG SIGNAL text, and it has its own notification budget.
|
||||
reads *"the correction the add-on builds up reached its +5000 W limit,
|
||||
command now +2000 W"*, titled *"Battery is not keeping up"*, logged
|
||||
as a `WARNING`, with an amber (not red) status row and its own notification
|
||||
(`goodwe_integrator_bound`), so it never replaces a software-fault notice still
|
||||
on screen. Brief engagements after a large load or solar step are normal. If it
|
||||
stays engaged, check that the battery is following its commands. Only a command
|
||||
over the rating is logged as a software fault, at `ERROR`, and it has its own
|
||||
notification budget.
|
||||
Why: [`DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md`](DECISIONS/SAFETY-40-the-integrator-bound-is-not-a-bug.md).
|
||||
|
||||
### The frozen-meter alarm
|
||||
@@ -2444,7 +2465,7 @@ since TARIFF-11 that row carries the provider plan itself:
|
||||
```
|
||||
Tariff: No fixed tariff set. Price plan from 412 s ago, for 48 hours ahead:
|
||||
day-ahead prices from the price feed, 60 prices over 33 hours,
|
||||
36 published and 24 estimated, 900+3600 s apart, not final yet
|
||||
36 published and 24 estimated, one every 15 minutes or every hour
|
||||
```
|
||||
|
||||
⚠️ **The two halves of that row are not computed at the same moment, and the
|
||||
@@ -2461,9 +2482,10 @@ Read the plan half as five separate facts: which *product* the feed publishes
|
||||
and whether it came from the feed or from your fixed tariff, how many
|
||||
**prices** it carries and how many **whole hours** they cover, how many are
|
||||
**published** (firm, for their own interval) against **estimated** (carried
|
||||
from yesterday under §8.3), how far **apart** they are in seconds — `900+3600`
|
||||
is the normal mixed steady state, quarter-hourly firm points beside hourly
|
||||
carried ones — and `not final yet` (provisional) whenever any is estimated.
|
||||
from yesterday under §8.3), and how far **apart** they are — "one every 15
|
||||
minutes or every hour" is the normal mixed steady state, quarter-hourly firm
|
||||
points beside hourly carried ones. The plan is provisional exactly when some
|
||||
prices are estimated, so the estimated count says it.
|
||||
|
||||
⚠️ **Points and hours are two different numbers and this row used to print one
|
||||
of them under the other's name.** `words` was `f"… {len(points)} h"`, so a
|
||||
@@ -3094,8 +3116,11 @@ stop control on the add-on page.
|
||||
|
||||
This part is for installers. The texts live in `app/messages.py` and follow
|
||||
`STYLE.md`. Every notification keeps a fixed `notification_id`, so a repeat
|
||||
replaces the card. The full figures and reason codes are on the ERROR or
|
||||
WARNING line of the add-on log.
|
||||
replaces the card. The ERROR or WARNING line of the add-on log names the
|
||||
reason code. The full figures are in `events` in the `status` JSON, also for a
|
||||
stop restored after a restart, and on the DEBUG line logged right after it.
|
||||
That DEBUG line only shows with “Log level” (log_level) set to `debug`; the
|
||||
default `info` hides it.
|
||||
|
||||
| Title | Severity | id | Card |
|
||||
|---|---|---|---|
|
||||
@@ -3204,7 +3229,7 @@ ends. That row says how to clear it.
|
||||
| Row starts with | What it tells you |
|
||||
|---|---|
|
||||
| Control | When control last ran. Red when it has stopped running: the control board then sets the inverter to 0 W. |
|
||||
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. |
|
||||
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. Just after a start it is amber and *waiting for the first reading*. Red means no reading for longer than the limit. |
|
||||
| Stuck-meter check | Whether the add-on can warn you about a stuck meter. |
|
||||
| Meter reading may be stuck | See *Meter reading may be stuck* under Notifications. |
|
||||
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
|
||||
@@ -3222,8 +3247,9 @@ ends. That row says how to clear it.
|
||||
|
||||
### For installers: status page
|
||||
|
||||
The texts are in `app/messages.py`. The full figures of any alarm are on its
|
||||
ERROR or WARNING line in the add-on log.
|
||||
The texts are in `app/messages.py`. An alarm's ERROR or WARNING line names its
|
||||
reason code, and its full figures are on the DEBUG line right after it and in
|
||||
`events` in the `status` JSON.
|
||||
|
||||
- **Stuck-meter check: off** needs `meter_source: homewizard_local` (label
|
||||
“Where the meter reading comes from”), a `max_w` (“Inverter rating”) and a
|
||||
@@ -3244,10 +3270,15 @@ ERROR or WARNING line in the add-on log.
|
||||
**Check for inverters working against each other** is SAFETY-31, **Delivery
|
||||
check** SAFETY-42.
|
||||
- **Month peak** and **15-minute periods measured** are TARIFF-01 and
|
||||
TARIFF-13. The store's state at start is the INFO line `capacity-tariff peak
|
||||
store: …`, and the peak guard's full reason is on its log line.
|
||||
TARIFF-13. The store's state at start is the INFO line `Month peak store:
|
||||
…`, and the peak guard's full reason is on the DEBUG line after its ERROR.
|
||||
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
|
||||
ERROR line carries the full `STALE_READING` reason and when it clears.
|
||||
ERROR line says when it clears and names `STALE_READING`. The full reason,
|
||||
with both possible causes, is on the DEBUG line after it.
|
||||
- **Meter: no reading for …** (red): the add-on logs one WARNING when the
|
||||
meter goes quiet, "No meter reading for …", and an INFO line when readings
|
||||
return, at most once every 10 minutes. Past `stale_input_s` the **Why**
|
||||
line names the missing reading.
|
||||
- The table at the top: **Why** is `messages.why()`, the arbiter's answer in
|
||||
words (`Resolution.explain()` stays on the log). **Tariff**, **Price
|
||||
history**, **Arbitrage** and **Shadow mode** keep every figure they had
|
||||
|
||||
@@ -246,11 +246,11 @@ class Allocation:
|
||||
block the failsafe is worse than the hole it closes.
|
||||
"""
|
||||
if tick is None or self.tick is None or self.tick != tick:
|
||||
return (f"the allocation record on hand was made on tick "
|
||||
f"{self.tick!r} and this is tick {tick!r} - a record whose "
|
||||
f"envelopes have moved cannot authorise a write")
|
||||
return (f"the power split on hand is from tick {self.tick!r}, "
|
||||
f"not this tick {tick!r}, and an old split cannot allow a "
|
||||
f"write")
|
||||
if not self.ok:
|
||||
return f"the allocator refused this tick ({self.explain()})"
|
||||
return f"no power split was possible this tick ({self.explain()})"
|
||||
if unit not in self.shares:
|
||||
return (f"{unit} has no share in this tick's allocation "
|
||||
f"({self.explain()})")
|
||||
|
||||
@@ -325,12 +325,13 @@ class CirculationWatchdog:
|
||||
return False
|
||||
|
||||
self.tripped = True
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
|
||||
self.reason = (
|
||||
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
|
||||
f"the inverters (peak {self.peak_w:.0f} W) for {held:.0f} s "
|
||||
f"continuously - the meter cannot see it, because it nets to zero "
|
||||
f"there (limits: over {self.cfg.min_w:.0f} W, held over "
|
||||
f"{self.cfg.hold_s:.0f} s)")
|
||||
f"the inverters for {held:.0f} s, peaking at {self.peak_w:.0f} W, "
|
||||
f"over the limits of {self.cfg.min_w:.0f} W and "
|
||||
f"{self.cfg.hold_s:.0f} s. The meter cannot see it, because it "
|
||||
f"nets to zero there")
|
||||
return True
|
||||
|
||||
def held_s(self, now: datetime) -> float:
|
||||
|
||||
@@ -142,6 +142,8 @@ import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from . import messages
|
||||
|
||||
# The reason code, distinct from any saturation-freeze code by construction:
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
@@ -234,31 +236,46 @@ CLEAR_CYCLES = 3
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something "
|
||||
"upstream computed a setpoint the actuator cannot deliver.")
|
||||
# UX-10: the reasons below are quoted by a WARNING or ERROR line, so they
|
||||
# follow STYLE.md's rule for a quoted reason (test_messages.py part 6): no
|
||||
# final full stop, the line adds it. The line around them says "software
|
||||
# fault" for an over-rating, so the reason does not. Each is one sentence, so
|
||||
# the logged line stays within 3 sentences and about 30 words.
|
||||
|
||||
|
||||
def loop_clamp_note(d) -> str:
|
||||
"""The loop-side alarm text for one `control.Decision`, or "".
|
||||
|
||||
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
|
||||
only a command over the rating is a BUG SIGNAL. Every number is the
|
||||
only a command over the rating is a software fault. Every number is the
|
||||
decision's own, i.e. from the tick that clamped.
|
||||
"""
|
||||
if d.clamped:
|
||||
return (f"{REASON_CODE}: the loop computed {d.requested_w:+.0f} W, over "
|
||||
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to "
|
||||
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
|
||||
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
|
||||
return (f"{REASON_CODE}: computed {d.requested_w:+.0f} W, cut to the "
|
||||
f"{d.clamped_w:+.0f} W rating, command now {d.target_w:+.0f} W")
|
||||
if d.i_clamped:
|
||||
return (f"{REASON_CODE}: the loop's integrator reached its "
|
||||
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command "
|
||||
f"this cycle was {d.target_w:+.0f} W. Brief "
|
||||
f"engagements after a large load or solar step are normal. If it "
|
||||
f"persists, check that the battery is following its commands.")
|
||||
return (f"{REASON_CODE}: the correction the add-on builds up reached "
|
||||
f"its {d.i_bound_w:+.0f} W limit, command now "
|
||||
f"{d.target_w:+.0f} W")
|
||||
return ""
|
||||
|
||||
|
||||
def write_clamp_note(named) -> str:
|
||||
"""The write-side alarm text: `named` is `[(inverter name or None,
|
||||
ClampResult)]` for every inverter whose write was cut back this cycle.
|
||||
|
||||
One inverter reads as `describe()`. Several read as one sentence of
|
||||
asked/sent pairs, so the line stays one sentence however many inverters
|
||||
there are. Which limit cut each one back is on its own action-log row
|
||||
(`describe()`) and in the notification."""
|
||||
if len(named) == 1:
|
||||
name, r = named[0]
|
||||
return f"{name}: {r.describe()}" if name else r.describe()
|
||||
return f"{REASON_CODE}: " + ", ".join(
|
||||
f"{name} asked {r.requested_w:+.0f} W and sent {r.applied_w:+.0f} W"
|
||||
for name, r in named)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
"""What the guard did, with the RAW value preserved.
|
||||
@@ -275,10 +292,12 @@ class ClampResult:
|
||||
bound: str = "" # "" | "max_w" | "device" - which stage bit
|
||||
|
||||
def describe(self) -> str:
|
||||
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
|
||||
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} "
|
||||
f"bound (rating {self.limit_w:.0f} W) - something upstream is "
|
||||
f"winding")
|
||||
# The sent value IS the limit that bound, so the rating is not
|
||||
# repeated: on "max_w" it equals the rating, and on "device" the
|
||||
# rating was not what cut it back.
|
||||
return (f"{REASON_CODE}: asked {self.requested_w:+.0f} W and sent "
|
||||
f"{self.applied_w:+.0f} W, "
|
||||
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}")
|
||||
|
||||
|
||||
def is_commandable(value) -> bool:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -424,15 +424,17 @@ class FreezeWatchdog:
|
||||
# page was telling, printed into the log and the HA notification where
|
||||
# it would outlive the page.
|
||||
ceiling = rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max)
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
|
||||
capped = ("" if ceiling >= abs(float(max_w))
|
||||
else f", capped from max_w {float(max_w):.0f} W by the "
|
||||
f"setpoint device's own rating")
|
||||
f"inverter's own rating")
|
||||
self.reason = (
|
||||
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
|
||||
f"without its measurement changing while the command stands at "
|
||||
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W rail{capped} "
|
||||
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
|
||||
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)")
|
||||
f"METER_FROZEN: the meter has not changed for "
|
||||
f"{float(unchanged_s):.0f} s while the command is "
|
||||
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W limit"
|
||||
f"{capped}. Limits: unchanged over {self.cfg.hold_s:.0f} s, "
|
||||
f"command over "
|
||||
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W")
|
||||
if self.active:
|
||||
return False
|
||||
self.active = True
|
||||
|
||||
@@ -594,31 +594,26 @@ class Freshness:
|
||||
# ("what did the register do" against "can I trust this entity"). One
|
||||
# helper serving both would have to drop whichever half the other does
|
||||
# not need, and the half it dropped would be the operator's next step.
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {shown_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W. TWO THINGS LOOK LIKE THIS AND THIS GATE "
|
||||
f"CANNOT TELL THEM APART: the register may have stopped reporting "
|
||||
f"reality, or the source may be unable to comply with the command "
|
||||
f"- a pack the BMS is refusing holds still under any setpoint, and "
|
||||
f"an honest register then reads exactly like a frozen one. Either "
|
||||
f"way the number cannot be read as this command's effect, so it is "
|
||||
f"reported as MISSING rather than as a measurement (limits: "
|
||||
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
|
||||
f"{self.bound.move_w:.0f} W and moved by over "
|
||||
f"{self.bound.move_w:.0f} W)")
|
||||
# UX-10: two plain sentences (test_messages.py part 6). SAFETY-56's
|
||||
# two explanations stay, in this order, and so does the admission
|
||||
# that this check cannot tell them apart. The reading is treated as
|
||||
# missing either way, which the log line around this says. The
|
||||
# limits (`self.bound`) are on main.py's DEBUG line beside it, so the
|
||||
# logged line stays within 3 sentences.
|
||||
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
|
||||
# in its own slot: the register's unchanged time is in the sentence
|
||||
# above, the WINDOW this verdict actually rests on is here, and so is
|
||||
# how many times this unit has been round the loop. Without the second
|
||||
# one the sentence reads as though 92 s of unchanged register were what
|
||||
# was compared against the 35 s limit, and it was not.
|
||||
if self.releases:
|
||||
self.reason += (
|
||||
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
|
||||
f"opened when the previous one was withdrawn, not when the "
|
||||
f"register last moved - this unit has now been graded "
|
||||
f"{self.releases + 1} times]")
|
||||
# in its own slot: the register's unchanged time, the WINDOW this
|
||||
# verdict actually rests on after a release (`regraded`), and how many
|
||||
# times this unit has been round the loop. Without the second one the
|
||||
# sentence reads as though 92 s of unchanged register were what was
|
||||
# compared against the 35 s limit, and it was not.
|
||||
regraded = (f", checked {self.releases + 1} times over a {held_s:.0f} s "
|
||||
f"window since the last stop was withdrawn"
|
||||
if self.releases else "")
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} W unchanged for {shown_s:.0f} s "
|
||||
f"while the command moved {moved_w:.0f} W to {standing_w:+.0f} W"
|
||||
f"{regraded}. The reading may have stopped, or the battery may be "
|
||||
f"unable to follow the command, and this check cannot tell which")
|
||||
|
||||
def unchanged_s(self, now_mono) -> float | None:
|
||||
"""Seconds since an arrival carried a different number. Status page.
|
||||
|
||||
@@ -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
|
||||
@@ -16,6 +16,39 @@ _LOG = logging.getLogger("goodwe.hass")
|
||||
CORE_API = "http://supervisor/core/api"
|
||||
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:
|
||||
"""UX-10: Home Assistant not answering, said in one plain line per outage.
|
||||
|
||||
A restart takes the REST API and the meter websocket down together, and
|
||||
each used to log the library's own text for every entity and service
|
||||
("502, message='Bad Gateway', url=URL(...)", "Received message 8:1000 is
|
||||
not str"). Now the first channel to fail says it once at WARNING, the raw
|
||||
text goes to DEBUG, and the last channel to recover says so at INFO.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.down_on = set()
|
||||
|
||||
def down(self, channel: str, what: str, detail) -> None:
|
||||
if not self.down_on:
|
||||
_LOG.warning("Home Assistant is not answering (%s), perhaps it is "
|
||||
"restarting. The add-on keeps trying.", what)
|
||||
self.down_on.add(channel)
|
||||
_LOG.debug("Home Assistant not answering on %s: %s", channel, detail)
|
||||
|
||||
def up(self, channel: str) -> None:
|
||||
if channel in self.down_on:
|
||||
self.down_on.discard(channel)
|
||||
if not self.down_on:
|
||||
_LOG.info("Home Assistant answers again.")
|
||||
|
||||
|
||||
OUTAGE = Outage()
|
||||
|
||||
|
||||
class HomeAssistant:
|
||||
@@ -58,10 +91,18 @@ class HomeAssistant:
|
||||
async with self.session.get(
|
||||
f"{CORE_API}/states/{entity_id}", headers=self._headers, timeout=10
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"read {entity_id}: {resp.status}")
|
||||
return None
|
||||
OUTAGE.up("api")
|
||||
if resp.status == 404:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
obj = await resp.json()
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection", f"read {entity_id}: {err}")
|
||||
return None
|
||||
# SAFETY-61: an undecodable body is an absence like any other failed
|
||||
# read, never an exception into the control loop.
|
||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
||||
@@ -73,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)
|
||||
@@ -113,17 +180,37 @@ class HomeAssistant:
|
||||
f"{CORE_API}/services/{domain}/{service}",
|
||||
headers=self._headers, json=data, timeout=10,
|
||||
) as resp:
|
||||
if resp.status in UNAVAILABLE:
|
||||
OUTAGE.down("api", f"error {resp.status}",
|
||||
f"service {domain}.{service}: {resp.status}")
|
||||
return False
|
||||
OUTAGE.up("api")
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
# 400 here usually means out-of-range for the entity - the
|
||||
# value is rejected outright, not clamped. Always log it:
|
||||
# silently dropped commands are how a controller ends up
|
||||
# believing something the hardware never did.
|
||||
# UX-10: plain words. While Home Assistant loads its
|
||||
# entities it answers 400 with only "400: Bad Request",
|
||||
# which says nothing, so only a body with more is quoted.
|
||||
said = body.strip()[:200]
|
||||
if said.lower() in (f"{resp.status}: {resp.reason}".lower(),
|
||||
""):
|
||||
said = "no details"
|
||||
entity = (data.get("entity_id", "no entity")
|
||||
if isinstance(data, dict) else "no entity")
|
||||
self._moan(f"svc:{domain}.{service}",
|
||||
"service %s.%s rejected (%s): %s",
|
||||
domain, service, resp.status, body[:200])
|
||||
"Home Assistant refused %s.%s for %s (error %s, "
|
||||
"%s). The entity may still be loading, or the "
|
||||
"value is out of range.", domain, service,
|
||||
entity, resp.status, said)
|
||||
return False
|
||||
return True
|
||||
except aiohttp.ClientConnectionError as err:
|
||||
OUTAGE.down("api", "no connection",
|
||||
f"service {domain}.{service}: {err}")
|
||||
return False
|
||||
# ValueError: an undecodable error body (SAFETY-61) is a failed write.
|
||||
except (aiohttp.ClientError, TimeoutError, ValueError) as err:
|
||||
self._moan(f"svcerr:{domain}.{service}", "service %s.%s failed: %s",
|
||||
|
||||
+343
-92
@@ -39,6 +39,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,
|
||||
@@ -49,8 +50,8 @@ from .circulation import (
|
||||
circulating_w, opposing,
|
||||
)
|
||||
from .clamp import (
|
||||
BUG_SIGNAL as CLAMP_BUG_SIGNAL, REASON_CODE as CLAMP_REASON, ClampWatchdog,
|
||||
clamp_to_rating, is_commandable, loop_clamp_note,
|
||||
REASON_CODE as CLAMP_REASON, ClampWatchdog,
|
||||
clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note,
|
||||
)
|
||||
from . import freshness
|
||||
from . import messages
|
||||
@@ -119,6 +120,9 @@ WRITE_BUDGET_S = 4.0
|
||||
# restart this add-on (measured on TEST-23), so a latched alarm is re-asserted
|
||||
# this often. Why: goodwe docs/DECISIONS.md, SAFETY-34.
|
||||
REANNOUNCE_S = 300.0
|
||||
# UX-11 review: a quiet meter logs at most one WARNING this often, because on
|
||||
# `ha_signed` a healthy meter under a flat load can go 42-97 s between changes.
|
||||
METER_QUIET_REPEAT_S = 600.0
|
||||
_mono = time.monotonic # patched by the checks; asyncio keeps the real one
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -230,6 +234,26 @@ def pack_energy_of(inv):
|
||||
inv.capacity_kwh, inv.max_w)
|
||||
|
||||
|
||||
def why_key(winner: str, claims) -> str:
|
||||
"""The key of `messages.WHY_WINNER` for a resolution's winner.
|
||||
|
||||
"safety" and "failsafe" each stand for several causes, so for those the
|
||||
winning claim's own reason is the key ("stopped", "inputs missing", "sign
|
||||
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"),
|
||||
"no claim was made" if not any(
|
||||
c.kind == "set" for c in claims) else "unsplit")
|
||||
return winner
|
||||
|
||||
|
||||
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
|
||||
provider: bool, max_age_s: float) -> str:
|
||||
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan.
|
||||
@@ -544,7 +568,7 @@ class Controller:
|
||||
# re-arm anything. It is still the difference between a pack that is
|
||||
# recalibrated on schedule and one that silently is not, which is
|
||||
# §12's opening sentence, so it is said where an operator looks.
|
||||
self.log_event("maintenance history did NOT migrate cleanly: "
|
||||
self.log_event("maintenance history did not move over cleanly: "
|
||||
+ self.maint_migration.explain(),
|
||||
hint=messages.hint("maint_history"))
|
||||
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
|
||||
@@ -595,10 +619,21 @@ class Controller:
|
||||
self._grade_store_migration(store)
|
||||
for inv in self.inverters:
|
||||
if inv.sign_wd.tripped:
|
||||
# UX-11: the plain sentence and the code. The saved reason,
|
||||
# with its figures, is on the DEBUG line beside it.
|
||||
_LOG.error("%s stays at 0 W after the restart because its "
|
||||
"power went the wrong way: %s. Start control once "
|
||||
"you know why.", inv.name,
|
||||
"power went the wrong way, so find out why before "
|
||||
"you start control: %s.", inv.name,
|
||||
messages.reason_code(inv.sign_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name,
|
||||
inv.sign_wd.reason or "(reason not recorded)")
|
||||
# UX-11 review: `events` starts empty and DEBUG is off by
|
||||
# default, so the saved figures go on the action log too.
|
||||
saved = inv.sign_wd.reason or messages.NO_REASON_SAVED
|
||||
self.log_event(f"FAILSAFE {inv.name}, saved before the "
|
||||
f"restart: {saved}",
|
||||
hint=messages.hint("stop_restored",
|
||||
unit=inv.name))
|
||||
|
||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||
# ⚠️ A LATCH THAT DOES NOT ACT, WHICH IS THE ONE ASYMMETRY WITH EVERY
|
||||
@@ -619,10 +654,17 @@ class Controller:
|
||||
for inv in self.inverters:
|
||||
if inv.delivery_wd.tripped:
|
||||
_LOG.error(
|
||||
"%s is still marked as not delivering after the restart: "
|
||||
"%s. Control keeps running, and only stopping and starting "
|
||||
"control clears this.",
|
||||
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
||||
"%s is still marked as not delivering after the restart "
|
||||
"while control keeps running, and only stopping and "
|
||||
"starting control clears this: %s.",
|
||||
inv.name, messages.reason_code(inv.delivery_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name,
|
||||
inv.delivery_wd.reason or "(reason not recorded)")
|
||||
saved = inv.delivery_wd.reason or messages.NO_REASON_SAVED
|
||||
self.log_event(f"NOT DELIVERING {inv.name}, saved before the "
|
||||
f"restart: {saved}",
|
||||
hint=messages.hint("not_delivering",
|
||||
unit=inv.name))
|
||||
|
||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
|
||||
@@ -651,10 +693,15 @@ class Controller:
|
||||
reason=str(store.data.get("site_sign_reason", "") or ""))
|
||||
if self.site_sign_wd.tripped:
|
||||
_LOG.error(
|
||||
"Every inverter stays at 0 W after the restart because the "
|
||||
"total power went the wrong way: %s. Start control once you "
|
||||
"know why.",
|
||||
self.site_sign_wd.reason or "(reason not recorded)")
|
||||
"After the restart every inverter stays at 0 W, as the total "
|
||||
"power went the wrong way, so find out why before starting "
|
||||
"control: %s.", messages.reason_code(self.site_sign_wd.reason))
|
||||
_LOG.debug("Evidence for the site: %s.",
|
||||
self.site_sign_wd.reason or "(reason not recorded)")
|
||||
self.log_event("FAILSAFE site, saved before the restart: "
|
||||
+ (self.site_sign_wd.reason
|
||||
or messages.NO_REASON_SAVED),
|
||||
hint=messages.hint("site_direction"))
|
||||
# And the per-unit detectors are told which question they are asking,
|
||||
# here as well as in `cycle()`. `checks()` and the status page read
|
||||
# `inv.sign_wd.cfg` and can be called before the first cycle has run, so
|
||||
@@ -704,10 +751,16 @@ class Controller:
|
||||
self._announced_mono = {}
|
||||
if self.circ_wd.tripped:
|
||||
_LOG.error(
|
||||
"Every inverter stays at 0 W after the restart because the "
|
||||
"inverters worked against each other: %s. Start control once "
|
||||
"you know why.",
|
||||
self.circ_wd.reason or "(reason not recorded)")
|
||||
"After the restart every inverter stays at 0 W, as the "
|
||||
"inverters worked against each other, so find out why before "
|
||||
"starting control: %s.",
|
||||
messages.reason_code(self.circ_wd.reason))
|
||||
_LOG.debug("Evidence for the site: %s.",
|
||||
self.circ_wd.reason or "(reason not recorded)")
|
||||
self.log_event("CIRCULATING POWER, saved before the restart: "
|
||||
+ (self.circ_wd.reason
|
||||
or messages.NO_REASON_SAVED),
|
||||
hint=messages.hint("circulating"))
|
||||
|
||||
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
||||
# itself is the cheap half.
|
||||
@@ -750,7 +803,7 @@ class Controller:
|
||||
self.peak = PeakTracker(self.peak_store,
|
||||
cap_w=float(opts.get("peak_cap_w", 3500)))
|
||||
self.peak.restore(self.p1.averager)
|
||||
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
|
||||
_LOG.info("Month peak store: %s", self.peak.restore_state)
|
||||
|
||||
# FCST-04. The §8.3 carry-forward store: the published prices of the
|
||||
# last few Brussels days, so the hours a day-ahead feed has not printed
|
||||
@@ -786,7 +839,7 @@ class Controller:
|
||||
# `.words` read below are unguarded, and a cache that raises here is an
|
||||
# add-on that does not start.
|
||||
self.price_history = PriceHistory(price_history_path)
|
||||
_LOG.info("§8.3 price history: %s", self.price_history.words)
|
||||
_LOG.info("Price history: %s", self.price_history.words)
|
||||
|
||||
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
|
||||
# design (peakguard.py holds no history between calls), so the only
|
||||
@@ -877,6 +930,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
|
||||
@@ -913,6 +972,11 @@ class Controller:
|
||||
self.following = False
|
||||
self.reason = "starting"
|
||||
self.why_words = "Starting"
|
||||
# UX-11: has this meter episode's WARNING been logged? See read_inputs.
|
||||
self._meter_quiet_logged = False
|
||||
# ...when the last WARNING was, and whether this episode logged one.
|
||||
self._meter_quiet_warned = None
|
||||
self._meter_quiet_loud = False
|
||||
# ALLOC-01. The allocation record from the last resolution, or None
|
||||
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
|
||||
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
|
||||
@@ -1005,6 +1069,69 @@ 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 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")
|
||||
|
||||
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
|
||||
@@ -1071,9 +1198,9 @@ class Controller:
|
||||
"""
|
||||
rep = self.store_migration
|
||||
if rep.ran and not rep.ok:
|
||||
_LOG.error("The saved safety stops did not copy over cleanly "
|
||||
"(%s), so they are read again from the older saved "
|
||||
"state.", rep.explain())
|
||||
_LOG.error("The saved safety stops did not copy over cleanly, so "
|
||||
"they are read again from the older saved state: %s.",
|
||||
rep.explain())
|
||||
data = getattr(store, "data", {})
|
||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||
if not legacy_tripped or not self.inverters:
|
||||
@@ -1212,9 +1339,10 @@ class Controller:
|
||||
first.store_set("sign_reason", first.sign_wd.reason)
|
||||
first.store_set("sign_tripped", True)
|
||||
_LOG.error(
|
||||
"%s is set to 0 W again, because an older saved safety stop was "
|
||||
"found: %s. Start control once you know why.",
|
||||
first.name, first.sign_wd.reason)
|
||||
"%s is set to 0 W again because an older saved safety stop was "
|
||||
"found, so find out why before you start control: %s.",
|
||||
first.name, messages.reason_code(first.sign_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
||||
"sign-disagreement failsafe is latched and no inverter came up "
|
||||
@@ -1678,6 +1806,22 @@ class Controller:
|
||||
"""
|
||||
return packshare.weights(self.pack_energy, target_w)
|
||||
|
||||
@property
|
||||
def why_words(self) -> str:
|
||||
"""The page's "Why" line. While control waits for a missing reading
|
||||
the age is counted when the page asks (UX-10), not frozen at the age
|
||||
the last cycle saw."""
|
||||
if self._why_waiting_since is not None:
|
||||
return messages.why_waiting(max(0.0, (
|
||||
datetime.now(timezone.utc)
|
||||
- self._why_waiting_since).total_seconds()))
|
||||
return self._why_words
|
||||
|
||||
@why_words.setter
|
||||
def why_words(self, text: str) -> None:
|
||||
self._why_words = text
|
||||
self._why_waiting_since = None
|
||||
|
||||
@property
|
||||
def inputs_ok(self) -> bool:
|
||||
"""The site meter, and EVERY pack's own two readings.
|
||||
@@ -1694,6 +1838,18 @@ class Controller:
|
||||
return all(inv.soc is not None and inv.batt is not None
|
||||
for inv in self.inverters)
|
||||
|
||||
def missing_reading(self) -> str:
|
||||
"""UX-11: the first reading `inputs_ok` finds missing, in words."""
|
||||
if self.grid is None or not self.inverters:
|
||||
return "meter"
|
||||
for inv in self.inverters:
|
||||
of = "" if len(self.inverters) == 1 else f" from {inv.name}"
|
||||
if inv.batt is None:
|
||||
return f"battery power{of}"
|
||||
if inv.soc is None:
|
||||
return f"battery level{of}"
|
||||
return "input"
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
@property
|
||||
def pv_roster(self) -> tuple:
|
||||
@@ -1758,6 +1914,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
|
||||
@@ -1853,6 +2010,34 @@ class Controller:
|
||||
horizon_h=float(price_option(o, "price_horizon_h")),
|
||||
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
|
||||
|
||||
# UX-11: the red meter row says "the add-on log shows why", so a meter
|
||||
# that goes quiet gets one WARNING per episode, not one per cycle. It
|
||||
# is a log line only: no notification, and nothing it decides.
|
||||
# A flapping meter logs at most one WARNING per METER_QUIET_REPEAT_S,
|
||||
# and the episodes in between go to DEBUG.
|
||||
if self.p1_enabled:
|
||||
if (self.p1.stale and not self._meter_quiet_logged
|
||||
and self.p1.published_age_s > self.p1.max_age_s):
|
||||
self._meter_quiet_logged = True
|
||||
now = _mono()
|
||||
self._meter_quiet_loud = (
|
||||
self._meter_quiet_warned is None
|
||||
or now - self._meter_quiet_warned >= METER_QUIET_REPEAT_S)
|
||||
if self._meter_quiet_loud:
|
||||
self._meter_quiet_warned = now
|
||||
_LOG.warning("No meter reading for over %s, so control "
|
||||
"waits and then sets the battery to 0 W.",
|
||||
messages.seconds(self.p1.max_age_s))
|
||||
else:
|
||||
_LOG.debug("No meter reading for over %s again.",
|
||||
messages.seconds(self.p1.max_age_s))
|
||||
elif not self.p1.stale and self._meter_quiet_logged:
|
||||
self._meter_quiet_logged = False
|
||||
if self._meter_quiet_loud:
|
||||
_LOG.info("Meter readings are back.")
|
||||
else:
|
||||
_LOG.debug("Meter readings are back.")
|
||||
|
||||
if self.inputs_ok:
|
||||
if self.inputs_bad_since is not None:
|
||||
self.log_event("inputs recovered",
|
||||
@@ -1893,6 +2078,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
|
||||
@@ -1921,11 +2107,21 @@ class Controller:
|
||||
# learns to skim it.
|
||||
# UX-08 2d: plain, with SAFETY-57's escape clause kept as an
|
||||
# ordered pair (time, then power), and the reason still in `%s`.
|
||||
_LOG.error("%s: battery power reading stopped changing. Inverters "
|
||||
"go to 0 W until it changes, or %.0f s after the "
|
||||
"command drops below %.0f W: %s",
|
||||
# UX-11: one sentence and the code. The reason, with SAFETY-56's
|
||||
# two explanations and its figures, and the limits are on the
|
||||
# DEBUG lines below and in the action log.
|
||||
_LOG.error("%s: battery power reading stopped changing, set to "
|
||||
"0 W until it changes, or %.0f s after the command "
|
||||
"drops below %.0f W: %s.",
|
||||
inv.name, inv.batt_fresh.bound.release_s,
|
||||
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
|
||||
inv.batt_fresh.bound.move_w,
|
||||
messages.reason_code(inv.batt_fresh.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason)
|
||||
_LOG.debug("%s: the limits are unchanged over %.0f s, a command "
|
||||
"over %.0f W and a move by over %.0f W.", inv.name,
|
||||
inv.batt_fresh.bound.unchanged_s,
|
||||
inv.batt_fresh.bound.move_w,
|
||||
inv.batt_fresh.bound.move_w)
|
||||
elif inv.batt_fresh.releases > was_releases:
|
||||
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
|
||||
# timer and says nothing is a latch no operator can audit - and this
|
||||
@@ -2078,9 +2274,10 @@ class Controller:
|
||||
f"refusing to broadcast {float(value):+.0f} W to "
|
||||
f"{len(targets)} inverters - that is the ARCH-01 fault; "
|
||||
f"commanding 0 W", hint=messages.hint("all_blocked"))
|
||||
_LOG.error("Refused to send %+.0f W to all %d inverters at "
|
||||
"once, so every inverter is set to 0 W. This is a "
|
||||
"software fault.", float(value), len(targets))
|
||||
_LOG.error("Refused to send %+.0f W to all %s at once, so "
|
||||
"every inverter is set to 0 W. This is a software "
|
||||
"fault.", float(value),
|
||||
messages.count(len(targets), "inverter"))
|
||||
_LOG.debug("ARCH-01: a site target handed to each unit is "
|
||||
"supplied by each of them, so the gain at the meter "
|
||||
"becomes N times the per-unit gain. Only 0 W may be "
|
||||
@@ -2154,8 +2351,8 @@ class Controller:
|
||||
self.log_event(
|
||||
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
|
||||
hint=messages.hint("write_blocked", unit=inv.name))
|
||||
_LOG.error("Refused to send %+.0f W to %s (%s), so it is set to "
|
||||
"0 W instead. This is a software fault.",
|
||||
_LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W "
|
||||
"instead, which is a software fault. The reason: %s.",
|
||||
value, inv.name, refusal)
|
||||
value = 0.0
|
||||
|
||||
@@ -2203,10 +2400,11 @@ class Controller:
|
||||
# one writer, and this is that writer emitting the value it emits for
|
||||
# every other refusal.
|
||||
if self.shadow and value != shadow.WIRE_W:
|
||||
_LOG.info("SHADOW MODE: not commanding %+.0f W to %s - holding "
|
||||
"%+.0f W. The decision was reached and recorded; "
|
||||
"nothing was sent to the inverter.",
|
||||
value, inv.name, shadow.WIRE_W)
|
||||
# UX-10: DEBUG, because it repeats every cycle in shadow mode. The
|
||||
# INFO line when shadow mode comes on says the same once.
|
||||
_LOG.debug("Shadow mode: would send %+.0f W to %s, and holds "
|
||||
"%+.0f W. Nothing was sent to the inverter.",
|
||||
value, inv.name, shadow.WIRE_W)
|
||||
value = shadow.WIRE_W
|
||||
|
||||
changed = (inv.last_write is None or value != inv.last_write)
|
||||
@@ -2449,9 +2647,11 @@ class Controller:
|
||||
self.store.set("sign_tripped", True)
|
||||
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
|
||||
hint=messages.hint("direction", unit=inv.name))
|
||||
_LOG.error("%s is set to 0 W because its power went the wrong "
|
||||
"way: %s. Only starting control clears this.",
|
||||
inv.name, inv.sign_wd.reason)
|
||||
_LOG.error("%s is set to 0 W until control is started, "
|
||||
"because its power went the wrong way: %s.",
|
||||
inv.name, messages.reason_code(inv.sign_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name,
|
||||
inv.sign_wd.reason)
|
||||
# ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'.
|
||||
# SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour
|
||||
# average at the grid connection, and a meter that has stopped moving
|
||||
@@ -2497,9 +2697,12 @@ class Controller:
|
||||
self.store.set("site_sign_tripped", True)
|
||||
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason,
|
||||
hint=messages.hint("site_direction"))
|
||||
_LOG.error("Every inverter is set to 0 W because the total "
|
||||
"power went the wrong way: %s. Only starting "
|
||||
"control clears this.", self.site_sign_wd.reason)
|
||||
_LOG.error("Every inverter is set to 0 W until control is "
|
||||
"started, because the total power went the wrong "
|
||||
"way: %s.",
|
||||
messages.reason_code(self.site_sign_wd.reason))
|
||||
_LOG.debug("Evidence for the site: %s.",
|
||||
self.site_sign_wd.reason)
|
||||
|
||||
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
|
||||
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
|
||||
@@ -2530,9 +2733,10 @@ class Controller:
|
||||
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason,
|
||||
hint=messages.hint("circulating"))
|
||||
_LOG.error(
|
||||
"Every inverter is set to 0 W because the inverters worked "
|
||||
"against each other: %s. Only starting control clears this.",
|
||||
self.circ_wd.reason)
|
||||
"Every inverter is set to 0 W until control is started, "
|
||||
"because the inverters worked against each other: %s.",
|
||||
messages.reason_code(self.circ_wd.reason))
|
||||
_LOG.debug("Evidence for the site: %s.", self.circ_wd.reason)
|
||||
|
||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
|
||||
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
|
||||
@@ -2638,9 +2842,11 @@ class Controller:
|
||||
hint=messages.hint("not_delivering",
|
||||
unit=inv.name))
|
||||
_LOG.error(
|
||||
"%s is not delivering: %s. Nothing was changed and control "
|
||||
"keeps running, and only stopping and starting control "
|
||||
"clears this.", inv.name, inv.delivery_wd.reason)
|
||||
"%s is not delivering while control keeps running, and "
|
||||
"only stopping and starting control clears this: %s.",
|
||||
inv.name, messages.reason_code(inv.delivery_wd.reason))
|
||||
_LOG.debug("Evidence for %s: %s.", inv.name,
|
||||
inv.delivery_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-42 reports and does not act: commanding a unit "
|
||||
"that is not listening achieves nothing (HW-01), and the "
|
||||
@@ -2666,7 +2872,12 @@ class Controller:
|
||||
# A single missed poll is not a fault. Hold, keep the heartbeat
|
||||
# going, and give it a few seconds to come back.
|
||||
self.reason = f"inputs missing {bad_for:.0f}s"
|
||||
# UX-10: the age is rendered when the page asks, not frozen at
|
||||
# this cycle's. With the meter reading gone the grid value
|
||||
# stops changing, so the next cycle is up to cycle_floor_s
|
||||
# away and the line used to read "0 seconds" all that time.
|
||||
self.why_words = messages.why_waiting(bad_for)
|
||||
self._why_waiting_since = now - timedelta(seconds=bad_for)
|
||||
return
|
||||
|
||||
claims = []
|
||||
@@ -3205,6 +3416,12 @@ 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")
|
||||
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
|
||||
@@ -3222,6 +3439,13 @@ 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()
|
||||
# ⚠️ `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
|
||||
@@ -3250,6 +3474,10 @@ 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.
|
||||
refused=self.batt_refused if len(self.inverters) == 1 else None,
|
||||
probe=probe,
|
||||
)
|
||||
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
||||
@@ -3258,6 +3486,7 @@ class Controller:
|
||||
self.sat_count = decision.sat_count
|
||||
self.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
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
|
||||
@@ -3483,7 +3712,7 @@ class Controller:
|
||||
# other unit never gets. Loud, every cycle, and it does not change
|
||||
# the resolution - the arbiter already routed by `actuator`.
|
||||
_LOG.error("A limit was filed on the wrong inverter, which is a "
|
||||
"software fault: %s", " / ".join(resolution.scope_errors))
|
||||
"software fault. %s.", " / ".join(resolution.scope_errors))
|
||||
if resolution.contradiction:
|
||||
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
|
||||
hint=messages.hint("clash"))
|
||||
@@ -3505,15 +3734,17 @@ class Controller:
|
||||
self.reason = resolution.explain()
|
||||
# UX-08: the same answer in plain words, for the page's "Why" line.
|
||||
# `reason` stays the technical record the log and the checks read.
|
||||
_why = resolution.winner
|
||||
if _why in ("safety", "failsafe"):
|
||||
_why = next((c.reason for c in claims
|
||||
if c.source == _why and c.kind == "set"),
|
||||
"no claim was made" if not any(
|
||||
c.kind == "set" for c in claims) else "unsplit")
|
||||
self.why_words = messages.why(_why, resolution.target_w,
|
||||
resolution.bound_by,
|
||||
contradiction=resolution.contradiction)
|
||||
# UX-10: `why_key()` maps a safety winner to its claim's reason, and
|
||||
# every key it can return has words in messages.WHY_WINNER.
|
||||
_why = why_key(resolution.winner, claims)
|
||||
if _why == "inputs missing" and not resolution.contradiction:
|
||||
# UX-11: past the hold, say which reading is missing.
|
||||
self.why_words = messages.why_missing(self.missing_reading(),
|
||||
stale_after)
|
||||
else:
|
||||
self.why_words = messages.why(
|
||||
_why, resolution.target_w, resolution.bound_by,
|
||||
contradiction=resolution.contradiction)
|
||||
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
|
||||
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
|
||||
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
|
||||
@@ -3692,16 +3923,18 @@ class Controller:
|
||||
self.o, control_started=self.auto)
|
||||
+ f"; inverter held at {shadow.WIRE_W:.0f} W",
|
||||
hint=messages.hint("shadow_on"))
|
||||
_LOG.warning(
|
||||
# UX-10: INFO. It follows a setting or a button press, and
|
||||
# no person has to act on it.
|
||||
_LOG.info(
|
||||
"Shadow mode is on, so every inverter stays at %.0f W and "
|
||||
"nothing the add-on decides is sent. %s.", shadow.WIRE_W,
|
||||
shadow.why(self.o, control_started=self.auto))
|
||||
elif not first:
|
||||
self.log_event(
|
||||
"SHADOW MODE OFF - the battery is being controlled again, "
|
||||
f"resuming from {shadow.WIRE_W:.0f} W",
|
||||
"Shadow mode off: the add-on controls the battery again, "
|
||||
f"starting from {shadow.WIRE_W:.0f} W",
|
||||
hint=messages.hint("shadow_off"))
|
||||
_LOG.warning(
|
||||
_LOG.info(
|
||||
"Shadow mode is off, so the add-on controls the battery "
|
||||
"again, starting from %.0f W.", shadow.WIRE_W)
|
||||
if not engaged:
|
||||
@@ -3776,9 +4009,10 @@ class Controller:
|
||||
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
|
||||
bug = engaged_write or loop_bug
|
||||
if engaged_write:
|
||||
reason = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
|
||||
for inv in clamped_units) + ". " + CLAMP_BUG_SIGNAL)
|
||||
_named = len(self.inverters) > 1
|
||||
reason = write_clamp_note([(inv.name if _named else None,
|
||||
inv.clamp_last)
|
||||
for inv in clamped_units])
|
||||
facts = [messages.Clamp(inv.name if _named else None,
|
||||
inv.clamp_last.requested_w,
|
||||
inv.clamp_last.applied_w,
|
||||
@@ -3810,8 +4044,9 @@ class Controller:
|
||||
# write-path engagements and 2 loop-side ones.
|
||||
if engaged and not engaged_write:
|
||||
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
|
||||
row = (f"{tag}: {self.clamp_wd.engagements} engagement(s) - "
|
||||
f"{reason}")
|
||||
row = (f"{tag}: "
|
||||
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
|
||||
f" - {reason}")
|
||||
if self.events and tag in self.events[0]:
|
||||
stamp = self.events[0].split(" ", 1)[0]
|
||||
self.events[0] = f"{stamp} {row}"
|
||||
@@ -3824,16 +4059,21 @@ class Controller:
|
||||
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
|
||||
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
||||
# it can never replace a BUG SIGNAL notice still on screen.
|
||||
_LOG.warning("The battery gives less than asked: %s Held back %d "
|
||||
"times so far.", self.clamp_wd.reason,
|
||||
self.clamp_wd.engagements)
|
||||
_LOG.warning("The battery gives less than asked: %s. Held back %s "
|
||||
"so far.", messages.reason_code(self.clamp_wd.reason),
|
||||
messages.times(self.clamp_wd.engagements))
|
||||
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
||||
_LOG.debug("A short hold after a large load or solar step is "
|
||||
"normal. If it lasts, check that the battery follows "
|
||||
"its commands.")
|
||||
await self.hass.call("persistent_notification", "create",
|
||||
messages.loop_bound().data())
|
||||
return
|
||||
_LOG.error(
|
||||
"Power request too high, which is a software fault: %s Control is "
|
||||
"still running. Cut back %d times so far.",
|
||||
self.clamp_wd.reason, self.clamp_wd.engagements)
|
||||
"Power request too high, a software fault: %s. Cut back %s so "
|
||||
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
|
||||
messages.times(self.clamp_wd.engagements))
|
||||
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-03: silently clipping this is how a winding integrator "
|
||||
"reached 14 768 W on this project before. The clamp makes every "
|
||||
@@ -3878,12 +4118,13 @@ class Controller:
|
||||
was, self._peak_saturated = self._peak_saturated, saturated
|
||||
if not saturated or was:
|
||||
return
|
||||
self.log_event("PEAK GUARD SATURATED - " + g.reason,
|
||||
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
||||
hint=messages.hint("peak_saturated"))
|
||||
_LOG.error(
|
||||
"The battery is asked for all it can give, but this 15-minute "
|
||||
"period still goes over the peak limit: %s. Switch off large "
|
||||
"loads.", g.reason)
|
||||
"Switch off large loads, the battery alone cannot keep this "
|
||||
"15-minute period under the peak limit: %s.",
|
||||
messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||
# is not a frozen meter and is not an out-of-range setpoint.
|
||||
@@ -3933,15 +4174,15 @@ class Controller:
|
||||
was, self._peak_floor_limited = self._peak_floor_limited, limited
|
||||
if not limited or was:
|
||||
return
|
||||
# No prefix: `reason` already opens with PEAK GUARD FLOOR-LIMITED, and
|
||||
# No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
|
||||
# a second copy is the kind of stutter that makes a 40-line action log
|
||||
# read like boilerplate. The saturated path needs its prefix because its
|
||||
# reason carries the word in the middle of a sentence, not at the front.
|
||||
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
|
||||
_LOG.error(
|
||||
"The peak guard may not draw the battery lower, so this 15-minute "
|
||||
"period will go over the peak limit: %s. Switch off large loads.",
|
||||
g.reason)
|
||||
"Switch off large loads, the peak guard may not draw the battery "
|
||||
"lower: %s.", messages.reason_code(g.reason))
|
||||
_LOG.debug("Evidence: %s.", g.reason)
|
||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||
# here - check_meter_frozen() gives the reasoning. A battery at its
|
||||
# energy floor is not a saturated inverter and is not a frozen meter,
|
||||
@@ -4101,8 +4342,9 @@ class Controller:
|
||||
hint=messages.hint("meter_frozen"))
|
||||
_LOG.error(
|
||||
"The meter shows the same value while the battery is asked for "
|
||||
"full power: %s. Control still follows it, so check the meter.",
|
||||
self.freeze_wd.reason)
|
||||
"full power, and control still follows it, so check the meter: "
|
||||
"%s.", messages.reason_code(self.freeze_wd.reason))
|
||||
_LOG.debug("Evidence: %s.", self.freeze_wd.reason)
|
||||
_LOG.debug(
|
||||
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
||||
"its age is fresh, the firmware watchdog is fed and the arbiter "
|
||||
@@ -4216,11 +4458,10 @@ class Controller:
|
||||
and standing is not None and standing.from_feed)
|
||||
if keep:
|
||||
_LOG.warning(
|
||||
"The price update was incomplete (%s), so the plan "
|
||||
"from %s, %.0f s ago, is kept until it is %.0f s old.",
|
||||
plan.words, self.tariff_at,
|
||||
(now - self.tariff_at).total_seconds(),
|
||||
TARIFF_MAX_AGE_S)
|
||||
"The price update was incomplete, so the plan from %s "
|
||||
"is kept until it is %s old. The update held %s.",
|
||||
messages.when(brussels(self.tariff_at)),
|
||||
messages.duration(TARIFF_MAX_AGE_S), plan.words)
|
||||
else:
|
||||
self.tariff_plan, self.tariff_at = plan, now
|
||||
_LOG.info("tariff refreshed: %s", plan.words)
|
||||
@@ -4231,8 +4472,9 @@ class Controller:
|
||||
# the same reason and on the same terms.
|
||||
_LOG.warning(
|
||||
"The price update failed (%s), so the plan from %s is kept "
|
||||
"until it is %.0f s old, and trading stops then.",
|
||||
err, self.tariff_at, TARIFF_MAX_AGE_S)
|
||||
"until it is %s old, and trading stops then.",
|
||||
err, messages.when(brussels(self.tariff_at)),
|
||||
messages.duration(TARIFF_MAX_AGE_S))
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(self.stop_event.wait(),
|
||||
timeout=TARIFF_REFRESH_S)
|
||||
@@ -4612,7 +4854,16 @@ class Controller:
|
||||
"text": messages.row_controller_ran(_pass_age)})
|
||||
if self.p1_enabled:
|
||||
age = self.p1.published_age_s
|
||||
if self.p1.stale:
|
||||
if (self.p1.stale and self.p1.age_s() is None
|
||||
and age <= self.p1.max_age_s):
|
||||
# UX-10: no reading yet since the start, still inside the
|
||||
# limit. Amber, not red: it is not a fault yet, but control
|
||||
# waits for it. Red at 0 s contradicted the limit the row
|
||||
# prints. Past the limit it is the red row below, as before.
|
||||
out.append({"ok": False, "warn": True, "id": "meter",
|
||||
"text": messages.row_meter_waiting(
|
||||
self.p1.max_age_s)})
|
||||
elif self.p1.stale:
|
||||
# The last error is on the add-on log's WARNING line (p1.py).
|
||||
out.append({"ok": False, "warn": False, "id": "meter",
|
||||
"text": messages.row_meter_stale(
|
||||
|
||||
@@ -351,7 +351,7 @@ class Maintenance:
|
||||
the action log rather than watching nothing happen.
|
||||
"""
|
||||
if blocked_by:
|
||||
msg = (f"maintenance: forced start REFUSED on {self.name} - "
|
||||
msg = (f"maintenance: {self.name} cannot start now, because "
|
||||
f"{blocked_by}")
|
||||
_LOG.warning("%s", msg)
|
||||
return [msg]
|
||||
@@ -378,10 +378,9 @@ class Maintenance:
|
||||
"""
|
||||
if self._deferred != why:
|
||||
self._deferred = why
|
||||
msg = (f"maintenance: {self.name} is due and is WAITING its turn - "
|
||||
f"{why}. It is idle under the control loop and will "
|
||||
f"recalibrate at the next start hour once the slot is free; "
|
||||
f"this is not a stalled phase.")
|
||||
msg = (f"maintenance: {self.name} is due and waits its turn, "
|
||||
f"because {why}. It starts at the next start hour once the "
|
||||
f"other battery is done. This is not a stalled phase.")
|
||||
_LOG.info("%s", msg)
|
||||
events.append(msg)
|
||||
return events
|
||||
|
||||
@@ -311,10 +311,37 @@ def seconds(value) -> str:
|
||||
return f"{n} second" if n == "1" else f"{n} seconds"
|
||||
|
||||
|
||||
# One token each, like a code, so the line keeps its length and stays greppable.
|
||||
NO_REASON_SAVED, NO_REASON_CODE = "REASON_NOT_SAVED", "REASON_WITHOUT_CODE"
|
||||
_REASON_CODE = re.compile(r"\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b")
|
||||
|
||||
|
||||
def reason_code(reason) -> str:
|
||||
"""UX-11: the code in a safety reason (`STALE_READING`), for the one plain
|
||||
WARNING or ERROR line that reports it. The reason with its figures goes on
|
||||
a DEBUG line beside it and into the action log. Saved reasons have always
|
||||
opened with their code, so an old state file reads the same way."""
|
||||
if not reason:
|
||||
return NO_REASON_SAVED
|
||||
m = _REASON_CODE.search(str(reason))
|
||||
return m.group(0) if m else NO_REASON_CODE
|
||||
|
||||
|
||||
def times(n) -> str:
|
||||
return "1 time" if int(n) == 1 else f"{int(n)} times"
|
||||
|
||||
|
||||
def count(n, noun, plural=None) -> str:
|
||||
"""`count(1, "price")` -> `1 price`, `count(3, "price")` -> `3 prices`."""
|
||||
return f"{int(n)} {noun if int(n) == 1 else plural or noun + 's'}"
|
||||
|
||||
|
||||
def when(at) -> str:
|
||||
"""`27 Sep at 09:15`, as the page writes a moment. `at` is already in the
|
||||
time zone the reader wants (the caller passes `price.brussels(t)`)."""
|
||||
return f"{at.day} {at:%b} at {at:%H:%M}"
|
||||
|
||||
|
||||
def for_unit(unit) -> str:
|
||||
"""` for inverter-b` on a site with several inverters, else nothing."""
|
||||
return f" for {unit}" if unit else ""
|
||||
@@ -383,6 +410,11 @@ def row_controller_stalled(age_s, veto_s) -> str:
|
||||
f"sets the inverter to 0 W after {seconds(veto_s)}.")
|
||||
|
||||
|
||||
def row_meter_waiting(limit_s) -> str:
|
||||
"""UX-10: no reading yet since the start, and still inside the limit."""
|
||||
return f"Meter: waiting for the first reading, limit {seconds(limit_s)}"
|
||||
|
||||
|
||||
def row_meter_stale(age_s, limit_s) -> str:
|
||||
return (f"Meter: no reading for {seconds(age_s)}, limit "
|
||||
f"{seconds(limit_s)}. The add-on log shows why.")
|
||||
@@ -766,6 +798,13 @@ WHY_WINNER = {
|
||||
"stopped": "Control is stopped",
|
||||
"inputs missing": "A reading is missing",
|
||||
"no claim was made": "Nothing asked for power",
|
||||
# UX-10: the claim reasons main.why_key() can return for a safety or
|
||||
# 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",
|
||||
}
|
||||
WHY_LIMIT = {
|
||||
"peak(quarter peak)": "the peak limit",
|
||||
@@ -793,6 +832,11 @@ def why(winner, target_w, bound_by=None, *, contradiction=False) -> str:
|
||||
return out
|
||||
|
||||
|
||||
def why_missing(what, limit_s) -> str:
|
||||
"""UX-11: past the hold, which reading is missing and what it does."""
|
||||
return f"No usable {what} reading for over {seconds(limit_s)}: 0 W"
|
||||
|
||||
|
||||
def why_waiting(missing_s) -> str:
|
||||
"""A reading went missing a moment ago: control holds and waits."""
|
||||
return f"A reading is missing for {seconds(missing_s)}, control waits"
|
||||
@@ -811,6 +855,8 @@ 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",
|
||||
"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",
|
||||
@@ -898,6 +944,7 @@ def status_samples() -> list:
|
||||
("row_controller_ran", GREEN, row_controller_ran(2)),
|
||||
("row_controller_stalled", RED, row_controller_stalled(45, 30)),
|
||||
("row_meter_stale", RED, row_meter_stale(40, 10)),
|
||||
("row_meter_waiting", AMBER, row_meter_waiting(30)),
|
||||
("row_meter", GREEN, row_meter(-350, 2, 3, 340)),
|
||||
("row_meter_frozen", RED, row_meter_frozen()),
|
||||
*(("row_freeze_off", AMBER, row_freeze_off(k)) for k in FREEZE_OFF),
|
||||
@@ -1052,6 +1099,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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ from datetime import datetime, timezone
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
from .hass import OUTAGE
|
||||
|
||||
_S = messages.setting # a refusal names the setting by its label (UX-08)
|
||||
|
||||
@@ -524,6 +525,16 @@ WS_URL = "ws://supervisor/core/websocket"
|
||||
BAD_STATES = ("unknown", "unavailable", "none", "")
|
||||
|
||||
|
||||
async def _receive_json(ws):
|
||||
"""The next JSON message. A socket Home Assistant closed (a restart) is a
|
||||
ConnectionResetError, not aiohttp's "Received message 8:1000 is not str"."""
|
||||
msg = await ws.receive()
|
||||
if msg.type is not aiohttp.WSMsgType.TEXT:
|
||||
raise ConnectionResetError(
|
||||
f"Home Assistant closed the connection ({msg.type.name})")
|
||||
return json.loads(msg.data)
|
||||
|
||||
|
||||
class HaDsmrSource:
|
||||
"""Subscribes to state_changed for the configured DSMR entities.
|
||||
|
||||
@@ -574,8 +585,12 @@ class HaDsmrSource:
|
||||
backoff = 1.0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except (aiohttp.ClientError, ConnectionError) as err:
|
||||
# UX-10: Home Assistant restarting, said once (hass.Outage).
|
||||
OUTAGE.down("websocket", "the meter connection closed", err)
|
||||
except Exception as err: # noqa: BLE001 - any transport fault retries
|
||||
_LOG.warning("P1 HA websocket: %s - reconnecting in %.0fs", err, backoff)
|
||||
_LOG.warning("The meter connection to Home Assistant failed "
|
||||
"(%s), trying again in %.0f s.", err, backoff)
|
||||
finally:
|
||||
self.connected = False
|
||||
await asyncio.sleep(backoff)
|
||||
@@ -583,16 +598,17 @@ class HaDsmrSource:
|
||||
|
||||
async def _session_once(self) -> None:
|
||||
async with self.session.ws_connect(WS_URL, heartbeat=30) as ws:
|
||||
hello = await ws.receive_json()
|
||||
hello = await _receive_json(ws)
|
||||
if hello.get("type") == "auth_required":
|
||||
await ws.send_json({"type": "auth", "access_token": self.token})
|
||||
reply = await ws.receive_json()
|
||||
reply = await _receive_json(ws)
|
||||
if reply.get("type") != "auth_ok":
|
||||
raise RuntimeError(f"auth rejected: {reply.get('message', reply)}")
|
||||
await ws.send_json({"id": 1, "type": "subscribe_events",
|
||||
"event_type": "state_changed"})
|
||||
await ws.send_json({"id": 2, "type": "get_states"})
|
||||
self.connected = True
|
||||
OUTAGE.up("websocket")
|
||||
_LOG.info("P1 ingest: subscribed to %s", ", ".join(sorted(self.ids)))
|
||||
|
||||
async for msg in ws:
|
||||
@@ -618,7 +634,7 @@ class HaDsmrSource:
|
||||
new = data.get("new_state") or {}
|
||||
self._absorb(data.get("entity_id"), new.get("state"))
|
||||
self._schedule()
|
||||
raise RuntimeError("websocket closed")
|
||||
raise ConnectionResetError("Home Assistant closed the connection")
|
||||
|
||||
def _absorb(self, entity_id: str | None, state) -> None:
|
||||
if not entity_id or entity_id not in self.ids:
|
||||
|
||||
@@ -402,27 +402,27 @@ from .tariff import QUARTER_S
|
||||
|
||||
# Why the projection may not be graded. Reasons, not booleans: the status page
|
||||
# and the action log both have to say WHICH, and "blind" alone is unactionable.
|
||||
BLIND_NO_BLOCK = "no open quarter - the meter has produced no sample yet"
|
||||
BLIND_NO_BLOCK = "no open 15-minute period, the meter has given no reading yet"
|
||||
BLIND_COVERAGE = "meter gap"
|
||||
BLIND_FROZEN = "SAFETY-18: the meter is answering without changing"
|
||||
BLIND_FROZEN = "the meter is answering without changing"
|
||||
|
||||
# Where the ceiling came from. Only the second is a relaxation, and only an
|
||||
# OBSERVED reading may produce it.
|
||||
CEILING_CAP = "peak_cap_w"
|
||||
CEILING_MONTH_PEAK = "the month's banked peak"
|
||||
CEILING_MONTH_PEAK = "this month's peak"
|
||||
|
||||
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
|
||||
# for BLIND_*'s reason: the status page, the action log and the operator
|
||||
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
|
||||
FLOOR_SOC_UNREADABLE = ("the battery state of charge was not measured this "
|
||||
"cycle, and an unreadable SoC is not a full battery")
|
||||
# ⚠️ SAFETY-25. "the peak guard's SoC floor", not "the SoC floor", and the
|
||||
# possessive is load-bearing: this string is what the status page, the action log
|
||||
# and the operator notification all print, and `peak_soc_floor` bounds THIS GUARD
|
||||
# ONLY. Widening it to "the SoC floor" would make the one sentence an operator
|
||||
# reads at the moment the battery stops shaving into a claim about the pack that
|
||||
# the grid-following loop is at that instant disproving.
|
||||
FLOOR_AT_SOC_FLOOR = "state of charge is at or below the peak guard's SoC floor"
|
||||
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
|
||||
"unread is not full")
|
||||
# ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
|
||||
# load-bearing: this string is what the action log and the add-on log print,
|
||||
# and `peak_soc_floor` (“Peak reserve”) bounds THIS GUARD ONLY. Widening it to
|
||||
# "the SoC floor" would make the one sentence an operator reads at the moment
|
||||
# the battery stops shaving into a claim about the pack that the
|
||||
# grid-following loop is at that instant disproving.
|
||||
FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
|
||||
|
||||
SOURCE = "peak"
|
||||
|
||||
@@ -566,8 +566,9 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
|
||||
return BLIND_NO_BLOCK
|
||||
gap = uncovered_s(projection)
|
||||
if max_hold_s is not None and gap > float(max_hold_s):
|
||||
return (f"{BLIND_COVERAGE}: {gap:.0f} s of this quarter uncovered, past "
|
||||
f"the {float(max_hold_s):.0f} s the averager will hold a sample")
|
||||
return (f"{BLIND_COVERAGE}, {gap:.0f} s of this 15-minute period not "
|
||||
f"covered, past the {float(max_hold_s):.0f} s a reading is "
|
||||
f"held")
|
||||
return None
|
||||
|
||||
|
||||
@@ -743,9 +744,9 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
return PeakDecision(
|
||||
engaged=True, floor_w=0.0, ceiling_w=ceiling, ceiling_from=from_where,
|
||||
blind=blind, projected_w=None,
|
||||
reason=(f"PEAK GUARD BLIND ({blind}) - the battery may not import "
|
||||
f"while the billed quarter cannot be measured; commanding no "
|
||||
f"charging (floor 0 W)"))
|
||||
reason=(f"PEAK_GUARD_BLIND: {blind}. The battery may not charge "
|
||||
f"while this 15-minute period cannot be measured, so it "
|
||||
f"is asked for no charging, floor 0 W"))
|
||||
|
||||
projected = float(projection.projected_w)
|
||||
if batt_w is None:
|
||||
@@ -807,17 +808,18 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
ceiling_from=from_where, projected_w=projected, excess_w=ex,
|
||||
wanted_w=wanted, floor_limited=True, soc=soc, soc_floor=soc_floor,
|
||||
soc_floor_reason=denied,
|
||||
# UX-10: plain sentences, every figure kept (test_messages.py
|
||||
# part 6). The code is one token, so it is not read as shouting.
|
||||
reason=(
|
||||
f"PEAK GUARD FLOOR-LIMITED: the quarter projects "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W ceiling "
|
||||
f"({from_where}) and {wanted:+.0f} W of discharge would hold "
|
||||
f"it, but {denied}"
|
||||
+ ("" if soc is None else f" ({float(soc):.0f} %")
|
||||
f"PEAK_GUARD_FLOOR_LIMITED: this 15-minute period heads for "
|
||||
f"{projected:.0f} W against a {ceiling:.0f} W limit "
|
||||
f"({from_where}), and {wanted:+.0f} W from the battery would "
|
||||
f"hold it. But {denied}"
|
||||
+ ("" if soc is None else f", at {float(soc):.0f} %")
|
||||
+ ("" if soc is None or soc_floor is None
|
||||
else f" vs {float(soc_floor):.0f} %")
|
||||
+ ("" if soc is None else ")")
|
||||
+ " - commanding no forced discharge, so THIS QUARTER WILL GO "
|
||||
"OVER THE CEILING. Charging stays blocked (floor 0 W)"))
|
||||
else f" against {float(soc_floor):.0f} %")
|
||||
+ ", so no forced discharge and no charging, and this period "
|
||||
"will go over the limit"))
|
||||
|
||||
floor = min(wanted, ceil_top)
|
||||
saturated = floor < wanted
|
||||
@@ -826,13 +828,15 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
|
||||
blind=None, projected_w=projected, excess_w=ex, wanted_w=wanted,
|
||||
saturated=saturated, soc=soc, soc_floor=soc_floor,
|
||||
reason=(
|
||||
f"PEAK GUARD: quarter projects {projected:.0f} W against a "
|
||||
f"{ceiling:.0f} W ceiling ({from_where}) with "
|
||||
f"PEAK_GUARD: this 15-minute period heads for {projected:.0f} W "
|
||||
f"against a {ceiling:.0f} W limit ({from_where}), with "
|
||||
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
|
||||
f"come off the meter; battery measured {float(batt_w):+.0f} W, "
|
||||
f"floor {floor:+.0f} W"
|
||||
+ (f" - SATURATED, {wanted:+.0f} W was needed and the inverter can "
|
||||
f"only give {ceil_top:.0f} W" if saturated else "")))
|
||||
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
|
||||
f"and is asked for {floor:+.0f} W"
|
||||
# Saturated means floor == ceil_top: the ask IS the most the
|
||||
# inverters give, so one clause says both.
|
||||
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
|
||||
if saturated else "")))
|
||||
|
||||
|
||||
def charge_headroom_w(decision) -> float | None:
|
||||
|
||||
@@ -150,6 +150,7 @@ from enum import Enum
|
||||
from textwrap import dedent
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from . import messages
|
||||
from .custom_formula import (
|
||||
EXPORT_FORMULA_OPTION, FormulaInvalid, PREVIEW_HOURS, parse_export_formula,
|
||||
parse_price_formula, preview_lines, price_eur_kwh, to_resolution)
|
||||
@@ -1738,11 +1739,11 @@ def _points_from_columnar_json(body, expect_unit, scale,
|
||||
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
|
||||
provisional=bool(provisional_default)))
|
||||
if dropped:
|
||||
_LOG.warning("The price feed gave no price for %d of %d times (index "
|
||||
"%s). Those times are left out, not counted as free.",
|
||||
len(dropped), len(times),
|
||||
_LOG.warning("The price feed gave no price for %d of %s, at index "
|
||||
"%s. Those are left out, not counted as free.",
|
||||
len(dropped), messages.count(len(times), "time"),
|
||||
", ".join(str(i) for i in dropped[:5])
|
||||
+ (", ..." if len(dropped) > 5 else ""))
|
||||
+ (" and more" if len(dropped) > 5 else ""))
|
||||
if not out:
|
||||
raise SourceUnavailable(
|
||||
f"the feed carried {len(times)} instants and a price for none of "
|
||||
@@ -2106,12 +2107,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
|
||||
pid = provider_id(opts)
|
||||
if not provider_configured(opts):
|
||||
return PriceRead.no_data(
|
||||
"no price provider configured (price_provider is 'off'); "
|
||||
"§11.4: static profile, plan provisional, no arbitrage")
|
||||
"no price feed is set (price_provider is 'off'), so the fixed "
|
||||
"tariff is used, the plan is estimated and there is no trading")
|
||||
cls = PROVIDERS.get(pid)
|
||||
if cls is None:
|
||||
return PriceRead.no_data(
|
||||
f"unknown price provider {pid!r}; shipped: "
|
||||
f"unknown price provider {pid!r}. The known ones are "
|
||||
f"{', '.join(sorted(PROVIDERS))}")
|
||||
|
||||
caps = cls.capabilities()
|
||||
|
||||
@@ -164,6 +164,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
|
||||
from . import messages
|
||||
from .maintenance import CHARGE_TIMEOUT_MIN, DRAIN_TIMEOUT_MIN
|
||||
from .price import PricePoint, PriceRead, _Outcome, brussels
|
||||
|
||||
@@ -411,13 +412,14 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
|
||||
if not out:
|
||||
reason = read.match(
|
||||
series=lambda r: (f"the {len(r.points)} published hours all fall "
|
||||
f"outside {since.isoformat()}..{until.isoformat()}"),
|
||||
series=lambda r: (f"all {messages.count(len(r.points), 'published price')} "
|
||||
f"fall outside {messages.when(brussels(since))} to "
|
||||
f"{messages.when(brussels(until))}"),
|
||||
no_data=lambda r: r.reason)
|
||||
return PriceRead.no_data(
|
||||
f"§8.3 carry-forward produced no hours: {reason}, and yesterday's "
|
||||
f"profile has nothing to fill them with either. §11.4 applies: "
|
||||
f"static profile, plan provisional, no arbitrage")
|
||||
f"no hours could be filled from yesterday's prices: {reason}. So "
|
||||
f"the fixed tariff is used, the plan is estimated and there is no "
|
||||
f"trading")
|
||||
|
||||
_audit(out, published)
|
||||
carried = sum(1 for p in out if _utc_hour(p.start) not in published)
|
||||
@@ -431,10 +433,11 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead,
|
||||
# hours" about a 48-hour horizon. The fill's unit is the HOUR and the
|
||||
# feed's is the POINT; nothing derived from one answers for the other.
|
||||
total_h = len({_utc_hour(p.start) for p in out})
|
||||
_LOG.info("§8.3: %d of %d hours carried forward from yesterday's "
|
||||
"profile and flagged provisional; the feed published %d "
|
||||
"points over the other %d hours",
|
||||
carried, total_h, len(out) - carried, total_h - carried)
|
||||
_LOG.info("%d of %s use yesterday's prices, marked as estimated. "
|
||||
"The feed published %s over the other %s.",
|
||||
carried, messages.count(total_h, "hour"),
|
||||
messages.count(len(out) - carried, "price"),
|
||||
messages.count(total_h - carried, "hour"))
|
||||
|
||||
product = read.match(series=lambda r: r.product,
|
||||
no_data=lambda _r: yesterday.product)
|
||||
@@ -525,8 +528,8 @@ class _Permitted(Gate):
|
||||
"one rule this type has.")
|
||||
self.action = action
|
||||
self.hours = pts
|
||||
self.words = (f"{action.name} permitted on {len(pts)} firm hours "
|
||||
f"({pts[0].start.isoformat()} ..)")
|
||||
self.words = (f"allowed on {messages.count(len(pts), 'published price')} "
|
||||
f"from {messages.when(brussels(pts[0].start))}")
|
||||
|
||||
|
||||
class _Blocked(Gate):
|
||||
@@ -570,8 +573,8 @@ def price_permits(read: PriceRead, now: datetime,
|
||||
return read.match(
|
||||
series=lambda r: _walk(r, hours, action),
|
||||
no_data=lambda r: Gate.block(
|
||||
f"{action.name} is gated on a price and there is no price data: "
|
||||
f"{r.reason}. §11.4 - and a missing price is not a cheap one"))
|
||||
f"{action.name} needs a price and there is none: {r.reason}. A "
|
||||
f"missing price is not a cheap one"))
|
||||
|
||||
|
||||
def _walk(series, hours, action) -> Gate:
|
||||
@@ -579,19 +582,18 @@ def _walk(series, hours, action) -> Gate:
|
||||
for u in hours:
|
||||
if not series.covers(u):
|
||||
return Gate.block(
|
||||
f"{action.name} commits {action.commits_h:g} h from "
|
||||
f"{hours[0].isoformat()} and no price covers "
|
||||
f"{u.isoformat()}. An hour outside the series is an hour with "
|
||||
f"no price (§11.4 for that hour), not a free one")
|
||||
f"{action.name} needs prices for {action.commits_h:g} hours "
|
||||
f"from {messages.when(brussels(hours[0]))}, and no price "
|
||||
f"covers {messages.when(brussels(u))}. An hour with no price "
|
||||
f"is not a free one")
|
||||
p = series.at(u)
|
||||
if p.provisional:
|
||||
return Gate.block(
|
||||
f"{action.name} commits {action.commits_h:g} h from "
|
||||
f"{hours[0].isoformat()} and {u.isoformat()} is PROVISIONAL - "
|
||||
f"carried from yesterday's profile or standing in for a dead "
|
||||
f"feed. §8.3: no irreversible action may be taken on a "
|
||||
f"provisional price. It becomes available when the day-ahead "
|
||||
f"print lands (~14-16 h CET, §5.4)")
|
||||
f"{action.name} needs prices for {action.commits_h:g} hours "
|
||||
f"from {messages.when(brussels(hours[0]))}, and the price for "
|
||||
f"{messages.when(brussels(u))} is only estimated. It can start "
|
||||
f"once the day-ahead prices are published, around 14:00 to "
|
||||
f"16:00")
|
||||
pts.append(p)
|
||||
return Gate.permit(action, pts)
|
||||
|
||||
@@ -622,5 +624,6 @@ def _go(maint, now, gate) -> Gate:
|
||||
|
||||
|
||||
def _no(gate) -> Gate:
|
||||
_LOG.warning("irreversible action refused - %s", gate.reason)
|
||||
_LOG.warning("An action that cannot be undone does not start: %s.",
|
||||
gate.reason)
|
||||
return gate
|
||||
|
||||
@@ -186,8 +186,9 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from . import messages
|
||||
from .price import PricePoint, PriceProduct, PriceRead, brussels
|
||||
from .price_forecast import fill_from_yesterday
|
||||
|
||||
@@ -345,8 +346,19 @@ def _row_of(day: str, points: int, hours: int) -> str:
|
||||
whole day to carry, and that is the HOUR figure; the point count says how
|
||||
finely it is priced. Neither answers for the other.
|
||||
"""
|
||||
return (f"{day} ({points} price{'' if points == 1 else 's'} "
|
||||
f"over {hours} hour{'' if hours == 1 else 's'})")
|
||||
# UX-10: the day as the page writes dates ("26 Sep"), not ISO.
|
||||
return (f"{points} price{'' if points == 1 else 's'} over {hours} "
|
||||
f"hour{'' if hours == 1 else 's'} for {day_words(day)}")
|
||||
|
||||
|
||||
def day_words(day) -> str:
|
||||
"""`'2026-09-26'` -> `'26 Sep'`, as the page writes a date. A key that is
|
||||
not a date is shown as it is, because this feeds a status row."""
|
||||
try:
|
||||
d = date.fromisoformat(str(day))
|
||||
except ValueError:
|
||||
return str(day)
|
||||
return f"{d.day} {d:%b}"
|
||||
|
||||
|
||||
def _bad_rows(rec) -> int:
|
||||
@@ -393,9 +405,8 @@ class PriceHistory:
|
||||
self.last_error = f"no durable volume at {parent}"
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong.
|
||||
_LOG.info("no price history: %s - §8.3 carry-forward has nothing "
|
||||
"to carry and the horizon stays as short as the feed",
|
||||
self.last_error)
|
||||
_LOG.info("No price history is kept (%s), so missing hours are "
|
||||
"not filled from yesterday's prices.", self.last_error)
|
||||
return
|
||||
self.ok = True
|
||||
self._load()
|
||||
@@ -449,15 +460,15 @@ class PriceHistory:
|
||||
# would not notice a writer this build has never met.
|
||||
if not isinstance(blob, dict) or isinstance(found, bool) \
|
||||
or not isinstance(found, int) or found != SCHEMA:
|
||||
self.last_error = f"schema {found!r}, not {SCHEMA}"
|
||||
_LOG.warning("price history at %s has %s - discarding it rather "
|
||||
"than guessing what its points mean",
|
||||
self.last_error = f"version {found!r}, not {SCHEMA}"
|
||||
_LOG.warning("Price history at %s has %s, so it is not used.",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
days = blob.get("days")
|
||||
if not isinstance(days, dict):
|
||||
self.last_error = "no `days` object"
|
||||
_LOG.warning("price history at %s has %s", self.path, self.last_error)
|
||||
self.last_error = "no days in it"
|
||||
_LOG.warning("Price history at %s has %s, so it is not used.",
|
||||
self.path, self.last_error)
|
||||
return
|
||||
self._days = self._usable_days(days)
|
||||
|
||||
@@ -504,20 +515,22 @@ class PriceHistory:
|
||||
kept[key] = rec
|
||||
notes = []
|
||||
if dropped:
|
||||
notes.append(f"{len(dropped)} unusable day record(s) dropped "
|
||||
f"({', '.join(sorted(dropped))})")
|
||||
notes.append(f"{messages.count(len(dropped), 'unusable day')} "
|
||||
f"dropped, " + ", ".join(day_words(d)
|
||||
for d in sorted(dropped)))
|
||||
if malformed:
|
||||
notes.append(
|
||||
f"{sum(malformed.values())} malformed point row(s) in "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
|
||||
f"{messages.count(sum(malformed.values()), 'bad price')} in "
|
||||
+ ", ".join(f"{day_words(d)} ({n})"
|
||||
for d, n in sorted(malformed.items())))
|
||||
if notes:
|
||||
# Recorded, not silent: `yesterday()` and `words` both report
|
||||
# `last_error`, and a cache that quietly discards half a file reads
|
||||
# to an operator exactly like a normal first day.
|
||||
self.last_error = "; ".join(notes)
|
||||
_LOG.warning("price history at %s: %s - the controller starts "
|
||||
"anyway and the horizon is as short as the feed makes "
|
||||
"it", self.path, self.last_error)
|
||||
self.last_error = " and ".join(notes)
|
||||
_LOG.warning("Price history at %s: %s. The add-on starts anyway, "
|
||||
"and fills in only what the rest holds.",
|
||||
self.path, self.last_error)
|
||||
return kept
|
||||
|
||||
def _save(self) -> bool:
|
||||
@@ -680,10 +693,11 @@ class PriceHistory:
|
||||
# undone with the rest of the merge, so those rows are still in the
|
||||
# file and the next write that completes drops them again.
|
||||
self.last_error = (
|
||||
f"{sum(junked.values())} malformed point row(s) dropped from "
|
||||
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
|
||||
_LOG.warning("price history at %s: %s - the record is rewritten "
|
||||
"without them and the refresh continues",
|
||||
f"{messages.count(sum(junked.values()), 'bad price')} dropped "
|
||||
f"from " + ", ".join(f"{day_words(d)} ({n})"
|
||||
for d, n in sorted(junked.items())))
|
||||
_LOG.warning("Price history at %s: %s. The file is saved without "
|
||||
"them and the price update goes on.",
|
||||
self.path, self.last_error)
|
||||
self._prune(stamp)
|
||||
if not self._save():
|
||||
@@ -752,21 +766,23 @@ class PriceHistory:
|
||||
if self.last_error and not self._days:
|
||||
return PriceRead.no_data(
|
||||
f"the price history at {self.path} is {self.last_error}, so "
|
||||
f"§8.3 has no profile to carry forward for {want}; the plan "
|
||||
f"covers only the hours the feed published")
|
||||
f"there are no prices from yesterday to fill in for "
|
||||
f"{day_words(want)}. The plan covers only the hours the feed "
|
||||
f"published")
|
||||
rec = self._days.get(want)
|
||||
if rec is None:
|
||||
have = ", ".join(sorted(self._days)) or "no days at all"
|
||||
have = (", ".join(day_words(d) for d in sorted(self._days))
|
||||
or "no days at all")
|
||||
return PriceRead.no_data(
|
||||
f"no published prices recorded for {want} (held: {have}), so "
|
||||
f"§8.3 has no profile to carry forward. A record for another "
|
||||
f"day is not a yesterday")
|
||||
f"no published prices are kept for {day_words(want)}, only for "
|
||||
f"{have}, so there are no prices from yesterday to fill in. A "
|
||||
f"record for another day is not a yesterday")
|
||||
points, bad = _parse_points(rec.get("points"))
|
||||
if not points:
|
||||
why = bad or "it is empty"
|
||||
return PriceRead.no_data(
|
||||
f"the {want} record holds no usable point ({why}), and an "
|
||||
f"empty series is not a free day")
|
||||
f"the {day_words(want)} record holds no usable price, {why}, "
|
||||
f"and an empty series is not a free day")
|
||||
try:
|
||||
product = PriceProduct(rec.get("product"))
|
||||
except ValueError:
|
||||
@@ -775,8 +791,9 @@ class PriceHistory:
|
||||
f"which is not a PriceProduct. The optimiser switches on that "
|
||||
f"enum, so a record that cannot name one cannot be a series")
|
||||
if bad:
|
||||
_LOG.warning("Price history: %s in the %s record were dropped, "
|
||||
"and %d points were kept.", bad, want, len(points))
|
||||
_LOG.warning("Price history: %s dropped from the %s record, and "
|
||||
"%s kept.", bad, day_words(want),
|
||||
messages.count(len(points), "price"))
|
||||
return PriceRead.series(points, product,
|
||||
str(rec.get("provider") or "price_history"),
|
||||
stamp)
|
||||
@@ -805,7 +822,7 @@ class PriceHistory:
|
||||
if not self._days:
|
||||
why = f" - {self.last_error}" if self.last_error else ""
|
||||
return f"empty{why} - nothing to carry forward yet"
|
||||
held = "; ".join(_row_of(d, *_held(self._days[d]))
|
||||
held = ", ".join(_row_of(d, *_held(self._days[d]))
|
||||
for d in sorted(self._days, key=str))
|
||||
# A file that PARTLY loaded says so, or the dropped days read as days
|
||||
# that were simply never written.
|
||||
@@ -833,7 +850,7 @@ def _parse_points(raw) -> tuple:
|
||||
continue
|
||||
out.append(pt)
|
||||
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
|
||||
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
|
||||
return tuple(out), (messages.count(dropped, "bad price") if dropped else "")
|
||||
|
||||
|
||||
class NoHistory:
|
||||
@@ -861,5 +878,5 @@ class NoHistory:
|
||||
|
||||
def yesterday(self, stamp: datetime) -> PriceRead:
|
||||
return PriceRead.no_data(
|
||||
"this caller keeps no price history (NoHistory), so §8.3 has no "
|
||||
"profile to carry forward")
|
||||
"this caller keeps no price history, so there are no prices from "
|
||||
"yesterday to fill in")
|
||||
|
||||
@@ -82,6 +82,7 @@ import os
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
from .configui import changed_keys
|
||||
from .inverter import build_roster
|
||||
from .validate import validate_startup_config, validate_unit_ranges
|
||||
@@ -287,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
|
||||
@@ -302,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()
|
||||
|
||||
@@ -328,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 "
|
||||
@@ -346,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
|
||||
@@ -381,8 +385,9 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
||||
"""
|
||||
problems = await problems_for(proposed, hass)
|
||||
if problems:
|
||||
_LOG.warning("The settings page did not save, because of %d "
|
||||
"problems. The first: %s", len(problems), problems[0])
|
||||
_LOG.warning("The settings page did not save, because of %s. The "
|
||||
"first: %s", messages.count(len(problems), "problem"),
|
||||
problems[0])
|
||||
raise SaveRefused("invalid", problems=problems)
|
||||
|
||||
info = await selfcfg.info()
|
||||
|
||||
@@ -1130,13 +1130,28 @@ class TariffPlan:
|
||||
PriceProduct.STATIC: "day/night prices",
|
||||
PriceProduct.FIXED: "a fixed price"}.get(self.product,
|
||||
str(self.product))
|
||||
# UX-10: the spacing in words, and no "not final yet": a plan is
|
||||
# provisional exactly when it holds an estimated price, which the
|
||||
# count already says.
|
||||
gaps = self.spacings_s
|
||||
return (f"{what} from {tag}, "
|
||||
f"{self.n_points} prices over {self.hours} hours, "
|
||||
f"{self.n_firm} published and {self.n_carried} estimated"
|
||||
+ (f", {'+'.join(str(g) for g in gaps)} s apart"
|
||||
if gaps else "")
|
||||
+ (", not final yet" if self.provisional else ""))
|
||||
+ (f", {spacing_words(gaps)}" if gaps else ""))
|
||||
|
||||
|
||||
def spacing_words(gaps) -> str:
|
||||
"""`(900, 3600)` -> `one every 15 minutes or every hour`."""
|
||||
def one(s):
|
||||
s = int(s)
|
||||
if s == 3600:
|
||||
return "every hour"
|
||||
if s % 3600 == 0:
|
||||
return f"every {s // 3600} hours"
|
||||
if s % 60 == 0:
|
||||
return f"every {s // 60} minutes"
|
||||
return f"every {s} seconds"
|
||||
return "one " + " or ".join(one(g) for g in gaps)
|
||||
|
||||
|
||||
def _whole_hours(points) -> int:
|
||||
@@ -1206,9 +1221,9 @@ def static_plan(opts: dict, since: datetime, until: datetime, *,
|
||||
if profile is None:
|
||||
return TariffPlan(
|
||||
PriceRead.no_data(
|
||||
"no static_profile configured; §11.4: plan provisional, no "
|
||||
"arbitrage. Self-consumption, peak shaving and reserve "
|
||||
"management are unaffected"),
|
||||
"no fixed tariff is set, so the plan is estimated and there "
|
||||
"is no trading. Self-consumption, peak limiting and the "
|
||||
"reserve are not affected"),
|
||||
None, "none")
|
||||
points = profile.series(since, until, provisional=provisional)
|
||||
return TariffPlan(
|
||||
@@ -1338,16 +1353,18 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
|
||||
try:
|
||||
profile = parse_static_profile(price_option(opts, "static_profile"))
|
||||
except ProfileInvalid as e:
|
||||
_LOG.warning("the price feed is down AND static_profile is unusable "
|
||||
"(%s), so there is no tariff at all", e)
|
||||
_LOG.warning("The price feed is down and the fixed tariff "
|
||||
"(static_profile) cannot be used either (%s), so there is "
|
||||
"no tariff at all.", e)
|
||||
profile = None
|
||||
if profile is None:
|
||||
_LOG.warning("%s, and no static_profile is set to fall back to, so "
|
||||
"prices are provisional and trading is off. Peak "
|
||||
"limiting and home use go on.", read.reason)
|
||||
_LOG.warning("The price feed failed and no fixed tariff is set, so "
|
||||
"trading is off, while peak limiting and home use go on. "
|
||||
"%s.", read.reason)
|
||||
return TariffPlan(read, None, "none")
|
||||
_LOG.warning("%s, so the static_profile tariff is used instead, marked "
|
||||
"provisional, and trading is off.", read.reason)
|
||||
_LOG.warning("The price feed failed, so the fixed tariff is used "
|
||||
"instead, marked as estimated, and trading is off. %s.",
|
||||
read.reason)
|
||||
points = profile.series(since, until, provisional=True)
|
||||
return TariffPlan(
|
||||
PriceRead.series(points, profile.product, "static_profile", stamp),
|
||||
|
||||
@@ -222,6 +222,8 @@ class MigrationReport:
|
||||
self.misdirected = []
|
||||
# Why nothing ran, when nothing ran. Empty means "already at schema".
|
||||
self.skipped = ""
|
||||
# The inverter list the moves were graded against, for `explain()`.
|
||||
self.roster = []
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
@@ -237,19 +239,27 @@ class MigrationReport:
|
||||
# the flat keys ARE its namespace - and an operator reading the
|
||||
# startup log needs to be told which, because only one of them
|
||||
# changes when a second inverter is declared.
|
||||
return self.skipped or (f"store already at schema "
|
||||
f"{self.from_version}; no "
|
||||
f"per-inverter migration needed")
|
||||
out = (f"migrated {len(self.moved)} flat key(s) to inverter "
|
||||
f"namespaces: " + ", ".join(sorted(self.moved)))
|
||||
return self.skipped or (f"the saved state is already at version "
|
||||
f"{self.from_version}, so nothing moves")
|
||||
# UX-10: plain sentences (test_messages.py part 6). Each problem is
|
||||
# named by its key and what went wrong, and `problems`, `lost` and
|
||||
# `misdirected` keep their full notes for the checks that read them.
|
||||
n = len(self.moved)
|
||||
out = (f"moved {n} saved {'value' if n == 1 else 'values'} to one part "
|
||||
f"per inverter: " + ", ".join(sorted(self.moved)) if n
|
||||
else "moved nothing")
|
||||
if self.kept:
|
||||
out += " (left alone, already namespaced: " + ", ".join(sorted(self.kept)) + ")"
|
||||
if self.problems:
|
||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||
if self.lost:
|
||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||
if self.misdirected:
|
||||
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
|
||||
out += ". Already in place: " + ", ".join(sorted(self.kept))
|
||||
def keys(notes):
|
||||
return ", ".join(note.split(" ", 1)[0] for note in notes)
|
||||
other = [n for n in self.problems
|
||||
if n not in self.lost and n not in self.misdirected]
|
||||
parts = ([f"lost {keys(self.lost)}"] if self.lost else []) + (
|
||||
[f"moved under an inverter not in the list {self.roster!r}: "
|
||||
f"{keys(self.misdirected)}"] if self.misdirected else []) + (
|
||||
[f"read back differently {keys(other)}"] if other else [])
|
||||
if parts:
|
||||
out += ". Problems: " + ", and ".join(parts)
|
||||
return out
|
||||
|
||||
|
||||
@@ -436,6 +446,7 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
if roster is None:
|
||||
return
|
||||
names = [str(n) for n in roster]
|
||||
rep.roster = names
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
|
||||
# through the same spelling the writer used rather than re-deriving it.
|
||||
#
|
||||
@@ -469,8 +480,8 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
if unit in names:
|
||||
continue
|
||||
rep.verified = False
|
||||
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
|
||||
f"on the roster {names!r} - nothing reads that key")
|
||||
note = (f"{target} was moved under {unit!r}, which is not in the "
|
||||
f"inverter list {names!r}, so nothing reads it")
|
||||
rep.problems.append(note)
|
||||
rep.misdirected.append(note)
|
||||
|
||||
@@ -536,14 +547,12 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
||||
names = [str(n) for n in roster]
|
||||
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
|
||||
if len(names) <= 1:
|
||||
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
|
||||
"history and are read in place, so there is nothing to "
|
||||
"move and the store layout does not change")
|
||||
rep.skipped = ("one inverter, so it is read where it is and nothing "
|
||||
"moves")
|
||||
return rep
|
||||
if rep.from_version >= MAINT_SCHEMA_VERSION:
|
||||
rep.skipped = (f"maintenance history already at schema "
|
||||
f"{rep.from_version}; written by a multi-inverter "
|
||||
f"version and not touched")
|
||||
rep.skipped = (f"already at version {rep.from_version}, written for "
|
||||
f"several inverters, so it is left as it is")
|
||||
return rep
|
||||
|
||||
rep.ran = True
|
||||
@@ -567,8 +576,8 @@ def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
||||
# the re-run a no-op where the keys did land, so refusing the marker
|
||||
# costs one repeated pass and buys the difference between a migration
|
||||
# that can still be fixed and one the store says already happened.
|
||||
_LOG.error("per-pack maintenance-history migration did NOT land and "
|
||||
"has not been marked done: %s", rep.explain())
|
||||
_LOG.error("Moving the maintenance history to one part per inverter "
|
||||
"did not work, so it is not marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
|
||||
@@ -114,6 +114,8 @@ import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone, tzinfo
|
||||
|
||||
from . import messages
|
||||
|
||||
_LOG = logging.getLogger("goodwe.tariff")
|
||||
|
||||
QUARTER_S = 900
|
||||
@@ -339,8 +341,8 @@ class PeakStore:
|
||||
# INFO, not ERROR: on a dev host or in a test container there is no
|
||||
# /data and nothing is wrong. A store that OPENED and then broke is
|
||||
# the error case, and it is logged as one below.
|
||||
_LOG.info("no peak store: %s - the monthly peak will read "
|
||||
"NOT_OBSERVED", self.last_error)
|
||||
_LOG.info("No peak store (%s), so the month peak cannot be read "
|
||||
"and only the peak limit is used.", self.last_error)
|
||||
return
|
||||
try:
|
||||
self._db = sqlite3.connect(self.path, timeout=5.0,
|
||||
@@ -360,8 +362,9 @@ class PeakStore:
|
||||
# fault must not stop a controller that is regulating a battery. The
|
||||
# cost of continuing is a peak that reads NOT_OBSERVED, and every
|
||||
# reader is built to treat that as "do not use me", not as 0 W.
|
||||
_LOG.error("peak store unusable at %s (%s) - the monthly peak will "
|
||||
"read NOT_OBSERVED, which is not a zero peak", self.path, err)
|
||||
_LOG.error("The peak store at %s cannot be used (%s), so only the "
|
||||
"peak limit is used. The month peak is unknown, not "
|
||||
"zero.", self.path, err)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._db is not None:
|
||||
@@ -672,12 +675,12 @@ class PeakTracker:
|
||||
now = now or datetime.now(timezone.utc)
|
||||
snap = self.store.load_partial()
|
||||
if not snap:
|
||||
self.restore_state = ("fresh: no checkpoint" if self.store.ok
|
||||
else "fresh: store unreadable")
|
||||
self.restore_state = ("fresh start, nothing saved" if self.store.ok
|
||||
else "fresh start, the store cannot be read")
|
||||
return self.restore_state
|
||||
block = snap.get("block")
|
||||
if block is None:
|
||||
self.restore_state = "fresh: checkpoint had no open block"
|
||||
self.restore_state = "fresh start, no open 15-minute period saved"
|
||||
return self.restore_state
|
||||
|
||||
current = int(now.timestamp() // QUARTER_S) * QUARTER_S
|
||||
@@ -686,11 +689,13 @@ class PeakTracker:
|
||||
# A reshaped accumulator (meter_phases changed) is a different
|
||||
# measurement, not a resumable one. Say so; do not half-resume.
|
||||
self.store.clear_partial()
|
||||
self.restore_state = "fresh: checkpoint does not fit this averager"
|
||||
self.restore_state = ("fresh start, the saved period does not "
|
||||
"fit the number of phases")
|
||||
return self.restore_state
|
||||
self.restore_state = (
|
||||
f"resumed the open block at {snap.get('elapsed', 0.0):.0f} s "
|
||||
f"of {QUARTER_S} s ({snap.get('acc', 0.0) / QUARTER_S:.0f} W banked)")
|
||||
f"resumed the open 15-minute period at "
|
||||
f"{snap.get('elapsed', 0.0):.0f} s of {QUARTER_S} s, with "
|
||||
f"{snap.get('acc', 0.0) / QUARTER_S:.0f} W counted so far")
|
||||
return self.restore_state
|
||||
|
||||
# The block ended while the add-on was down. Its measured part really
|
||||
@@ -704,10 +709,14 @@ class PeakTracker:
|
||||
if snap.get("pp_acc") and snap.get("last_pp") else None))
|
||||
banked = self.store.record_block(closed, self.policy)
|
||||
self.store.clear_partial()
|
||||
# UX-10: plain words and the local time, not ISO and "banked".
|
||||
self.restore_state = (
|
||||
f"closed a stale block from {closed.start.isoformat()} at "
|
||||
f"{closed.offtake_avg_w:.0f} W (partial coverage), "
|
||||
+ ("banked" if banked else "NOT banked - store unwritable"))
|
||||
f"closed the 15-minute period from "
|
||||
f"{messages.when(closed.start.astimezone(BILLING_TZ))} at "
|
||||
f"{closed.offtake_avg_w:.0f} W, partly measured, "
|
||||
+ ("and saved it" if banked
|
||||
else "but could not save it, because the store cannot be "
|
||||
"written"))
|
||||
return self.restore_state
|
||||
|
||||
# -- steady state -------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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"}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
@@ -343,6 +345,46 @@ from .watchdog import SIGN_OPTION_DEFAULTS
|
||||
|
||||
_LOG = logging.getLogger("goodwe.config")
|
||||
|
||||
|
||||
class _WarnOnce(logging.Filter):
|
||||
"""UX-10: a settings warning is said once per start. The validator runs
|
||||
twice at start (before the first cycle and again once the inverter's range
|
||||
is known), and a warning about options that have not changed is the same
|
||||
news both times. Refusals are ERROR and pass untouched."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.seen = set()
|
||||
|
||||
def filter(self, record):
|
||||
if record.levelno != logging.WARNING:
|
||||
return True
|
||||
text = record.getMessage()
|
||||
if text in self.seen:
|
||||
return False
|
||||
self.seen.add(text)
|
||||
return True
|
||||
|
||||
|
||||
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
|
||||
@@ -765,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
|
||||
@@ -775,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] = []
|
||||
|
||||
@@ -1103,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
|
||||
@@ -1149,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
|
||||
@@ -1173,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 "
|
||||
@@ -1181,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
|
||||
@@ -1314,12 +1367,11 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if profile is not None and profile.product is not None \
|
||||
and len(set(profile.prices)) == 1:
|
||||
_LOG.info(
|
||||
"static_profile carries one distinct price (%.4f EUR/kWh "
|
||||
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
|
||||
"off by arithmetic - a flat series has no spread - and the "
|
||||
"add-on runs self-consumption only, which is §9.2's handling "
|
||||
"for this product. Peak shaving, reserve management and the "
|
||||
"maintenance cycle are unaffected.", profile.prices[0])
|
||||
"static_profile has one price (%.4f EUR/kWh all-in), so this "
|
||||
"site has a fully fixed price. A flat price has no spread to "
|
||||
"trade on, so the add-on uses the battery for self-consumption "
|
||||
"only. Peak limiting, the reserve and battery maintenance are "
|
||||
"not affected.", profile.prices[0])
|
||||
|
||||
# ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT
|
||||
# 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read
|
||||
@@ -1400,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.
|
||||
@@ -1455,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"])
|
||||
@@ -1468,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.
|
||||
#
|
||||
@@ -1505,9 +1566,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
_LOG.info(
|
||||
"%s is set: injection priced as (%g x index %+g) x "
|
||||
"(1 + %g/100) %s, i.e. %.4g x the wholesale price with a "
|
||||
"%+.5f EUR/kWh offset. ⚠️ NO STRATEGY IN THIS ADD-ON READS "
|
||||
"IT: arbitrage.py prices the sell side as displaced IMPORT "
|
||||
"and compares two all-in CONSUMPTION prices, so this option "
|
||||
"%+.5f EUR/kWh offset. No part of the add-on reads it yet: "
|
||||
"trading prices the sell side as displaced import and "
|
||||
"compares two all-in consumption prices, so this setting "
|
||||
"changes no decision the add-on takes today. It is what you "
|
||||
"are shown, and the shape a future export-aware strategy "
|
||||
"would read.",
|
||||
@@ -1548,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 "
|
||||
@@ -1559,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
|
||||
@@ -1570,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
|
||||
@@ -1621,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 == "":
|
||||
|
||||
@@ -670,11 +670,10 @@ class SignWatchdog:
|
||||
# ⚠️ `self.against` IS THE ONLY THING SAFETY-30 CHANGED IN THIS STRING,
|
||||
# and it defaults to the wording that shipped - "a meter reading" - so a
|
||||
# one-inverter install's `reason` is byte-identical to what an operator
|
||||
# (and test_watchdog.py) has always read. A per-unit instance names what
|
||||
# it actually compared instead, because "commanded +1500 W against a
|
||||
# meter reading -1500 W" would be a false statement about which
|
||||
# instrument produced the evidence, on the one line somebody woken by
|
||||
# this alarm reads first.
|
||||
# has always read (test_watchdog.py pins its meaning). A per-unit
|
||||
# instance names what it actually compared instead, because "commanded
|
||||
# +1500 W against a meter reading -1500 W" would be a false statement
|
||||
# about which instrument produced the evidence.
|
||||
self.reason = (
|
||||
f"SIGN_DISAGREEMENT: commanded {commanded_w:+.0f} W against "
|
||||
f"{self.against} {against_w:+.0f} W for {held:.0f} s continuously "
|
||||
|
||||
@@ -24,7 +24,8 @@ 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 logging
|
||||
@@ -32,6 +33,8 @@ import logging
|
||||
from aiohttp import web
|
||||
|
||||
from . import messages, selfconfig
|
||||
from .haentities import EntityCache
|
||||
from .validate import config_notes
|
||||
from .config_page import CONFIG_PAGE
|
||||
from .configui import changed_keys, field_model, problems_by_field
|
||||
|
||||
@@ -140,6 +143,7 @@ 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)
|
||||
|
||||
async def index(_request):
|
||||
return web.Response(text=PAGE, content_type="text/html")
|
||||
@@ -216,11 +220,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 +299,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,12 +315,21 @@ 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
|
||||
|
||||
|
||||
def access_log():
|
||||
"""UX-10: the page polls /status every few seconds, and aiohttp logs each
|
||||
request at INFO. That access log is kept for a DEBUG log level only."""
|
||||
return (logging.getLogger("aiohttp.access")
|
||||
if _LOG.isEnabledFor(logging.DEBUG) else None)
|
||||
|
||||
|
||||
async def start(controller, port: int = 8099, selfcfg=None):
|
||||
runner = web.AppRunner(build_app(controller, selfcfg))
|
||||
runner = web.AppRunner(build_app(controller, selfcfg),
|
||||
access_log=access_log())
|
||||
await runner.setup()
|
||||
site = web.TCPSite(runner, "0.0.0.0", port)
|
||||
await site.start()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
@@ -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, "
|
||||
@@ -521,7 +526,8 @@ check(f"the action log records every engagement, coalesced into one row per "
|
||||
f"episode ({len(rows_r)} row(s), {ctl_r.clamp_wd.engagements} "
|
||||
f"engagement(s))",
|
||||
len(rows_r) == ctl_r.clamp_wd.episodes
|
||||
and f"{ctl_r.clamp_wd.engagements} engagement(s)" in rows_r[0])
|
||||
and messages.count(ctl_r.clamp_wd.engagements, "engagement")
|
||||
in rows_r[0])
|
||||
|
||||
# ⚠️ REGRESSION, AND IT WAS A REAL BUG BEFORE IT WAS A CHECK. `log_event()` keeps
|
||||
# 40 rows. A row per engagement flushes that ring inside 40 cycles of a winding
|
||||
@@ -538,7 +544,8 @@ check(f"a long winding episode does NOT flood the 40-row event log "
|
||||
f"({len(rows_l)} rows for {ctl_l.clamp_wd.engagements} engagements)",
|
||||
ctl_l.clamp_wd.engagements > 40 and len(rows_l) <= ctl_l.clamp_wd.episodes)
|
||||
check("...and the count in the row is the counter's, so nothing is lost",
|
||||
f"{ctl_l.clamp_wd.engagements} engagement(s)" in nth(rows_l, -1))
|
||||
messages.count(ctl_l.clamp_wd.engagements, "engagement")
|
||||
in nth(rows_l, -1))
|
||||
# The RED row exists only while engaged, and the episode above is short, so it
|
||||
# is captured mid-run rather than asserted after the hysteresis has cleared it.
|
||||
check("the status page flags it WHILE it is engaged - amber, since it is the "
|
||||
@@ -578,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)
|
||||
|
||||
@@ -1037,7 +1048,8 @@ check("...and the operator is told WHICH bound bit, so a device-rating clamp "
|
||||
and messages.LIMIT_WORDS["device"] in notes_w[0]["message"]
|
||||
and messages.LIMIT_WORDS["max_w"] not in notes_w[0]["message"])
|
||||
check("...and the action log carries the row, written by write_setpoint()",
|
||||
any(REASON_CODE in e and "device bound" in e for e in ctl_w.events))
|
||||
any(REASON_CODE in e and messages.LIMIT_WORDS["device"] in e
|
||||
for e in ctl_w.events))
|
||||
|
||||
# The same stimulus, the same clamp, the two calls swapped. This is the ticket
|
||||
# in one pair of runs.
|
||||
@@ -1125,8 +1137,8 @@ check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
|
||||
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
|
||||
"past the 2000 W rating printed four words later - which is the alarm "
|
||||
"reading backwards on the one line an operator is woken by",
|
||||
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
|
||||
f"{_sent_x:+.0f} W" in e for e in _rows_x))
|
||||
any(f"asked {_asked_x:+.0f} W and sent {_sent_x:+.0f} W" in e
|
||||
for e in _rows_x))
|
||||
# UX-08: the notification is a SECOND surface off the same numbers, in its own
|
||||
# words (messages.output_clamp): asked first, sent second.
|
||||
def _asked_then_sent(msg, asked, sent):
|
||||
@@ -1142,8 +1154,8 @@ check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION, which is a SECOND surfac
|
||||
bool(notes_x) and _asked_then_sent(notes_x[0]["message"], _asked_x, _sent_x))
|
||||
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
|
||||
"checks above discriminate rather than decorate",
|
||||
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
|
||||
f"{_asked_x:+.0f} W" in e for e in _rows_x)
|
||||
not any(f"asked {_sent_x:+.0f} W and sent {_asked_x:+.0f} W" in e
|
||||
for e in _rows_x)
|
||||
and not _asked_then_sent(nth(notes_x, 0)["message"], _sent_x, _asked_x)
|
||||
and present(nth(notes_x, 0)))
|
||||
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
|
||||
@@ -1154,8 +1166,8 @@ check("...and the same ordered pair holds on the DEVICE-bound row, where the "
|
||||
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
|
||||
"two also render differently",
|
||||
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
|
||||
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
|
||||
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
|
||||
and any(f"asked {_asked_w:+.0f} W and sent {_sent_w:+.0f} W" in e
|
||||
for e in ctl_w.events)
|
||||
and _asked_then_sent(nth(notes_w, 0)["message"], _asked_w, _sent_w))
|
||||
|
||||
|
||||
@@ -2028,16 +2040,17 @@ check("PRECONDITION: the integrator bound engaged, and on a tick where the "
|
||||
"carried integrator AND the emitted command both differ from the bound",
|
||||
any(d.i_w != _f(d, 'i_bound_w') and d.target_w != _f(d, 'i_bound_w')
|
||||
for d, t, r in wind))
|
||||
check("defect 1: the integrator bound is NOT reported as a BUG SIGNAL",
|
||||
wind and all("BUG SIGNAL" not in r for d, t, r in wind))
|
||||
check("defect 1: ...it names the anti-windup bound, from the same tick",
|
||||
wind and all(f"its {_f(d, 'i_bound_w'):+.0f} W anti-windup bound" in r
|
||||
check("defect 1: the integrator bound is NOT reported as an over-rating",
|
||||
wind and all(" rating" not in r and "fault" not in r
|
||||
for d, t, r in wind))
|
||||
check("defect 1: ...it names the integrator's bound, from the same tick",
|
||||
wind and all(f"reached its {_f(d, 'i_bound_w'):+.0f} W limit" in r
|
||||
for d, t, r in wind))
|
||||
check("defect 2: the integrator accumulator is not printed as a request",
|
||||
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
|
||||
for d, t, r in wind))
|
||||
check("the command quoted is that tick's Decision, not a later read",
|
||||
wind and all(f"command this cycle was {d.target_w:+.0f} W" in r
|
||||
wind and all(f"command now {d.target_w:+.0f} W" in r
|
||||
for d, t, r in wind))
|
||||
check("at the bound nothing is described as 'above' or 'over' the rating",
|
||||
wind and all(" above " not in r and " over " not in r
|
||||
@@ -2049,9 +2062,12 @@ check("it still NOTIFIES (not suppressed), under a title that claims no bug",
|
||||
check("...under its OWN notification_id, so it cannot replace a BUG SIGNAL "
|
||||
"notice still on screen",
|
||||
notes_i and all(n["notification_id"] == BOUND_ID for n in notes_i))
|
||||
# UX-11: the WARNING quotes the code. The reason is on the DEBUG line beside
|
||||
# it (test_messages.py part 6).
|
||||
check("...logged as a WARNING, not an ERROR",
|
||||
any(x.levelno == logging.WARNING and "anti-windup" in x.getMessage()
|
||||
for x in log_i)
|
||||
any(x.levelno == logging.WARNING
|
||||
and x.getMessage().startswith("The battery gives less than asked: "
|
||||
"OUTPUT_CLAMP.") for x in log_i)
|
||||
and not any(x.levelno >= logging.ERROR and "OUTPUT_CLAMP" in x.getMessage()
|
||||
for x in log_i))
|
||||
check("...and the status row is amber, not red",
|
||||
@@ -2070,17 +2086,18 @@ for d, t, r in over[:2]:
|
||||
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
|
||||
"rewrote the command AND the integrator differs from the request",
|
||||
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
|
||||
check("the over-rating KEEPS the BUG SIGNAL",
|
||||
over and all("BUG SIGNAL" in r for d, t, r in over))
|
||||
check("the over-rating says it was cut to the rating",
|
||||
over and all(" W rating, command now " in r for d, t, r in over))
|
||||
check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
|
||||
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("defect 3: 'clamped to' is the clamp's own result, not self.target",
|
||||
over and all(f"clamped to {_f(d, 'clamped_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("defect 3: 'cut to' is the clamp's own result, not self.target",
|
||||
over and all(f"cut to the {_f(d, 'clamped_w'):+.0f} W rating" in r
|
||||
for d, t, r in over))
|
||||
check("...and its ERROR says it is a software fault and that control keeps "
|
||||
"running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
|
||||
"it, which is still in main.py)",
|
||||
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
|
||||
and "Control is still running" in x.getMessage() for x in log_o)
|
||||
and "control runs on" in x.getMessage() for x in log_o)
|
||||
and "reached 14 768 W on this project" in
|
||||
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||
"main.py"), encoding="utf-8").read())
|
||||
|
||||
@@ -1147,8 +1147,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 +1160,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
|
||||
|
||||
|
||||
|
||||
@@ -1644,9 +1644,8 @@ check("...and a re-trip's reason names the TWO durations separately, each "
|
||||
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
|
||||
f.stale and f.releases == 1
|
||||
and "unchanged for 92 s" in f.reason
|
||||
and "the 35 s window this verdict rests on opened when the previous "
|
||||
"one was withdrawn" in f.reason
|
||||
and "graded 2 times" in f.reason)[-1])(
|
||||
and "checked 2 times over a 35 s window since the last stop was "
|
||||
"withdrawn" in f.reason)[-1])(
|
||||
Freshness(BOUNDS["batt"])))
|
||||
|
||||
|
||||
@@ -1799,8 +1798,8 @@ check("...and the two runs were driven at 50 % and 11 % SoC and the sentence "
|
||||
# bytes, and "the register may be unable to comply / the source may have
|
||||
# stopped reporting reality" is a different and wrong claim.
|
||||
_S56_EXPLANATIONS = [
|
||||
("DEAD", "the register may have stopped reporting reality"),
|
||||
("REFUSED", "the source may be unable to comply with the command"),
|
||||
("DEAD", "reading may have stopped"),
|
||||
("REFUSED", "battery may be unable to follow the command"),
|
||||
]
|
||||
# ⚠️ WHAT THE THREE SENTENCES USED TO ASSERT. Each is a claim about the
|
||||
# REGISTER or about the ENTITY, and 11a measured the register to be alive on
|
||||
@@ -1814,6 +1813,11 @@ _S56_ACCUSATIONS = [
|
||||
]
|
||||
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
|
||||
if "battery power reading stopped changing" in r.getMessage()]
|
||||
# UX-11: the ERROR line quotes the code, and the reason itself is on the DEBUG
|
||||
# line logged beside it.
|
||||
_dbg56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
|
||||
if r.levelno == logging.DEBUG
|
||||
and r.getMessage().startswith("Evidence for ")] or [""]
|
||||
|
||||
|
||||
def _s56_row(c):
|
||||
@@ -1856,7 +1860,7 @@ check("PRECONDITION: all four operator-facing surfaces were really produced by "
|
||||
"two are the same string, which is 11b again on the log surface",
|
||||
len(_log56) == 2 and _log56[0] == _log56[1]
|
||||
and all(len(s) > 30 for s in _S56_SURFACES.values())
|
||||
and _r56_ref in _log56[-1])
|
||||
and _log56[-1].endswith(": STALE_READING.") and _r56_ref in _dbg56[-1])
|
||||
_idx56 = [_r56_ref.find(t) for _w, t in _S56_EXPLANATIONS]
|
||||
check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
|
||||
f"{_S56_EXPLANATIONS[0][1]!r} at {_idx56[0]} then "
|
||||
@@ -1868,7 +1872,7 @@ check(f"11c. BOTH EXPLANATIONS ARE NAMED, AND IN THAT ORDER: "
|
||||
check("...and the sentence says in its own words that it CANNOT choose, rather "
|
||||
"than leaving two explanations side by side for a reader to rank - an "
|
||||
"operator who reads a list picks the first item",
|
||||
"CANNOT TELL THEM APART" in _r56_ref)
|
||||
"this check cannot tell which" in _r56_ref)
|
||||
# ⚠️ THE POSITIVE CONTROL FOR THE `not any(...)` BELOW, AND IT IS NOT OPTIONAL:
|
||||
# a quantifier over an empty list is True, and so is one over three literals
|
||||
# with a typo in each. These are the three sentences as 0.10.1 SHIPPED them,
|
||||
|
||||
@@ -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")
|
||||
@@ -497,7 +497,7 @@ check("a FORCED start is refused while another pack holds the slot, and the "
|
||||
"phase machine does not move. The calendar is a preference an operator "
|
||||
"may overrule; one pack at a time is a property of the grid connection "
|
||||
"and a button does not add a second one",
|
||||
_b8.phase == IDLE and len(_ev) == 1 and "REFUSED" in _ev[0]
|
||||
_b8.phase == IDLE and len(_ev) == 1 and "cannot start now" in _ev[0]
|
||||
and "inverter-a" in _ev[0])
|
||||
check("...and an unblocked forced start still works, so the refusal is a "
|
||||
"condition and not a disabled feature",
|
||||
@@ -534,7 +534,7 @@ check("...and the deferred pack's PHASE does not move either, so "
|
||||
_b9.phase == IDLE and _b9.store.get_time("phase_started") is None)
|
||||
check("...and the words say WAITING, name the pack it waits for, and say it "
|
||||
"will run next - none of which any failure path in this module says",
|
||||
"WAITING" in _r9[1].events[0]
|
||||
"waits its turn" in _r9[1].events[0]
|
||||
and "inverter-a" in _r9[1].events[0]
|
||||
and "not a stalled phase" in _r9[1].events[0]
|
||||
and "ABORT" not in _r9[1].events[0] and "stuck" not in _r9[1].events[0])
|
||||
@@ -687,7 +687,7 @@ print(f" a store already written by a multi-inverter version: {_rep3.explain(
|
||||
check("a store already at the per-pack schema is NOT TOUCHED - not one key "
|
||||
"written - and says so",
|
||||
not _rep3.ran and _m3.data == _before3 and _m3.writes == []
|
||||
and "not touched" in _rep3.explain())
|
||||
and "left as it is" in _rep3.explain())
|
||||
|
||||
# ⚠️ AND BREAK IT. The read-back postcondition is the whole difference between
|
||||
# this and a two-line copy, and it is inherited from `migrate_unit_keys()`
|
||||
@@ -766,7 +766,7 @@ check("...and the refusal NAMES the namespace it used AND the roster it was "
|
||||
"graded against, rather than merely saying something went wrong - the "
|
||||
"operator's next question is 'which name, and which one should it have "
|
||||
"been', and a bare `not ok` answers neither",
|
||||
"WRONG NAMESPACE" in _rep5.explain()
|
||||
"moved under an inverter not in the list" in _rep5.explain()
|
||||
and unit_key(UNIT_A, "last_completed") in _rep5.explain()
|
||||
and repr(ROSTER) in _rep5.explain())
|
||||
check("⚠️ AND THE MARKER IS NOT STAMPED, WHICH IS THE WHOLE REMEDY. A stamped "
|
||||
@@ -893,7 +893,7 @@ check("...and the counterfactual: the SAME two calls with the roster's own "
|
||||
"`already at schema` early return, not a refusal. So 9c is about the "
|
||||
"NAME surviving into the second start, not about second calls",
|
||||
_rep7ok_a.ok and _m7ok.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION
|
||||
and _rep7ok_b.ran is False and "already at schema" in _rep7ok_b.explain())
|
||||
and _rep7ok_b.ran is False and "already at version" in _rep7ok_b.explain())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1240,13 +1240,13 @@ check("an engagement that is not saturated raises NO notification - §10.3's "
|
||||
"goodwe_peak_saturated" not in hass.notifications)
|
||||
check("...but it does reach the action log, which is where a control action "
|
||||
"that moves energy belongs",
|
||||
any("PEAK GUARD" in e for e in c.events))
|
||||
n_events = sum(1 for e in c.events if "PEAK GUARD" in e)
|
||||
any("PEAK_GUARD" in e for e in c.events))
|
||||
n_events = sum(1 for e in c.events if "PEAK_GUARD" in e)
|
||||
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(c.cycle())
|
||||
check("...and it is edge-logged, not level-logged: a second engaged cycle adds "
|
||||
"no second line, so a quarter of engagement cannot push the cause of the "
|
||||
"event off a 40-line log",
|
||||
sum(1 for e in c.events if "PEAK GUARD" in e) == n_events)
|
||||
sum(1 for e in c.events if "PEAK_GUARD" in e) == n_events)
|
||||
|
||||
# The saturated case, through the same real cycle.
|
||||
# A floor of 1.5x what the inverter can give: it saturates wherever in the
|
||||
@@ -1661,8 +1661,8 @@ check("...and it still says what it WOULD have taken, so the under-shave is a "
|
||||
near(d_lim.wanted_w, WANTED, 1e-6) and d_lim.wanted_w > d_lim.floor_w)
|
||||
check("...and its one-line explanation tells the operator the consequence in "
|
||||
"the words that matter - the quarter is going over the ceiling",
|
||||
"FLOOR-LIMITED" in d_lim.explain()
|
||||
and "WILL GO OVER THE CEILING" in d_lim.explain()
|
||||
"FLOOR_LIMITED" in d_lim.explain()
|
||||
and "will go over the limit" in d_lim.explain()
|
||||
and f"{CEIL:.0f} W" in d_lim.explain())
|
||||
check("an unreadable SoC lands in the same branch, with the OTHER reason - so "
|
||||
"an operator reading one line can tell a flat battery from a dead sensor",
|
||||
@@ -1944,7 +1944,7 @@ check("the status page carries exactly one peak-guard row and it is NOT ok - "
|
||||
"the money is going to be spent and a human is the only actuator left",
|
||||
len(rows24) == 1 and not rows24[0]["ok"] and not rows24[0]["warn"]
|
||||
and "peak limit will be passed" in rows24[0]["text"])
|
||||
check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
|
||||
check("...and it reaches the action log too", any("FLOOR_LIMITED" in e
|
||||
for e in c24.events))
|
||||
# A missing SoC sensor, through the same real cycle. `inputs_ok` stops the loop
|
||||
# at 0 W in that state, so the guard's own answer is what is graded here.
|
||||
|
||||
@@ -4107,7 +4107,8 @@ _T11_SLOTS = {
|
||||
"carried": r"\d+ published and (\d+) estimated",
|
||||
"horizon": r"for (\d+) hours ahead",
|
||||
"age_s": r"Price plan from (\d+) s ago",
|
||||
"spacings": r"([\d+]+) s apart",
|
||||
# UX-10: the spacing is in words ("one every 15 minutes or every hour").
|
||||
"spacings": r"one (every [a-z0-9 ]+?)(?:,|\.|$)",
|
||||
# ⚠️ THE *Arbitrage* ROW'S OWN AGE SLOT, and it is deliberately the same
|
||||
# pattern for "decided N s ago" and "the last one was taken N s ago" -
|
||||
# WHICH of the two sentences was rendered is graded by inequality below,
|
||||
@@ -4125,6 +4126,18 @@ _T11_SLOTS = {
|
||||
}
|
||||
|
||||
|
||||
def _spacing_s(raw):
|
||||
"""`every 15 minutes or every hour` -> `[900, 3600]`; raises otherwise."""
|
||||
unit = {"second": 1, "minute": 60, "hour": 3600}
|
||||
out = []
|
||||
for part in raw.split(" or "):
|
||||
m = re.fullmatch(r"every (?:(\d+) )?(second|minute|hour)s?", part.strip())
|
||||
if m is None:
|
||||
raise ValueError(f"no spacing in {part!r}")
|
||||
out.append(int(m.group(1) or 1) * unit[m.group(2)])
|
||||
return out
|
||||
|
||||
|
||||
def _slot(row, name):
|
||||
"""The number in ONE NAMED SLOT of a rendered row, or `_Raised`.
|
||||
|
||||
@@ -4140,18 +4153,18 @@ def _slot(row, name):
|
||||
if m is None:
|
||||
raise ValueError(f"no {name!r} slot in {row[:90]!r}")
|
||||
raw = m.group(1)
|
||||
return ([int(x) for x in raw.split("+")] if name == "spacings"
|
||||
else int(raw))
|
||||
return (_spacing_s(raw) if name == "spacings" else int(raw))
|
||||
return derived(_get)
|
||||
|
||||
|
||||
_T11_GOOD = ("x. Price plan from 7 s ago, for 48 hours ahead: day-ahead "
|
||||
"prices from the price feed, 132 prices over 33 hours, "
|
||||
"36 published and 96 estimated, 900+3600 s apart")
|
||||
"36 published and 96 estimated, one every 15 minutes or every "
|
||||
"hour")
|
||||
_T11_SHIPPED_DEFECT = ("x. Price plan from 7 s ago, for 48 hours ahead: "
|
||||
"day-ahead prices from the price feed, 132 prices over "
|
||||
"132 hours, 96 published and 36 estimated, 900+3600 s "
|
||||
"apart")
|
||||
"132 hours, 96 published and 36 estimated, one every "
|
||||
"15 minutes or every hour")
|
||||
# The slots divide into the ones a *Tariff*/*Arbitrage* ROW carries and the
|
||||
# ones a REFUSAL sentence carries; each group is calibrated against its own
|
||||
# well-formed example, because a slot asked of the wrong kind of sentence is
|
||||
@@ -4399,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 "
|
||||
|
||||
@@ -66,6 +66,7 @@ import sys
|
||||
from named_absence import first, nth
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app import messages as _msg_fc
|
||||
from app import price as _price
|
||||
from app import price_forecast as _fc
|
||||
from app import static_tariff as _st
|
||||
@@ -668,8 +669,10 @@ check("with neither a published hour nor a yesterday, the result is no_data - "
|
||||
check("...so it has no .points to iterate and no .get() to default out of",
|
||||
raises(lambda: _nothing.points, Absence)
|
||||
and raises(lambda: _nothing.get("points", []), Absence))
|
||||
check("...and its reason names §11.4 for an operator",
|
||||
"§11.4" in _nothing.reason)
|
||||
check("...and its reason says the fixed tariff is used and there is no "
|
||||
"trading (§11.4), in words an operator reads",
|
||||
"fixed tariff is used" in _nothing.reason
|
||||
and "no trading" in _nothing.reason)
|
||||
check("a naive `since` is refused rather than silently interpreted as UTC",
|
||||
raises(lambda: fill_from_yesterday(TODAY_PART, yesterday=YESTERDAY,
|
||||
since=datetime(2026, 1, 15),
|
||||
@@ -745,9 +748,10 @@ check("...and the phase machine did not move: it is still idle, so the "
|
||||
_m.phase == IDLE)
|
||||
check("...and the reason names §8.3, the offending hour, and when the real "
|
||||
"price is expected",
|
||||
"§8.3" in _gate_bad.reason
|
||||
and bool(_first_prov) and _first_prov.isoformat() in _gate_bad.reason
|
||||
and "14-16" in _gate_bad.reason)
|
||||
"is only estimated" in _gate_bad.reason
|
||||
and bool(_first_prov)
|
||||
and _msg_fc.when(brussels(_first_prov)) in _gate_bad.reason
|
||||
and "14:00 to 16:00" in _gate_bad.reason)
|
||||
|
||||
# The positive arm, so the block above is discriminating rather than universal.
|
||||
_firm_long = series_of(hours_between(START,
|
||||
@@ -772,7 +776,8 @@ _gate_none = begin_phase(_m3, START, PriceRead.no_data("the feed timed out"),
|
||||
MAINTENANCE_DRAIN)
|
||||
check("no price data at all BLOCKS an irreversible action too - a missing "
|
||||
"price is not a cheap one (§11.4)",
|
||||
_gate_none.kind == "blocked" and "§11.4" in _gate_none.reason
|
||||
_gate_none.kind == "blocked"
|
||||
and "missing price is not a cheap one" in _gate_none.reason
|
||||
and _m3.phase == IDLE)
|
||||
_short = series_of(DAY[:4], lambda u: t_price(brussels(u).hour),
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead",
|
||||
|
||||
@@ -1020,7 +1020,7 @@ check("a store holding only the day before LAST answers no_data, not those "
|
||||
_y3.kind == "no_data")
|
||||
check(f"...and the reason names BOTH the date wanted and the dates held, so an "
|
||||
f"operator can see which it is: {why(_y3)!r}",
|
||||
"2026-01-14" in why(_y3) and "2026-01-12" in why(_y3))
|
||||
"14 Jan" in why(_y3) and "12 Jan" in why(_y3))
|
||||
|
||||
_h4 = store()
|
||||
_ph.PriceHistory._remember(
|
||||
@@ -1217,7 +1217,7 @@ json.dump({"version": SCHEMA + 99, "days": {}}, open(_p7, "w", encoding="utf-8")
|
||||
_h7 = PriceHistory(_p7)
|
||||
check("a file from a schema this build does not know is discarded rather than "
|
||||
"guessed at - its points might mean something else",
|
||||
"schema" in (_h7.last_error or "")
|
||||
"version" in (_h7.last_error or "")
|
||||
and _h7.yesterday(NOW).kind == "no_data")
|
||||
|
||||
# A record whose points are malformed. THE DEFECT IS A DEFAULTED PRICE.
|
||||
@@ -1270,7 +1270,7 @@ json.dump({"version": SCHEMA, "days": {
|
||||
_h14 = PriceHistory(_p14)
|
||||
check("a day record that is not a dict is DROPPED and REPORTED, and the rest "
|
||||
"of the file still loads - one bad record is not a bad file",
|
||||
_h14.ok and "unusable day record" in (_h14.last_error or "")
|
||||
_h14.ok and "unusable day" in (_h14.last_error or "")
|
||||
and sorted(_h14._days) == ["2026-01-13"])
|
||||
_w14 = derived(lambda: _h14.words)
|
||||
_y14 = derived(lambda: _h14.yesterday(NOW).kind)
|
||||
@@ -1291,7 +1291,7 @@ for _shape in ("oops", [1, 2, 3], 7, None, {"points": "not a list"}):
|
||||
check(f"...and the same holds for a day record of {type(_shape).__name__} "
|
||||
f"{_shape!r}: dropped, reported, and every accessor answers "
|
||||
f"({_wX!r}, {_yX!r})",
|
||||
_hX._days == {} and "unusable day record" in (_hX.last_error or "")
|
||||
_hX._days == {} and "unusable day" in (_hX.last_error or "")
|
||||
and isinstance(_wX, str) and _yX == "no_data")
|
||||
|
||||
_pF = os.path.join(tmpdir(), "price_history.json")
|
||||
@@ -1300,7 +1300,7 @@ json.dump({"version": float(SCHEMA), "days": {}},
|
||||
check(f"a `version` of {float(SCHEMA)!r} is refused although it EQUALS "
|
||||
f"{SCHEMA} - a schema check that cannot tell 1 from 1.0 is one that "
|
||||
f"would not notice a writer this build has never met",
|
||||
"schema" in (PriceHistory(_pF).last_error or ""))
|
||||
"version" in (PriceHistory(_pF).last_error or ""))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1328,7 +1328,7 @@ check(f"...and a store holding a day names the DATE, the POINT COUNT and the "
|
||||
f"HOUR COUNT {words_of(_h_words)!r} - 'a cache exists' and 'the cache "
|
||||
f"holds yesterday, all 24 hours of it' are different facts, and only "
|
||||
f"the second says the horizon will be full tomorrow morning",
|
||||
words_of(_h_words) == f"{YDAY_D} (24 prices over 24 hours)")
|
||||
words_of(_h_words) == "24 prices over 24 hours for 14 Jan")
|
||||
|
||||
# ⚠️ AND THE TWO NUMBERS ARE NOT THE SAME NUMBER - FCST-05's LOW. The row
|
||||
# shipped as `f"{d} ({_n_points(rec)} h)"`, which counts POINTS and prints them
|
||||
@@ -1362,15 +1362,15 @@ check(f"...and the row reports BOTH, each under its own unit: {_qw!r}. "
|
||||
f"A day of 96 quarter-hourly prints is 96 points over 24 h, and the "
|
||||
f"figure that says whether tomorrow's fill has a whole day to carry is "
|
||||
f"the HOUR one",
|
||||
_qw == f"{YDAY_D} (96 prices over 24 hours)")
|
||||
_qw == "96 prices over 24 hours for 14 Jan")
|
||||
check(f"...and specifically the row does NOT say '96 h' {_qw!r} - a point "
|
||||
f"count wearing the hour's name is H1's defect, and this row is where "
|
||||
f"an operator would have read it",
|
||||
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 prices " in _qw)
|
||||
isinstance(_qw, str) and "(96 h)" not in _qw and _qw.startswith("96 prices "))
|
||||
check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the "
|
||||
f"days it dropped read as days that were simply never written",
|
||||
isinstance(_w14, str) and (_h14.last_error or "") in _w14
|
||||
and "2026-01-13" in _w14)
|
||||
and "13 Jan" in _w14)
|
||||
check("NoHistory() has a sentence of its own rather than an empty cell",
|
||||
NoHistory().words == "not kept")
|
||||
check("...and `words` never raises, whatever the file holds. It is read once "
|
||||
@@ -1546,8 +1546,8 @@ for _shape, _bad in _BAD_STARTS:
|
||||
f"{_wload!r}: ONE point held, and the row that is not a point named "
|
||||
f"rather than counted",
|
||||
isinstance(_wload, str)
|
||||
and _wload.startswith(f"{TODAY_D} (1 price over 1 hour) [")
|
||||
and "1 malformed point row(s) in" in _wload)
|
||||
and _wload.startswith("1 price over 1 hour for 15 Jan [")
|
||||
and "1 bad price in" in _wload)
|
||||
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
|
||||
# OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND
|
||||
# at open and `_remember()` records what it DROPPED on the refresh; both
|
||||
@@ -1562,22 +1562,22 @@ for _shape, _bad in _BAD_STARTS:
|
||||
f"'{TODAY_D} (1 h)' - the status row reporting a full cache while "
|
||||
f"every tariff refresh was dying on it",
|
||||
isinstance(_wafter, str)
|
||||
and "malformed point row(s) dropped from" in _wafter
|
||||
and TODAY_D in _wafter)
|
||||
and "bad price dropped from" in _wafter
|
||||
and "15 Jan" in _wafter)
|
||||
check(f"...and the row MOVED from what was found to what was dropped "
|
||||
f"{_wload.split('[')[-1]!r} -> {_wafter.split('[')[-1]!r}, so the "
|
||||
f"two notes are not one note read twice",
|
||||
isinstance(_wload, str) and isinstance(_wafter, str)
|
||||
and "malformed point row(s) in" in _wload
|
||||
and "malformed point row(s) in" not in _wafter
|
||||
and "malformed point row(s) dropped from" not in _wload)
|
||||
and "bad price in" in _wload
|
||||
and "bad price in" not in _wafter
|
||||
and "bad price dropped from" not in _wload)
|
||||
# ...and a SECOND refresh over the same store is clean, which is the state
|
||||
# a restarted add-on comes up in.
|
||||
_hM2 = PriceHistory(_pM)
|
||||
check(f"...and the store reopened from that file is CLEAN: the whole day "
|
||||
f"held and nothing to report {words_of(_hM2)!r}, so the note clears "
|
||||
f"itself rather than becoming permanent furniture",
|
||||
words_of(_hM2) == f"{TODAY_D} ({_want} prices over {_want} hours)"
|
||||
words_of(_hM2) == f"{_want} prices over {_want} hours for 15 Jan"
|
||||
and _hM2.last_error is None)
|
||||
|
||||
# ...and the shipped path, end to end, through the caller that catches
|
||||
@@ -1641,7 +1641,7 @@ print("5d. FCST-06 - the OTHER way to be a store that cannot persist")
|
||||
# read-only and again on a full tmpfs:
|
||||
#
|
||||
# last_error = 'could not write ([Errno 30] Read-only file system)'
|
||||
# words = '2026-01-14 (24 prices over 24 hours); 2026-01-15 (24 points
|
||||
# words = '2026-01-14 (24 points over 24 h); 2026-01-15 (24 points
|
||||
# over 24 h) [could not write (...)]'
|
||||
# yesterday()= series, 24 points
|
||||
# ON DISK = {} <- and gone on the next restart
|
||||
@@ -1701,7 +1701,7 @@ check(f"...and `words` and `yesterday()` AGREE about that store "
|
||||
f"{_h16.words[:44]!r}: neither names a day the file does not hold, and "
|
||||
f"both name the write failure. They disagreed - `words` printed two "
|
||||
f"full days BESIDE the error while `yesterday()` answered 24 points",
|
||||
YDAY_D not in _h16.words and TODAY_D not in _h16.words
|
||||
"14 Jan" not in _h16.words and "15 Jan" not in _h16.words
|
||||
and "could not write" in _h16.words
|
||||
and _y16.kind == "no_data" and "could not write" in why(_y16))
|
||||
check(f"...and it DEGRADES rather than raising: the carry still returned a "
|
||||
|
||||
@@ -697,13 +697,21 @@ 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.
|
||||
_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), `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 == ["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 "
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
|
||||
@@ -448,7 +448,7 @@ print(f" restore() across the boundary said: {said}")
|
||||
aug7, sep7 = st7.peak(month="2026-08"), st7.peak(month="2026-09")
|
||||
check("a restart that straddles the month boundary BANKS the dead August "
|
||||
"quarter rather than dropping measured energy",
|
||||
said.startswith("closed a stale block") and aug7.is_observed)
|
||||
said.startswith("closed the 15-minute period") and aug7.is_observed)
|
||||
check(f"...at its partial-coverage figure ({aug7.peak_w:.2f} W = "
|
||||
f"{RUN_W * 399 / QUARTER_S:.2f} W, the seconds it actually covered)",
|
||||
near(aug7.peak_w, RUN_W * 399 / QUARTER_S, 1e-6))
|
||||
@@ -1325,7 +1325,7 @@ print(f" quarters banked in {month_key(_sb)}: {_before_stale} -> "
|
||||
check("a block closed by restore() rather than by sync() is counted too - the "
|
||||
"old counter incremented only in sync() and missed it entirely",
|
||||
_before_stale == 0 and _tr13.recorded_in(_sb) == 1
|
||||
and "banked" in _state)
|
||||
and "and saved it" in _state)
|
||||
|
||||
# --- the count is scoped to the MONTH it is printed beside --------------------
|
||||
# ⚠️ TWO MONTHS AT DIFFERENT COUNTS. Equal counts would be satisfied by a
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -1693,11 +1693,11 @@ check("...and a card ON THIS LEG that the coefficients do not reproduce is "
|
||||
|
||||
# ⚠️ THE SENTENCE DOCS.md RESTS ON, AND IT IS THE ONE MOST LIKELY TO ROT.
|
||||
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
|
||||
and "NO STRATEGY IN THIS ADD-ON READS IT" in m]
|
||||
and "No part of the add-on reads it yet" in m]
|
||||
check("the startup line says, in so many words, that NO STRATEGY IN THIS "
|
||||
"ADD-ON READS IT - the operator is told what the option does not do in "
|
||||
"the same breath as it being accepted",
|
||||
len(_T16_INFO) == 1 and "displaced IMPORT" in _T16_INFO[0])
|
||||
len(_T16_INFO) == 1 and "displaced import" in _T16_INFO[0])
|
||||
# ⚠️ THE ASYMMETRY IS ASSERTED AS AN ORDERED PAIR, NOT AS TWO NUMBERS IN ONE
|
||||
# SENTENCE (MUTATION.md habit 14). "6 and 0 appear somewhere" is satisfied by
|
||||
# the two legs TRANSPOSED - which is the sentence telling an operator that the
|
||||
@@ -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:
|
||||
|
||||
@@ -1422,14 +1422,28 @@ for _t in range(0, 400, 5):
|
||||
_wd_unit.evaluate(-1000.0, 1500.0, True, T0 + timedelta(seconds=_t))
|
||||
print(f" site: {_wd_site.reason!r}")
|
||||
print(f" unit: {_wd_unit.reason!r}")
|
||||
check("⚠️ THE SITE INSTANCE'S SENTENCE IS BYTE-IDENTICAL TO THE ONE THAT "
|
||||
"SHIPPED. It is what an operator reads on the status page, what goes in "
|
||||
"the action log and what is persisted to /data/state.json, so it is "
|
||||
"asserted against the literal rather than against a format string",
|
||||
_wd_site.reason == (
|
||||
"SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
|
||||
"+1500 W for 180 s continuously (limits: both over 300 W, held over "
|
||||
"180 s)"))
|
||||
# UX-11: this was a byte pin. The reason is now the evidence: the action
|
||||
# log, the DEBUG line and /data/state.json carry it, and the ERROR line quotes
|
||||
# only its code (`messages.reason_code()`). So what is pinned is what it says:
|
||||
# the code in front, where every saved reason has had it and where the ERROR
|
||||
# line finds it, then the command and the reading in that order (habit 14),
|
||||
# the time held and both limits.
|
||||
_sr = _wd_site.reason
|
||||
_order = [_sr.find(t) for t in ("commanded -1000 W", "meter reading +1500 W",
|
||||
"for 180 s", "both over 300 W",
|
||||
"held over 180 s")]
|
||||
check("⚠️ THE SITE INSTANCE'S REASON SAYS WHAT SHIPPED, IN THE SAME ORDER: "
|
||||
"the code first, then what was commanded, what the meter read, how "
|
||||
f"long, and the two limits ({_order})",
|
||||
_sr.startswith("SIGN_DISAGREEMENT: ")
|
||||
and messages.reason_code(_sr) == "SIGN_DISAGREEMENT"
|
||||
and all(i > 0 for i in _order) and _order == sorted(_order))
|
||||
# UX-11 review: the text itself did not change, and it is what state.json
|
||||
# holds, so the byte pin stays beside the meaning pin.
|
||||
check("...and it is still byte-identical to the one that shipped",
|
||||
_sr == ("SIGN_DISAGREEMENT: commanded -1000 W against a meter reading "
|
||||
"+1500 W for 180 s continuously (limits: both over 300 W, held "
|
||||
"over 180 s)"))
|
||||
check("...and the per-unit instance names what it ACTUALLY compared instead. "
|
||||
"'against a meter reading' there would be a false statement about which "
|
||||
"instrument produced the evidence, on the one line somebody woken by "
|
||||
|
||||
@@ -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()
|
||||
@@ -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__)
|
||||
Reference in New Issue
Block a user