Merge pull request 'UX-08 2d: plain log lines, the rest of the status page, and DOCS' (#136) from UX-08-2d-logs-docs into release/1.0
infra-pin / fresh (push) Successful in 1s

This commit was merged in pull request #136.
This commit is contained in:
2026-09-27 08:40:33 +02:00
38 changed files with 1338 additions and 742 deletions
+10 -2
View File
@@ -1,13 +1,15 @@
# STYLE.md - how user-facing text is written (UX-08)
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Kinds that have not been rewritten yet are on its allowlist, and that allowlist only shrinks.
This applies to everything a home owner or installer reads: notifications, phone pushes, the add-on page, refusals, add-on log lines at WARNING and above, and the names and descriptions of options. It is enforced by `goodwe_controller/test_messages.py`. Its allowlist is empty since phase 2d and its cap is 0: a new string that breaks the rule fails the test.
## 1. Shape
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
- **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. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated.
- **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.
- **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.
## 2. Numbers
@@ -48,8 +50,14 @@ A setting is named by its label in `translations/en.yaml`, in quotes when it sit
| safety stop (a stop that only starting control clears) | failsafe latch |
| software fault | BUG SIGNAL |
| control board | master, ESP32 |
| power request, what the add-on asks | command, setpoint (on the page) |
| the power went the wrong way | sign disagreement, command and meter point opposite ways |
| check for inverters working against each other | circulating power check |
| month peak, 15-minute periods measured | billed quarter, banked, soft limit, open block |
| shadow mode | a mode that makes every decision but commands 0 W. The name is kept. In shadow mode a text says what the add-on **would** send, never that it sent it. |
**Banned in titles and bodies** (the test fails on them): SAFETY-/TARIFF-/TEL-/CFG-/FW-/HW-nn and other card ids, integrator, anti-windup, windup, clamp, clamped, saturated, BUG SIGNAL, latch, latched, arbiter, allocator, register, `0x…`, layer 1/2/3, heartbeat, watchdog, telegram, ACK, CRC, OTA, Modbus, planner, `§n`, file names, IP addresses, option keys in snake_case, **HA entity ids**, and runs of 2 or more CAPITALISED words.
HA entity ids are allowed only in refusals and validator messages, where the installer has to find that entity. They are never allowed in alarms or status rows.
**Glossary.** DOCS.md "Words used on this page" explains, once, the words a home owner meets without context: control board, isolator, Developer Tools, shadow mode, safety stop, peak limit, peak guard, peak reserve and cut-out level. A home-owner text may use them without explaining them again. Card ids stay out of DOCS.md headings: they go on a *For installers* line under the heading.
@@ -44,3 +44,20 @@ Validator refusals, the parse errors they carry, and every option name and descr
2. **Citations moved to DOCS.** Option descriptions say "Measured value, leave it unchanged" or similar, and the record behind the value is under "For installers: where the numbers come from". The UX-01 citation check in `test_translations.py` now checks the tab for that instruction and DOCS for the record.
3. **What left the refusals:** the shipped default in the peak-reserve reason, the superseded-default note in the levy-table reason (DOCS already covers it), the endpoints of the withdrawn providers (DOCS "Energy price providers"), the plant measurement behind `slew_w` (DOCS, beside the setting), and the leg-specific paragraphs of the tariff-formula errors. Each reason keeps the rejected value, the limit and what to change.
4. **Not rewritten:** errors that only a code defect can raise, such as plugin-contract and argument-type checks in `price.py`, `price_forecast.py`, `static_tariff.py`, `arbitrage.py` and `main.py`, which a home owner never reads. INFO lines such as the tariff-formula preview were also left.
# UX-08 phase 2d: log lines, the rest of the status page, and DOCS
The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons (the semicolon rule now covers log lines too). **Unchanged:** every log level, every trigger and cadence, every notification id, every row's colour and when it shows, the banner cascade, and every refusal count.
1. **No code prefixes on log lines.** Nothing outside the repositories greps them, except `/home/homelab/clampwatch.sh`, a one-off site watch from 2026-09-05 that greps `OUTPUT_CLAMP` and `Engagements so far`. `OUTPUT_CLAMP` is still in the reason the line quotes; "Engagements so far" is now "Cut back N times so far". The tests that found a line by its card id (`SAFETY-37`, `SAFETY-44`, `CONTROL LOOP DIED`, `UNGRADED`) now find it by its words.
2. **Reason texts keep their codes.** A line quotes a watchdog's reason (`%s`), such as `STALE_READING: …` or `OUTPUT_CLAMP: …`. Those are the evidence an installer searches for and many tests pin them, so they were not rewritten. The line around them is plain.
3. **Reasoning moved to DEBUG, not deleted,** for the 10 lines marked DROP-to-DEBUG (for example the 14 768 W history behind the over-rating alarm, why SAFETY-42 reports and does not act, and the Eneco card advice). The 25-word line keeps the action and the numbers. SAFETY-57's promise stays on the ERROR line, as an ordered pair: "until it changes, or 17 s after the command drops below 250 W".
4. **The line under the buttons** showed the newest raw action-log entry (`OUTPUT_CLAMP …`, `FAILSAFE …: SIGN_DISAGREEMENT`, `ALLOCATOR …`). Each `log_event()` call now also names a plain hint from `messages.HINTS`, and the page shows that. The action log itself is unchanged, because tests and later analysis read it, and the page no longer shows it. It stays reachable as `events` in the `status` JSON (review of #136), because some entries are written nowhere else: a dropped inverter's reason, the unplaced watts, the peak guard's projection and the shadow decision.
5. **The top table.** *Why* is `messages.why()`: the arbiter's winner and limit in words ("Keeping the grid near zero: 440 W, held back by the peak limit"). `self.reason` keeps `Resolution.explain()` for the log and the checks. *Tariff*, *Price history*, *Arbitrage* and *Shadow mode* keep every figure TARIFF-11 and SAFETY-47 put there, each in its own slot, in words. Labels: *Battery level*, *Solar production*, *Power request*.
6. **The month-peak row became two rows**, because one sentence of 15 words cannot hold the peak, its time, its share of the limit and TARIFF-13's coverage figure. The store's state at start (`restore_state`) left the row: it is the INFO line at start.
7. **The peak guard rows** name the projection first and the limit second, as before (SAFETY-38). The blind row says what it means for the owner: the battery does not charge until the period can be measured again.
8. **Wording that changed meaning-carrying words**: "command and meter point opposite ways" is now "the power went the wrong way" (banner) and "…went against what the add-on asked" (row). The N=1 banner still names the meter, the N≥2 texts still say "own power". The inverter range reads "up to 5 000 W each way" instead of "-5 000 W to 5 000 W".
9. **Refusals are counted as shown.** Setting placeholders are expanded to label and key, and the long cases the 2c test found are driven through the validator. Seven refusals were shortened. The card refusals no longer show `c_eur_per_kwh`, `vat_pct` or "its own card", and a text that does not parse says "not valid near character N" instead of the parser's words. A missing value shows as *empty*, not `None`.
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.
+167 -88
View File
@@ -7,45 +7,73 @@ balance cells and recalibrate its coulomb counter.
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
v0.1.0 and outdated; where it disagrees with this page, this page wins.
## Words used on this page
- **Control board**: the small computer on the inverter's meter cable. It
passes the add-on's power request to the inverter. If the add-on goes quiet
for about 30 seconds, it sets the inverter to 0 W by itself. (For
installers: a T-CAN485 board with an ESP32 chip on the RS485 bus.)
- **Isolator**: the switch that cuts an inverter off from the mains. Switching
it off stops the inverter, whatever the add-on or the control board asks.
- **Developer Tools**: the Home Assistant page that lists every entity and its
current value, on its *States* tab. Use it to check that a sensor the add-on
reads is really there and updating.
- **Shadow mode**: the add-on makes every decision but sends nothing, so every
inverter stays at 0 W. Pages and notifications say what it *would* have done.
- **Safety stop**: the add-on has set one inverter, or all of them, to 0 W and
keeps it there until you start control on the add-on page.
- **Peak limit**: the 15-minute average grid use you want to stay under this
month, because your capacity tariff bills the highest one.
- **Peak guard**: the part of the add-on that uses the battery to keep each
15-minute period under the peak limit.
- **Peak reserve** and **battery cut-out level**: the charge the peak guard
keeps back, and the level where the battery switches itself off.
## Before you start
You need:
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
- The vendor's meter-emulating controller **disconnected** from the bus
- A T-CAN485 (ESP32) flashed with `firmware/goodwe-master.yaml`
- A control board: a T-CAN485 (ESP32) flashed with
`firmware/goodwe-master.yaml`
- A grid-power sensor already working in Home Assistant, updating every ~5–10 s
After installing, turn on **Watchdog** on the add-on's Info tab. A crashed
control loop stops the add-on at 0 W, and the Supervisor restarts it only when
Watchdog is on (it is off by default). See
`DECISIONS/SAFETY-61-control-task-death.md`.
After installing, turn on **Watchdog** on the add-on's Info tab. If control
stops by accident, the add-on sets the inverters to 0 W and exits, and Home
Assistant starts it again only when Watchdog is on. It is off by default.
## The safety model, in short
The inverter **holds its last command forever** — it has no meter-timeout. So:
The inverter **keeps its last power request forever**. It has no timeout of its
own. So:
- The ESP32 commands 0 W if this add-on stops refreshing for ~30 s, and keeps
commanding it.
- This add-on commands 0 W when its inputs go missing, when you stop control,
and when it shuts down.
- This add-on commands 0 W **and latches there** if what it is commanding and
what the meter reads point opposite ways for three minutes — see "The
- The control board sets the inverter to 0 W if this add-on stops sending for
about 30 seconds, and keeps it there.
- This add-on sets the inverters to 0 W when a reading goes missing, when you
stop control, and when it shuts down.
- This add-on sets the inverters to 0 W **and keeps them there** if the power
goes the opposite way to what it asks for three minutes. See "The
sign-disagreement failsafe" below.
- The **optional RS485 e-stop** is the only thing that covers this machine
dying. Without it, a failed host leaves the battery latched at its last
command until someone intervenes.
- This add-on **raises an alarm — and nothing more —** when the meter keeps
answering with a reading that never changes while the command sits at the
rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff
exposure of the grid-following loop".
- The **optional emergency stop** is the only thing that covers this computer
failing. Without it, the battery keeps its last power request until someone
steps in.
- This add-on **raises an alarm, and does nothing more,** when the meter keeps
answering with a value that never changes while the battery is asked for
full power. See "The frozen-meter alarm" and, before you rely on it,
"Capacity-tariff exposure of the grid-following loop".
**If anything looks wrong: stop the add-on.** That commands 0 W and the
hardware holds it there.
**If anything looks wrong: stop the add-on.** That sets the inverters to 0 W,
and the control board keeps them there.
*For installers: a crashed control task is SAFETY-61, see
`DECISIONS/SAFETY-61-control-task-death.md`.*
## Configuration
### ⚠️ There are TWO places to configure this add-on (UX-02)
### ⚠️ There are TWO places to configure this add-on
*For installers: card UX-02.*
Since **UX-02** the ingress panel has a **Configuration** page of its own,
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
@@ -108,7 +136,9 @@ One column, big targets, sections collapsed until you open them, and the
refusals pinned where you cannot scroll past them. It is the screen for
standing in front of the inverter.
### What the Configuration tab actually shows you (UX-01)
### What the Configuration tab actually shows you
*For installers: card UX-01.*
Since **UX-01** the add-on ships `translations/en.yaml` and `translations/nl.yaml`,
so Home Assistant's *Configuration* tab renders human labels and a description
@@ -179,10 +209,10 @@ The label and description each setting shows on the Configuration tab, in tab or
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
| Tariff | Distribution, levies and VAT (EUR/kWh) | `price_taxes_eur_kwh` | Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates. |
| Tariff | Injection price (EUR/kWh) | `export_price_eur_kwh` | What you are paid per kWh fed into the grid, all in. It may be negative. |
| Tariff | Fixed or day/night tariff (JSON) | `static_profile` | Your fixed or day/night contract as one line of JSON, prices all in. Fill it in even on a dynamic contract, as the fallback when the price feed fails. |
| Tariff | Day/night levies and VAT (JSON) | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. |
| Tariff | Your own tariff formula (JSON) | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card as one line of JSON, and the add-on checks them against the card. |
| Tariff | Your own injection formula (JSON) | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
| Tariff | Fixed or day/night tariff | `static_profile` | Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback. |
| Tariff | Day/night levies and VAT | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. |
| Tariff | Your own tariff formula | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card. |
| Tariff | Your own injection formula | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
@@ -218,9 +248,9 @@ The label and description each setting shows on the Configuration tab, in tab or
| Advanced | Ignore errors below (W) | `deadband_w` | Errors smaller than this are ignored. Keep it small, since any resting point inside it holds. |
| Advanced | Meter resting point (W) | `target_grid_w` | What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day. |
| Advanced | Command rounding (W) | `step_w` | The command is rounded to a multiple of this, so noise does not change it every cycle. |
| Advanced | Inverter at limit gap (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. |
| Advanced | At-limit shortfall (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. |
| Advanced | Cycles at limit before freezing | `saturation_cycles` | How many cycles in a row at the limit before control stops building up error. Do not set it to 1. |
| Advanced | Error build-up limit (W) | `integrator_max_w` | Caps the error that control saves up from past cycles. 0 means “Inverter rating”. Do not raise it above the rating. |
| Advanced | Catch-up limit (W) | `integrator_max_w` | Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating. |
| Advanced | Command refresh interval (s) | `heartbeat_s` | How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit. |
| Advanced | Missing inputs time limit (s) | `stale_input_s` | How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”. |
| Advanced | Longest time between cycles (s) | `cycle_floor_s` | A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s. |
@@ -246,7 +276,7 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
- **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
- **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
- **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different-tariff-18).
- **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different).
- **Day/night levies and VAT** (`price_taxes_profile`): for Wallonia and Brussels, where distribution has a day and a night rate (ORES 13,27 against 7,39 c/kWh, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10). The figures are levies alone, added to the market price. The add-on refuses to start if it and the flat figure disagree. See [Levies and VAT by time of day](#levies-and-vat-by-time-of-day-price_taxes_profile).
- **Injection price** (`export_price_eur_kwh`): one number because no supplier here publishes a dynamic injection curve. In Belgium it is often near zero, and at a sunny midday it can be negative.
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
@@ -259,19 +289,19 @@ When the add-on refuses to start, each reason names the setting by its label and
- **Draw-down and recharge rate** (`maintenance_discharge_w`, `maintenance_charge_w`): the draw-down rate above the rating is cut back, not refused. A recharge rate above the rating would ask for more than the limit allows and resolve to 0 W, so maintenance would never charge. That is why the add-on refuses it.
- **Draw down to** (`maintenance_soc_floor`): the monthly cycle goes this deep on purpose, while “Peak reserve” is a depth peak limiting never reaches. 11 % against 20 % is why they are two settings.
- **Wrong-direction threshold and time** (`sign_disagree_w`, `sign_disagree_s`): measured, not chosen (SAFETY-02). On the captured healthy day (2026-08-23) signs agree on 93.4 % of gated samples, on the fault day (2026-08-20) 0.0-10.5 %, with opposed runs up to 1752 s. The longest legitimate opposed run is 30 s, and the fault day still trips 13 times at 300 s, so 180 s sits in a wide gap. The threshold must stay below “Inverter rating”.
- **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit gap. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
- **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit shortfall. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
- **Site wrong-direction threshold** (`site_sign_disagree_w`): the single-inverter 300 W carried across deliberately (SAFETY-30), because near zero the sign is noise whatever is behind the one meter.
- **Site wrong-direction time** (`site_sign_disagree_s`): derived from the same 1890 runs (SAFETY-30). The worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, so 180 s would false-trip. 325 s is 1.51 times that and inside the 900 s billing period.
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode-safety-10).
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode).
- **Control gain** (`gain`): measured on real hardware and already at the limit.
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
- **Ignore errors below, Meter resting point** (`deadband_w`, `target_grid_w`): the meter's import counter bills every positive watt, so a resting point at 0 W leaks about half the deadband as billed import, roughly 0.2-0.35 kWh a day at 15 W. -10 W moves that leak to export.
- **Inverter at limit gap** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
- **Error build-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
- **At-limit shortfall** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
- **Catch-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
- **Command refresh interval** (`heartbeat_s`): the control board refuses a link that has been quiet for 30 s.
- **Longest time between cycles** (`cycle_floor_s`): keep it under “Reading too old after”, since past that there is no reading left to act on.
@@ -286,9 +316,9 @@ When the add-on refuses to start, each reason names the setting by its label and
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. Read only to divide the site target between packs by **energy** headroom — see below |
| `setpoint_entity` | yes | The ESPHome `number.*_goodwe_setpoint_w` |
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity-tel-11) |
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) |
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities-tel-14) |
| `pv_entity` | | Optional. **One** solar inverter's power sensor, `+` = producing. Measurement only — it changes no control behaviour. See [PV production](#pv-production-pv_entity) |
| `pv_entities` | | Optional. **Several** solar inverters, one row each — use this instead of `pv_entity` when your roof has more than one. Setting it **ignores** `pv_entity`. See [Several PV inverters](#several-pv-inverters-pv_entities) |
| `pv_invert_entities` | | Optional. The PV entities above that read **negative** while producing; each one's sign is flipped. See [The sign](#the-sign-and-pv_invert_entities) |
Use the ESP32's readings rather than the inverter's cloud or dongle sensors:
those serve cached values, and a stale reading here ends the maintenance charge
@@ -344,7 +374,7 @@ inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
| `soc_entity` | | This pack's state of charge |
| `batt_entity` | | This unit's battery power, `+` = discharging |
| `batt_invert` | | Flip if needed |
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs-opt-09) |
| `capacity_kwh` | | This pack's usable capacity. State it on every entry or on none — see [OPT-09](#how-the-site-target-is-divided-between-the-packs) |
⚠️ **Nothing is inherited.** An entry that leaves out `soc_entity` has **no SoC
sensor** — it does *not* fall back to the flat one. That is deliberate: every
@@ -387,7 +417,9 @@ in the log rather than silent, but it is an outage until someone puts the
single-inverter entity back in that field. **Before downgrading, write down the
`setpoint_entity` of the unit you want to keep running.**
#### How the site target is divided between the packs (OPT-09)
#### How the site target is divided between the packs
*For installers: card OPT-09.*
Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every
version before it the split is a fixed 50/50 regardless of what either pack can
@@ -508,8 +540,9 @@ accumulator is doing:
| `not-running` | control is stopped **and** shadow mode is off, so the loop did not run |
⚠️ **`Loop reason` is not the `Why` row on the status page, and the two will
often disagree.** `Why` is the *arbiter's* sentence — `loop -> -1200 W` — which
names the strategy that won and the watts it won with. It has never carried the
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
near zero: -1 200 W* — which names the strategy that won and the watts it won
with. The log keeps the arbiter's own `loop -> -1200 W`. It has never carried the
strategy's own reason, so a loop that is slew-limited or frozen says so **only**
here.
@@ -567,10 +600,12 @@ and `Inverter B` — are two names to the validator and one id once slugged. The
add-on gives the second one a `_2` suffix so nothing collides, but the ids you
get then depend on roster order. Give your inverters plainly different names.
### PV production (`pv_entity`, TEL-11)
### PV production (`pv_entity`)
*For installers: card TEL-11.*
> **More than one solar inverter?** Use `pv_entities` instead — see
> [Several PV inverters](#several-pv-inverters-pv_entities-tel-12) below. The
> [Several PV inverters](#several-pv-inverters-pv_entities) below. The
> rest of this section applies to both.
@@ -604,7 +639,9 @@ Only the third is a software defect, and **none of them can be told apart
without a record of production.** This entity is that record. The attribution
itself is OPT-10's, with a week of history behind it.
#### The sign, and `pv_invert_entities` (TEL-14)
#### The sign, and `pv_invert_entities`
*For installers: card TEL-14.*
**Positive must mean producing.** Some sensors — typically a kWh meter or an
ESPHome power clamp in the PV circuit — read negative while producing and a few
@@ -642,7 +679,9 @@ pictures, which is exactly the distinction the whole card exists to preserve.
A failed read also **clears** the previous good one: the value never outlives
its own sensor.
### Several PV inverters (`pv_entities`, TEL-12)
### Several PV inverters (`pv_entities`)
*For installers: card TEL-12.*
A multi-string or multi-orientation roof routinely carries **two or three PV
inverters**, each publishing its own power sensor into Home Assistant. List them
@@ -1025,7 +1064,9 @@ behind it would trip the firmware watchdog on a system that is working fine.
| `delivery_hold_s` | 185 | **Two or more inverters only.** How long one inverter may stand at a discharge share it is not producing - short by more than `saturation_w`, with no reason this add-on can read - while at least one sibling is **not** in that state, before it is named. **It reports; it does not act.** Nothing is commanded differently and the site goes on running short by that unit's share |
| `shadow_mode` | `auto` | **Run every decision and command nothing.** `auto` = shadow mode while control is stopped, ordinary control once you press Start; `on` = shadow mode always; `off` = ordinary control. See *Shadow mode* below |
#### Shadow mode (`shadow_mode`, SAFETY-10)
#### Shadow mode (`shadow_mode`)
*For installers: card SAFETY-10.*
Shadow mode runs the **whole** decision path — the price strategy, the capacity
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
@@ -1192,11 +1233,13 @@ If the setpoint this add-on is emitting and the grid figure it is regulating on
point **opposite ways** — charging the battery while the house imports, or
discharging it while the house exports — with both over `sign_disagree_w`, for
`sign_disagree_s` **continuously**, the add-on commands **0 W** and holds it
there. The banner reads *Command and meter point opposite ways*, the add-on
there. The banner reads *The meter saw power go the wrong way*, the add-on
log says which two numbers disagreed and for how long, and the `Controller
status` entity reports `failsafe`.
#### ⚠️ At two or more inverters this is TWO checks, not one (SAFETY-30)
#### ⚠️ At two or more inverters this is TWO checks, not one
*For installers: card SAFETY-30.*
The paragraph above describes what a **one-inverter** install does, and it is
unchanged: there is one meter, one actuator, and comparing the command against
@@ -1285,7 +1328,9 @@ agreement is 93.4 %; on the four captured fault clusters it is 0.3 %, 0.9 %,
says so.
#### ⚠️ A unit that stops DELIVERING is a third fault again (SAFETY-42)
#### ⚠️ A unit that stops DELIVERING is a third fault again
*For installers: card SAFETY-42.*
The two checks above both key on **direction**. A unit sitting at **0 W against
a non-zero share** has no direction: it is not pushing the wrong way, it is
@@ -1393,7 +1438,9 @@ correct toward that wrong value instead of holding a stale command. It is bounde
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
safety property.
#### When the freeze lets go, and when it deliberately never does (SAFETY-13)
#### When the freeze lets go, and when it deliberately never does
*For installers: card SAFETY-13.*
While the loop is frozen the command's **magnitude may fall but never rise**. That is
the anti-windup rule, and at `prev_w == 0` it reads as a refusal to *start*: with
@@ -1668,8 +1715,9 @@ exactly like the SoC drift the monthly maintenance cycle exists to correct.
capacity-peak guard will not spend the pack for money below 20 %"*, and
**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 status-page row reading `PEAK GUARD
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
`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**
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.
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A
@@ -1685,7 +1733,9 @@ Why 20 %: the reference pack is 13.8 kWh usable, so 10 points of margin over the
BMS cut-out is about 1.6× the guard's worst measured day. If your pack is
smaller, or your quarters are worse, raise it.
#### ⚠️ What `peak_soc_floor` does **not** protect (SAFETY-25)
#### ⚠️ What `peak_soc_floor` does **not** protect
*For installers: card SAFETY-25.*
**On a sustained-load evening this add-on will take your battery to the BMS
cut-out, whatever `peak_soc_floor` says.** That is measured, not argued. Replayed
@@ -1878,7 +1928,9 @@ unchanged is 12 W, which is inside the 15 W deadband — so the command there is
parked at 0 W and the two halves of the rule cannot both be true. At the schema
floor of 60 s that figure is 60 W and only the margin is left.
### The circulating-power alarm (SAFETY-31)
### The circulating-power alarm
*For installers: card SAFETY-31.*
> **Only at two or more inverters.** At the shipped roster of one this quantity
> is identically zero for every reading, no claim is ever filed, and the status
@@ -2345,7 +2397,9 @@ distinct state from "prices, all of them zero": the plan is marked provisional
and arbitrage is disabled, per the degradation matrix. It never falls back to a
price of zero, which would read as free electricity.
#### The hours the feed has not published yet (FCST-04)
#### The hours the feed has not published yet
*For installers: card FCST-04.*
A day-ahead auction publishes tomorrow's prices once a day, at **14–16 h CET**.
Before that, the feed is serving today and nothing else — so a `price_horizon_h`
@@ -2388,29 +2442,28 @@ firm/provisional split whenever that split is what refused a trade. And
since TARIFF-11 that row carries the provider plan itself:
```
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead,
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing,
provisional
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
```
⚠️ **The two halves of that row are not computed at the same moment, and the
row says so.** Everything before the semicolon is the static-profile or Home
row says so.** Everything before "Price plan" is the static-profile or Home
Assistant price-entity sentence, regenerated on the **30 s tick** from options
alone — no network. Everything after it is the **background refresh's**
resolution, which runs every 1800 s and is served for up to 5400 s before it is
retired; `fetched N s ago` is the age of the fetch that produced it and is the
retired; `Price plan from N s ago` is the age of the fetch that produced it and is the
only figure in the row that tells you whether the feed is still answering. On a
site with a provider configured and no usable plan the row says that too,
rather than falling silently back to the static half.
Read the plan half as five separate facts: which *product* the feed publishes
and which *plugin* read it, how many **points** it carries and how many **whole
hours** those points cover, how many of the points are **firm** (published for
their own interval) against **carried** (filled from yesterday under §8.3), the
**spacing** of the points in seconds — `900+3600` is the normal mixed steady
state, quarter-hourly firm points beside hourly carried ones — and `provisional`
whenever any point is carried.
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.
⚠️ **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
@@ -2585,7 +2638,9 @@ uses the result as a cheap-window hint for the maintenance charge. They are
— configure two of them and the add-on refuses to start, naming the conflict,
because the add-on reads exactly one and the loser is silent. Use one.
### The three buckets — which price components can move a decision (TARIFF-17)
### The three buckets — which price components can move a decision
*For installers: card TARIFF-17.*
**Read this before adding a price component of any kind.** The optimiser reads
exactly two quantities off a price series, and nothing else:
@@ -2641,7 +2696,9 @@ Three consequences worth having in mind:
is the only ORDER component the add-on has. Anything added next belongs in one
of the three, and saying which is part of adding it.
### What `price_taxes_eur_kwh` is made of, and why yours is different (TARIFF-18)
### What `price_taxes_eur_kwh` is made of, and why yours is different
*For installers: card TARIFF-18.*
**It ships at `0.11736` EUR/kWh.** That is the all-in non-energy part of a
Flemish residential bill — everything on your invoice that is *not* the energy
@@ -3095,8 +3152,8 @@ does not protect*.
P1 meter, so every other guard, which reads that net figure, stays quiet. This
has happened on this hardware once for 6 h 25 min. The alarm is latched,
survives restarts, and is raised again every 5 min until control is started.
The watts and the time window are in the log; the *Circulating power check*
row shows the live watts. See *The circulating-power alarm*.
The watts and the time window are in the log. The *Check for inverters working
against each other* row shows the live watts. See *The circulating-power alarm*.
**An inverter is not delivering.** SAFETY-42. The unit stood well short of its
share of the site target for longer than `delivery_hold_s`. A unit at 0 W is
@@ -3114,16 +3171,22 @@ and command figures are in the log. See *The frozen-meter alarm*.
## The Web UI
The ingress panel shows live values, why the controller is commanding what it
is, and a **Commissioning** checklist that names any problem in words. It also
carries the three buttons: start/stop control, force a maintenance cycle, and
abort one.
The add-on page shows live values, why the battery does what it does, and a
**Commissioning** checklist that names any problem in words. It also has three
buttons: start or stop control, force a maintenance cycle, and abort one.
Under the buttons is the last thing that happened, in a few words.
Since **UX-02** it has a second page, **Configuration** — see *There are TWO
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
inverter.** The configuration page reads and writes the add-on's own settings
and can restart the add-on; the set of commands that reach the hardware is
exactly the three buttons it always had.
It has a second page, **Configuration**. See *There are TWO places to configure
this add-on*, above. **It adds nothing you can do to the inverter.** It reads
and writes the add-on's own settings and can restart the add-on. Only the three
buttons reach the inverter.
*For installers: the Configuration page is UX-02. The line under the buttons is
`messages.hint()`, the plain form of the newest entry of the add-on's own
action log (`Controller.events`). The page no longer shows that log, but the
`status` JSON still carries its last 40 entries as `events`: open the add-on
page and add `status` to its address. The action-log tables in this document
refer to it.*
## Status page
@@ -3144,14 +3207,16 @@ ends. That row says how to clear it.
| Meter | The grid reading, its age, how many readings were refused, and how long its value has not changed. |
| 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 peak, and how close it is to your peak limit. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. |
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
| 15-minute periods measured this month | How many 15-minute periods the add-on has measured and saved this month. |
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. *Idle, heading for …* is where this 15-minute period is heading. *Waiting for meter readings* after a restart means the battery does not charge until the add-on can measure the period again. |
| Battery level, Battery power, Grid power | The reading of that entity. *No usable reading* means the entity is missing, or its reading is too old. Check it in Developer Tools before replacing it. |
| Inverter control | Whether the inverter can be controlled, and the power range it accepts. |
| Inverter control | Whether the inverter can be controlled, and how much it can charge and give. |
| *name*: battery power reading stopped changing | The reading did not change while the add-on asked for a change. It may be stuck, or the battery may be refusing. The add-on sets the inverters to 0 W until it changes. |
| *name*: … cannot be controlled, commands refused, “Setpoint entity” is not set | Whether each inverter still takes commands. |
| Power request too high, Battery is not keeping up | See those titles under Notifications. Each shows how often it happened since the add-on started, and *fine now* once it has cleared. In shadow mode the row says what *would* have been sent. |
| Direction check, Site direction check | Whether each inverter's power goes the way it is told. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
| Circulating power check, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
| Direction check, Site direction check | Whether each inverter's power goes the way the add-on asks. *Opposite for 12 of 60 seconds* counts toward a stop. When it stops, the inverter, or every inverter, is set to 0 W until you start control. |
| Check for inverters working against each other, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
| Delivery check, *name* is not delivering | Only with several inverters. See *An inverter is not delivering*. |
| Solar production | Your solar production. Sources are numbered in the order of “Solar production entities, per inverter”. A strongly negative reading by day means that entity reads the wrong way round: add it to “Reversed-sign solar entities”. |
@@ -3176,7 +3241,17 @@ ERROR or WARNING line in the add-on log.
***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can
write, so the site refuses to run at part strength and holds 0 W.
- **Direction check, Site direction check** are the sign checks (SAFETY-02/30).
**Circulating power check** is SAFETY-31, **Delivery check** SAFETY-42.
**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.
- ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The
ERROR line carries the full `STALE_READING` reason and when it clears.
- 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
(TARIFF-11), in words.
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
@@ -3190,7 +3265,9 @@ battery power, circulating power, state of charge, maintenance phase and
controller status by MQTT discovery. This is observability only — the controller works fine without a
broker, and MQTT problems can never affect control.
## Before you commission inverter B: `tools/rehearse_roster.py` (ARCH-02)
## Before you commission inverter B
*For installers: card ARCH-02. The tool is `tools/rehearse_roster.py`.*
The day the `inverters:` roster is declared, two things change that are
**one-way on a live box and invisible until after the restart that applies
@@ -3371,7 +3448,9 @@ maintenance bookkeeping — and if a cycle ran during it, correct
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
decision, not a discovery made at the inverter.
## Before you deploy: `tools/predeploy_check.py` (PKG-09)
## Before you deploy
*For installers: card PKG-09. The tool is `tools/predeploy_check.py`.*
Two ways a deploy has gone wrong on this system, both of which the Supervisor
reports as success:
+3 -4
View File
@@ -413,10 +413,9 @@ function showProblems(payload){
save.disabled = total > 0;
if(total){
note(banner, 'bad', 'This configuration would not start.',
'The add-on refuses to run on it and would stop with exit 78. '
+ 'Saving is blocked until these are fixed; nothing has been written. '
+ 'Each message below is the add-on\\u2019s own words, from the same '
+ 'check that runs at startup.');
'The add-on would refuse to start on it. Saving is blocked until these '
+ 'are fixed, and nothing has been written. Each message below comes '
+ 'from the same check that runs at start.');
banner.insertBefore(banner.lastChild, banner.firstChild);
msg(total + (total === 1 ? ' problem' : ' problems')
+ ' - saving is blocked', 'bad');
+4 -1
View File
@@ -289,7 +289,10 @@ def problems_by_field(problems, keys):
cross-field rule is about all of its fields, and hiding it from one of them
would be this card's own defect in miniature.
"""
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_])" + re.escape(key)
# ⚠️ `.` BEFORE THE KEY DOES NOT COUNT (UX-08 2d): `inverters.max_w`
# names a setting of one inverter in the list, which is changed under
# `inverters`, and it must not also land beside the flat `max_w`.
patterns = [(key, re.compile(r"(?<![A-Za-z0-9_.])" + re.escape(key)
+ r"(?![A-Za-z0-9_])")) for key in keys]
by_key, unanchored = {}, []
for problem in problems:
+12 -14
View File
@@ -175,6 +175,8 @@ import math
from dataclasses import dataclass
from datetime import date, datetime, timezone
from . import messages
class FormulaInvalid(Exception):
"""`price_formula` cannot be used. Raised by the parser, refused by
@@ -285,30 +287,28 @@ _LEGS = {
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
'"expect_incl_vat":15.55}}'),
when=("Leave it empty unless “Dynamic price source” (price_provider) "
"is 'custom_formula'."),
when="Leave it empty unless “Dynamic price source” is 'custom_formula'.",
zero_factor=" and trading never finds a price difference",
flat="use “Fixed or day/night tariff” (static_profile) instead",
neg_factor="Put the minus sign on the offset, not on the factor",
card_required=True,
card_note="The add-on needs it to check your two numbers",
card_causes=("that network charges are in “Distribution, levies and VAT” "
"(price_taxes_eur_kwh), not in the offset"),
card_causes=("the units, the VAT, and that network charges are not "
"in the offset"),
),
EXPORT_FORMULA_OPTION: _Leg(
option=EXPORT_FORMULA_OPTION,
what="injection",
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
when=("Fill it in only if your injection price follows the market. It "
"is used whatever the price source is."),
when="Fill it in only if your injection price follows the market.",
zero_factor="",
flat="use “Injection price” (export_price_eur_kwh) instead",
neg_factor="Put the minus sign on the offset, not on the factor",
card_required=False,
card_note=("It is optional here. Without it the add-on only checks "
"the size of your two numbers"),
card_causes="that vat_pct is 0 for injection",
card_causes="the units, and that the VAT is 0 for injection",
),
}
@@ -527,10 +527,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
value = json.loads(text)
except ValueError as e:
raise FormulaInvalid(
f"{option} is not valid JSON ({e}). Write it on one line, for "
f"example\n"
f" {leg.example}\n"
f"{leg.when}")
f"{messages.not_valid(option, e)}. Copy the example from the "
f"documentation. {leg.when}")
if not isinstance(value, dict):
raise FormulaInvalid(
f"{option} is {type(value).__name__}, not a JSON object with "
@@ -685,9 +683,9 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
got = in_card_units(spec, spec.card_index_eur_per_mwh)
leg = _LEGS[spec.option]
return (
f"{spec.option} does not match its own card: it gives {got:.4f} "
f"{spec.units} and the card says {spec.card_expect_incl_vat:g}. Check "
f"the units, the VAT and {leg.card_causes}.")
f"{spec.option} gives {got:.4f} "
f"{messages.UNIT_WORDS.get(spec.units, spec.units)}, the card says "
f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
# --------------------------------------------------------------------------- #
+2 -1
View File
@@ -408,7 +408,8 @@ class Inverter:
stop the site (`inputs_ok`). Folding one into the other is how a
measurement fault ends up handing a sibling extra power.
"""
return self.batt_fresh.note(self.name)
return (messages.row_battery_stuck(self.name) if self.batt_fresh.stale
else "")
def link_note(self, now: datetime) -> str:
"""One line for the status page. Empty when there is nothing to say.
+305 -214
View File
@@ -73,7 +73,7 @@ from . import packshare
from . import pv as pv_read
from . import shadow
from .p1 import P1Ingest, build_source, is_enabled
from .price import Uncertainty, price_option, provider_configured
from .price import Uncertainty, brussels, price_option, provider_configured
from .price_history import PriceHistory
from .static_tariff import resolve_tariff, static_status
from .tariff import PeakStore, PeakTracker
@@ -267,14 +267,15 @@ def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
past `max_age_s`, is the exact state the soak measured; rendering it as
the bare static sentence is how it stayed invisible for 802 samples.
"""
# UX-08: plain words, every figure kept. The tick's sentence is "now";
# the plan carries its own age.
if plan is None:
if not provider:
return tick_words
return (f"{tick_words} (recomputed this tick); NO provider plan - "
f"none has been resolved yet, or the last one is older than "
f"{max_age_s:.0f} s")
return (f"{tick_words} (recomputed this tick); provider plan fetched "
f"{age_s:.0f} s ago for a {horizon_h:.0f} h horizon: {plan.words}")
return (f"{tick_words}. No price plan: none yet, or the last one is "
f"over {max_age_s:.0f} s old")
return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for "
f"{horizon_h:.0f} hours ahead: {plan.words}")
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
@@ -312,21 +313,21 @@ def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
render, from one that never reports it - and the bound TARIFF-10's
comment claimed did not exist is ~15 s of it.
"""
# UX-08: plain words; the setting is named by its label.
if not on:
return "off - `arbitrage_enabled` is false, so nothing trades"
return (f"Off, “{messages.LABELS['arbitrage_enabled']}” is not "
f"switched on")
if decided_at is None:
return (f"ON (`arbitrage_enabled`), but NO DECISION has been reached "
f"yet - no cycle has run as far as the arbitrage step. The "
f"loop's own reason: {why or 'none recorded'}")
return (f"On, but no decision yet, because control has not got that "
f"far. Control says: {why or 'no reason recorded'}")
age = (now - decided_at).total_seconds()
stale = cycle_at is not None and decided_at < cycle_at
head = (f"no decision this cycle ({why or 'no reason recorded'}); the "
f"last one was taken {age:.0f} s ago: " if stale else
f"decided {age:.0f} s ago: ")
head = (f"On, no decision this cycle ({why or 'no reason recorded'}), "
f"the last one {age:.0f} s ago: " if stale else
f"On, decided {age:.0f} s ago: ")
if decision is None:
return (f"ON (`arbitrage_enabled`), no decision this cycle "
f"({why or 'no reason recorded'}), and none stands from an "
f"earlier one")
return (f"On, no decision this cycle ({why or 'no reason recorded'}) "
f"and none before")
return head + decision.explain()
@@ -354,6 +355,8 @@ class Controller:
# their behalf, and a guard that raises AttributeError on the way to
# saying so is a guard that stops the add-on booting.
self.events: list[str] = []
# UX-08: the last event in plain words, for the page. See log_event().
self.hint = ""
# --- DEV-02: THE ROSTER, BUILT BEFORE ANYTHING THAT READS A RATING ---
# ⚠️ EVERY PER-DEVICE VALUE IN THIS CONTROLLER LIVES ON ONE OF THESE
@@ -467,7 +470,8 @@ class Controller:
"soc_entity / batt_entity / batt_invert / max_w keys are NOT "
"read in this shape; each entry carries its own."
% (len(self.inverters),
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)))
", ".join(f"{i.name} {i.max_w:.0f} W" for i in self.inverters)),
hint=messages.hint("roster", n=len(self.inverters)))
self.tuning = Tuning(
gain=float(opts.get("gain", 0.6)),
@@ -541,7 +545,8 @@ class Controller:
# 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.maint_migration.explain())
+ self.maint_migration.explain(),
hint=messages.hint("maint_history"))
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
# and the procedure is the same on every pack in the roster; what is per
@@ -590,11 +595,9 @@ class Controller:
self._grade_store_migration(store)
for inv in self.inverters:
if inv.sign_wd.tripped:
_LOG.error("SAFETY-02 was TRIPPED on %s before this restart and "
"stays tripped: %s. That inverter is held at 0 W. "
"Restarting the add-on does not clear this - start "
"control from the UI once you know why the command "
"opposed the meter.", inv.name,
_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,
inv.sign_wd.reason or "(reason not recorded)")
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
@@ -616,11 +619,9 @@ class Controller:
for inv in self.inverters:
if inv.delivery_wd.tripped:
_LOG.error(
"SAFETY-42 was TRIPPED on %s before this restart and stays "
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
"guard reports and does not act - so if that inverter is "
"still not delivering, the site is still short by its "
"share. Restarting does not clear this.",
"%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)")
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
@@ -650,10 +651,9 @@ class Controller:
reason=str(store.data.get("site_sign_reason", "") or ""))
if self.site_sign_wd.tripped:
_LOG.error(
"SAFETY-30 (site) was TRIPPED before this restart and stays "
"tripped: %s. Every inverter is held at 0 W. Restarting does "
"not clear this - start control from the UI once you know why "
"the site's total command opposed the meter.",
"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)")
# And the per-unit detectors are told which question they are asking,
# here as well as in `cycle()`. `checks()` and the status page read
@@ -704,10 +704,9 @@ class Controller:
self._announced_mono = {}
if self.circ_wd.tripped:
_LOG.error(
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
"%s. Every inverter is held at 0 W. Restarting does not clear "
"this - start control from the UI once you know why two "
"inverters were pushing against each other.",
"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)")
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
@@ -913,6 +912,7 @@ class Controller:
# than re-derived a cycle later. Starts False: nothing is on the wire.
self.following = False
self.reason = "starting"
self.why_words = "Starting"
# 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
@@ -959,7 +959,7 @@ class Controller:
# TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window:
# no" is an evidenced answer rather than a bare no.
self.tariff_words = "no static profile"
self.tariff_words = "No fixed tariff set"
# OPT-04. The tariff plan the BACKGROUND task last resolved, and when.
# cycle() reads these two attributes and never awaits a fetch - see
@@ -1005,10 +1005,15 @@ class Controller:
self.loop_pass_mono = None
# -- helpers -------------------------------------------------------------
def log_event(self, msg: str) -> None:
def log_event(self, msg: str, hint: str | None = None) -> None:
"""`msg` goes on the action log, the add-on's own technical record.
`hint` is `messages.hint()`'s plain words for the same event, and is
what the page shows under the buttons (UX-08)."""
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
self.events.insert(0, f"{stamp} {msg}")
del self.events[40:]
if hint is not None:
self.hint = f"{datetime.now().astimezone():%H:%M} {hint}"
def _grade_store_migration(self, store) -> None:
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
@@ -1066,9 +1071,9 @@ class Controller:
"""
rep = self.store_migration
if rep.ran and not rep.ok:
_LOG.error("SAFETY-02: the per-inverter store migration did not "
"verify (%s). The latch is being re-derived from the "
"legacy keys.", rep.explain())
_LOG.error("The saved safety stops did not copy over cleanly "
"(%s), so they are read again from the older saved "
"state.", rep.explain())
data = getattr(store, "data", {})
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
@@ -1194,10 +1199,8 @@ class Controller:
return
if stranded:
_LOG.error(
"SAFETY-44: this store carries a LATCHED sign-disagreement "
"failsafe under %s, and that unit is NOT on today's roster "
"%s - so no machine reads it and the site came up DRIVING on "
"a state file that says it is latched. Re-arming below.",
"A saved safety stop belongs to %s, which is not in the "
"inverter list %s, so it is set again on the first inverter.",
", ".join(sorted(stranded)), sorted(on_roster))
first = self.inverters[0]
# The one state this method exists for.
@@ -1209,17 +1212,18 @@ class Controller:
first.store_set("sign_reason", first.sign_wd.reason)
first.store_set("sign_tripped", True)
_LOG.error(
"SAFETY-02 LATCH GUARD: this store carries a LATCHED "
"sign-disagreement failsafe under the pre-DEV-02 flat key and NO "
"inverter came up latched. Either the per-inverter migration lost "
"it, or it was written by an older add-on while this install was "
"rolled back. Both are a disarmed safety guard on an install whose "
"state file says it is protected, so the latch has been re-armed "
"on %s from the legacy value: %s. %s is held at 0 W until control "
"is started from the UI.",
first.name, first.sign_wd.reason, first.name)
"%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)
_LOG.debug(
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
"sign-disagreement failsafe is latched and no inverter came up "
"latched. Either the per-inverter migration lost it, or an older "
"add-on wrote it during a rollback. Both would be a disarmed "
"guard, so the latch is re-armed on %s.", first.name)
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
+ first.sign_wd.reason)
+ first.sign_wd.reason,
hint=messages.hint("stop_restored", unit=first.name))
# -- the roster, and the N=1 views of it ---------------------------------
@property
@@ -1805,7 +1809,7 @@ class Controller:
now_p = await self.hass.number(now_e)
avg_p = await self.hass.number(avg_e)
self.cheap = (now_p is not None and avg_p is not None and now_p <= avg_p)
self.tariff_words = "Home Assistant price entities"
self.tariff_words = "Prices from Home Assistant entities"
else:
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
@@ -1851,7 +1855,8 @@ class Controller:
if self.inputs_ok:
if self.inputs_bad_since is not None:
self.log_event("inputs recovered")
self.log_event("inputs recovered",
hint=messages.hint("inputs_back"))
self.inputs_bad_since = None
elif self.inputs_bad_since is None:
self.inputs_bad_since = datetime.now(timezone.utc)
@@ -1886,7 +1891,8 @@ class Controller:
# §11.1 rule 1 and is already what a missing reading does here.
inv.batt = inv.batt_fresh.reading()
if inv.batt_fresh.stale and not was_stale:
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
hint=messages.hint("battery_stuck", unit=inv.name))
# ⚠️ 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
@@ -1913,26 +1919,31 @@ class Controller:
# this line already interpolates, is where the pair is spelled out.
# Saying it in both halves of one sentence is how an operator
# learns to skim it.
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
"ON THE REGISTER - %s. Treating it as MISSING, which "
"commands 0 W. It clears itself the moment the register "
"reports a different value, and failing that %.0f s "
"after the standing command falls below %.0f W "
"(SAFETY-57: a verdict whose own consequence is a 0 W "
"command cannot be tested again from inside it).",
inv.name, inv.batt_fresh.reason,
inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w)
# 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",
inv.name, inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
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
# one is withdrawing a SITE STOP. `release_note()` is written to say
# that nothing has been learned about the register, because nothing
# has.
self.log_event(f"STALE WITHDRAWN {inv.name}")
_LOG.warning("SAFETY-57: %s",
inv.batt_fresh.release_note(inv.name))
self.log_event(f"STALE WITHDRAWN {inv.name}",
hint=messages.hint("battery_back", unit=inv.name))
# Not "recovered": nothing is known about the reading yet, so the
# line gives the value it still holds and the window it is
# rechecked over (SAFETY-57).
_LOG.warning("%s: the command stayed below %.0f W for %.0f s, so "
"battery power reading %+.0f W is used again and "
"rechecked over %.0f s.", inv.name,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.release_s, inv.batt_fresh.held,
inv.batt_fresh.bound.unchanged_s)
_LOG.debug("SAFETY-57: %s", inv.batt_fresh.release_note(inv.name))
@property
def shadow(self) -> bool:
@@ -2066,14 +2077,15 @@ class Controller:
self.log_event(
f"refusing to broadcast {float(value):+.0f} W to "
f"{len(targets)} inverters - that is the ARCH-01 fault; "
f"commanding 0 W")
_LOG.error("write_setpoint() was asked to put %+.0f W on every "
"one of %d inverters. A site target handed to each "
"unit is supplied by EACH of them: the gain at the "
"meter becomes N times the per-unit gain. Only 0 W "
"may be written site-wide; shares come from the "
"allocator. Commanding 0 W.", float(value),
len(targets))
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.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 "
"written site-wide, and shares come from the "
"allocator.")
value = 0.0
else:
targets = [unit]
@@ -2101,7 +2113,8 @@ class Controller:
# must not be counted as a clamp engagement, or a transport bug lands in
# the winding-loop counter.
if not is_commandable(value):
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W")
self.log_event(f"non-finite setpoint {value!r} - commanding 0 W",
hint=messages.hint("bad_value"))
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
"0 W and continuing to command. This is a bug upstream, "
"not a limit.", value)
@@ -2139,11 +2152,11 @@ class Controller:
refusal = self.write_refusal(value, inv)
if refusal is not None:
self.log_event(
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W")
_LOG.error("SAFETY-28 REFUSED a write of %+.0f W to %s: %s. "
"Commanding 0 W instead. Every setpoint must be one the "
"allocator placed on THIS tick, for THIS unit - see "
"Controller.write_refusal().", value, inv.name, refusal)
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.",
value, inv.name, refusal)
value = 0.0
# ⚠️ THIS UNIT'S OWN THREE NUMBERS. `inv.max_w` is the supervised cap on
@@ -2158,7 +2171,8 @@ class Controller:
# (§15), and it is a different surface from the notification, which
# IS deduplicated. The count is what says how hard the loop pushed;
# the notification only says that it did.
self.log_event(f"{inv.name}: {result.describe()}")
self.log_event(f"{inv.name}: {result.describe()}",
hint=messages.hint("write_cut", unit=inv.name))
value = result.applied_w
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
@@ -2209,12 +2223,12 @@ class Controller:
timeout=WRITE_BUDGET_S)
except (asyncio.TimeoutError, TimeoutError):
ok = False
_LOG.error("the write of %+.0f W to %s (%s) did not complete in "
"%.0f s and was abandoned. It will be re-issued on the "
"next tick; no other inverter waited for it.",
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
"dropped, so it is sent again next cycle.",
value, inv.name, entity, WRITE_BUDGET_S)
self.log_event(f"{inv.name}: write timed out after "
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick")
f"{WRITE_BUDGET_S:.0f} s - re-issuing next tick",
hint=messages.hint("write_slow", unit=inv.name))
now = datetime.now(timezone.utc)
inv.note_write(bool(ok), now)
if ok:
@@ -2339,7 +2353,8 @@ class Controller:
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
res = machine.tick(now, inv.soc, blocked_by=blocked)
for msg in res.events:
self.log_event(msg)
self.log_event(msg, hint=messages.hint("maintenance",
phase=machine.phase))
maint_results.append(res)
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
# deferral is edge-logged (it lasts as long as the other pack's cycle),
@@ -2432,11 +2447,10 @@ class Controller:
# operator press that clears the namespaced ones.
self.store.set("sign_reason", inv.sign_wd.reason)
self.store.set("sign_tripped", True)
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason)
_LOG.error("SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on "
"that inverter and holding it until control is "
"started again. This survives an add-on restart; "
"restarting will not clear it.",
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)
# ⚠️ 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
@@ -2481,14 +2495,11 @@ class Controller:
# the same reason `circ_*` is.
self.store.set("site_sign_reason", self.site_sign_wd.reason)
self.store.set("site_sign_tripped", True)
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason)
_LOG.error("SAFETY-30 TRIPPED at the SITE - %s. That is the "
"SUM of every standing command opposing the meter, "
"which no single inverter is answerable for. "
"Commanding 0 W on every inverter and holding it "
"until control is started again. This survives an "
"add-on restart; restarting will not clear it.",
self.site_sign_wd.reason)
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)
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
@@ -2516,12 +2527,12 @@ class Controller:
self.store.set("circ_reason", self.circ_wd.reason)
self.store.set("circ_tripped", True)
self._circ_announced = False
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason)
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason,
hint=messages.hint("circulating"))
_LOG.error(
"SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and "
"holding it until control is started again. This survives an "
"add-on restart AND a Home Assistant restart; restarting will "
"not clear it.", self.circ_wd.reason)
"Every inverter is set to 0 W because the inverters worked "
"against each other: %s. Only starting control clears this.",
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
@@ -2623,16 +2634,18 @@ class Controller:
inv.store_set("delivery_tripped", True)
self._delivery_announced.discard(inv.name)
self.log_event(f"NOT DELIVERING {inv.name}: "
+ inv.delivery_wd.reason)
+ inv.delivery_wd.reason,
hint=messages.hint("not_delivering",
unit=inv.name))
_LOG.error(
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
"DIFFERENTLY: this guard reports and does not act, because "
"commanding a unit that is not listening achieves nothing "
"(HW-01) and the only re-allocation this add-on can express "
"would throw away a partially-delivering unit's remaining "
"output. The site is short by that unit's share until "
"somebody looks at it. This survives an add-on AND a Home "
"Assistant restart.", inv.name, inv.delivery_wd.reason)
"%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)
_LOG.debug(
"SAFETY-42 reports and does not act: commanding a unit "
"that is not listening achieves nothing (HW-01), and the "
"only re-allocation the add-on can express would throw "
"away a partly delivering unit's output.")
stale_after = float(self.o.get("stale_input_s", 15))
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
@@ -2653,6 +2666,7 @@ 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"
self.why_words = messages.why_waiting(bad_for)
return
claims = []
@@ -2828,7 +2842,8 @@ class Controller:
actuator=self.actuator_of("safety")))
elif not self.inputs_ok:
if self.target != 0.0:
self.log_event("inputs missing - commanding 0 W")
self.log_event("inputs missing - commanding 0 W",
hint=messages.hint("inputs_missing"))
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
actuator=self.actuator_of("safety")))
@@ -2949,7 +2964,12 @@ class Controller:
if engaged_now != self._peak_engaged:
self._peak_engaged = engaged_now
if self.peak_guard is not None:
self.log_event(self.peak_guard.explain())
_pg = self.peak_guard
self.log_event(_pg.explain(), hint=messages.hint(
"peak_off" if not _pg.engaged else
"peak_blind" if _pg.blind else
"peak_reserve" if _pg.floor_limited else
"peak_saturated" if _pg.saturated else "peak_on"))
# --- OPT-04: price arbitrage (§10.1 priority 3) ----------------------
# ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole
@@ -3061,7 +3081,10 @@ class Controller:
# operator needs - "it started buying at 03:00", not 240 copies.
if self.arb.action != self._arb_action:
self._arb_action = self.arb.action
self.log_event(self.arb.explain())
self.log_event(self.arb.explain(), hint=messages.hint(
"trade_charge" if self.arb.action == arbitrage.CHARGE else
"trade_give" if self.arb.action == arbitrage.DISCHARGE
else "trade_off"))
# --- maintenance -----------------------------------------------------
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
@@ -3145,7 +3168,8 @@ class Controller:
self._maint_shape_suspended = True
self.log_event("peak at risk - maintenance charge shaping "
"suspended so the peak claim is not "
"contradicted")
"contradicted",
hint=messages.hint("maint_paused"))
else:
self._maint_shape_suspended = False
hi = 0.0
@@ -3229,7 +3253,8 @@ class Controller:
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
f"{site_batt:.0f} W)")
f"{site_batt:.0f} W)",
hint=messages.hint("not_keeping_up"))
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
@@ -3358,7 +3383,8 @@ class Controller:
# made by commanding one pack to charge while another discharges.
# `allocator.plan()` carries the argument for each.
if not resolution.allocated:
self.log_event("ARBITER: " + resolution.allocation.explain())
self.log_event("ARBITER: " + resolution.allocation.explain(),
hint=messages.hint("split"))
resolution = Resolution(0.0, "failsafe", None, False,
resolution.considered,
unit_bounds=resolution.unit_bounds,
@@ -3394,7 +3420,7 @@ class Controller:
f"ARBITER: the site target was allocated across "
f"{len(resolution.shares)} inverters and this controller has no "
f"setpoint entity for {undeliverable} - refusing to command a "
f"partial site")
f"partial site", hint=messages.hint("no_setpoint"))
# ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
# DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below
# commands each inverter `shares.get(name, 0.0)`, so carrying the
@@ -3422,9 +3448,11 @@ class Controller:
for _u, _why in (self.allocation.dropped if self.allocation else ()):
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
f"({_why}) - the remaining inverter(s) now carry "
f"its share of the site target")
f"its share of the site target",
hint=messages.hint("unit_dropped", unit=_u))
if self._dropped_last and not _dropped:
self.log_event("ALLOCATOR: every inverter is back in the split")
self.log_event("ALLOCATOR: every inverter is back in the split",
hint=messages.hint("units_back"))
self._dropped_last = _dropped
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
# `unmet_w` is the site target the envelopes could not absorb. It is
@@ -3441,9 +3469,11 @@ class Controller:
if _unmet:
self.log_event(
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
f"placed on any inverter - {self.allocation.explain()}")
f"placed on any inverter - {self.allocation.explain()}",
hint=messages.hint("unmet", w=_unmet))
else:
self.log_event("ALLOCATOR: the whole site target fits again")
self.log_event("ALLOCATOR: the whole site target fits again",
hint=messages.hint("unmet_gone"))
self._unmet_last = _unmet
if resolution.scope_errors:
# A claim reached the arbiter on an actuator its source's scope does
@@ -3452,9 +3482,11 @@ class Controller:
# card removes, and a site constraint on one unit is a bound the
# other unit never gets. Loud, every cycle, and it does not change
# the resolution - the arbiter already routed by `actuator`.
_LOG.error("ARBITER SCOPE: %s", "; ".join(resolution.scope_errors))
_LOG.error("A limit was filed on the wrong inverter, which is a "
"software fault: %s", " / ".join(resolution.scope_errors))
if resolution.contradiction:
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log")
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
hint=messages.hint("clash"))
# --- SAFETY-02's arming gate ------------------------------------------
# Was the number about to go on the wire chosen by the grid-following
# loop, or by a strategy that commands against the meter on purpose?
@@ -3471,6 +3503,17 @@ class Controller:
self.following = loop_is_following(resolution, self.loop_w)
self.target = resolution.target_w
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)
# --- 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
@@ -3647,20 +3690,20 @@ class Controller:
self.log_event(
"SHADOW MODE ON - " + shadow.why(
self.o, control_started=self.auto)
+ f"; inverter held at {shadow.WIRE_W:.0f} W")
+ f"; inverter held at {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_on"))
_LOG.warning(
"SAFETY-10 SHADOW MODE IS ON. The full decision path runs "
"and NOTHING is commanded: every inverter is held at "
"%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W,
"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")
f"resuming from {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_off"))
_LOG.warning(
"SAFETY-10 shadow mode is OFF. This controller is "
"commanding the battery again, starting from the "
"%+.0f W that was standing on the wire.", shadow.WIRE_W)
"Shadow mode is off, so the add-on controls the battery "
"again, starting from %.0f W.", shadow.WIRE_W)
if not engaged:
self.shadow_would_w = None
self.shadow_sized_from_meter = False
@@ -3693,7 +3736,9 @@ class Controller:
self._shadow_winner = edge
self.log_event(shadow.log_line(
would_w=resolution.target_w, reason=resolution.explain(),
sized_from_meter=self.shadow_sized_from_meter))
sized_from_meter=self.shadow_sized_from_meter),
hint=messages.hint("shadow_decision",
sw=resolution.target_w))
self.target = shadow.WIRE_W
for inv in self.inverters:
inv.target = shadow.WIRE_W
@@ -3771,25 +3816,29 @@ class Controller:
stamp = self.events[0].split(" ", 1)[0]
self.events[0] = f"{stamp} {row}"
else:
self.log_event(row)
self.log_event(row, hint=messages.hint(
"power_too_high" if bug else "not_keeping_up"))
if not fired:
return
if not self.clamp_wd.bug:
# 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("SAFETY-03 - %s Engagements so far: %d.",
self.clamp_wd.reason, self.clamp_wd.engagements)
_LOG.warning("The battery gives less than asked: %s Held back %d "
"times so far.", self.clamp_wd.reason,
self.clamp_wd.engagements)
await self.hass.call("persistent_notification", "create",
messages.loop_bound().data())
return
_LOG.error(
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
"reached 14 768 W on this project before: the clamp makes every "
"dashboard read a legal number while the thing producing it goes on "
"climbing. The command IS clamped and the loop is STILL RUNNING - "
"this is an alarm, not a trip. Engagements so far: %d.",
"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)
_LOG.debug(
"SAFETY-03: silently clipping this is how a winding integrator "
"reached 14 768 W on this project before. The clamp makes every "
"dashboard read a legal number while the thing producing it goes "
"on climbing. This is an alarm, not a trip.")
# A fixed notification_id, for the reason check_meter_frozen() gives -
# and a DIFFERENT one from every other alarm this add-on raises, because
# a clamp is not a frozen meter and neither is a saturation freeze.
@@ -3829,13 +3878,12 @@ 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(
"SAFETY-07 - %s. The guard is asking for everything the inverter "
"has and the quarter is still projected over the ceiling: this "
"quarter will set or raise the month's billed capacity peak. The "
"controller is STILL RUNNING and still shaving; there is nothing "
"further it can do. Shed load.", g.reason)
"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)
# 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.
@@ -3889,12 +3937,10 @@ class Controller:
# 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)
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
_LOG.error(
"SAFETY-24 - %s. The controller is STILL RUNNING and charging is "
"still blocked, but it is not discharging to hold this quarter, so "
"the quarter will set or raise the month's billed capacity peak. "
"Shed load, or lower peak_soc_floor if this pack can afford it.",
"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)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A battery at its
@@ -4051,15 +4097,17 @@ class Controller:
dev_max=self.site_dev_bound("dev_max"))
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason)
self.log_event("METER FROZEN? " + self.freeze_wd.reason,
hint=messages.hint("meter_frozen"))
_LOG.error(
"SAFETY-18 - %s. The grid-following loop is regulating against a "
"number that has stopped moving, and nothing in this stack stops "
"it: the meter is still answering, so the age is fresh, the "
"firmware watchdog is fed and the arbiter only bounds the "
"magnitude. CHECK THE METER. Sustained for a quarter of an hour "
"this sets the month's billed capacity-tariff peak. This is an "
"ALARM ONLY - the loop is still running.", self.freeze_wd.reason)
"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)
_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 "
"only bounds the magnitude. Kept up for a quarter of an hour, it "
"sets the month's billed capacity peak.")
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
# rather than stacking. An operator who has to dismiss eleven identical
# notifications learns to dismiss the twelfth without reading it.
@@ -4168,12 +4216,9 @@ class Controller:
and standing is not None and standing.from_feed)
if keep:
_LOG.warning(
"tariff refresh degraded (%s); KEEPING the plan "
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
"so it retires %.0f s after that fetch; arbitrage, "
"where it is armed, stops with it. A kept plan is "
"never presented as a current one.", plan.words,
self.tariff_at,
"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)
else:
@@ -4185,9 +4230,9 @@ class Controller:
# here is something nobody expected, and the plan is kept for
# the same reason and on the same terms.
_LOG.warning(
"tariff refresh raised (%s); keeping the plan resolved at "
"%s. It retires on its own after %.0f s and arbitrage "
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
"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)
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.stop_event.wait(),
timeout=TARIFF_REFRESH_S)
@@ -4239,7 +4284,8 @@ class Controller:
if problems:
refuse(problems)
self.refused = problems
self.log_event("REFUSED: " + problems[0])
self.log_event("REFUSED: " + problems[0],
hint=messages.hint("refused"))
# Signal, then return WITHOUT entering the loop. amain() wakes
# on this, runs the normal wind-down (which commands 0 W) and
# re-raises ConfigRefused afterwards, so the exit code is 78 and
@@ -4646,21 +4692,28 @@ class Controller:
# a normal state on the 1st; "unreadable" is a broken instrument, and
# a status page that spelled them the same way would let a 0 W read
# look like a quiet month right up until SAFETY-07 shaved nothing.
# UX-08: in plain words. The store's state at start is on the INFO
# line `capacity-tariff peak store: ...` and `_pk.explain()` on the
# log; the coverage figure (TARIFF-13) is a row of its own.
_pk = self.peak.peak(when)
if _pk.is_unreadable:
out.append({"ok": False, "warn": True,
"text": "capacity-tariff peak: " + _pk.explain()})
out.append({"ok": False, "warn": True, "id": "month_peak",
"text": messages.row_month_peak_unreadable()})
else:
# §9.1's "warning entity at 80 %", as a status row rather than
# a second entity - see the constructor on not doubling up.
_frac = self.peak.warning_frac(when) or 0.0
_ok = _frac < 0.8
out.append({"ok": _ok, "warn": not _ok,
"text": _pk.explain()
+ f" - {_frac * 100:.0f} % of the "
f"{self.peak.cap_w:.0f} W soft limit"
+ f", {self.peak.recorded} quarter(s) banked"
+ f", on start: {self.peak.restore_state}"})
out.append({"ok": _ok, "warn": not _ok, "id": "month_peak",
"text": (messages.row_month_peak_none()
if not _pk.is_observed else
messages.row_month_peak(
_pk.peak_w,
brussels(_pk.quarter_start),
_frac))})
out.append({"ok": True, "warn": False, "id": "periods_measured",
"text": messages.row_periods_measured(
self.peak.recorded)})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way
@@ -4715,10 +4768,16 @@ class Controller:
else ()))})
elif _pg.blind:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": _pg.explain()})
"text": messages.row_peak_guard_blind(
peakguard.blind_kind(_pg.blind))})
elif _pg.engaged:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_on(
_pg.floor_w or 0.0, _pg.ceiling_w,
shadow=self.shadow)})
else:
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
"id": "peak_guard", "text": _pg.explain()})
out.append({"ok": True, "warn": False, "id": "peak_guard",
"text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
@@ -4913,7 +4972,7 @@ class Controller:
# watching something that cannot happen. Say what is true instead.
out.append({"ok": True, "warn": False, "id": "circulating",
"text": messages.row_one_inverter(
"Circulating power check")})
messages.CHECK_CIRCULATING)})
else:
held = self.circ_wd.held_s(datetime.now(timezone.utc))
out.append({"ok": True, "warn": False, "id": "circulating",
@@ -5079,7 +5138,7 @@ class Controller:
# the row's first sentence, so it stays a headline.
level, banner = "bad", messages.banner_not_ready(bad[0])
elif not self.auto:
level, banner = "warn", "Stopped — inverter commanded to 0 W"
level, banner = "warn", messages.banner_stopped()
elif any(i.delivery_wd.tripped for i in self.inverters):
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
# WHICH IS THE WHOLE "reports, does not act" DECISION IN ONE LINE.
@@ -5137,10 +5196,11 @@ class Controller:
# question it answers is the one those two cannot: at "Grid 0 W,
# Battery 0 W" the site is either becalmed or throwing away every
# watt it makes, and only this row separates them.
*(() if not self.pv_roster else ((("PV", w(self.pv_w)),))),
("State of charge", "—" if self.pack_floor_soc is None
*(() if not self.pv_roster else ((("Solar production",
w(self.pv_w)),))),
("Battery level", "—" if self.pack_floor_soc is None
else f"{self.pack_floor_soc:g} %"),
("Commanded", w(self.target)),
("Power request", w(self.target)),
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
# "Commanded 0 W" above and a "Why" below that explains a trade is
# the exact pair an operator would otherwise have to guess at; this
@@ -5155,8 +5215,8 @@ class Controller:
# - the page runs on its own task and `self.peak_guard` has
# moved on. See `Controller.__init__`.
sized_from_meter=self.shadow_sized_from_meter,
reason=self.reason)),
("Why", self.reason),
reason=self.why_words)),
("Why", self.why_words),
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
else f"{self.maint.phase} ({self.maint_unit.name})"),
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
@@ -5195,11 +5255,16 @@ class Controller:
("Arbitrage", arbitrage_row(
on=arbitrage.enabled(self.o), decision=self.arb,
decided_at=self.arb_at, cycle_at=self.cycle_at,
now=datetime.now(timezone.utc), why=self.reason)),
now=datetime.now(timezone.utc), why=self.why_words)),
]
return {
"banner": banner, "level": level, "rows": rows, "checks": checks,
"hint": self.events[0] if self.events else "",
"hint": self.hint,
# The action log itself, newest first. The page does not show it
# (UX-08 2d), but an installer can still read it in the `status`
# JSON, because several events (a dropped inverter's reason, the
# peak guard's projection) are written nowhere else.
"events": list(self.events),
}
async def handle_action(self, what: str):
@@ -5207,7 +5272,9 @@ class Controller:
if what == "auto_toggle":
self.auto = not self.auto
self.store.set("auto", self.auto)
self.log_event("control started" if self.auto else "control stopped")
self.log_event("control started" if self.auto else "control stopped",
hint=messages.hint("control_started" if self.auto
else "control_stopped"))
if self.auto:
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
# It clears on a deliberate human start, not on the condition
@@ -5230,7 +5297,9 @@ class Controller:
inv.store_set("sign_tripped", False)
inv.store_set("sign_reason", "")
self.log_event(f"sign-disagreement failsafe cleared by "
f"operator on {inv.name}")
f"operator on {inv.name}",
hint=messages.hint("stop_cleared",
unit=inv.name))
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY.
# This is what makes `flat True + namespaced False` MEAN
# something, and it is the reason `_grade_store_migration()` can
@@ -5271,7 +5340,8 @@ class Controller:
# moment a second inverter was configured again.
if self.site_sign_wd.tripped:
self.log_event("site sign-disagreement failsafe cleared "
"by operator")
"by operator",
hint=messages.hint("site_stop_cleared"))
self.site_sign_wd.reset()
self.store.set("site_sign_tripped", False)
self.store.set("site_sign_reason", "")
@@ -5289,7 +5359,8 @@ class Controller:
self.circ_wd.reset()
self.store.set("circ_tripped", False)
self.store.set("circ_reason", "")
self.log_event("circulating-power trip cleared by operator")
self.log_event("circulating-power trip cleared by operator",
hint=messages.hint("site_stop_cleared"))
self._circ_announced = False
# SAFETY-42, cleared by the SAME press, and for a reason that is
# NOT the one above. The other three latches are cleared here
@@ -5307,7 +5378,10 @@ class Controller:
for inv in self.inverters:
if inv.delivery_wd.tripped:
self.log_event(f"not-delivering report on {inv.name} "
f"cleared by operator")
f"cleared by operator",
hint=messages.hint(
"not_delivering_cleared",
unit=inv.name))
inv.delivery_wd.reset()
inv.store_set("delivery_tripped", False)
inv.store_set("delivery_reason", "")
@@ -5333,14 +5407,18 @@ class Controller:
# bare `maint_start` with no unit, and inventing a unit parameter
# here would ship half a surface: the page would still have one
# button and the API would have two meanings.
for msg in self._maint_button(now).force_start(now):
self.log_event(msg)
_m = self._maint_button(now)
for msg in _m.force_start(now):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
return {"phase": self.maint.phase}
if what == "maint_abort":
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
for msg in self._maint_button(now).abort(now, "operator"):
self.log_event(msg)
_m = self._maint_button(now)
for msg in _m.abort(now, "operator"):
self.log_event(msg, hint=messages.hint("maintenance",
phase=_m.phase))
await self.command_site_zero()
return {"phase": self.maint.phase}
return None
@@ -5362,8 +5440,9 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
if stop.is_set() or controller.stopping:
return # the normal wind-down, or run_control()'s refusal path
exc = None if task.cancelled() else task.exception()
_LOG.critical("CONTROL LOOP DIED (%s) - commanding 0 W and exiting with "
"status %d", "cancelled" if task.cancelled() else
_LOG.critical("Control stopped unexpectedly (%s), so every inverter is "
"set to 0 W and the add-on exits with code %d.",
"cancelled" if task.cancelled() else
(repr(exc) if exc else "returned"), EXIT_CONTROL_DIED,
exc_info=exc)
died.append(exc)
@@ -5381,21 +5460,33 @@ async def docs_link_facts(session, token: str) -> dict:
Asked together with a 3 s budget: a link may never hold up control start.
"""
async def ask(url, field):
"""`(value, why)`: the field, or None and a short reason (UX-09)."""
try:
async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={
"Authorization": f"Bearer {token}"}) as r:
body = await r.json(content_type=None)
data = body.get("data", body) if isinstance(body, dict) else {}
return data.get(field) if isinstance(data, dict) else None
value = data.get(field) if isinstance(data, dict) else None
return value, (None if value else f"no {field} in its answer")
except Exception as err: # noqa: BLE001 - a link, never a stop
_LOG.info("docs link: %s unreadable (%s)", url, err)
return None
# A timeout's own text is empty, so the type is always named.
text = str(err).strip()
return None, (f"{type(err).__name__}: {text}" if text
else type(err).__name__)
slug, ha = await asyncio.gather(
(slug, why_slug), (ha, why_ha) = await asyncio.gather(
ask("http://supervisor/addons/self/info", "slug"),
ask("http://supervisor/info", "homeassistant"))
out = {"slug": slug, "ha_version": ha}
_LOG.info("docs link: slug %s, Home Assistant %s", slug, ha)
# ONE line, whatever happened: the link is a convenience, not a fault.
if slug and ha:
_LOG.info("Documentation link: add-on %s, Home Assistant %s", slug, ha)
else:
missed = [f"{what} ({why})" for what, why in
(("add-on name", why_slug), ("Home Assistant version", why_ha))
if why]
_LOG.info("Documentation link: the Supervisor gave no %s, so the "
"link is guessed or left out.", " and no ".join(missed))
return out
+246 -13
View File
@@ -10,6 +10,7 @@ per title; the body ends with a link to it.
import os
import re
from dataclasses import dataclass
from datetime import datetime
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
# that no action a person must take is lost; everything else gets one.
@@ -335,13 +336,13 @@ def banner_circulating() -> str:
def banner_site_direction() -> str:
return "Total command and meter point opposite ways. All inverters at 0 W."
return "Total power went the wrong way. All inverters at 0 W."
def banner_direction(several: bool) -> str:
if several:
return "An inverter's own power opposes its command. It is at 0 W."
return "Command and meter point opposite ways. Inverter set to 0 W."
return "An inverter's own power went the wrong way. Set to 0 W."
return "The meter saw power go the wrong way. Inverter at 0 W."
def banner_not_delivering(units) -> str:
@@ -350,6 +351,10 @@ def banner_not_delivering(units) -> str:
return f"{names(units)} {verb} not delivering. Control keeps running."
def banner_stopped() -> str:
return "Stopped — the inverters are set to 0 W"
def banner_shadow(latched: bool) -> str:
"""SAFETY-10: first in the cascade, never displaced by another banner.
@@ -502,9 +507,15 @@ def row_commands_refused(name, held_s, limit_s) -> str:
def row_control(lo_w, hi_w, unit=None) -> str:
"""The inverter's own range: charging below 0 W, giving power above it."""
head = f"Inverter control{for_unit(unit)}: ready"
if lo_w is None or hi_w is None or lo_w > 0 or hi_w < 0:
def w(v):
return "?" if v is None else signed_watts(v)
return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}"
return f"{head}, from {w(lo_w)} to {w(hi_w)}"
if abs(float(lo_w)) == float(hi_w):
return f"{head}, up to {watts(hi_w)} each way"
return f"{head}, {watts(lo_w)} charging, {watts(hi_w)} giving"
# Rows: the two loop alarms. The names are the notification titles, so the
@@ -541,9 +552,9 @@ def row_write_refused(unit=None) -> str:
# Rows: the direction checks (SAFETY-02/30).
def row_direction_trip(unit=None) -> str:
"""Per inverter. `unit` only at several inverters, graded on its own power."""
what = (f"{unit}: command and its own power point opposite ways, so it is"
if unit else "Command and meter point opposite ways, so the "
"inverter is")
what = (f"{unit}'s own power went against what the add-on asked, so it is"
if unit else
"Grid power went against what the add-on asked, so the inverter is")
return (f"{what} set to 0 W. Find out why, then start control. A restart "
f"does not clear this.")
@@ -557,9 +568,9 @@ def row_direction_check(held_s, hold_s, unit=None) -> str:
def row_site_direction_trip() -> str:
return ("Total command and meter point opposite ways, so every inverter is "
"set to 0 W. The cause is the total, not one inverter. Only starting "
"control clears this.")
return ("Grid power went against the add-on's total request, so every "
"inverter is set to 0 W. The cause is the total, not one inverter. "
"Only starting control clears this.")
def row_site_direction_check(held_s, hold_s) -> str:
@@ -575,10 +586,15 @@ def row_circulating_trip() -> str:
"0 W. The meter cannot see this. Only starting control clears this.")
CHECK_CIRCULATING = "Check for inverters working against each other"
def row_circulating_check(now_w, held_s, hold_s) -> str:
return ("Circulating power check: on"
+ (f", now {watts(now_w)}" if now_w else "")
+ (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s else ""))
if held_s:
return (f"{CHECK_CIRCULATING}: {watts(now_w or 0)} for {held_s:.0f} of "
f"{seconds(hold_s)}")
return (f"{CHECK_CIRCULATING}: on"
+ (f", now {watts(now_w)}" if now_w else ""))
def row_one_inverter(check: str) -> str:
@@ -676,6 +692,184 @@ def row_pv_stray(n) -> str:
f"entity in use")
# Rows: this month's peak and the peak guard (UX-08 phase 2d). The detail
# (the projection's reason, the store's state at start) is in the add-on log.
def row_month_peak(peak_w, at, frac) -> str:
"""`at` is the local start of the 15-minute period that set the peak."""
return (f"Month peak: {watts(peak_w)} on {at.day} {at:%b} at {at:%H:%M}, "
f"{frac * 100:.0f} % of the peak limit")
def row_month_peak_none() -> str:
return "Month peak: none recorded yet this month"
def row_month_peak_unreadable() -> str:
"""The store did not answer. This is not a zero peak (TARIFF-01)."""
return "Month peak: cannot be read, so only the peak limit is used"
def row_periods_measured(n) -> str:
"""TARIFF-13's coverage figure. `n` is an int, or a text when unreadable."""
shown = n if isinstance(n, int) else "cannot be read"
return f"15-minute periods measured this month: {shown}"
def row_peak_guard_idle(projected_w, limit_w, month_peak: bool) -> str:
"""The projection first and the limit second (SAFETY-38's ordered pair)."""
limit = (f"this month's {watts(limit_w)} peak" if month_peak
else f"the {watts(limit_w)} peak limit")
return f"Peak guard: idle, heading for {watts(projected_w)}, under {limit}"
def row_peak_guard_quiet() -> str:
return "Peak guard: idle"
BLIND_WORDS = {
"no_block": "waiting for meter readings",
"gap": "meter readings have a gap",
"frozen": "meter reading is stuck",
}
def row_peak_guard_blind(why: str) -> str:
"""`why` is a key of BLIND_WORDS. The battery may give power, not charge."""
return (f"Peak guard: {BLIND_WORDS[why]}, so the battery does not charge "
f"meanwhile")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
f"{watts(limit_w)}")
return (f"Peak guard: asks the battery for {watts(floor_w)} to stay under "
f"{watts(limit_w)}")
def row_battery_stuck(unit) -> str:
"""SAFETY-37/56. A fact, not a verdict: the reading may be stuck, or the
battery may be refusing, and the add-on cannot tell which."""
return (f"{unit}: battery power reading stopped changing, so it is not used "
f"for now")
# The top of the page: why the battery does what it does (the "Why" line).
WHY_WINNER = {
"loop": "Keeping the grid near zero",
"peak": "Keeping under the peak limit",
"arbitrage": "Trading on the price",
"maintenance": "Battery maintenance",
"failsafe": "Safety stop, power went the wrong way",
"sign_site": "Safety stop, total power went the wrong way",
"circulation": "Safety stop, inverters worked against each other",
"stopped": "Control is stopped",
"inputs missing": "A reading is missing",
"no claim was made": "Nothing asked for power",
}
WHY_LIMIT = {
"peak(quarter peak)": "the peak limit",
"peak(blind)": "no charging while the meter is unclear",
"peak(SoC floor)": "the peak reserve",
"supervised": RATING,
"device": "the inverter's own limit",
"pack": "the battery level",
"reserve": "the battery reserve",
"maintenance": "battery maintenance",
}
def why(winner, target_w, bound_by=None, *, contradiction=False) -> str:
"""`winner` is the arbiter's winning source, or its reason for the two
that share a source ("stopped", "inputs missing", "no claim was made").
`bound_by` is `Resolution.bound_by`, e.g. "peak(quarter peak)"."""
if contradiction:
return "Two limits clash, so every inverter is set to 0 W"
out = f"{WHY_WINNER.get(winner, 'Other')}: {signed_watts(target_w)}"
if bound_by:
limit = WHY_LIMIT.get(bound_by) or WHY_LIMIT.get(
str(bound_by).split("(")[0], "another limit")
out += f", held back by {limit}"
return out
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"
# The last thing that happened, shown under the buttons. The add-on keeps its
# own technical record of each event (`Controller.events`); the page shows
# these words instead. Every `log_event()` call names one (test_messages.py).
HINTS = {
"control_started": "Control started",
"control_stopped": "Control stopped",
"settings_saved": "Settings saved, they apply after a restart",
"restart_requested": "Restart requested from the settings page",
"roster": "Inverter list read: {n} inverters",
"maint_history": "Maintenance history did not move over cleanly",
"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",
"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",
"write_cut": "{unit}: " + T_OUTPUT_CLAMP.lower(),
"write_slow": "{unit}: the inverter answered too slowly, trying again",
"direction": "{unit}: power went the wrong way, it is set to 0 W",
"site_direction": "Total power went the wrong way, inverters set to 0 W",
"circulating": T_CIRCULATING + ", all set to 0 W",
"not_delivering": T_NOT_DELIVERING,
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
"power_too_high": T_OUTPUT_CLAMP,
"not_keeping_up": T_LOOP_BOUND,
"trade_charge": "Trading: charging while the price is low",
"trade_give": "Trading: giving power while the price is high",
"trade_off": "Trading: waiting for a better price",
"split": "The power split between inverters changed",
"clash": "Two limits clash, inverters set to 0 W",
"no_setpoint": "An inverter has no “Setpoint entity”, inverters stay at 0 W",
"unit_dropped": "{unit} left out of control, the others take its share",
"units_back": "Every inverter is back in control",
"unmet": "The inverters cannot give {w} of the power asked",
"unmet_gone": "The inverters can give all the power asked again",
"shadow_on": "Shadow mode on, inverters stay at 0 W",
"shadow_off": "Shadow mode off, the add-on controls the battery",
"shadow_decision": "Shadow mode: would ask the battery for {sw}",
"refused": "Settings refused, the add-on log shows why",
"inputs_missing": "A reading is missing, inverters set to 0 W",
"inputs_back": "All readings are back",
"stop_cleared": "{unit}: safety stop cleared by starting control",
"site_stop_cleared": "Safety stop cleared by starting control",
"not_delivering_cleared": "{unit}: not-delivering alarm cleared",
"maintenance": "Battery maintenance: {phase}",
"maint_paused": "Maintenance charging paused to stay under the peak limit",
}
def hint(key: str, **kw) -> str:
"""The plain words for one event. `w` is formatted as watts, `sw` as
watts with their sign (positive gives power, negative charges)."""
if "w" in kw:
kw["w"] = watts(kw["w"])
if "sw" in kw:
kw["sw"] = signed_watts(kw["sw"])
return HINTS[key].format(**kw)
def hint_samples() -> list:
"""`(key, text)` for every hint, rendered, for the test."""
kw = {"n": 2, "unit": "inverter-b", "w": 1200, "sw": -1200,
"phase": "drain"}
return [(k, hint(k, **{f: kw[f] for f in kw if "{" + f + "}" in v}))
for k, v in HINTS.items()]
RED, AMBER, GREEN = "bad", "warn", "ok"
@@ -769,6 +963,24 @@ def status_samples() -> list:
("row_pv_negative", AMBER, row_pv_negative(2, 3, -1500, False)),
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
("row_pv_stray", AMBER, row_pv_stray(2)),
("banner_stopped", None, banner_stopped()),
("row_control", GREEN, row_control(-4000, 5000, "inverter-b")),
("row_month_peak", GREEN, row_month_peak(
4998, datetime(2026, 8, 12, 19, 15), 0.83)),
("row_month_peak_none", GREEN, row_month_peak_none()),
("row_month_peak_unreadable", AMBER, row_month_peak_unreadable()),
("row_periods_measured", GREEN, row_periods_measured(120)),
("row_periods_measured", GREEN, row_periods_measured("unreadable")),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500,
shadow=True)),
("row_battery_stuck", AMBER, row_battery_stuck("inverter-b")),
("row_circulating_check", GREEN, row_circulating_check(0, 0, 60)),
]
@@ -843,6 +1055,27 @@ LABELS = {
}
def shown(value) -> str:
"""A setting's value inside a refusal: `'abc'`, `5`, or `empty` for a value
that is missing or blank, never Python's `None` (UX-08 2d)."""
if value is None or (isinstance(value, str) and not value.strip()):
return "empty"
return repr(value)
def not_valid(option: str, err) -> str:
"""A setting whose text does not parse, without the parser's own words:
`static_profile is not valid near character 6` (UX-08 2d)."""
pos = getattr(err, "pos", None)
if isinstance(pos, int):
return f"{option} is not valid near character {pos + 1}"
return f"{option} is not valid"
# The units a formula's "units" field may name, as a reader writes them.
UNIT_WORDS = {"c_eur_per_kwh": "c/kWh", "eur_per_kwh": "EUR/kWh"}
def setting(key: str, unit: str = "") -> str:
"""`max_w` -> `“Inverter rating” (max_w)`. For one inverter in a list it is
`“Inverter rating” for inverter-b (inverters.max_w)`, so the config panel
+6 -7
View File
@@ -472,7 +472,7 @@ class P1Ingest:
"""
self.parse_errors += 1
self.last_error = str(err)
_LOG.warning("P1 telegram rejected: %s", err)
_LOG.warning("A meter message could not be read: %s", err)
# -- what consumers read -------------------------------------------------
def age_s(self) -> float | None:
@@ -1167,7 +1167,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
problems.append(
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
f"are {imp or '<blank>'!r} and {exp or '<blank>'!r}.")
f"are {messages.shown(imp)} and {messages.shown(exp)}.")
elif source == SOURCE_HA_SIGNED:
net = str(opts.get("p1_net_entity", "") or "").strip()
phase_net = [str(e).strip() for e in
@@ -1194,7 +1194,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
try:
int(port_s)
except ValueError:
problems.append(f"{_S('p1_host')} is {opts.get('p1_host')!r}, and "
problems.append(f"{_S('p1_host')} is {messages.shown(opts.get('p1_host'))}, and "
"its port is not a number.")
try:
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
@@ -1204,7 +1204,7 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
poll_s = float(5 if raw is None or raw == "" else raw)
except (TypeError, ValueError):
problems.append(f"{_S('meter_poll_s')} is "
f"{opts.get('meter_poll_s')!r}, which is not a "
f"{messages.shown(opts.get('meter_poll_s'))}, which is not a "
"number.")
else:
if poll_s <= 0:
@@ -1269,9 +1269,8 @@ def build_source(opts: dict, ingest: P1Ingest, session, broker: dict | None):
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
# can NEVER be fresh.
if poll_s > ingest.max_age_s / 2:
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
"%.1f: one missed poll makes the reading stale. The meter "
"updates every ~5 s; 5 s against 30 s is the tested pair.",
_LOG.warning("“Time between meter reads” (%.1f s) is too close to "
"“Reading too old after” (%.1f s). Use 5 s and 30 s.",
poll_s, ingest.max_age_s)
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
if source == SOURCE_MQTT:
+17 -4
View File
@@ -395,6 +395,7 @@ not need re-testing. Nothing here indexes a phase.
from dataclasses import dataclass
from . import messages
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
from .control import peak_at_risk
from .tariff import QUARTER_S
@@ -485,17 +486,29 @@ class PeakDecision:
reason: str = ""
def explain(self) -> str:
"""Idle: the status row's plain words (UX-08). Engaged: `reason`, the
technical record with every figure, for the log and the action log."""
if self.floor_limited:
return self.reason
if not self.engaged:
if self.projected_w is None:
return f"peak guard idle - {self.reason}"
return (f"peak guard idle - quarter projects "
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
f"ceiling ({self.ceiling_from})")
return messages.row_peak_guard_quiet()
return messages.row_peak_guard_idle(
self.projected_w, self.ceiling_w,
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
return self.reason
def blind_kind(blind: str) -> str:
"""Which of the three kinds of blindness, as a key of
`messages.BLIND_WORDS`, for the status row."""
if blind == BLIND_FROZEN:
return "frozen"
if blind == BLIND_NO_BLOCK:
return "no_block"
return "gap"
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
"""The average this quarter may not exceed, and where the number came from.
+21 -23
View File
@@ -1314,8 +1314,8 @@ def code_leaks(cls) -> list:
src = dedent(inspect.getsource(cls))
tree = ast.parse(src)
except (OSError, TypeError, IndentationError, SyntaxError) as e:
_LOG.warning("could not read %s's source for the framework-key scan "
"(%s); the double-counting guard is NOT enforced for it",
_LOG.warning("Could not read the code of price source %s (%s), so its "
"double-counting check is skipped.",
getattr(cls, "id", cls), e)
return []
for node in ast.walk(tree):
@@ -1718,9 +1718,8 @@ def _points_from_columnar_json(body, expect_unit, scale,
# Not a refusal: today's prices are still prices. But §11.4 makes a
# dead feed look exactly like a quiet one, so the warning that the
# source is going away has to be emitted while it still works.
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
"but this endpoint is scheduled to stop",
doc.get("deprecated"))
_LOG.warning("The price feed says it will stop (deprecated=%r), "
"though it still answers for now.", doc.get("deprecated"))
out = []
dropped = []
for i, (t, v) in enumerate(zip(times, prices)):
@@ -1739,9 +1738,8 @@ 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 feed published no price for %d of %d instants "
"(index %s); those instants are left OUT of the series, "
"never zero-filled - a missing hour is not a free hour",
_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),
", ".join(str(i) for i in dropped[:5])
+ (", ..." if len(dropped) > 5 else ""))
@@ -1790,16 +1788,16 @@ def dayahead_feed_url(plugin_id, opts, since, until, base, bzn) -> str:
override = str(opts.get("price_feed_url") or "").strip()
if override:
_LOG.warning(
"price_feed_url is set, so %s is requesting %s exactly as "
"given and is adding NO date range to it. The public feed "
"answers a single Brussels day to a request that names no "
"range, and every hour past that day is then carried forward "
"from yesterday's profile and flagged provisional - §8.3 "
"drops provisional hours from the trade set, so arbitrage "
"would never see tomorrow as firm. Clear price_feed_url to "
"let this plugin ask for the %s..%s it was given.",
"price_feed_url is set, so %s asks for %s as given, and "
"tomorrow's prices may stay provisional. Clear it to ask for %s "
"to %s.",
plugin_id, override, *(d.isoformat()
for d in brussels_date_range(since, until)))
_LOG.debug(
"The public feed answers one Brussels day to a request that names "
"no range. Every later hour is then copied from yesterday and "
"marked provisional, and §8.3 leaves provisional hours out of "
"trading.")
return override
first, last = brussels_date_range(since, until)
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
@@ -2083,8 +2081,8 @@ def _emit_startup_preview(plugin, pid: str, points) -> None:
try:
lines = list(plugin.startup_preview(points))
except Exception as e: # noqa: BLE001
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
"unaffected - this is a log line, not a decision",
_LOG.warning("%s could not show its start-up preview (%s: %s). Prices "
"are not affected.",
pid, type(e).__name__, e)
return
for line in lines:
@@ -2131,12 +2129,12 @@ async def load_prices(opts: dict, since: datetime, until: datetime,
# Not silent, and not a traceback into the 30 s tick. validate.py
# should have refused this at startup; if the add-on is running with it
# anyway, the operator is told which key and what is wrong with it.
_LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch "
"has NO tax component to add - it is not falling back to "
"price_taxes_eur_kwh, because a silently different tax "
"basis is the defect this option exists to remove. Fix the "
"table or clear it; validate.py refuses it at startup",
_LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes "
"are added to this price update. Fix the table or clear it.",
type(e).__name__, e)
_LOG.debug("price_taxes_eur_kwh is not used instead, because a "
"silently different tax basis is the fault this setting "
"exists to remove.")
return PriceRead.no_data(
f"price_taxes_profile cannot be used: {e}")
if table is not None:
+7 -7
View File
@@ -345,8 +345,8 @@ 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} point{'' if points == 1 else 's'} "
f"over {hours} h)")
return (f"{day} ({points} price{'' if points == 1 else 's'} "
f"over {hours} hour{'' if hours == 1 else 's'})")
def _bad_rows(rec) -> int:
@@ -437,9 +437,9 @@ class PriceHistory:
# exactly what this handler says, and a third `ValueError` subclass
# nobody has thought of is how this defect arrived the first time.
self.last_error = f"unreadable ({err})"
_LOG.warning("price history at %s is %s - starting from empty. "
"§8.3 carry-forward has nothing to carry until the "
"next refresh has written a full day",
_LOG.warning("Price history at %s is %s, so it starts empty. "
"Missing hours cannot be filled in until a full day "
"is saved.",
self.path, self.last_error)
return
found = blob.get("version") if isinstance(blob, dict) else None
@@ -775,8 +775,8 @@ 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; "
"%d point(s) carried forward", bad, want, len(points))
_LOG.warning("Price history: %s in the %s record were dropped, "
"and %d points were kept.", bad, want, len(points))
return PriceRead.series(points, product,
str(rec.get("provider") or "price_history"),
stamp)
+4 -4
View File
@@ -381,8 +381,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
"""
problems = await problems_for(proposed, hass)
if problems:
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
"nothing written. First: %s", len(problems), problems[0])
_LOG.warning("The settings page did not save, because of %d "
"problems. The first: %s", len(problems), problems[0])
raise SaveRefused("invalid", problems=problems)
info = await selfcfg.info()
@@ -390,8 +390,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
actual = fingerprint(stored)
if actual != expect:
changed = changed_keys(stored, proposed)
_LOG.warning("panel REFUSED to save: the stored options changed "
"underneath this page (keys: %s); nothing written",
_LOG.warning("The settings page did not save, because the settings "
"changed while it was open (keys: %s).",
", ".join(changed) or "none this page also edited")
raise SaveRefused("stale", changed=changed, stored=stored)
+19 -22
View File
@@ -318,11 +318,8 @@ WIRE_W = 0.0
#: a cap that does not bind - and a number invented for the row would be a
#: second helping of the defect this constant exists to disclose.
SIZED_FROM_METER = (
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
"trade against a meter the battery is moving, so this is an UPPER BOUND "
"and not a prediction; how far under it a live run lands is a closed-loop "
"question and a shadow run cannot answer it")
"This is an upper limit, not a forecast, because the meter did not see "
"the battery move.")
def mode(opts: dict) -> str:
@@ -373,18 +370,18 @@ def why(opts: dict, *, control_started: bool) -> str:
# discount. What is true in every live state is that what this add-on
# decides reaches the inverter; whether control is RUNNING is the
# `Stopped` banner's question and it is answered there.
# UX-08: the same four rungs, in plain words. The setting is named by its
# value, "automatic" for `auto`, so each rung still reads differently.
m = mode(opts)
live = "what the add-on decides reaches the inverters"
inert = "the add-on decides but sends nothing"
if m == "off":
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
"add-on decides DOES reach the inverter")
return f"Off, {live}"
if m == "on":
return ("on (shadow_mode: on) - decisions are recorded and nothing "
"is commanded")
return f"On, {inert}"
if control_started:
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
"mode: what this add-on decides DOES reach the inverter")
return ("on (shadow_mode: auto, control is stopped) - decisions are "
"recorded and nothing is commanded")
return f"Off (automatic, control is started), {live}"
return f"On (automatic, control is stopped), {inert}"
def words(opts: dict, *, control_started: bool, would_w: float | None,
@@ -411,10 +408,10 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
that decided it did not apply, which is the shape of defect this card is
about. TEST-14's rule with a new name on it.
⚠️ AND THE PHRASE IS "ON THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
⚠️ AND THE PHRASE IS "THIS CYCLE", UNCONDITIONALLY, NOT ONLY WHEN THE
CAVEAT FIRES. Every number a shadow run records was reached from a plant
that did not respond; "WOULD HAVE COMMANDED -2 000 W" reads as a forecast
of a live run and no number here is one. The ⚠️ below is the STRONGER,
that did not respond; "would ask for -2 000 W" alone reads as a forecast
of a live run and no number here is one. UX-08 2d put it in plain words. The ⚠️ below is the STRONGER,
conditional statement about the cycles where the answer can be multiples
out - see this module's header for the measured table.
@@ -431,19 +428,19 @@ def words(opts: dict, *, control_started: bool, would_w: float | None,
if not engaged(opts, control_started=control_started):
return head
if would_w is None:
return (f"{head}; no decision has been reached yet - the inverter is "
f"held at {WIRE_W:.0f} W")
return (f"{head}. No decision yet, the inverters stay at "
f"{WIRE_W:.0f} W.")
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
# the whole sentence it ate the comma out of `head` - and `head` is the
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
# masking rule says must survive.
num = f"{float(would_w):+,.0f}".replace(",", " ")
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
f"held at {WIRE_W:.0f} W")
out = (f"{head}. This cycle it would ask for {num} W, the inverters stay "
f"at {WIRE_W:.0f} W.")
if sized_from_meter:
out += f" {SIZED_FROM_METER}"
if reason:
out += f" - {reason}"
out += f" Why: {reason}."
return out
@@ -504,6 +501,6 @@ def problems(opts: dict) -> list:
text = str("" if raw is None else raw).strip().lower()
if text in MODES:
return []
return [f"{messages.setting('shadow_mode')} is {raw!r}. Choose "
return [f"{messages.setting('shadow_mode')} is {messages.shown(raw)}. Choose "
+ ", ".join(f"'{m}'" for m in MODES[:-1])
+ f" or '{MODES[-1]}'."]
+34 -29
View File
@@ -204,6 +204,7 @@ import math
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from . import messages
from .price import (
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
Uncertainty, _Outcome, brussels, load_prices, price_option,
@@ -597,11 +598,9 @@ def parse_static_profile(value, *, option: str = "static_profile",
value = json.loads(text)
except ValueError as e:
raise ProfileInvalid(
f"{option} is not valid JSON ({e}). Write it on one line with "
f"a \"periods\" list, for example\n"
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
f"Leave it empty if you have no fixed tariff.")
f"{messages.not_valid(option, e)}. Copy the example from the "
f"documentation, or leave it empty if you have no fixed "
f"tariff.")
if isinstance(value, dict):
tz = str(value.get("timezone", "") or "").strip().lower()
@@ -1120,18 +1119,24 @@ class TariffPlan:
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
surface said either.
"""
# UX-08: plain words, every figure in its own slot as before.
if not self.has_prices:
return f"no tariff - {self.read.reason}"
tag = {"provider": self.read.provider, "static": "static_profile",
"fallback": "static_profile (feed down)"}.get(self.origin,
self.origin)
return f"no prices, {self.read.reason}"
tag = {"provider": "the price feed", "static": "your fixed tariff",
"fallback": "your fixed tariff, because the feed is down"}.get(
self.origin, self.origin)
what = {PriceProduct.DAY_AHEAD: "day-ahead prices",
PriceProduct.REALTIME: "real-time prices",
PriceProduct.STATIC: "day/night prices",
PriceProduct.FIXED: "a fixed price"}.get(self.product,
str(self.product))
gaps = self.spacings_s
return (f"{self.product.value} from {tag}, "
f"{self.n_points} points over {self.hours} h, "
f"{self.n_firm} firm / {self.n_carried} carried"
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
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 "")
+ (", provisional" if self.provisional else ""))
+ (", not final yet" if self.provisional else ""))
def _whole_hours(points) -> int:
@@ -1225,21 +1230,22 @@ def static_status(opts: dict, now: datetime):
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
now=now)
except ProfileInvalid as e:
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
"should have been refused at startup - see validate.py", e)
return False, f"static_profile unusable: {e}"
_LOG.warning("static_profile cannot be used (%s), so there is no "
"cheap window. The start-up check should have refused "
"it.", e)
return False, f"Fixed tariff cannot be used: {e}"
if not plan.has_prices:
return False, "no static profile"
return False, "No fixed tariff set"
cheap = plan.cheap_now(now)
window = plan.window_at(now)
price = plan.price_at(now)
when = plan.profile.next_change(now)
until = "" if when is None else f" until {brussels(when):%H:%M}"
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
f"mean {plan.mean_eur_kwh():.4f} over "
f"{MEAN_WINDOW_DAYS} d - "
f"{'CHEAP window' if cheap else 'not a cheap window'}")
kind = ("Fixed" if plan.product is PriceProduct.FIXED else "Day/night")
return cheap, (f"{kind} tariff, {window}{until}: {price:.4f} EUR/kWh, "
f"{'cheap' if cheap else 'not cheap'} against the "
f"{MEAN_WINDOW_DAYS}-day average of "
f"{plan.mean_eur_kwh():.4f}")
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
@@ -1336,13 +1342,12 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
"(%s), so there is no tariff at all", e)
profile = None
if profile is None:
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
"back to. The plan is provisional and arbitrage is off; "
"self-consumption, peak shaving and reserve management "
"continue (§6, §10.1).", read.reason)
_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)
return TariffPlan(read, None, "none")
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
"PROVISIONAL, arbitrage off.", read.reason)
_LOG.warning("%s, so the static_profile tariff is used instead, marked "
"provisional, and trading is off.", read.reason)
points = profile.series(since, until, provisional=True)
return TariffPlan(
PriceRead.series(points, profile.product, "static_profile", stamp),
+2 -2
View File
@@ -328,8 +328,8 @@ def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
# ⚠️ NOT MARKED DONE. A migration that lost state must run again on the
# next start rather than be recorded as finished - the one thing worse
# than a failed migration is a failed migration a later version skips.
_LOG.error("per-inverter store migration LOST STATE and has not been "
"marked done: %s", rep.explain())
_LOG.error("Moving the saved state to one part per inverter lost "
"data, so it is not marked done: %s", rep.explain())
return rep
+57 -72
View File
@@ -647,7 +647,7 @@ def _roster_problems(opts: dict) -> list[str]:
where = f"inverters[{i}]"
if not isinstance(entry, dict):
problems.append(
f"{where} in the “Inverter list” is {entry!r}. Each entry needs "
f"{where} in the “Inverter list” is {messages.shown(entry)}. Each entry needs "
"a name, a setpoint entity and an inverter rating.")
continue
name = str(entry.get("name") or "").strip()
@@ -670,7 +670,7 @@ def _roster_problems(opts: dict) -> list[str]:
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
problems.append(
f"{who} has no usable {S('max_w')}: it is "
f"{entry.get('max_w')!r}. Set this inverter's own rating in "
f"{messages.shown(entry.get('max_w'))}. Set this inverter's own rating in "
"watts.")
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
@@ -690,7 +690,7 @@ def _roster_problems(opts: dict) -> list[str]:
val = None
if val is None or not (val > 0.0):
problems.append(
f"{who} has {S('capacity_kwh')} {raw!r}, which is not a usable "
f"{who} has {S('capacity_kwh')} {messages.shown(raw)}, which is not a usable "
"number. Set it in kWh, or leave it empty on every inverter.")
else:
stated.append(who)
@@ -831,7 +831,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# has answered; it is not called from here because at startup no device has.
max_w = _number(opts, "max_w", 2000.0)
if max_w is None:
problems.append(f"{S('max_w')} is {opts.get('max_w')!r}, which is not "
problems.append(f"{S('max_w')} is {messages.shown(opts.get('max_w'))}, which is not "
"a number. Set it in watts.")
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
# the check is not for the Supervisor path - it is because
@@ -866,8 +866,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if phases is None or max_age_s is None:
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
f"both be numbers. They are "
f"{opts.get('meter_phases')!r} and "
f"{opts.get('meter_max_age_s')!r}.")
f"{messages.shown(opts.get('meter_phases'))} and "
f"{messages.shown(opts.get('meter_max_age_s'))}.")
else:
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
@@ -918,14 +918,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
weakest = min(caps) if caps else None
if sign_w is None:
problems.append(f"{S('sign_disagree_w')} is "
f"{opts.get('sign_disagree_w')!r}, which is not a "
f"{messages.shown(opts.get('sign_disagree_w'))}, which is not a "
"number. Set it back to 300 W.")
elif weakest is not None and weakest > 0 and sign_w >= weakest:
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
problems.append(
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the "
f"{S('max_w', where)} is {weakest:.0f} W, so this safety stop can "
f"never trip. Lower it below {weakest:.0f} W, or raise the rating.")
f"{S('sign_disagree_w')} is {sign_w:.0f} W, not below "
f"{S('max_w', where)} at {weakest:.0f} W, so this safety stop can "
f"never trip. Lower it or raise the rating.")
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
@@ -972,15 +972,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if caps_ix else "")
if unit_sign_w is None:
problems.append(f"{S('unit_sign_disagree_w')} is "
f"{opts.get('unit_sign_disagree_w')!r}, which is not a "
f"{messages.shown(opts.get('unit_sign_disagree_w'))}, which is not a "
"number. Set it back to "
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
problems.append(
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, but the "
f"{S('max_w', unit_where)} is {weakest:.0f} W, so this safety stop "
f"can never trip. Lower it below {weakest:.0f} W, or raise the "
"rating.")
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, not below "
f"{S('max_w', unit_where)} at {weakest:.0f} W, so this safety stop "
f"can never trip. Lower it or raise the rating.")
# --- 6. a maintenance charge the ceiling can actually accommodate --------
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
@@ -1054,7 +1053,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if charge_w is None:
problems.append(
f"{S('maintenance_charge_w')} is "
f"{opts.get('maintenance_charge_w')!r}, which is not a number. Set "
f"{messages.shown(opts.get('maintenance_charge_w'))}, which is not a number. Set "
"it in watts.")
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
problems.append(
@@ -1089,7 +1088,7 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
slew_w = _number(opts, "slew_w", 0.0)
rail = site_rail_w(opts)
if slew_w is None:
problems.append(f"{S('slew_w')} is {opts.get('slew_w')!r}, which is "
problems.append(f"{S('slew_w')} is {messages.shown(opts.get('slew_w'))}, which is "
"not a number. Set it to 0 to follow the inverter "
"rating.")
elif slew_w < 0:
@@ -1133,8 +1132,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if soc_floor is None or bms_floor is None:
problems.append(
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
f"both be numbers. They are {opts.get('peak_soc_floor')!r} and "
f"{_raw!r}.")
f"both be numbers. They are {messages.shown(opts.get('peak_soc_floor'))} and "
f"{messages.shown(_raw)}.")
elif soc_floor <= bms_floor:
problems.append(
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
@@ -1147,10 +1146,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
if integ_w is not None and max_w is not None and integ_w > max_w:
_LOG.warning(
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
"arbiter still bounds the wire to +-max_w - but the only thing the "
"extra headroom buys is more wrong-direction power to unwind after "
"saturation. 0 (follow max_w) is the recommended value.",
"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)
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
@@ -1173,15 +1170,13 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
if (disch_w is not None and maint_cap is not None and maint_cap > 0
and disch_w > maint_cap):
_LOG.warning(
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
"refused - the drain phase SETS the setpoint and the arbiter clamps "
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
"so nothing is silent and the cycle still completes. But it drains "
"at %.0f W, not the %.0f W you configured, and the drain phase has "
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
"surprise.",
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
maint_cap)
"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)
_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 "
"a 720 min timeout.", maint_cap)
return problems
@@ -1289,10 +1284,9 @@ def _price_problems(opts: dict) -> list[str]:
if str(opts.get(k, "") or "").strip()]
if pid != "off" and ha_price:
problems.append(
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} "
f"{'is' if len(ha_price) == 1 else 'are'} also set and would win. "
f"Clear {'it' if len(ha_price) == 1 else 'them'}, or set the price "
"source to 'off'.")
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} would "
f"win over it. Clear {'it' if len(ha_price) == 1 else 'them'}, or "
"set the source to 'off'.")
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
@@ -1302,7 +1296,8 @@ def _price_problems(opts: dict) -> list[str]:
if ha_price and str(opts.get("static_profile", "") or "").strip():
problems.append(
f"{S('static_profile')} is set, but {_labels(ha_price)} would win "
"over it. Clear the one you do not use.")
f"over it. Clear the fixed tariff or "
f"{'the entity' if len(ha_price) == 1 else 'the two entities'}.")
# ⚠️ THE PROFILE ITSELF, PARSED AT STARTUP RATHER THAN AT 02:00. A profile
# that does not tile the day has hours with no price, and the only ways to
@@ -1374,9 +1369,8 @@ def _price_problems(opts: dict) -> list[str]:
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
problems.append(
f"{S(TAXES_PROFILE_OPTION)} is set, so "
f"{S('price_taxes_eur_kwh')} is not used, but it reads {scalar} "
f"instead of {shipped}. Set it back to {shipped}, or clear the "
"table.")
f"{S('price_taxes_eur_kwh')} is not used. Set it back to "
f"{shipped} from {scalar}, or clear the table.")
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
@@ -1403,10 +1397,9 @@ def _price_problems(opts: dict) -> list[str]:
# observe. §14's warning box governs CONTRADICTIONS, and this is
# not one.
_LOG.warning(
"%s carries negative levies %s EUR/kWh. That is accepted, but "
"a tax component below zero is usually a sign error: the "
"add-on will SUBTRACT it from the published price in those "
"hours.", TAXES_PROFILE_OPTION, negative)
"%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)
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
# STARTUP RATHER THAN AT 03:00.
@@ -1459,11 +1452,9 @@ def _price_problems(opts: dict) -> list[str]:
# silent loser this module's case (a) is about, so it is said out
# loud rather than left to be discovered from a bill.
_LOG.warning(
"price_formula is filled in but price_provider is %r, so the "
"coefficients decide NOTHING - only 'custom_formula' reads "
"them. Set price_provider to 'custom_formula' to use them, or "
"clear price_formula so the configuration says what the "
"add-on is doing.", pid)
"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)
if spec is not None and pid == "custom_formula":
taxes = _number(opts, "price_taxes_eur_kwh",
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
@@ -1552,20 +1543,18 @@ def _price_problems(opts: dict) -> list[str]:
# bounds in `implausible_injection()` are applied, and the
# operator is told which of the two checks did NOT run.
_LOG.warning(
"%s has no \"card\" block, so its coefficients are "
"UNGRADED: nothing recomputed a printed price from them. "
"That is accepted because it is the real market - Eneco "
"prints its injection formula with no price beside it, so "
"there is nothing to grade against on that card - and "
"refusing would make the published line inexpressible. "
"What IS checked is the shape of the coefficients: the "
"pass-through (%.4g x the wholesale price) and the offset "
"(%+.5f EUR/kWh). If YOUR card prints an injection "
"estimate, add {\"month\": ..., \"index_eur_per_mwh\": "
"..., \"expect_incl_vat\": ...} and the add-on will "
"refuse to start unless your numbers reproduce it.",
"%s has no \"card\" block, so its numbers are not checked "
"against a printed price, only their shape (pass-through "
"%.4g, offset %+.5f EUR/kWh).",
EXPORT_FORMULA_OPTION, xspec.passthrough,
xspec.offset_eur_kwh)
_LOG.debug(
"Accepted because some suppliers (Eneco) print the "
"injection formula with no price beside it. If your card "
"prints an injection estimate, add {\"month\": ..., "
"\"index_eur_per_mwh\": ..., \"expect_incl_vat\": ...} "
"and the add-on refuses to start unless your numbers "
"reproduce it.")
else:
xaged = stale_card(xspec)
if xaged:
@@ -1578,11 +1567,9 @@ def _price_problems(opts: dict) -> list[str]:
# every install TARIFF-02 shipped, none of which could have had one.
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
_LOG.warning(
"price_provider is %r but static_profile is empty. §11.4's "
"degradation row is 'no price data -> fall back to the static "
"profile'; with none configured, a feed outage leaves this site "
"with no tariff at all rather than with a coarse one. Not refused, "
"but filling it in is what makes that row work.", pid)
"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)
# ⚠️ 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
@@ -1598,7 +1585,7 @@ def _price_problems(opts: dict) -> list[str]:
if thresh is None or thresh < 0:
problems.append(
f"{S('cycle_cost_threshold_eur')} is "
f"{opts.get('cycle_cost_threshold_eur')!r}. Set it to 0 or more, "
f"{messages.shown(opts.get('cycle_cost_threshold_eur'))}. Set it to 0 or more, "
"in EUR/kWh.")
if arbitrage_on(opts):
@@ -1655,11 +1642,9 @@ def refuse(problems: list[str]) -> None:
at all and even the exit status distinguishes the two.
"""
_LOG.error("=" * 72)
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
_LOG.error("the add-on will start as soon as the settings below are fixed.")
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
_LOG.error("Configuration, then start the add-on again.")
_LOG.error("The add-on did not start, because its settings have problems.")
_LOG.error("Nothing is broken. Fix the settings below, then start it again.")
_LOG.error("They are on the add-on's Configuration tab.")
_LOG.error("-" * 72)
for i, problem in enumerate(problems, 1):
_LOG.error(" %d. %s", i, problem)
+7 -8
View File
@@ -31,7 +31,7 @@ import logging
from aiohttp import web
from . import selfconfig
from . import messages, selfconfig
from .config_page import CONFIG_PAGE
from .configui import changed_keys, field_model, problems_by_field
@@ -125,11 +125,8 @@ refresh(); setInterval(refresh, 3000);
# them too, and says so, because a green page that is green for a reason the
# operator cannot see is the exact failure this add-on keeps refusing to ship.
DEVICE_NOTE = (
"%s did not report a range, so the two checks that compare a configured "
"ceiling against what the device itself accepts were skipped - here and at "
"startup, identically. Everything else was checked. If the device answers "
"later and disagrees, the add-on refuses to start then, with the same "
"message you would have seen here.")
"%s did not report its power range, so two checks were skipped, here and "
"at start. If they fail later, the add-on will not start then.")
def build_app(controller, selfcfg=None) -> web.Application:
@@ -264,7 +261,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
_LOG.error("saving options failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("configuration saved from the panel - "
"not applied until the add-on restarts")
"not applied until the add-on restarts",
hint=messages.hint("settings_saved"))
return web.json_response({"ok": True, **result})
async def config_restart(request):
@@ -288,7 +286,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
except selfconfig.SupervisorUnavailable as err:
_LOG.error("restart request failed: %s", err)
return web.json_response({"error": str(err)}, status=502)
controller.log_event("restart requested from the configuration panel")
controller.log_event("restart requested from the configuration panel",
hint=messages.hint("restart_requested"))
return web.json_response({"ok": True})
app = web.Application()
+7 -6
View File
@@ -1487,13 +1487,14 @@ s47check("the shipped row carries the caveat at the worst cycle: the operator is
s47check("...and it still carries the watts, in their own slot, and the standing "
"0 W beside them - the caveat NARROWS the claim rather than deleting "
"the record a shadow run exists to produce",
"WOULD HAVE COMMANDED" in _s47_row and "inverter held at 0 W" in _s47_row
"it would ask for" in _s47_row
and "the inverters stay at 0 W" in _s47_row
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
s47check("...and the watts are introduced as what it would have commanded ON "
"THIS CYCLE, in every shadow state and not only the caveated ones - no "
"number a shadow run records is a forecast of a live run",
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row)
s47check("...and the watts are introduced as what it would ask for THIS CYCLE, "
"in every shadow state and not only the caveated ones - no number a "
"shadow run records is a forecast of a live run (UX-08: in plain words)",
"This cycle it would ask for" in _s47_row
and "This cycle it would ask for" in _s47_first_row)
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
+18 -11
View File
@@ -77,6 +77,7 @@ Read MUTATION.md before you report a survivor.
import asyncio
import io
import math
import os
import re
import sys
from datetime import datetime, timedelta, timezone
@@ -1874,8 +1875,8 @@ check("...and it is FIRST in the cascade: the same controller has other "
"conditions to report and none of them displaces this sentence",
_live_pay["banner"] != _shad_pay["banner"]
and "Shadow mode" not in _live_pay["banner"])
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
r"([-\d]+) W", _shad_rows[_ROW])
_slot = re.search(r"This cycle it would ask for ([-+][\d ]+) W, the inverters "
r"stay at ([-\d]+) W", _shad_rows[_ROW])
check("the row answers 'what would it have done' with a NUMBER in its own "
"named slot, and says where the inverter actually is in a second one - "
"two slots in one sentence, so a transposition moves them",
@@ -1889,15 +1890,16 @@ if _slot is not None:
check("...and the second slot is the STANDING command, so the two cannot "
"be read as one number",
float(_slot.group(2)) == 0.0)
check("the row answers 'and why' by carrying the arbiter's own sentence, "
"rather than a re-derivation of it",
_shad_c.reason in _shad_rows[_ROW])
check("the row answers 'and why' by carrying the page's own Why line, the "
"arbiter's answer in plain words (UX-08), rather than a re-derivation",
_shad_c.why_words in _shad_rows[_ROW]
and dict(_shad_pay["rows"])["Why"] == _shad_c.why_words)
check("with shadow mode off the row says so, and says what follows from it - "
"which is the arrangement that makes the one above a claim about this "
"controller and not about the string",
"NOT in shadow mode" in _live_rows[_ROW]
and "DOES reach the inverter" in _live_rows[_ROW]
and "WOULD HAVE COMMANDED" not in _live_rows[_ROW])
_live_rows[_ROW].startswith("Off")
and "reaches the inverters" in _live_rows[_ROW]
and "would ask for" not in _live_rows[_ROW])
# ⚠️ A STANDING LATCH IS SHOWN RATHER THAN HIDDEN. The shadow banner sits above
# the three `bad` branches, so it has to say what they would have said - a
@@ -2074,9 +2076,14 @@ 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("...and the 14 768 W rationale is still in its ERROR",
any(x.levelno == logging.ERROR and "14 768 W" in x.getMessage()
for x in log_o))
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 "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())
check("...under the over-rating title, quoting a tick's own asked and sent "
"numbers in that order, with a red status row",
clamp_notes(hass_o)
+4 -4
View File
@@ -189,13 +189,13 @@ check(f"the wind-down ran: 0 W written AFTER the fault (writes after = {after})"
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
["cleanup"] in ev[(fault_at or 0):])
died_line = next((i for i, ln in enumerate(err.splitlines())
if "CONTROL LOOP DIED (RuntimeError('" + FAULT + "'))" in ln), None)
if "Control stopped unexpectedly (RuntimeError('" + FAULT + "'))" in ln), None)
wind_line = next((i for i, ln in enumerate(err.splitlines())
if "shutting down - commanding 0 W" in ln), None)
check("a CRITICAL line names the exception and the exit status",
died_line is not None
and "CRITICAL" in err.splitlines()[died_line]
and "commanding 0 W and exiting with status 70" in err.splitlines()[died_line])
and "set to 0 W and the add-on exits with code 70" in err.splitlines()[died_line])
check("the traceback is logged, down to the frame that raised",
"Traceback (most recent call last)" in err
and f"RuntimeError: {FAULT}" in err and "in cycle" in err)
@@ -211,7 +211,7 @@ print("2. the same entry point stopped by SIGTERM - the callback must stay quiet
rc2, out2, err2, ev2 = run_child("clean")
print(f" exit status {rc2}; events {ev2}")
check("an ordinary stop still exits 0", rc2 == 0)
check("an ordinary stop logs no CONTROL LOOP DIED", "CONTROL LOOP DIED" not in err2)
check("an ordinary stop logs no unexpected stop", "Control stopped unexpectedly" not in err2)
check("an ordinary stop still winds down to 0 W, from a non-zero command",
any(e[0] == "w" and e[1] != 0.0 for e in ev2)
and [e for e in ev2 if e[0] == "w"][-1:] == [["w", 0.0]])
@@ -222,7 +222,7 @@ rc3, out3, err3, ev3 = run_child("refuse")
print(f" exit status {rc3}")
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
rc3 == 78)
check("and is not reported as a death", "CONTROL LOOP DIED" not in err3)
check("and is not reported as a death", "Control stopped unexpectedly" not in err3)
if rc3 != 78:
print(tail(err3))
+16 -14
View File
@@ -360,7 +360,7 @@ check("...and `inputs_ok` goes False, so the site stops rather than regulating "
_cs.inputs_ok is False and _cf.inputs_ok is True)
check("...and the operator gets a line saying which unit and why, on the same "
"status list the liveness notes use",
any("STALE_READING" in n and "inverter-a" in n
any("stopped changing" in n and "inverter-a" in n
for n in _cs.liveness_notes())
and _cf.liveness_notes() == [])
check("...and the raw number is still recorded for diagnostics, so the "
@@ -1581,7 +1581,7 @@ check("...and the site stops holding inputs bad, which is what ends the "
_c57.inputs_bad_since is None and _c57.inputs_ok is True)
_ev57 = [e for e in _c57.events if "STALE" in e]
_msgs57 = [r.getMessage() for r in _LOGREC]
_rel57 = [m for m in _msgs57 if "STALE_WITHDRAWN" in m]
_rel57 = [m for m in _msgs57 if "is used again and rechecked" in m]
print(f" events: {[e.split(' ', 1)[1][:44] for e in _ev57]}")
check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATOR "
"CAN AUDIT - and this one is withdrawing a SITE STOP. Both edges reach "
@@ -1592,8 +1592,9 @@ check("⚠️ A LATCH THAT LETS GO ON A TIMER AND SAYS NOTHING IS ONE NO OPERATO
check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has "
"happened: it says in its own words that nothing has been learned about "
"the register, and it prints the value the register is STILL holding",
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in nth(_rel57, 0)
and f"still holding {A_FROZEN_W:+.0f}" in nth(_rel57, 0))
"used again" in nth(_rel57, 0) and "recovered" not in nth(_rel57, 0)
and f"reading {A_FROZEN_W:+.0f} W is used again and rechecked"
in nth(_rel57, 0))
# --- 10h. the two sentences, as ORDERED PAIRS (habit 14) ---------------------
@@ -1602,7 +1603,8 @@ check("⚠️ AND THE RELEASE LINE REFUSES TO CLAIM A RECOVERY, because none has
# could be transposed at zero bytes - 17.0 s and 250 W in the stale line's new
# escape clause, and 250 W and 17.0 s again in the release note - so each is
# asserted with its value embedded in its own literal position.
_stale57 = [m for m in _msgs57 if "SAFETY-37" in m]
_stale57 = [m for m in _msgs57
if "battery power reading stopped changing" in m]
print(f" escape clause: ...{nth(_stale57, 0)[-150:]}")
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
"PAIR. It used to end 'It clears itself the moment the register reports "
@@ -1610,17 +1612,16 @@ check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDER
"before it is what stops the register being asked. The new clause names "
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
"red",
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
f"or {BOUNDS['batt'].release_s:.0f} s after the command drops below "
f"{BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
check("...and the release note names 250 W in the POWER slot and 17 s in the "
"TIME slot, in that order, which is the reverse arrangement of the same "
"two numbers",
f"sat below {BOUNDS['batt'].move_w:.0f} W for "
f"stayed below {BOUNDS['batt'].move_w:.0f} W for "
f"{BOUNDS['batt'].release_s:.0f} s" in nth(_rel57, 0))
check("...and the 35.0 s the register is re-graded over is named in the note "
"too, because it is what the operator is being told to wait out",
f"re-graded over a fresh {BOUNDS['batt'].unchanged_s:.0f} s window"
in nth(_rel57, 0))
f"rechecked over {BOUNDS['batt'].unchanged_s:.0f} s" in nth(_rel57, 0))
check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
"WHICH IS WHAT THIS CHECK WAS FOR. It used to require 'battery-power "
"register is not following its own command' to still be present, so "
@@ -1632,7 +1633,7 @@ check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN,
present(nth(_stale57, 0))
and "battery-power register is not following its own command"
not in _stale57[0]
and "did not move under a command that did" in _stale57[0])
and "stopped changing" in _stale57[0])
check("...and a re-trip's reason names the TWO durations separately, each "
"beside its own value, because after a release the verdict's window and "
"the register's own unchanged time are different facts",
@@ -1811,7 +1812,8 @@ _S56_ACCUSATIONS = [
("app/main.py _read_unit()", "register is not following its own command"),
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:] if "SAFETY-37" in r.getMessage()]
_log56 = [r.getMessage() for r in _LOGREC[_S56_LOG0:]
if "battery power reading stopped changing" in r.getMessage()]
def _s56_row(c):
@@ -1952,8 +1954,8 @@ check("⚠️ AND THE DISCRIMINATOR IT POINTS AT EXISTS AND IS SELECTIVE, which
"row below?' answers the question this one declines to answer. A row "
"that pointed at a surface present in both states would be worse than "
"the accusation it replaced",
any("STALE_READING" in r["text"] for r in _c56_ref.checks())
and not any("STALE_READING" in r["text"] for r in _c56_abs.checks()))
any("stopped changing" in r["text"] for r in _c56_ref.checks())
and not any("stopped changing" in r["text"] for r in _c56_abs.checks()))
check("...and the row's wording is NOT shared with the log line's, which is a "
"design choice rather than an omission: the two sentences have no clause "
"in common longer than the word 'entity', because one explains a "
+1 -1
View File
@@ -832,7 +832,7 @@ check("...and the per-inverter rows now say WHAT the unit was compared "
"look at P1 for a fault that is visible only on one inverter's own power "
"sensor",
len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
and all("its own power" in r["text"] for r in _c3r.checks()
and all("own power" in r["text"] for r in _c3r.checks()
if r.get("id") == "direction"))
# The operator's clear, which is one button and has to reach every latch.
+4 -2
View File
@@ -1581,9 +1581,11 @@ try:
_logged, _ = controller(NAMED_OPTS, store=_log_store)
finally:
logging.getLogger("goodwe").removeHandler(_h)
_s44_lines = [m for m in _records if "SAFETY-44" in m]
# UX-08 2d: the line is plain now, so it is found by its words, not a card id.
_s44_lines = [m for m in _records
if "which is not in the inverter list" in m]
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line "
check("⭐ the repair SAYS SO, at ERROR, in one plain line - deleting this line "
"left the whole suite green, and it is how an operator learns their "
"latch was re-armed rather than never lost",
len(_s44_lines) == 1)
+182 -15
View File
@@ -289,20 +289,66 @@ print("\n2. no notification is worded outside app/messages.py")
APP = HERE / "app"
def render(node):
"""A string expression as text, placeholders as {expr}; None otherwise."""
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
# setting is expanded to the label and key `messages.setting()` prints, so
# `{S('max_w')}` counts as “Inverter rating” (max_w), three words, not one.
SETTING_CALLS = ("S", "_S", "setting", "messages.setting")
CONSTANTS = {} # module-level NAME = "text", per file, for keys
def _key(node):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.Name):
return CONSTANTS.get(node.id)
return None
def expand_setting(node):
"""The shown text of a placeholder that names a setting, else None."""
if isinstance(node, ast.Call) and node.args:
name = ast.unparse(node.func)
if name in SETTING_CALLS:
key = _key(node.args[0])
if key not in messages.LABELS:
return None
unit = node.args[1] if len(node.args) > 1 else None
plain = unit is None or (isinstance(unit, ast.Constant)
and not unit.value)
return messages.setting(key, "" if plain else "inverter-b")
if name == "_labels" and isinstance(node.args[0], (ast.List, ast.Tuple)):
keys = [_key(e) for e in node.args[0].elts]
if all(k in messages.LABELS for k in keys):
return " and ".join(messages.setting(k) for k in keys)
if (isinstance(node, ast.Subscript)
and ast.unparse(node.value) == "messages.LABELS"):
key = _key(node.slice)
return messages.LABELS.get(key) if key else None
return None
def render(node, expand=False):
"""A string expression as text, placeholders as {expr}; None otherwise.
`expand` shows setting placeholders as the page shows them."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
return "".join(v.value if isinstance(v, ast.Constant)
else "{" + ast.unparse(v.value) + "}" for v in node.values)
out = []
for v in node.values:
if isinstance(v, ast.Constant):
out.append(v.value)
continue
shown = expand_setting(v.value) if expand else None
out.append(shown if shown is not None
else "{" + ast.unparse(v.value) + "}")
return "".join(out)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
a, b = render(node.left), render(node.right)
a, b = render(node.left, expand), render(node.right, expand)
if a is None and b is None:
return None
return (a if a is not None else "{…}") + (b if b is not None else "{…}")
if isinstance(node, ast.IfExp):
a, b = render(node.body), render(node.orelse)
a, b = render(node.body, expand), render(node.orelse, expand)
if a is None and b is None:
return None
return (a or "") + " / " + (b or "")
@@ -346,14 +392,26 @@ LIMITS = { # kind: (words, keys ok, entity ids ok)
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
def strings_in(node):
def strings_in(node, expand=False):
"""Top-level string expressions inside `node` (not their parts)."""
t = render(node)
t = render(node, expand)
if t is not None:
yield node, t
return
for child in ast.iter_child_nodes(node):
yield from strings_in(child)
yield from strings_in(child, expand)
def _module_constants(tree):
"""Module-level `NAME = "text"` assignments."""
return {t.id: n.value.value for n in tree.body
if isinstance(n, ast.Assign) and isinstance(n.value, ast.Constant)
and isinstance(n.value.value, str)
for t in n.targets if isinstance(t, ast.Name)}
_MODCONST = {f.stem: _module_constants(ast.parse(f.read_text(encoding="utf-8")))
for f in sorted(APP.glob("*.py"))}
def collect():
@@ -363,6 +421,16 @@ def collect():
if f.name == "messages.py":
continue
tree = ast.parse(f.read_text(encoding="utf-8"))
# The file's own constants, and those it imports from another app
# module (`from .validate import TAXES_PROFILE_OPTION`), so a key
# named by a constant from elsewhere is expanded too.
CONSTANTS.clear()
CONSTANTS.update(_module_constants(tree))
for n in tree.body:
if isinstance(n, ast.ImportFrom) and n.level == 1 and n.module:
theirs = _MODCONST.get(n.module, {})
CONSTANTS.update({a.asname or a.name: theirs[a.name]
for a in n.names if a.name in theirs})
for node in ast.walk(tree):
if isinstance(node, ast.Dict):
for k, v in zip(node.keys, node.values):
@@ -387,20 +455,22 @@ def collect():
out.append(("log", f"{f.name}:{n.lineno}", t))
elif re.search(r"problems\.(append|extend)$", name):
for a in node.args:
for n, t in strings_in(a):
for n, t in strings_in(a, expand=True):
if len(words(t)) > 1:
out.append(("refusal", f"{f.name}:{n.lineno}", t))
tr = TL.parse_translation_file(HERE / "translations" / "en.yaml")
# UX-08 2d: the Dutch option texts are held to the same length.
for lang in ("en", "nl"):
tr = TL.parse_translation_file(HERE / "translations" / f"{lang}.yaml")
def walk(conf, path):
for key, v in (conf or {}).items():
if not isinstance(v, dict):
continue
if isinstance(v.get("name"), str):
out.append(("option-name", f"en.yaml:{path}{key}",
out.append(("option-name", f"{lang}.yaml:{path}{key}",
v["name"].split("·")[-1].strip()))
if isinstance(v.get("description"), str):
out.append(("option-desc", f"en.yaml:{path}{key}",
out.append(("option-desc", f"{lang}.yaml:{path}{key}",
v["description"]))
if isinstance(v.get("fields"), dict):
walk(v["fields"], f"{path}{key}.")
@@ -413,7 +483,7 @@ def problems_of(kind, text):
p = []
if len(words(text)) > lim:
p.append(f"over {lim} words ({len(words(text))})")
if kind != "log" and ";" in text: # logs are phase 2d
if ";" in text:
p.append("semicolon")
return p + text_problems(text, keys=keys, entities=ents)
@@ -426,7 +496,7 @@ ALLOW_FILE = HERE / "ux08_allowlist.txt"
# The allowlist may never grow. The test demands the list be exactly this long,
# so deleting lines means lowering this number in the same change, and adding
# one means raising it where a reviewer sees it.
ALLOWLIST_MAX = 43
ALLOWLIST_MAX = 0
def read_allowlist(path):
@@ -491,6 +561,30 @@ _own_banner = {"row_direction_trip", "row_site_direction_trip",
"row_circulating_trip", "row_peak_reserve", "row_power_too_high"}
_long = [t for fn, c, t in STATUS if c == messages.RED and fn not in _own_banner
and len(words(messages.banner_not_ready({"text": t}))) > 12]
# The hint under the buttons (UX-08 2d): the last event in plain words, one
# per key of messages.HINTS, held to the rule for a green row.
for key, text in messages.hint_samples():
p = row_problems(messages.GREEN, text)
check(f"hint {text!r}{' - ' + '; '.join(p) if p else ''}", not p)
_calls, _bare = 0, []
for f in sorted(APP.glob("*.py")):
for node in ast.walk(ast.parse(f.read_text(encoding="utf-8"))):
if (isinstance(node, ast.Call)
and ast.unparse(node.func).endswith("log_event")):
_calls += 1
h = next((k.value for k in node.keywords if k.arg == "hint"), None)
if not (isinstance(h, ast.Call)
and ast.unparse(h.func) in ("messages.hint", "hint")):
_bare.append(f"{f.name}:{node.lineno}")
check(f"every event the add-on records gives the page its plain words, from "
f"messages.hint() ({_calls} calls, without: {_bare})",
_calls >= 40 and not _bare)
_raw = ("OUTPUT_CLAMP", "BUG SIGNAL", "FAILSAFE", "SIGN_DISAGREEMENT",
"ALLOCATOR", "UNMET", "ARBITER", "PEAK GUARD")
check("no hint carries the raw event codes the page used to show",
not [t for _k, t in messages.hint_samples()
if any(r in t for r in _raw)])
check(f"every other red row makes a 'Not ready' banner of at most 12 words "
f"(too long: {_long})", not _long)
@@ -555,6 +649,79 @@ check("...and a refusal quotes the label and then the key, so the config "
"panel still files it under that setting",
messages.setting("max_w", "inverter-b")
== "“Inverter rating” for inverter-b (inverters.max_w)")
# UX-08 2d: the refusals as the validator SHOWS them. The scan above expands
# setting labels in a template, but a reason that carries a parser's error
# (`{e}`) is only as long as that error, so the long ones found by the 2c test
# are driven for real here and held to the same 30 words.
from app import custom_formula as _cf # noqa: E402
from app.validate import validate_startup_config # noqa: E402
_BASE = {"setpoint_entity": "number.goodwe_setpoint", "max_w": 2000,
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
"meter_source": "off", "maintenance_charge_w": 2000,
"maintenance_discharge_w": 2000}
_CARD = _cf._LEGS["price_formula"].example.replace("15.55", "15.05")
_TABLE = ('{"periods":[{"name":"day","from":"07:00","to":"22:00","days":'
'"mon-fri","eur_per_kwh":0.1},{"name":"night","from":"22:00","to":'
'"07:00","days":"mon-fri","eur_per_kwh":0.08},{"name":"night",'
'"from":"00:00","to":"24:00","days":"sat,sun","eur_per_kwh":0.08}]}')
_ROSTER = {k: v for k, v in _BASE.items()
if k not in ("setpoint_entity", "soc_entity", "batt_entity",
"max_w")}
_ROSTER["inverters"] = [
dict(name=n, setpoint_entity=f"number.sp_{n[-1]}", max_w=w,
soc_entity=f"sensor.soc_{n[-1]}", batt_entity=f"sensor.batt_{n[-1]}",
bms_soc_floor=10)
for n, w in (("inverter-a", 2000), ("inverter-b", 200))]
_DRIVEN = {
"formula card": dict(_BASE, price_provider="custom_formula",
price_formula=_CARD),
"formula not valid": dict(_BASE, price_provider="custom_formula",
price_formula='{"index": "epex_be"} x'),
"injection card": dict(_BASE, price_export_formula=_CARD),
"roster thresholds": dict(_ROSTER, maintenance_charge_w=100,
maintenance_discharge_w=100),
"levies table and flat": dict(_BASE, price_taxes_profile=_TABLE,
price_taxes_eur_kwh=0.1198),
"feed and entities": dict(_BASE, price_provider="generic_dayahead",
price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
"fixed tariff and entities": dict(_BASE, static_profile=_TABLE,
price_now_entity="sensor.now",
price_avg_entity="sensor.avg"),
"fixed tariff not valid": dict(_BASE, static_profile='{"periods": []} x'),
}
_LEAKS = ("c_eur_per_kwh", "vat_pct", "its own card", "line 1 column",
"None", "JSON", "one you do not use")
_shown, _long, _leaky = [], [], []
for _name, _o in _DRIVEN.items():
_ps = validate_startup_config(_o, -5000, 5000)
_shown.append((_name, len(_ps)))
_long += [f"{_name}: {len(words(t))} words: {t}" for t in _ps
if len(words(t)) > 30]
_leaky += [f"{_name}: {t}" for t in _ps if any(x in t for x in _LEAKS)]
check(f"every driven case is refused ({_shown})",
all(n >= 1 for _n, n in _shown)
and dict(_shown)["roster thresholds"] >= 2)
check(f"...and every refusal, as SHOWN, is at most 30 words (too long: "
f"{_long})", not _long)
check(f"...and shows no parser text, file units or Python's None (found: "
f"{_leaky})", not _leaky)
# The config panel files a reason beside every setting whose key it names, but
# `inverters.max_w` names a setting of one inverter in the list, not the flat
# `max_w` (UX-08 2d, from the 2c test).
from app.configui import problems_by_field # noqa: E402
_by, _un = problems_by_field(
["“Inverter rating” for inverter-b (inverters.max_w) is 200 W.",
"“Inverter rating” (max_w) is 0."], ["max_w", "inverters"])
check("a roster refusal shows beside `inverters` only, and a flat one beside "
f"`max_w` ({ {k: len(v) for k, v in _by.items()} })",
_by.get("inverters") == [
"“Inverter rating” for inverter-b (inverters.max_w) is 200 W."]
and _by.get("max_w") == ["“Inverter rating” (max_w) is 0."]
and not _un)
print(f"CI: UX-08 allowlist {len(allow)} entries "
+ ", ".join(f"{k} {sum(1 for x in allow if x.startswith(k + ' '))}"
for k in LIMITS))
+21 -5
View File
@@ -1221,6 +1221,19 @@ rows = [r for r in c.checks() if r.get("id") == "peak_guard"]
print(" status row: " + (rows[0]["text"] if rows else "(none)"))
check("the status page carries exactly one peak-guard row, flagged",
len(rows) == 1 and rows[0]["warn"])
# UX-08 2d: the engaged row names what the battery is asked for FIRST and the
# limit SECOND. Both are watts, so only the ordered pair tells them apart.
from app import messages as _msg_on # noqa: E402
_on = (f"asks the battery for {_msg_on.watts(c.peak_guard.floor_w)} to stay "
f"under {_msg_on.watts(c.peak_guard.ceiling_w)}")
_on_swapped = (f"asks the battery for {_msg_on.watts(c.peak_guard.ceiling_w)} "
f"to stay under {_msg_on.watts(c.peak_guard.floor_w)}")
check("...and it names the battery's share first and the limit second, and "
"the two render differently so the swap is visible",
len(rows) == 1 and _on in rows[0]["text"]
and _on_swapped not in rows[0]["text"]
and _msg_on.watts(c.peak_guard.floor_w)
!= _msg_on.watts(c.peak_guard.ceiling_w))
check("an engagement that is not saturated raises NO notification - §10.3's "
"'clamp AND alarm' is SAFETY-03's rule about a BUG SIGNAL, and a house "
"load bigger than the inverter is not a bug",
@@ -5932,10 +5945,12 @@ _S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_OK, soc_floor=SOC_FLOOR)
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a "
f"{_S38_CEIL_W:.0f} W ceiling")
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a "
f"{_S38_PROJ.projected_w:.0f} W ceiling")
# UX-08 2d: the row is in plain words now, and the pair is still ordered.
from app import messages as _msg38 # noqa: E402
_S38_LITERAL = (f"heading for {_msg38.watts(_S38_PROJ.projected_w)}, under "
f"this month's {_msg38.watts(_S38_CEIL_W)} peak")
_S38_SWAPPED = (f"heading for {_msg38.watts(_S38_CEIL_W)}, under "
f"this month's {_msg38.watts(_S38_PROJ.projected_w)} peak")
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
print(f" {_S38_IDLE.explain()!r}")
@@ -5958,7 +5973,8 @@ check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
_S38_SWAPPED not in _S38_IDLE.explain())
check("...and the row still names WHERE the ceiling came from, which is the "
"difference between a cap an operator set and a peak the month already "
"banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain())
"banked", "under this month's" in _S38_IDLE.explain()
and "peak limit" not in _S38_IDLE.explain())
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
+34 -33
View File
@@ -2189,7 +2189,7 @@ _p = validate_startup_config(
check("a provider AND the Home Assistant price entities is refused - two price "
"sources on one site", len(_p) == 1)
check("...and the message says which one actually wins",
why(_p, "are also set and would win"))
why(_p, "would win over it"))
# TARIFF-03 added a second source with the same silent-loser shape.
_ps = validate_startup_config(
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
@@ -3427,8 +3427,8 @@ check(f"network charges folded into `offset` move the card's own price to "
isinstance(_double_price, float) and abs(_double_price - 15.55) > 10)
check(f"...and it is refused, by the CARD rule, naming the discrepancy and "
f"the network-charge cause ({_double[:60]!r})",
"does not match its own card" in _double and "the card says" in _double
and "(price_taxes_eur_kwh), not in the offset" in _double)
"the card says" in _double
and "network charges are not in the offset" in _double)
_CORRUPTIONS = {
"VAT left out (vat_pct 0)": spoilt(vat_pct=0),
@@ -3447,7 +3447,7 @@ for _name, _blob in sorted(_CORRUPTIONS.items()):
_why = refusal(_blob)
check(f"{_name}: refused, and by the CARD rule rather than by another one "
f"({_why[:48]!r})",
"does not match its own card" in _why and "the card says" in _why)
"the card says" in _why)
# ⚠️ THE TOLERANCE IS A REAL EDGE AND IT IS GRADED FROM BOTH SIDES, or
# "within 0.01" and "within anything" would be the same check.
@@ -3459,7 +3459,7 @@ _outside = refusal(spoilt(card_expect_incl_vat=round(
+ CARD_TOLERANCE * 2.0, 6)))
check(f"half a tolerance out passes and twice a tolerance out does not, so "
f"{CARD_TOLERANCE} is the number being enforced rather than a shape",
_inside == "" and "does not match its own card" in _outside)
_inside == "" and "the card says" in _outside)
# A card that is absent is refused, and NOT by the generic unknown-field rule.
check(f"a formula with NO card block is refused, and the message says why the "
@@ -3851,12 +3851,12 @@ check("a formula that does not reproduce its card is refused AT STARTUP, "
why(validate_startup_config(
vbase(price_provider="custom_formula",
price_formula=json.dumps(spoilt(offset=13.0))),
-5000, 5000), "does not match its own card"))
-5000, 5000), "the card says"))
check("...and a formula that is not JSON at all is refused with the shape it "
"wanted, rather than with a traceback",
why(validate_startup_config(
vbase(price_provider="custom_formula", price_formula="0.102*spot+1"),
-5000, 5000), "not valid JSON"))
-5000, 5000), "price_formula is not valid near character"))
check(f"...and a card outside the plausible band is refused too, WITH THE "
f"SHIPPED taxes in place - the flat "
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} must not be able to "
@@ -4100,13 +4100,14 @@ def _t11_render(over=None, *, plan=None, plan_age_s=0.0, soc=55.0, cycles=1,
# row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are
# asserted.
_T11_SLOTS = {
"points": r"(\d+) points over \d+ h",
"hours": r"\d+ points over (\d+) h",
"firm": r"(\d+) firm / \d+ carried",
"carried": r"\d+ firm / (\d+) carried",
"horizon": r"for a (\d+) h horizon",
"age_s": r"fetched (\d+) s ago",
"spacings": r"([\d+]+) s spacing",
# UX-08 2d: the row is in plain words; every figure keeps its own slot.
"points": r"(\d+) prices over \d+ hours",
"hours": r"\d+ prices over (\d+) hours",
"firm": r"(\d+) published and \d+ estimated",
"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",
# ⚠️ 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,
@@ -4144,13 +4145,13 @@ def _slot(row, name):
return derived(_get)
_T11_GOOD = ("x (recomputed this tick); provider plan fetched 7 s ago for a "
"48 h horizon: dynamic_dayahead from generic_dayahead, "
"132 points over 33 h, 36 firm / 96 carried, 900+3600 s spacing")
_T11_SHIPPED_DEFECT = ("x (recomputed this tick); provider plan fetched 7 s "
"ago for a 48 h horizon: dynamic_dayahead from "
"generic_dayahead, 132 points over 132 h, "
"96 firm / 36 carried, 900+3600 s spacing")
_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")
_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")
# 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,11 +4400,11 @@ check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
f"in the options blob",
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
f"({_T11_ON_HELD[1].reason!r}), so the operator is told which input is "
f"({_T11_ON_HELD[1].why_words!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].reason
and _T11_ON_HELD[1].reason in _T11_ON_HELD[0]["Arbitrage"])
_T11_ON_HELD[1].why_words
and _T11_ON_HELD[1].why_words 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 "
@@ -4449,10 +4450,10 @@ check(f"DRIVEN, no clock touched: a decision stands "
and _T11_DROP_STALE is True
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
check(f"...and it names the reason the cycle stopped "
f"({_T11_ON_DROPPED[1].reason!r}) beside the age of the decision it is "
f"({_T11_ON_DROPPED[1].why_words!r}) beside the age of the decision it is "
f"still showing, so 'the strategy decided this' and 'the strategy last "
f"decided this' are not one sentence",
_T11_ON_DROPPED[1].reason in _T11_ON_DROPPED[0]["Arbitrage"]
_T11_ON_DROPPED[1].why_words in _T11_ON_DROPPED[0]["Arbitrage"]
and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int))
check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than "
@@ -4585,8 +4586,8 @@ check(f"...and the row an operator reads names the SPACING that makes those "
check("...and this is the same wording FCST-04's *Price history* row already "
"used, so the two rows describing the same feed can be compared rather "
"than being two conventions on one page",
"points over" in _T11_E2E_ROW
and "points over" in ("" if isinstance(_T11_E2E, _Raised)
"prices over" in _T11_E2E_ROW
and "prices over" in ("" if isinstance(_T11_E2E, _Raised)
else _T11_E2E["Price history"]))
@@ -5360,14 +5361,14 @@ _T16_DIFFER = [
# UX-08 2c: a negative factor gets one sentence on both legs now ("put the
# minus sign on the offset"), so it left this table.
("a card miss -> what else to check on THIS leg",
spoilt(factor=0.120), "(price_taxes_eur_kwh), not in the offset",
"vat_pct is 0 for injection"),
spoilt(factor=0.120), "network charges are not in the offset",
"the VAT is 0 for injection"),
("invalid JSON -> WHEN this option is read at all, which is not the "
"same answer on the two legs: `price_formula` decides nothing unless "
"price_provider is 'custom_formula', and the injection leg is read "
"whatever the consumption provider is",
"{not json", "(price_provider) is 'custom_formula'",
"used whatever the price source is"),
"{not json", "unless “Dynamic price source” is 'custom_formula'",
"only if your injection price follows the market"),
]
for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER:
_a, _b = ((_reason(lambda b=_blob: parse_price_formula(b)),
@@ -5549,7 +5550,7 @@ check(f"a card block ON THIS LEG is graded by the same rung at the same "
f"15.55) is refused, naming the option the operator typed "
f"({_T16_SPOILT_CARD[:64]!r})",
_T16_WITH_CARD == ""
and "does not match its own card" in _T16_SPOILT_CARD
and "the card says" in _T16_SPOILT_CARD
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION))
check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is "
f"enforced on this leg too rather than 'something'",
+6 -6
View File
@@ -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 points over 24 h)")
words_of(_h_words) == f"{YDAY_D} (24 prices over 24 hours)")
# ⚠️ 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,11 +1362,11 @@ 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 points over 24 h)")
_qw == f"{YDAY_D} (96 prices over 24 hours)")
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 points " in _qw)
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 prices " in _qw)
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
@@ -1546,7 +1546,7 @@ 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 point over 1 h) [")
and _wload.startswith(f"{TODAY_D} (1 price over 1 hour) [")
and "1 malformed point row(s) 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
@@ -1577,7 +1577,7 @@ for _shape, _bad in _BAD_STARTS:
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} points over {_want} h)"
words_of(_hM2) == f"{TODAY_D} ({_want} prices over {_want} hours)"
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 points over 24 h); 2026-01-15 (24 points
# words = '2026-01-14 (24 prices over 24 hours); 2026-01-15 (24 points
# over 24 h) [could not write (...)]'
# yesterday()= series, 24 points
# ON DISK = {} <- and gone on the next restart
+9 -9
View File
@@ -516,7 +516,7 @@ check("⚠️ AN UNSET OPTION ADDS NO STATUS ROW AND NO PAGE ROW - not a caution
"input; this is instrumentation and a permanent caution on every install "
"that never asked for it is the warning spam the card forbids",
not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "PV"])
and not [r for r in page_rows(c_off) if r[0] == "Solar production"])
check("...and the whole row list is otherwise the same length, so nothing else "
"moved either", len(_on_rows) == len(_off_rows) + 1)
_pv_row = [r["text"] for r in pv_rows(c_on)]
@@ -527,7 +527,7 @@ check("a good read says the watts, as a green row, and names no entity id",
_pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
and "sensor." not in _pv_row[0]
and pv_rows(c_on)[0]["ok"] is True
and ("PV", "3 210 W") in page_rows(c_on))
and ("Solar production", "3 210 W") in page_rows(c_on))
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
_bad_row = pv_rows(c_bad)
@@ -552,7 +552,7 @@ check("⭐ ...and it is a CAUTION, never a `bad` row: `status_payload()` builds
and c_bad.status_payload()["banner"]
== c_off.status_payload()["banner"])
check("...and the page row renders an absence as an em dash, not as 0 W",
("PV", "—") in page_rows(c_bad))
("Solar production", "—") in page_rows(c_bad))
# ==============================================================================
@@ -936,7 +936,7 @@ print(f" {_many_row}")
check("a healthy roster says the SITE total and how many sources it came from",
_many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
f"sources"]
and ("PV", "3 750 W") in page_rows(_c_many))
and ("Solar production", "3 750 W") in page_rows(_c_many))
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
_gap_row = pv_rows(_c_gap)
@@ -960,8 +960,8 @@ check("⭐ ...and it is a CAUTION, never a `bad` row, at a roster exactly as at
== c_off.status_payload()["banner"])
check("...and the page row is an em dash, not a smaller number: the one row an "
"operator glances at cannot show a partial total either",
("PV", "—") in page_rows(_c_gap)
and ("PV", "3 300 W") not in page_rows(_c_gap))
("Solar production", "—") in page_rows(_c_gap)
and ("Solar production", "3 300 W") not in page_rows(_c_gap))
# ⚠️ THE ACCEPTANCE CRITERION THE REST OF THIS FILE CANNOT STATE: at a roster of
# ONE the surfaces are 0.11.0's, character for character. `c_on` above is the
@@ -978,7 +978,7 @@ check("⭐ ONE ENTITY IS ONE PATH, however it was declared: the status "
== [r["text"] for r in pv_rows(c_on)]
== [f"Solar production: {messages.signed_watts(3210)}"]
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
.index("PV")] == ("PV", "3 210 W"))
.index("Solar production")] == ("Solar production", "3 210 W"))
check("...and a refused read at a roster of one is the one-sensor refusal "
"too, not the roster one - `no reading, <reason>`",
[r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
@@ -1007,7 +1007,7 @@ check("...and that caution appears ONLY when both are set - not on the 0.11.0 "
check("...and an install with NO PV roster still adds no row of any kind - the "
"whole of TEL-12 is invisible at the shipped default",
not pv_rows(c_off)
and not [r for r in page_rows(c_off) if r[0] == "PV"]
and not [r for r in page_rows(c_off) if r[0] == "Solar production"]
and len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
and run(cycles=1)[1].state_calls == []
and run(cycles=1, pv_entities=[])[1].state_calls == []
@@ -1106,7 +1106,7 @@ check("the status row prints the total AFTER the sign",
_mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
f"from 3 sources"])
check("...and the page's PV row is the corrected total",
("PV", "3 270 W") in page_rows(_c_mix))
("Solar production", "3 270 W") in page_rows(_c_mix))
_c_hole_f, _, _ = run(cycles=1, by_entity=dict(MIX, **{E1: [None]}), **PV_FLIP)
check("a holed roster with a flipped member names the missing source, not "
"the flipped one",
+2 -2
View File
@@ -172,7 +172,7 @@ class FakeController:
self.inverters = []
self.events = []
def log_event(self, text):
def log_event(self, text, hint=None):
self.events.append(text)
def status_payload(self):
@@ -552,7 +552,7 @@ class H:
async def limits(self, e): return (None, None, None)
class C:
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
def log_event(self, t): pass
def log_event(self, t, hint=None): pass
def status_payload(self): return {}
async def handle_action(self, w): raise AssertionError("no")
+1 -1
View File
@@ -527,7 +527,7 @@ check("...and arbitrage is refused, per §11.4",
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
and arbitrage_allowed(_none.read) is False)
check("...and static_status() says so in words rather than reporting a price",
static_status(opts(None), THU) == (False, "no static profile"))
static_status(opts(None), THU) == (False, "No fixed tariff set"))
# ⚠️ THE TILING REFUSAL. An uncovered hour has no price and the only ways to
# serve it are to invent one or to fail when the clock reaches it.
+40 -27
View File
@@ -116,6 +116,7 @@ from pathlib import Path
from named_absence import given, nth, present
from app import messages
from app.main import Controller
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
from app.tariff import (
@@ -977,16 +978,22 @@ check("...and the peak is unchanged by the repeat",
# quarter in August, so every check below passed in August and failed from
# 2026-09-01 - on a commit nobody had touched. The suite must grade the month
# it created, not the month the machine happens to be running in.
rows = [r for r in c.checks(when=B) if "capacity-tariff" in r["text"] or "peak" in r["text"]]
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
# UX-08 2d: the rows are in plain words and are found by their `id`. The peak
# and the coverage figure (TARIFF-13) are two rows now, one sentence each.
rows = c.checks(when=B)
peak_rows = [r for r in rows if r.get("id") == "month_peak"]
cov_rows = [r for r in rows if r.get("id") == "periods_measured"]
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row",
len(peak_rows) == 1)
check("...naming the peak, the soft limit and a coverage figure, all derived "
"from the controller that was actually built",
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
and f"{given(SHIPPED_CAP_W):.0f} W soft limit" in peak_rows[0]["text"]
and "quarter(s) banked" in peak_rows[0]["text"])
print(" status row: " + (cov_rows[0]["text"] if cov_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row, and one "
"coverage row beside it",
len(peak_rows) == 1 and len(cov_rows) == 1)
check("...naming the peak and its share of the peak limit, and the coverage "
"figure, all derived from the controller that was actually built",
bool(peak_rows) and "Month peak: 4 998 W on " in peak_rows[0]["text"]
and f"{4998 / given(SHIPPED_CAP_W) * 100:.0f} % of the peak limit"
in peak_rows[0]["text"]
and bool(cov_rows) and "periods measured" in cov_rows[0]["text"])
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
# NOT TO `when` - TARIFF-13, and it is a KNOWN GAP stated rather than rounded
@@ -1007,22 +1014,23 @@ print(f" the row read at {month_key(B)} carries the tally for "
check("the row's coverage figure is the LIVE month's tally - graded against a "
"row count taken straight from quarter_blocks, so this pins the gap "
"rather than restating the property that produces it",
bool(peak_rows)
and f", {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
bool(cov_rows)
and cov_rows[0]["text"].endswith(f"this month: {_live_rows}"))
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
f"on a {given(SHIPPED_CAP_W):.0f} W limit",
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
# The half that matters: the same page with a store that cannot answer.
c2 = built(TMP / "no-such-dir" / "nope.db")
u_rows = [r for r in c2.checks(when=B) if "capacity-tariff peak" in r["text"]]
u_rows = [r for r in c2.checks(when=B) if r.get("id") == "month_peak"]
print(" status row (unreadable store): "
+ (u_rows[0]["text"] if u_rows else "(none)"))
check("an unreadable store gets its OWN row, saying UNREADABLE", len(u_rows) == 1)
check("an unreadable store gets its OWN row, saying it cannot be read",
len(u_rows) == 1)
check("...and it does NOT read as a quiet month at 0 W - without this, a broken "
"instrument looks exactly like a month with no peak",
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"]
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"])
bool(u_rows) and "cannot be read" in u_rows[0]["text"]
and "%" not in u_rows[0]["text"] and u_rows[0]["warn"])
check("...and the healthy row and the unreadable row are different text",
bool(u_rows) and bool(peak_rows)
and u_rows[0]["text"] != peak_rows[0]["text"])
@@ -1040,7 +1048,7 @@ check("...and the healthy row and the unreadable row are different text",
# (That asymmetry in the row's own text is worth a look on its own; it is what
# made the original filter brittle. Not changed here - it is product text with
# other suites reading it.)
_peak_rows = lambda rows: [r for r in rows if "quarter(s) banked" in r["text"]]
_peak_rows = lambda rows: [r for r in rows if r.get("id") == "month_peak"]
_later = B + timedelta(days=45) # same fixture, a later billing month
_other_peak = _peak_rows(c.checks(when=_later))
@@ -1048,12 +1056,12 @@ print(f" the SAME controller read at {_later.date()}: "
+ (_other_peak[0]["text"] if _other_peak else "(no peak row)"))
check("asked for a LATER month, the same controller reports that month's "
"absence of a quarter, not August's 4998 W - the row follows `when`",
bool(_other_peak) and "4998 W" not in _other_peak[0]["text"]
and "no completed quarter recorded yet" in _other_peak[0]["text"])
bool(_other_peak) and "4 998 W" not in _other_peak[0]["text"]
and "none recorded yet" in _other_peak[0]["text"])
check("...and that is NO_RECORD, distinguishable from BOTH an observed peak and "
"an unreadable store - a month with no quarter is not a broken instrument",
bool(_other_peak) and bool(peak_rows)
and "UNREADABLE" not in _other_peak[0]["text"]
and "cannot be read" not in _other_peak[0]["text"]
and _other_peak[0]["text"] != peak_rows[0]["text"])
check("...and it is not FLAGGED, because an empty month is not an exceedance",
bool(_other_peak) and _other_peak[0]["ok"] and not _other_peak[0]["warn"])
@@ -1076,7 +1084,7 @@ check("calling checks() with NO argument still reads the month being billed "
bool(_now_peak) and (bool(peak_rows)
and _now_peak[0]["text"] == peak_rows[0]["text"]
if _today_is_fixture_month
else "no completed quarter recorded yet"
else "none recorded yet"
in _now_peak[0]["text"]))
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
@@ -1253,21 +1261,25 @@ for _q in range(_N13):
# Ground truth from the OTHER tier, which the tracker did not supply - the count
# under test must be graded against something that is not itself (habit 2).
_truth_before = len(c13.peak.store.blocks_in(_mon13))
_cov_row = lambda c: [r for r in c.checks()
if r.get("id") == "periods_measured"][0]["text"]
_row_before = _peak_rows(c13.checks())[0]["text"]
print(" before restart: " + _row_before)
_cov_before = _cov_row(c13)
print(" before restart: " + _row_before + " / " + _cov_before)
check(f"precondition: the fixture banked {_N13} quarters in {_mon13}, so a "
f"count of 0 and a count of the truth are different numbers",
_truth_before == _N13 and _N13 > 1)
check(f"...and the row says so before the restart",
f"{_N13} quarter(s) banked" in _row_before)
_cov_before.endswith(f"this month: {_N13}"))
c13.peak.store.close()
# THE RESTART.
c13b = built(_T13)
_row_after = _peak_rows(c13b.checks())[0]["text"]
_cov_after = _cov_row(c13b)
_truth_after = len(c13b.peak.store.blocks_in(_mon13))
print(" after restart: " + _row_after)
print(" after restart: " + _row_after + " / " + _cov_after)
print(f" rows in quarter_blocks: {_truth_before} before, "
f"{_truth_after} after; the restarted tracker has banked "
f"{len(c13b.peak._seen)} block(s) of its own through sync()")
@@ -1279,16 +1291,17 @@ check("precondition: the second Controller is a REAL restart - it shares no "
check("the peak and its attributed quarter survive the restart - the durable "
"half, asserted so that the coverage claim below is not graded against a "
"reading that had already collapsed",
_row_before.split(" - ")[0] == _row_after.split(" - ")[0]
and f"{_W13:.0f} W" in _row_after)
_row_before == _row_after
and f"Month peak: {messages.watts(_W13)} on" in _row_after)
check("...and the COUNT BESIDE IT DOES NOT COLLAPSE TO ZERO, which is the "
"defect: a coverage figure that resets while the value beside it "
"persists actively misinforms",
"; 0 quarter(s) banked" not in _row_after)
not _cov_after.endswith("this month: 0"))
check(f"...and it is the store's own tally, {_truth_after}, not a "
f"process-lifetime counter - graded against a COUNT OF ROWS in the "
f"other tier rather than against the property under test",
f"{_truth_after} quarter(s) banked" in _row_after and _truth_after >= _N13)
_cov_after.endswith(f"this month: {_truth_after}")
and _truth_after >= _N13)
# ⚠️ The restarted run banks one MORE than it drove, and that is correct: the
# checkpointed open block was stale, so `restore()` closed and banked it. The
+8 -8
View File
@@ -1223,7 +1223,8 @@ def _verdict(opts, token):
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
if not ps:
return (False, None, None)
m = re.search(r"for (\S+) \(inverters\.max_w\) is (\d+) W", ps[0])
m = re.search(r"for (\S+) \(inverters\.max_w\),? (?:is|at) (\d+) W",
ps[0])
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
@@ -1431,8 +1432,8 @@ check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
"refusal says WHICH ONE WINS and what to do - not merely that the two "
"are incompatible",
why(_T17_P_BOTH, "(price_taxes_profile) is set, so")
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads "
"0.1198"))
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used. Set it "
"back to"))
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
"Walloon site would have to clear a key it never touched. The refusal is "
"about a value the operator chose, and the shipped default is the one "
@@ -1482,8 +1483,7 @@ check("...while a value that is NEITHER the shipped default NOR a superseded "
"one is still REFUSED beside the table, naming it - the set of "
"'untouched' figures widened by exactly the old defaults and by nothing "
"else",
why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads "
"0.1198")
why(_T17_P_BOTH, "from 0.1198, or clear")
and 0.1198 not in _T18_SUPERSEDED
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
@@ -1660,7 +1660,7 @@ check("...and a malformed blob is refused as a malformed blob, naming the "
# coefficients against arithmetic done with the same coefficients. So the leg
# starts, and the operator is told WHICH check did not run.
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
if lvl == "WARNING" and "UNGRADED" in m]
if lvl == "WARNING" and "not checked against a printed price" in m]
print(f" {(_T16_UNGRADED or [''])[0][:118]}")
check("a leg with no card block starts, and says at startup that its "
"coefficients are UNGRADED - naming what was checked instead, so the "
@@ -1683,13 +1683,13 @@ check("...and a leg that DOES carry a card does NOT print it, or the sentence "
"would be noise an operator learns to ignore rather than a report of "
"which check ran",
_T16_WITH_CARD == []
and not [m for lvl, m in _T16_CARD_LOG if "UNGRADED" in m])
and not [m for lvl, m in _T16_CARD_LOG if "not checked against a printed price" in m])
# ⚠️ AND A CARD ON THIS LEG IS STILL GRADED, at startup, by the same rung.
check("...and a card ON THIS LEG that the coefficients do not reproduce is "
"REFUSED at startup, so the optional block is optional and not inert",
why(_t16_start(price_export_formula=_T16_CONS.replace(
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
"price_export_formula does not match its own card"))
"price_export_formula gives"))
# ⚠️ 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"
+8 -1
View File
@@ -375,6 +375,13 @@ check("the status page says so in words: a red row, at 0 W, cleared by start",
for c in ctrl.checks()))
check("the banner leads with it",
ctrl.status_payload()["banner"] == messages.banner_direction(False))
check("...the line under the buttons says it in plain words, and the action "
"log with the raw reason is still reachable in the status JSON",
ctrl.status_payload()["hint"].endswith(
messages.hint("direction", unit=ctrl.inverters[0].name))
and ctrl.status_payload()["events"] == ctrl.events
and any(e.split(" ", 1)[1].startswith("FAILSAFE ")
for e in ctrl.status_payload()["events"]))
check("it stays at 0 W on later cycles too",
hass.writes[-1] == 0.0 and hass.writes.count(0.0) >= 1)
@@ -1930,7 +1937,7 @@ check("the status page carries a SITE row of its own, and the banner says the "
any(ck.get("id") == "site_direction" and not ck["ok"]
and not ck["warn"] and "every inverter" in ck["text"]
for ck in _c31.checks())
and "Total command" in _c31.status_payload()["banner"]
and "Total power" in _c31.status_payload()["banner"]
and "inverter-" not in _c31.status_payload()["banner"])
_c31_mode = _c31.o.get("shadow_mode")
_c31.o["shadow_mode"] = "on"
+9 -9
View File
@@ -161,16 +161,16 @@ configuration:
name: "Tariff · Injection price (EUR/kWh)"
description: "What you are paid per kWh fed into the grid, all in. It may be negative."
static_profile:
name: "Tariff · Fixed or day/night tariff (JSON)"
description: "Your fixed or day/night contract as one line of JSON, prices all in. Fill it in even on a dynamic contract, as the fallback when the price feed fails."
name: "Tariff · Fixed or day/night tariff"
description: "Your fixed or day/night contract on one line, prices all in, as the documentation shows. Fill it in even on a dynamic contract, as the fallback."
price_taxes_profile:
name: "Tariff · Day/night levies and VAT (JSON)"
name: "Tariff · Day/night levies and VAT"
description: "Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both."
price_formula:
name: "Tariff · Your own tariff formula (JSON)"
description: "For custom_formula only. Type the numbers from your supplier's tariff card as one line of JSON, and the add-on checks them against the card."
name: "Tariff · Your own tariff formula"
description: "For custom_formula only. Type the numbers from your supplier's tariff card on one line, as the documentation shows. The add-on checks them against the card."
price_export_formula:
name: "Tariff · Your own injection formula (JSON)"
name: "Tariff · Your own injection formula"
description: "Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price."
price_csv_path:
name: "Tariff · Price CSV file"
@@ -278,14 +278,14 @@ configuration:
name: "Advanced · Command rounding (W)"
description: "The command is rounded to a multiple of this, so noise does not change it every cycle."
saturation_w:
name: "Advanced · Inverter at limit gap (W)"
name: "Advanced · At-limit shortfall (W)"
description: "When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this."
saturation_cycles:
name: "Advanced · Cycles at limit before freezing"
description: "How many cycles in a row at the limit before control stops building up error. Do not set it to 1."
integrator_max_w:
name: "Advanced · Error build-up limit (W)"
description: "Caps the error that control saves up from past cycles. 0 means “Inverter rating”. Do not raise it above the rating."
name: "Advanced · Catch-up limit (W)"
description: "Limits how hard control keeps pushing while the battery falls behind. 0 means “Inverter rating”. Do not raise it above the rating."
heartbeat_s:
name: "Advanced · Command refresh interval (s)"
description: "How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit."
+10 -10
View File
@@ -59,7 +59,7 @@ configuration:
description: "Zet dit aan als de sensor van deze omvormer leveren als een negatief getal toont."
capacity_kwh:
name: "Bruikbare capaciteit (kWh) (optioneel)"
description: "De bruikbare capaciteit van deze batterij, van het datablad. Vul ze bij elke omvormer in of bij geen enkele. De add-on verdeelt het werk dan volgens de energie die elke batterij nog heeft."
description: "De bruikbare capaciteit van deze batterij, van het datablad. Vul ze bij elke omvormer in of bij geen. Dan verdeelt de add-on het werk volgens de energie in elke batterij."
estop_fitted:
name: "Hardware · RS485-noodstop is geplaatst"
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
@@ -142,16 +142,16 @@ configuration:
name: "Tarief · Injectievergoeding (EUR/kWh)"
description: "Wat u krijgt per kWh die u op het net zet, alles inbegrepen. Dit mag negatief zijn."
static_profile:
name: "Tarief · Vast of dag/nacht-tarief (JSON)"
description: "Uw vaste of dag/nacht-contract als één regel JSON, prijzen alles inbegrepen. Vul dit ook in bij een dynamisch contract. Het is de reserve als de prijsfeed uitvalt."
name: "Tarief · Vast of dag/nacht-tarief"
description: "Uw vaste of dag/nacht-contract op één regel, prijzen alles inbegrepen, zoals de documentatie toont. Vul dit ook in bij een dynamisch contract. Het is de reserve als de prijsfeed uitvalt."
price_taxes_profile:
name: "Tarief · Heffingen en btw dag/nacht (JSON)"
name: "Tarief · Heffingen en btw dag/nacht"
description: "Laat dit leeg, tenzij uw netbeheerder een ander dag- en nachttarief rekent, zoals in Wallonië en Brussel. Vul dit of het vaste bedrag hierboven in, niet allebei."
price_formula:
name: "Tarief · Uw eigen tariefformule (JSON)"
description: "Alleen voor custom_formula. Typ de getallen van de tariefkaart van uw leverancier als één regel JSON. De add-on controleert ze met de kaart."
name: "Tarief · Uw eigen tariefformule"
description: "Alleen voor custom_formula. Typ de getallen van de tariefkaart van uw leverancier op één regel, zoals de documentatie toont. De add-on controleert ze met de kaart."
price_export_formula:
name: "Tarief · Uw eigen injectieformule (JSON)"
name: "Tarief · Uw eigen injectieformule"
description: "Optioneel. De injectieformule van uw tariefkaart, in dezelfde vorm als uw eigen tariefformule. Laat leeg om de vaste injectievergoeding te gebruiken."
price_csv_path:
name: "Tarief · CSV-bestand met prijzen"
@@ -259,14 +259,14 @@ configuration:
name: "Geavanceerd · Afronding van het commando (W)"
description: "Het commando wordt afgerond op een veelvoud hiervan, zodat ruis het niet elke cyclus verandert."
saturation_w:
name: "Geavanceerd · Verschil omvormer aan grens (W)"
name: "Geavanceerd · Tekort aan de grens (W)"
description: "Levert een omvormer zoveel minder dan gevraagd, dan zit ze aan haar grens. De drempel verkeerde richting per omvormer is de helft hiervan."
saturation_cycles:
name: "Geavanceerd · Cycli aan grens voor bevriezen"
description: "Hoeveel cycli na elkaar aan de grens voor de regeling stopt met afwijking opbouwen. Zet dit niet op 1."
integrator_max_w:
name: "Geavanceerd · Grens opgebouwde afwijking (W)"
description: "Begrenst de afwijking die de regeling uit vorige cycli opspaart. 0 betekent het vermogen van de omvormer. Zet dit niet hoger dan dat vermogen."
name: "Geavanceerd · Inhaalgrens (W)"
description: "Begrenst hoe hard de regeling blijft duwen als de batterij achterloopt. 0 betekent het vermogen van de omvormer. Zet dit niet hoger dan dat vermogen."
heartbeat_s:
name: "Geavanceerd · Commando opnieuw sturen na (s)"
description: "Hoe vaak het commando opnieuw gestuurd wordt, ook als het gelijk bleef. Houd dit ruim onder de eigen grens van 30 s van de stuurmodule."
+5 -50
View File
@@ -1,50 +1,5 @@
# UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start".
# These are waiting for phase 2d (log); phase 2b emptied the row and banner
# kinds and phase 2c the refusal and option kinds. Delete the line when you
# rewrite the string, because a stale line fails the test. Never add a line for a new string: write the new
# string to STYLE.md instead.
log a5e99679fd80 # main.py banned: SAFETY-02, latch :: 'SAFETY-02: the per-inverter store migration did not verify (%s). The l'
log 128b18b32d86 # main.py over 25 words (41); banned: LATCHED, SAFETY-44, latched :: 'SAFETY-44: this store carries a LATCHED sign-disagreement failsafe und'
log f60446541ae5 # main.py over 25 words (83); banned: LATCH, LATCHED, SAFETY-02, latch; capitals: LATCH GUARD :: 'SAFETY-02 LATCH GUARD: this store carries a LATCHED sign-disagreement '
log 04d06bf223c8 # main.py over 25 words (73); banned: REGISTER, SAFETY-37, SAFETY-57, register; capitals: WHICH IS NOT BY ITSELF :: 'SAFETY-37: %s battery-power reading did not move under a command that '
log aec01642bd07 # main.py banned: SAFETY-57 :: 'SAFETY-57: %s'
log aebbca17acb3 # main.py over 25 words (52); banned: allocator :: 'write_setpoint() was asked to put %+.0f W on every one of %d inverters'
log b74c3018461e # main.py over 25 words (30); banned: SAFETY-28, allocator :: 'SAFETY-28 REFUSED a write of %+.0f W to %s: %s. Commanding 0 W instead'
log cefffafa8b27 # main.py over 25 words (31) :: 'the write of %+.0f W to %s (%s) did not complete in %.0f s and was aba'
log 1001c6250b85 # main.py over 25 words (29); banned: SAFETY-02 :: 'SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on that inverter and hold'
log 8252ef5eefad # main.py over 25 words (48); banned: SAFETY-30 :: 'SAFETY-30 TRIPPED at the SITE - %s. That is the SUM of every standing '
log 8a65cf945e00 # main.py over 25 words (32); banned: SAFETY-31 :: 'SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and holding i'
log 5d2beed2f1a2 # main.py over 25 words (65); banned: HW-01, SAFETY-42; capitals: NOTHING IS COMMANDED DIFFERENTLY :: 'SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED DIFFERENTLY: this g'
log d50b775b9cd4 # main.py banned: ARBITER; capitals: ARBITER SCOPE :: 'ARBITER SCOPE: %s'
log 749c2c6fe81d # main.py over 25 words (26); banned: SAFETY-10, heartbeat; capitals: SHADOW MODE IS ON :: 'SAFETY-10 SHADOW MODE IS ON. The full decision path runs and NOTHING i'
log 4d271dfc6f1c # main.py banned: SAFETY-10 :: 'SAFETY-10 shadow mode is OFF. This controller is commanding the batter'
log 730387e0da2b # main.py banned: SAFETY-03 :: 'SAFETY-03 - %s Engagements so far: %d.'
log 3bf899e99297 # main.py over 25 words (56); banned: SAFETY-03, clamp, clamped, integrator; capitals: STILL RUNNING :: 'SAFETY-03 - %s Silently clipping this is how a winding integrator reac'
log 78cb6bd09633 # main.py over 25 words (48); banned: SAFETY-07; capitals: STILL RUNNING :: 'SAFETY-07 - %s. The guard is asking for everything the inverter has an'
log cc735997adc7 # main.py over 25 words (44); banned: SAFETY-24; capitals: STILL RUNNING :: 'SAFETY-24 - %s. The controller is STILL RUNNING and charging is still '
log ebcb428a70d0 # main.py over 25 words (70); banned: SAFETY-18, arbiter, watchdog; capitals: CHECK THE METER :: 'SAFETY-18 - %s. The grid-following loop is regulating against a number'
log 38018b887495 # main.py over 25 words (43) :: 'tariff refresh degraded (%s); KEEPING the plan resolved at %s, %.0f s '
log 0eab1ec96fe8 # main.py capitals: CONTROL LOOP DIED :: 'CONTROL LOOP DIED (%s) - commanding 0 W and exiting with status %d'
log db529f2c419d # main.py over 25 words (40); banned: SAFETY-02 :: 'SAFETY-02 was TRIPPED on %s before this restart and stays tripped: %s.'
log c5aadd5c8bf9 # main.py over 25 words (45); banned: SAFETY-42; capitals: NOTHING IS BEING COMMANDED DIFFERENTLY :: 'SAFETY-42 was TRIPPED on %s before this restart and stays tripped: %s.'
log d72bbdb7e595 # main.py over 25 words (39); banned: SAFETY-30 :: 'SAFETY-30 (site) was TRIPPED before this restart and stays tripped: %s'
log 8b880e65a7c3 # main.py over 25 words (38); banned: SAFETY-31 :: 'SAFETY-31 was TRIPPED before this restart and stays tripped: %s. Every'
log 23f58b7f305c # p1.py:1264 over 25 words (30) :: 'meter_poll_s %.1f leaves no room under meter_max_age_s %.1f: one misse'
log 88a7251e842b # p1.py:470 banned: telegram :: 'P1 telegram rejected: %s'
log 0dd83d6dfaf4 # price.py over 25 words (30) :: 'the feed published no price for %d of %d instants (index %s); those in'
log bcfd7062e28e # price.py over 25 words (79); banned: §8 :: 'price_feed_url is set, so %s is requesting %s exactly as given and is '
log b57bc6de4a6b # price.py over 25 words (47); banned: validate.py :: 'price_taxes_profile cannot be used (%s: %s), so this fetch has NO tax '
log d5595aaaacc4 # price_history.py banned: §8 :: 'price history at %s is %s - starting from empty. §8.3 carry-forward ha'
log dbed0b8e35ea # static_tariff.py banned: validate.py :: 'static_profile is unusable (%s); no cheap window. This should have bee'
log 8fbe9f5407ea # static_tariff.py over 25 words (28); banned: §1, §6 :: '§11.4: %s, and no static_profile is configured to fall back to. The pl'
log 4ce29e043d89 # static_tariff.py banned: §1 :: '§11.4: %s - falling back to the static profile, plan marked PROVISIONA'
log a3c7c9719ed2 # store.py capitals: LOST STATE :: 'per-inverter store migration LOST STATE and has not been marked done: '
log 5b537a77052b # validate.py over 25 words (41); banned: arbiter, saturation :: 'integrator_max_w %.0f W is above max_w %.0f W. Not refused - the arbit'
log 12ae40f9d289 # validate.py over 25 words (73); banned: arbiter, clamp, clamps :: 'maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not refused - '
log c6dc799502cf # validate.py over 25 words (32) :: '%s carries negative levies %s EUR/kWh. That is accepted, but a tax com'
log 1688a7478ceb # validate.py over 25 words (36) :: 'price_formula is filled in but price_provider is %r, so the coefficien'
log f37f9fce4433 # validate.py over 25 words (96) :: '%s has no "card" block, so its coefficients are UNGRADED: nothing reco'
log 396fbdd009c2 # validate.py over 25 words (52); banned: §1 :: "price_provider is %r but static_profile is empty. §11.4's degradation "
log 37bba6ed37e3 # validate.py capitals: REFUSING TO START :: 'REFUSING TO START: the add-on configuration is not usable.'
# UX-08 ratchet (test_messages.py part 3). Each line would be a user-facing
# string that does not follow STYLE.md yet: "<kind> <sha1-12 of its text> #
# where :: start". Phase 2d emptied it: every row, banner, refusal, option text
# and WARNING+ log line now follows STYLE.md, and ALLOWLIST_MAX is 0. Never add
# a line for a new string: write the new string to STYLE.md instead.