UX-08 2d: plain log lines, the rest of the status page, and DOCS
Every WARNING and ERROR line is one plain line of at most 25 words, with no card ids, capitals or semicolons; the reasoning of the ten longest is on a DEBUG line. The ux08 allowlist is empty and its cap is 0 (43 -> 0). The status page: the month-peak and peak-guard rows, the stale-reading row, the top table (Why, Tariff, Arbitrage, Shadow mode, labels), the Stopped banner and the line under the buttons, which now shows the plain hint of each event instead of the raw action log. The Supervisor question at start logs one line with a real reason. Refusals are counted as shown (labels expanded, long cases driven), seven were shortened, the card and parse errors lost their internals, a blank value reads "empty", and the panel no longer files inverters.max_w under the flat max_w. Option names lose "(JSON)", the NL texts are checked too. DOCS gets a glossary (control board, isolator, Developer Tools) and card ids move from headings to a "For installers" line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
@@ -1,13 +1,15 @@
|
|||||||
# STYLE.md - how user-facing text is written (UX-08)
|
# 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
|
## 1. Shape
|
||||||
- **Title**: at most 5 everyday words. It says what is wrong ("Meter reading may be stuck"). No "GoodWe:" prefix, no full stop.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
- **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.
|
- **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
|
## 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 |
|
| safety stop (a stop that only starting control clears) | failsafe latch |
|
||||||
| software fault | BUG SIGNAL |
|
| software fault | BUG SIGNAL |
|
||||||
| control board | master, ESP32 |
|
| 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. |
|
| 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.
|
**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.
|
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.
|
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.
|
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.
|
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.
|
||||||
|
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.
|
||||||
|
|||||||
+164
-88
@@ -7,45 +7,73 @@ balance cells and recalibrate its coulomb counter.
|
|||||||
Installers: the printed field guide (`docs/GoodWe-RS485-Field-Guide.pdf`) is
|
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.
|
v0.1.0 and outdated; where it disagrees with this page, this page wins.
|
||||||
|
|
||||||
|
## Words used on this page
|
||||||
|
|
||||||
|
- **Control board**: the small computer wired to the inverter's RS485 meter
|
||||||
|
bus (a T-CAN485 board with an ESP32 chip). 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.
|
||||||
|
- **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
|
## Before you start
|
||||||
|
|
||||||
You need:
|
You need:
|
||||||
|
|
||||||
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
|
- A GoodWe **ES / BP family** inverter (AA55 / RS485 meter-bus generation)
|
||||||
- The vendor's meter-emulating controller **disconnected** from the bus
|
- 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
|
- 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
|
After installing, turn on **Watchdog** on the add-on's Info tab. If control
|
||||||
control loop stops the add-on at 0 W, and the Supervisor restarts it only when
|
stops by accident, the add-on sets the inverters to 0 W and exits, and Home
|
||||||
Watchdog is on (it is off by default). See
|
Assistant starts it again only when Watchdog is on. It is off by default.
|
||||||
`DECISIONS/SAFETY-61-control-task-death.md`.
|
|
||||||
|
|
||||||
## The safety model, in short
|
## 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
|
- The control board sets the inverter to 0 W if this add-on stops sending for
|
||||||
commanding it.
|
about 30 seconds, and keeps it there.
|
||||||
- This add-on commands 0 W when its inputs go missing, when you stop control,
|
- This add-on sets the inverters to 0 W when a reading goes missing, when you
|
||||||
and when it shuts down.
|
stop control, and when it shuts down.
|
||||||
- This add-on commands 0 W **and latches there** if what it is commanding and
|
- This add-on sets the inverters to 0 W **and keeps them there** if the power
|
||||||
what the meter reads point opposite ways for three minutes — see "The
|
goes the opposite way to what it asks for three minutes. See "The
|
||||||
sign-disagreement failsafe" below.
|
sign-disagreement failsafe" below.
|
||||||
- The **optional RS485 e-stop** is the only thing that covers this machine
|
- The **optional emergency stop** is the only thing that covers this computer
|
||||||
dying. Without it, a failed host leaves the battery latched at its last
|
failing. Without it, the battery keeps its last power request until someone
|
||||||
command until someone intervenes.
|
steps in.
|
||||||
- This add-on **raises an alarm — and nothing more —** when the meter keeps
|
- This add-on **raises an alarm, and does nothing more,** when the meter keeps
|
||||||
answering with a reading that never changes while the command sits at the
|
answering with a value that never changes while the battery is asked for
|
||||||
rail. See "The frozen-meter alarm" and, before you rely on it, "Capacity-tariff
|
full power. See "The frozen-meter alarm" and, before you rely on it,
|
||||||
exposure of the grid-following loop".
|
"Capacity-tariff exposure of the grid-following loop".
|
||||||
|
|
||||||
**If anything looks wrong: stop the add-on.** That commands 0 W and the
|
**If anything looks wrong: stop the add-on.** That sets the inverters to 0 W,
|
||||||
hardware holds it there.
|
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
|
## 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,
|
Since **UX-02** the ingress panel has a **Configuration** page of its own,
|
||||||
beside Home Assistant's *Configuration* tab. Both edit the **same** settings —
|
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
|
refusals pinned where you cannot scroll past them. It is the screen for
|
||||||
standing in front of the inverter.
|
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`,
|
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
|
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 | 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 | 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 | 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 | 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 (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 | 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 (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 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 (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 | 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 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 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. |
|
| 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 | 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 | 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 | 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 | 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 | 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 | 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. |
|
| 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”.
|
- **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.
|
- **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.
|
- **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).
|
- **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.
|
- **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).
|
- **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 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.
|
- **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”.
|
- **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 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.
|
- **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.
|
- **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 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.
|
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
|
||||||
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
|
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. 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.
|
- **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.
|
- **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.
|
- **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.
|
- **At-limit shortfall** (`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.
|
- **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.
|
- **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.
|
- **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 |
|
| `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 |
|
| `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` |
|
| `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_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-tel-12) |
|
| `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-tel-14) |
|
| `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:
|
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
|
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 |
|
| `soc_entity` | | This pack's state of charge |
|
||||||
| `batt_entity` | | This unit's battery power, `+` = discharging |
|
| `batt_entity` | | This unit's battery power, `+` = discharging |
|
||||||
| `batt_invert` | | Flip if needed |
|
| `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
|
⚠️ **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
|
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
|
single-inverter entity back in that field. **Before downgrading, write down the
|
||||||
`setpoint_entity` of the unit you want to keep running.**
|
`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
|
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
|
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 |
|
| `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
|
⚠️ **`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
|
often disagree.** `Why` is the *arbiter's* answer in words — *Keeping the grid
|
||||||
names the strategy that won and the watts it won with. It has never carried the
|
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**
|
strategy's own reason, so a loop that is slew-limited or frozen says so **only**
|
||||||
here.
|
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
|
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.
|
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
|
> **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.
|
> 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
|
without a record of production.** This entity is that record. The attribution
|
||||||
itself is OPT-10's, with a week of history behind it.
|
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
|
**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
|
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
|
A failed read also **clears** the previous good one: the value never outlives
|
||||||
its own sensor.
|
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
|
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
|
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 |
|
| `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` | `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
|
Shadow mode runs the **whole** decision path — the price strategy, the capacity
|
||||||
guard, the maintenance shaping, the grid-following loop, the arbiter that ranks
|
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
|
point **opposite ways** — charging the battery while the house imports, or
|
||||||
discharging it while the house exports — with both over `sign_disagree_w`, for
|
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
|
`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
|
log says which two numbers disagreed and for how long, and the `Controller
|
||||||
status` entity reports `failsafe`.
|
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
|
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
|
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.
|
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
|
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
|
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
|
happened to a lying meter whose value merely jittered — bit-exact repetition was never a
|
||||||
safety property.
|
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
|
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
|
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
|
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.
|
**nothing more**. See below for what that costs on a real evening.
|
||||||
- **When it binds you are told**, once per episode, with notification
|
- **When it binds you are told**, once per episode, with notification
|
||||||
`goodwe_peak_soc_floor` and a status-page row reading `PEAK GUARD
|
`goodwe_peak_soc_floor` and a red status-page row reading *Battery at its
|
||||||
FLOOR-LIMITED`: the quarter **will** go over the ceiling, so shedding load is
|
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 only thing left that changes the bill. Failing to shave is never silent —
|
||||||
the euro figure is the whole point of the guard.
|
the euro figure is the whole point of the guard.
|
||||||
- **An unreadable SoC is treated as floor-limited, not as a full battery.** A
|
- **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
|
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.
|
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
|
**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
|
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
|
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.
|
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
|
> **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
|
> 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
|
and arbitrage is disabled, per the degradation matrix. It never falls back to a
|
||||||
price of zero, which would read as free electricity.
|
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**.
|
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`
|
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:
|
since TARIFF-11 that row carries the provider plan itself:
|
||||||
|
|
||||||
```
|
```
|
||||||
Tariff: no static profile (recomputed this tick); provider plan fetched 412 s
|
Tariff: No fixed tariff set. Price plan from 412 s ago, for 48 hours ahead:
|
||||||
ago for a 48 h horizon: dynamic_dayahead from generic_dayahead,
|
day-ahead prices from the price feed, 60 prices over 33 hours,
|
||||||
60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing,
|
36 published and 24 estimated, 900+3600 s apart, not final yet
|
||||||
provisional
|
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ **The two halves of that row are not computed at the same moment, and the
|
⚠️ **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
|
Assistant price-entity sentence, regenerated on the **30 s tick** from options
|
||||||
alone — no network. Everything after it is the **background refresh's**
|
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
|
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
|
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,
|
site with a provider configured and no usable plan the row says that too,
|
||||||
rather than falling silently back to the static half.
|
rather than falling silently back to the static half.
|
||||||
|
|
||||||
Read the plan half as five separate facts: which *product* the feed publishes
|
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
|
and whether it came from the feed or from your fixed tariff, how many
|
||||||
hours** those points cover, how many of the points are **firm** (published for
|
**prices** it carries and how many **whole hours** they cover, how many are
|
||||||
their own interval) against **carried** (filled from yesterday under §8.3), the
|
**published** (firm, for their own interval) against **estimated** (carried
|
||||||
**spacing** of the points in seconds — `900+3600` is the normal mixed steady
|
from yesterday under §8.3), how far **apart** they are in seconds — `900+3600`
|
||||||
state, quarter-hourly firm points beside hourly carried ones — and `provisional`
|
is the normal mixed steady state, quarter-hourly firm points beside hourly
|
||||||
whenever any point is carried.
|
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
|
⚠️ **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
|
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,
|
— 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.
|
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
|
**Read this before adding a price component of any kind.** The optimiser reads
|
||||||
exactly two quantities off a price series, and nothing else:
|
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
|
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.
|
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
|
**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
|
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
|
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,
|
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.
|
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*
|
The watts and the time window are in the log. The *Check for inverters working
|
||||||
row shows the live watts. See *The circulating-power alarm*.
|
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
|
**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
|
share of the site target for longer than `delivery_hold_s`. A unit at 0 W is
|
||||||
@@ -3114,16 +3171,19 @@ and command figures are in the log. See *The frozen-meter alarm*.
|
|||||||
|
|
||||||
## The Web UI
|
## The Web UI
|
||||||
|
|
||||||
The ingress panel shows live values, why the controller is commanding what it
|
The add-on page shows live values, why the battery does what it does, and a
|
||||||
is, and a **Commissioning** checklist that names any problem in words. It also
|
**Commissioning** checklist that names any problem in words. It also has three
|
||||||
carries the three buttons: start/stop control, force a maintenance cycle, and
|
buttons: start or stop control, force a maintenance cycle, and abort one.
|
||||||
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
|
It has a second page, **Configuration**. See *There are TWO places to configure
|
||||||
places to configure this add-on*, above. ⚠️ **It adds nothing you can do to the
|
this add-on*, above. **It adds nothing you can do to the inverter.** It reads
|
||||||
inverter.** The configuration page reads and writes the add-on's own settings
|
and writes the add-on's own settings and can restart the add-on. Only the three
|
||||||
and can restart the add-on; the set of commands that reach the hardware is
|
buttons reach the inverter.
|
||||||
exactly the three buttons it always had.
|
|
||||||
|
*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`), which the page no longer shows.*
|
||||||
|
|
||||||
## Status page
|
## Status page
|
||||||
|
|
||||||
@@ -3144,14 +3204,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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| Month peak | This month's highest 15-minute average, when it happened, and how close it is to your peak limit. Amber from 80 %. |
|
||||||
| Peak guard, Load too high for battery, Battery at its peak reserve | What the add-on does to keep your peak down. |
|
| 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. |
|
| 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. |
|
| *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. |
|
| 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. |
|
| 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. |
|
||||||
| Circulating power check, Inverters worked against each other | Only with several inverters. See *Inverters working against each other*. |
|
| 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*. |
|
| 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”. |
|
| 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 +3238,17 @@ ERROR or WARNING line in the add-on log.
|
|||||||
***name*: “Setpoint entity” is not set** (amber): a roster entry nobody can
|
***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.
|
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).
|
- **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
|
- **Solar production** reads `pv_entity` or `pv_entities` (TEL-11/12). The
|
||||||
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
|
*Reversed-sign solar entities* setting is `pv_invert_entities` (TEL-14).
|
||||||
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
|
- **Shadow mode** (SAFETY-10): the banner turns red when a latch is up
|
||||||
@@ -3190,7 +3262,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
|
controller status by MQTT discovery. This is observability only — the controller works fine without a
|
||||||
broker, and MQTT problems can never affect control.
|
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
|
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
|
**one-way on a live box and invisible until after the restart that applies
|
||||||
@@ -3371,7 +3445,9 @@ maintenance bookkeeping — and if a cycle ran during it, correct
|
|||||||
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
|
`unit/<first>/last_completed` by hand before advancing again.** This is a stated
|
||||||
decision, not a discovery made at the inverter.
|
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
|
Two ways a deploy has gone wrong on this system, both of which the Supervisor
|
||||||
reports as success:
|
reports as success:
|
||||||
|
|||||||
@@ -413,10 +413,9 @@ function showProblems(payload){
|
|||||||
save.disabled = total > 0;
|
save.disabled = total > 0;
|
||||||
if(total){
|
if(total){
|
||||||
note(banner, 'bad', 'This configuration would not start.',
|
note(banner, 'bad', 'This configuration would not start.',
|
||||||
'The add-on refuses to run on it and would stop with exit 78. '
|
'The add-on would refuse to start on it. Saving is blocked until these '
|
||||||
+ 'Saving is blocked until these are fixed; nothing has been written. '
|
+ 'are fixed, and nothing has been written. Each message below comes '
|
||||||
+ 'Each message below is the add-on\\u2019s own words, from the same '
|
+ 'from the same check that runs at start.');
|
||||||
+ 'check that runs at startup.');
|
|
||||||
banner.insertBefore(banner.lastChild, banner.firstChild);
|
banner.insertBefore(banner.lastChild, banner.firstChild);
|
||||||
msg(total + (total === 1 ? ' problem' : ' problems')
|
msg(total + (total === 1 ? ' problem' : ' problems')
|
||||||
+ ' - saving is blocked', 'bad');
|
+ ' - saving is blocked', 'bad');
|
||||||
|
|||||||
@@ -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
|
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.
|
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]
|
+ r"(?![A-Za-z0-9_])")) for key in keys]
|
||||||
by_key, unanchored = {}, []
|
by_key, unanchored = {}, []
|
||||||
for problem in problems:
|
for problem in problems:
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ import math
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from . import messages
|
||||||
|
|
||||||
|
|
||||||
class FormulaInvalid(Exception):
|
class FormulaInvalid(Exception):
|
||||||
"""`price_formula` cannot be used. Raised by the parser, refused by
|
"""`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,'
|
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
|
||||||
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
||||||
'"expect_incl_vat":15.55}}'),
|
'"expect_incl_vat":15.55}}'),
|
||||||
when=("Leave it empty unless “Dynamic price source” (price_provider) "
|
when="Leave it empty unless “Dynamic price source” is 'custom_formula'.",
|
||||||
"is 'custom_formula'."),
|
|
||||||
zero_factor=" and trading never finds a price difference",
|
zero_factor=" and trading never finds a price difference",
|
||||||
flat="use “Fixed or day/night tariff” (static_profile) instead",
|
flat="use “Fixed or day/night tariff” (static_profile) instead",
|
||||||
neg_factor="Put the minus sign on the offset, not on the factor",
|
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||||
card_required=True,
|
card_required=True,
|
||||||
card_note="The add-on needs it to check your two numbers",
|
card_note="The add-on needs it to check your two numbers",
|
||||||
card_causes=("that network charges are in “Distribution, levies and VAT” "
|
card_causes=("the units, the VAT, and that network charges are not "
|
||||||
"(price_taxes_eur_kwh), not in the offset"),
|
"in the offset"),
|
||||||
),
|
),
|
||||||
EXPORT_FORMULA_OPTION: _Leg(
|
EXPORT_FORMULA_OPTION: _Leg(
|
||||||
option=EXPORT_FORMULA_OPTION,
|
option=EXPORT_FORMULA_OPTION,
|
||||||
what="injection",
|
what="injection",
|
||||||
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
||||||
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
|
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
|
||||||
when=("Fill it in only if your injection price follows the market. It "
|
when="Fill it in only if your injection price follows the market.",
|
||||||
"is used whatever the price source is."),
|
|
||||||
zero_factor="",
|
zero_factor="",
|
||||||
flat="use “Injection price” (export_price_eur_kwh) instead",
|
flat="use “Injection price” (export_price_eur_kwh) instead",
|
||||||
neg_factor="Put the minus sign on the offset, not on the factor",
|
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||||
card_required=False,
|
card_required=False,
|
||||||
card_note=("It is optional here. Without it the add-on only checks "
|
card_note=("It is optional here. Without it the add-on only checks "
|
||||||
"the size of your two numbers"),
|
"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)
|
value = json.loads(text)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option} is not valid JSON ({e}). Write it on one line, for "
|
f"{messages.not_valid(option, e)}. Copy the example from the "
|
||||||
f"example\n"
|
f"documentation. {leg.when}")
|
||||||
f" {leg.example}\n"
|
|
||||||
f"{leg.when}")
|
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option} is {type(value).__name__}, not a JSON object with "
|
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)
|
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
||||||
leg = _LEGS[spec.option]
|
leg = _LEGS[spec.option]
|
||||||
return (
|
return (
|
||||||
f"{spec.option} does not match its own card: it gives {got:.4f} "
|
f"{spec.option} gives {got:.4f} "
|
||||||
f"{spec.units} and the card says {spec.card_expect_incl_vat:g}. Check "
|
f"{messages.UNIT_WORDS.get(spec.units, spec.units)}, the card says "
|
||||||
f"the units, the VAT and {leg.card_causes}.")
|
f"{spec.card_expect_incl_vat:g}. Check {leg.card_causes}.")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -408,7 +408,8 @@ class Inverter:
|
|||||||
stop the site (`inputs_ok`). Folding one into the other is how a
|
stop the site (`inputs_ok`). Folding one into the other is how a
|
||||||
measurement fault ends up handing a sibling extra power.
|
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:
|
def link_note(self, now: datetime) -> str:
|
||||||
"""One line for the status page. Empty when there is nothing to say.
|
"""One line for the status page. Empty when there is nothing to say.
|
||||||
|
|||||||
+300
-214
@@ -73,7 +73,7 @@ from . import packshare
|
|||||||
from . import pv as pv_read
|
from . import pv as pv_read
|
||||||
from . import shadow
|
from . import shadow
|
||||||
from .p1 import P1Ingest, build_source, is_enabled
|
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 .price_history import PriceHistory
|
||||||
from .static_tariff import resolve_tariff, static_status
|
from .static_tariff import resolve_tariff, static_status
|
||||||
from .tariff import PeakStore, PeakTracker
|
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
|
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.
|
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 plan is None:
|
||||||
if not provider:
|
if not provider:
|
||||||
return tick_words
|
return tick_words
|
||||||
return (f"{tick_words} (recomputed this tick); NO provider plan - "
|
return (f"{tick_words}. No price plan: none yet, or the last one is "
|
||||||
f"none has been resolved yet, or the last one is older than "
|
f"over {max_age_s:.0f} s old")
|
||||||
f"{max_age_s:.0f} s")
|
return (f"{tick_words}. Price plan from {age_s:.0f} s ago, for "
|
||||||
return (f"{tick_words} (recomputed this tick); provider plan fetched "
|
f"{horizon_h:.0f} hours ahead: {plan.words}")
|
||||||
f"{age_s:.0f} s ago for a {horizon_h:.0f} h horizon: {plan.words}")
|
|
||||||
|
|
||||||
|
|
||||||
def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now,
|
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
|
render, from one that never reports it - and the bound TARIFF-10's
|
||||||
comment claimed did not exist is ~15 s of it.
|
comment claimed did not exist is ~15 s of it.
|
||||||
"""
|
"""
|
||||||
|
# UX-08: plain words; the setting is named by its label.
|
||||||
if not on:
|
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:
|
if decided_at is None:
|
||||||
return (f"ON (`arbitrage_enabled`), but NO DECISION has been reached "
|
return (f"On, but no decision yet, because control has not got that "
|
||||||
f"yet - no cycle has run as far as the arbitrage step. The "
|
f"far. Control says: {why or 'no reason recorded'}")
|
||||||
f"loop's own reason: {why or 'none recorded'}")
|
|
||||||
age = (now - decided_at).total_seconds()
|
age = (now - decided_at).total_seconds()
|
||||||
stale = cycle_at is not None and decided_at < cycle_at
|
stale = cycle_at is not None and decided_at < cycle_at
|
||||||
head = (f"no decision this cycle ({why or 'no reason recorded'}); the "
|
head = (f"On, no decision this cycle ({why or 'no reason recorded'}), "
|
||||||
f"last one was taken {age:.0f} s ago: " if stale else
|
f"the last one {age:.0f} s ago: " if stale else
|
||||||
f"decided {age:.0f} s ago: ")
|
f"On, decided {age:.0f} s ago: ")
|
||||||
if decision is None:
|
if decision is None:
|
||||||
return (f"ON (`arbitrage_enabled`), no decision this cycle "
|
return (f"On, no decision this cycle ({why or 'no reason recorded'}) "
|
||||||
f"({why or 'no reason recorded'}), and none stands from an "
|
f"and none before")
|
||||||
f"earlier one")
|
|
||||||
return head + decision.explain()
|
return head + decision.explain()
|
||||||
|
|
||||||
|
|
||||||
@@ -354,6 +355,8 @@ class Controller:
|
|||||||
# their behalf, and a guard that raises AttributeError on the way to
|
# their behalf, and a guard that raises AttributeError on the way to
|
||||||
# saying so is a guard that stops the add-on booting.
|
# saying so is a guard that stops the add-on booting.
|
||||||
self.events: list[str] = []
|
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 ---
|
# --- DEV-02: THE ROSTER, BUILT BEFORE ANYTHING THAT READS A RATING ---
|
||||||
# ⚠️ EVERY PER-DEVICE VALUE IN THIS CONTROLLER LIVES ON ONE OF THESE
|
# ⚠️ 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 "
|
"soc_entity / batt_entity / batt_invert / max_w keys are NOT "
|
||||||
"read in this shape; each entry carries its own."
|
"read in this shape; each entry carries its own."
|
||||||
% (len(self.inverters),
|
% (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(
|
self.tuning = Tuning(
|
||||||
gain=float(opts.get("gain", 0.6)),
|
gain=float(opts.get("gain", 0.6)),
|
||||||
@@ -541,7 +545,8 @@ class Controller:
|
|||||||
# recalibrated on schedule and one that silently is not, which is
|
# recalibrated on schedule and one that silently is not, which is
|
||||||
# §12's opening sentence, so it is said where an operator looks.
|
# §12's opening sentence, so it is said where an operator looks.
|
||||||
self.log_event("maintenance history did NOT migrate cleanly: "
|
self.log_event("maintenance history did NOT migrate cleanly: "
|
||||||
+ self.maint_migration.explain())
|
+ self.maint_migration.explain(),
|
||||||
|
hint=messages.hint("maint_history"))
|
||||||
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
|
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
|
||||||
# PROCEDURE - how hard to drain, how full to charge, how long to hold -
|
# 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
|
# 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)
|
self._grade_store_migration(store)
|
||||||
for inv in self.inverters:
|
for inv in self.inverters:
|
||||||
if inv.sign_wd.tripped:
|
if inv.sign_wd.tripped:
|
||||||
_LOG.error("SAFETY-02 was TRIPPED on %s before this restart and "
|
_LOG.error("%s stays at 0 W after the restart because its "
|
||||||
"stays tripped: %s. That inverter is held at 0 W. "
|
"power went the wrong way: %s. Start control once "
|
||||||
"Restarting the add-on does not clear this - start "
|
"you know why.", inv.name,
|
||||||
"control from the UI once you know why the command "
|
|
||||||
"opposed the meter.", inv.name,
|
|
||||||
inv.sign_wd.reason or "(reason not recorded)")
|
inv.sign_wd.reason or "(reason not recorded)")
|
||||||
|
|
||||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||||
@@ -616,11 +619,9 @@ class Controller:
|
|||||||
for inv in self.inverters:
|
for inv in self.inverters:
|
||||||
if inv.delivery_wd.tripped:
|
if inv.delivery_wd.tripped:
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"SAFETY-42 was TRIPPED on %s before this restart and stays "
|
"%s is still marked as not delivering after the restart: "
|
||||||
"tripped: %s. NOTHING IS BEING COMMANDED DIFFERENTLY - this "
|
"%s. Control keeps running, and only stopping and starting "
|
||||||
"guard reports and does not act - so if that inverter is "
|
"control clears this.",
|
||||||
"still not delivering, the site is still short by its "
|
|
||||||
"share. Restarting does not clear this.",
|
|
||||||
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
||||||
|
|
||||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||||
@@ -650,10 +651,9 @@ class Controller:
|
|||||||
reason=str(store.data.get("site_sign_reason", "") or ""))
|
reason=str(store.data.get("site_sign_reason", "") or ""))
|
||||||
if self.site_sign_wd.tripped:
|
if self.site_sign_wd.tripped:
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"SAFETY-30 (site) was TRIPPED before this restart and stays "
|
"Every inverter stays at 0 W after the restart because the "
|
||||||
"tripped: %s. Every inverter is held at 0 W. Restarting does "
|
"total power went the wrong way: %s. Start control once you "
|
||||||
"not clear this - start control from the UI once you know why "
|
"know why.",
|
||||||
"the site's total command opposed the meter.",
|
|
||||||
self.site_sign_wd.reason or "(reason not recorded)")
|
self.site_sign_wd.reason or "(reason not recorded)")
|
||||||
# And the per-unit detectors are told which question they are asking,
|
# And the per-unit detectors are told which question they are asking,
|
||||||
# here as well as in `cycle()`. `checks()` and the status page read
|
# here as well as in `cycle()`. `checks()` and the status page read
|
||||||
@@ -704,10 +704,9 @@ class Controller:
|
|||||||
self._announced_mono = {}
|
self._announced_mono = {}
|
||||||
if self.circ_wd.tripped:
|
if self.circ_wd.tripped:
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"SAFETY-31 was TRIPPED before this restart and stays tripped: "
|
"Every inverter stays at 0 W after the restart because the "
|
||||||
"%s. Every inverter is held at 0 W. Restarting does not clear "
|
"inverters worked against each other: %s. Start control once "
|
||||||
"this - start control from the UI once you know why two "
|
"you know why.",
|
||||||
"inverters were pushing against each other.",
|
|
||||||
self.circ_wd.reason or "(reason not recorded)")
|
self.circ_wd.reason or "(reason not recorded)")
|
||||||
|
|
||||||
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
# 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.
|
# than re-derived a cycle later. Starts False: nothing is on the wire.
|
||||||
self.following = False
|
self.following = False
|
||||||
self.reason = "starting"
|
self.reason = "starting"
|
||||||
|
self.why_words = "Starting"
|
||||||
# ALLOC-01. The allocation record from the last resolution, or None
|
# ALLOC-01. The allocation record from the last resolution, or None
|
||||||
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
|
# 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
|
# 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
|
# 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:
|
# 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.
|
# 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.
|
# OPT-04. The tariff plan the BACKGROUND task last resolved, and when.
|
||||||
# cycle() reads these two attributes and never awaits a fetch - see
|
# cycle() reads these two attributes and never awaits a fetch - see
|
||||||
@@ -1005,10 +1005,15 @@ class Controller:
|
|||||||
self.loop_pass_mono = None
|
self.loop_pass_mono = None
|
||||||
|
|
||||||
# -- helpers -------------------------------------------------------------
|
# -- 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")
|
stamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
|
||||||
self.events.insert(0, f"{stamp} {msg}")
|
self.events.insert(0, f"{stamp} {msg}")
|
||||||
del self.events[40:]
|
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:
|
def _grade_store_migration(self, store) -> None:
|
||||||
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
|
"""DEV-02. Did SAFETY-02's latch actually survive the key rename?
|
||||||
@@ -1066,9 +1071,9 @@ class Controller:
|
|||||||
"""
|
"""
|
||||||
rep = self.store_migration
|
rep = self.store_migration
|
||||||
if rep.ran and not rep.ok:
|
if rep.ran and not rep.ok:
|
||||||
_LOG.error("SAFETY-02: the per-inverter store migration did not "
|
_LOG.error("The saved safety stops did not copy over cleanly "
|
||||||
"verify (%s). The latch is being re-derived from the "
|
"(%s), so they are read again from the older saved "
|
||||||
"legacy keys.", rep.explain())
|
"state.", rep.explain())
|
||||||
data = getattr(store, "data", {})
|
data = getattr(store, "data", {})
|
||||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||||
if not legacy_tripped or not self.inverters:
|
if not legacy_tripped or not self.inverters:
|
||||||
@@ -1194,10 +1199,8 @@ class Controller:
|
|||||||
return
|
return
|
||||||
if stranded:
|
if stranded:
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"SAFETY-44: this store carries a LATCHED sign-disagreement "
|
"A saved safety stop belongs to %s, which is not in the "
|
||||||
"failsafe under %s, and that unit is NOT on today's roster "
|
"inverter list %s, so it is set again on the first inverter.",
|
||||||
"%s - so no machine reads it and the site came up DRIVING on "
|
|
||||||
"a state file that says it is latched. Re-arming below.",
|
|
||||||
", ".join(sorted(stranded)), sorted(on_roster))
|
", ".join(sorted(stranded)), sorted(on_roster))
|
||||||
first = self.inverters[0]
|
first = self.inverters[0]
|
||||||
# The one state this method exists for.
|
# 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_reason", first.sign_wd.reason)
|
||||||
first.store_set("sign_tripped", True)
|
first.store_set("sign_tripped", True)
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"SAFETY-02 LATCH GUARD: this store carries a LATCHED "
|
"%s is set to 0 W again, because an older saved safety stop was "
|
||||||
"sign-disagreement failsafe under the pre-DEV-02 flat key and NO "
|
"found: %s. Start control once you know why.",
|
||||||
"inverter came up latched. Either the per-inverter migration lost "
|
first.name, first.sign_wd.reason)
|
||||||
"it, or it was written by an older add-on while this install was "
|
_LOG.debug(
|
||||||
"rolled back. Both are a disarmed safety guard on an install whose "
|
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
||||||
"state file says it is protected, so the latch has been re-armed "
|
"sign-disagreement failsafe is latched and no inverter came up "
|
||||||
"on %s from the legacy value: %s. %s is held at 0 W until control "
|
"latched. Either the per-inverter migration lost it, or an older "
|
||||||
"is started from the UI.",
|
"add-on wrote it during a rollback. Both would be a disarmed "
|
||||||
first.name, first.sign_wd.reason, first.name)
|
"guard, so the latch is re-armed on %s.", first.name)
|
||||||
self.log_event("FAILSAFE re-armed from the pre-DEV-02 store keys - "
|
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 ---------------------------------
|
# -- the roster, and the N=1 views of it ---------------------------------
|
||||||
@property
|
@property
|
||||||
@@ -1805,7 +1809,7 @@ class Controller:
|
|||||||
now_p = await self.hass.number(now_e)
|
now_p = await self.hass.number(now_e)
|
||||||
avg_p = await self.hass.number(avg_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.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:
|
else:
|
||||||
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
# TARIFF-03. §9.2 makes a static day/night tariff a first-class
|
||||||
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
|
# product, and its required EMS handling is TIME-WINDOW SHIFTING.
|
||||||
@@ -1851,7 +1855,8 @@ class Controller:
|
|||||||
|
|
||||||
if self.inputs_ok:
|
if self.inputs_ok:
|
||||||
if self.inputs_bad_since is not None:
|
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
|
self.inputs_bad_since = None
|
||||||
elif self.inputs_bad_since is None:
|
elif self.inputs_bad_since is None:
|
||||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
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.
|
# §11.1 rule 1 and is already what a missing reading does here.
|
||||||
inv.batt = inv.batt_fresh.reading()
|
inv.batt = inv.batt_fresh.reading()
|
||||||
if inv.batt_fresh.stale and not was_stale:
|
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
|
# ⚠️ SAFETY-57 REWROTE THE LAST SENTENCE OF THIS LINE AND NOTHING
|
||||||
# ELSE IN IT. The escape clause used to read "It clears itself the
|
# ELSE IN IT. The escape clause used to read "It clears itself the
|
||||||
# moment the register reports a different value", full stop - and
|
# 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.
|
# this line already interpolates, is where the pair is spelled out.
|
||||||
# Saying it in both halves of one sentence is how an operator
|
# Saying it in both halves of one sentence is how an operator
|
||||||
# learns to skim it.
|
# learns to skim it.
|
||||||
_LOG.error("SAFETY-37: %s battery-power reading did not move under "
|
# UX-08 2d: plain, with SAFETY-57's escape clause kept as an
|
||||||
"a command that did, WHICH IS NOT BY ITSELF A VERDICT "
|
# ordered pair (time, then power), and the reason still in `%s`.
|
||||||
"ON THE REGISTER - %s. Treating it as MISSING, which "
|
_LOG.error("%s: battery power reading stopped changing. Inverters "
|
||||||
"commands 0 W. It clears itself the moment the register "
|
"go to 0 W until it changes, or %.0f s after the "
|
||||||
"reports a different value, and failing that %.0f s "
|
"command drops below %.0f W: %s",
|
||||||
"after the standing command falls below %.0f W "
|
inv.name, inv.batt_fresh.bound.release_s,
|
||||||
"(SAFETY-57: a verdict whose own consequence is a 0 W "
|
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
|
||||||
"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)
|
|
||||||
elif inv.batt_fresh.releases > was_releases:
|
elif inv.batt_fresh.releases > was_releases:
|
||||||
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
|
# ⚠️ 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
|
# 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
|
# one is withdrawing a SITE STOP. `release_note()` is written to say
|
||||||
# that nothing has been learned about the register, because nothing
|
# that nothing has been learned about the register, because nothing
|
||||||
# has.
|
# has.
|
||||||
self.log_event(f"STALE WITHDRAWN {inv.name}")
|
self.log_event(f"STALE WITHDRAWN {inv.name}",
|
||||||
_LOG.warning("SAFETY-57: %s",
|
hint=messages.hint("battery_back", unit=inv.name))
|
||||||
inv.batt_fresh.release_note(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
|
@property
|
||||||
def shadow(self) -> bool:
|
def shadow(self) -> bool:
|
||||||
@@ -2066,14 +2077,15 @@ class Controller:
|
|||||||
self.log_event(
|
self.log_event(
|
||||||
f"refusing to broadcast {float(value):+.0f} W to "
|
f"refusing to broadcast {float(value):+.0f} W to "
|
||||||
f"{len(targets)} inverters - that is the ARCH-01 fault; "
|
f"{len(targets)} inverters - that is the ARCH-01 fault; "
|
||||||
f"commanding 0 W")
|
f"commanding 0 W", hint=messages.hint("all_blocked"))
|
||||||
_LOG.error("write_setpoint() was asked to put %+.0f W on every "
|
_LOG.error("Refused to send %+.0f W to all %d inverters at "
|
||||||
"one of %d inverters. A site target handed to each "
|
"once, so every inverter is set to 0 W. This is a "
|
||||||
"unit is supplied by EACH of them: the gain at the "
|
"software fault.", float(value), len(targets))
|
||||||
"meter becomes N times the per-unit gain. Only 0 W "
|
_LOG.debug("ARCH-01: a site target handed to each unit is "
|
||||||
"may be written site-wide; shares come from the "
|
"supplied by each of them, so the gain at the meter "
|
||||||
"allocator. Commanding 0 W.", float(value),
|
"becomes N times the per-unit gain. Only 0 W may be "
|
||||||
len(targets))
|
"written site-wide, and shares come from the "
|
||||||
|
"allocator.")
|
||||||
value = 0.0
|
value = 0.0
|
||||||
else:
|
else:
|
||||||
targets = [unit]
|
targets = [unit]
|
||||||
@@ -2101,7 +2113,8 @@ class Controller:
|
|||||||
# must not be counted as a clamp engagement, or a transport bug lands in
|
# must not be counted as a clamp engagement, or a transport bug lands in
|
||||||
# the winding-loop counter.
|
# the winding-loop counter.
|
||||||
if not is_commandable(value):
|
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 "
|
_LOG.error("refusing to write a non-finite setpoint %r - commanding "
|
||||||
"0 W and continuing to command. This is a bug upstream, "
|
"0 W and continuing to command. This is a bug upstream, "
|
||||||
"not a limit.", value)
|
"not a limit.", value)
|
||||||
@@ -2139,11 +2152,11 @@ class Controller:
|
|||||||
refusal = self.write_refusal(value, inv)
|
refusal = self.write_refusal(value, inv)
|
||||||
if refusal is not None:
|
if refusal is not None:
|
||||||
self.log_event(
|
self.log_event(
|
||||||
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W")
|
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
|
||||||
_LOG.error("SAFETY-28 REFUSED a write of %+.0f W to %s: %s. "
|
hint=messages.hint("write_blocked", unit=inv.name))
|
||||||
"Commanding 0 W instead. Every setpoint must be one the "
|
_LOG.error("Refused to send %+.0f W to %s (%s), so it is set to "
|
||||||
"allocator placed on THIS tick, for THIS unit - see "
|
"0 W instead. This is a software fault.",
|
||||||
"Controller.write_refusal().", value, inv.name, refusal)
|
value, inv.name, refusal)
|
||||||
value = 0.0
|
value = 0.0
|
||||||
|
|
||||||
# ⚠️ THIS UNIT'S OWN THREE NUMBERS. `inv.max_w` is the supervised cap on
|
# ⚠️ 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
|
# (§15), and it is a different surface from the notification, which
|
||||||
# IS deduplicated. The count is what says how hard the loop pushed;
|
# IS deduplicated. The count is what says how hard the loop pushed;
|
||||||
# the notification only says that it did.
|
# 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
|
value = result.applied_w
|
||||||
|
|
||||||
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
|
# --- SAFETY-10: AND THIS IS THE LAST THING BEFORE THE WIRE -----------
|
||||||
@@ -2209,12 +2223,12 @@ class Controller:
|
|||||||
timeout=WRITE_BUDGET_S)
|
timeout=WRITE_BUDGET_S)
|
||||||
except (asyncio.TimeoutError, TimeoutError):
|
except (asyncio.TimeoutError, TimeoutError):
|
||||||
ok = False
|
ok = False
|
||||||
_LOG.error("the write of %+.0f W to %s (%s) did not complete in "
|
_LOG.error("Sending %+.0f W to %s (%s) took over %.0f s and was "
|
||||||
"%.0f s and was abandoned. It will be re-issued on the "
|
"dropped, so it is sent again next cycle.",
|
||||||
"next tick; no other inverter waited for it.",
|
|
||||||
value, inv.name, entity, WRITE_BUDGET_S)
|
value, inv.name, entity, WRITE_BUDGET_S)
|
||||||
self.log_event(f"{inv.name}: write timed out after "
|
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)
|
now = datetime.now(timezone.utc)
|
||||||
inv.note_write(bool(ok), now)
|
inv.note_write(bool(ok), now)
|
||||||
if ok:
|
if ok:
|
||||||
@@ -2339,7 +2353,8 @@ class Controller:
|
|||||||
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
|
for inv, machine, blocked in zip(self.inverters, self.maints, maint_plan):
|
||||||
res = machine.tick(now, inv.soc, blocked_by=blocked)
|
res = machine.tick(now, inv.soc, blocked_by=blocked)
|
||||||
for msg in res.events:
|
for msg in res.events:
|
||||||
self.log_event(msg)
|
self.log_event(msg, hint=messages.hint("maintenance",
|
||||||
|
phase=machine.phase))
|
||||||
maint_results.append(res)
|
maint_results.append(res)
|
||||||
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
|
# ⚠️ 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),
|
# 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.
|
# operator press that clears the namespaced ones.
|
||||||
self.store.set("sign_reason", inv.sign_wd.reason)
|
self.store.set("sign_reason", inv.sign_wd.reason)
|
||||||
self.store.set("sign_tripped", True)
|
self.store.set("sign_tripped", True)
|
||||||
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason)
|
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
|
||||||
_LOG.error("SAFETY-02 TRIPPED on %s - %s. Commanding 0 W on "
|
hint=messages.hint("direction", unit=inv.name))
|
||||||
"that inverter and holding it until control is "
|
_LOG.error("%s is set to 0 W because its power went the wrong "
|
||||||
"started again. This survives an add-on restart; "
|
"way: %s. Only starting control clears this.",
|
||||||
"restarting will not clear it.",
|
|
||||||
inv.name, inv.sign_wd.reason)
|
inv.name, inv.sign_wd.reason)
|
||||||
# ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'.
|
# ⚠️ 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
|
# SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour
|
||||||
@@ -2481,14 +2495,11 @@ class Controller:
|
|||||||
# the same reason `circ_*` is.
|
# the same reason `circ_*` is.
|
||||||
self.store.set("site_sign_reason", self.site_sign_wd.reason)
|
self.store.set("site_sign_reason", self.site_sign_wd.reason)
|
||||||
self.store.set("site_sign_tripped", True)
|
self.store.set("site_sign_tripped", True)
|
||||||
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason)
|
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason,
|
||||||
_LOG.error("SAFETY-30 TRIPPED at the SITE - %s. That is the "
|
hint=messages.hint("site_direction"))
|
||||||
"SUM of every standing command opposing the meter, "
|
_LOG.error("Every inverter is set to 0 W because the total "
|
||||||
"which no single inverter is answerable for. "
|
"power went the wrong way: %s. Only starting "
|
||||||
"Commanding 0 W on every inverter and holding it "
|
"control clears this.", self.site_sign_wd.reason)
|
||||||
"until control is started again. This survives an "
|
|
||||||
"add-on restart; restarting will not clear it.",
|
|
||||||
self.site_sign_wd.reason)
|
|
||||||
|
|
||||||
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
|
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
|
||||||
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
|
# 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_reason", self.circ_wd.reason)
|
||||||
self.store.set("circ_tripped", True)
|
self.store.set("circ_tripped", True)
|
||||||
self._circ_announced = False
|
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(
|
_LOG.error(
|
||||||
"SAFETY-31 TRIPPED - %s. Commanding 0 W on every inverter and "
|
"Every inverter is set to 0 W because the inverters worked "
|
||||||
"holding it until control is started again. This survives an "
|
"against each other: %s. Only starting control clears this.",
|
||||||
"add-on restart AND a Home Assistant restart; restarting will "
|
self.circ_wd.reason)
|
||||||
"not clear it.", self.circ_wd.reason)
|
|
||||||
|
|
||||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's 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
|
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
|
||||||
@@ -2623,16 +2634,18 @@ class Controller:
|
|||||||
inv.store_set("delivery_tripped", True)
|
inv.store_set("delivery_tripped", True)
|
||||||
self._delivery_announced.discard(inv.name)
|
self._delivery_announced.discard(inv.name)
|
||||||
self.log_event(f"NOT DELIVERING {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(
|
_LOG.error(
|
||||||
"SAFETY-42 TRIPPED on %s - %s. NOTHING IS COMMANDED "
|
"%s is not delivering: %s. Nothing was changed and control "
|
||||||
"DIFFERENTLY: this guard reports and does not act, because "
|
"keeps running, and only stopping and starting control "
|
||||||
"commanding a unit that is not listening achieves nothing "
|
"clears this.", inv.name, inv.delivery_wd.reason)
|
||||||
"(HW-01) and the only re-allocation this add-on can express "
|
_LOG.debug(
|
||||||
"would throw away a partially-delivering unit's remaining "
|
"SAFETY-42 reports and does not act: commanding a unit "
|
||||||
"output. The site is short by that unit's share until "
|
"that is not listening achieves nothing (HW-01), and the "
|
||||||
"somebody looks at it. This survives an add-on AND a Home "
|
"only re-allocation the add-on can express would throw "
|
||||||
"Assistant restart.", inv.name, inv.delivery_wd.reason)
|
"away a partly delivering unit's output.")
|
||||||
|
|
||||||
stale_after = float(self.o.get("stale_input_s", 15))
|
stale_after = float(self.o.get("stale_input_s", 15))
|
||||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
# ⚠️ `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
|
# A single missed poll is not a fault. Hold, keep the heartbeat
|
||||||
# going, and give it a few seconds to come back.
|
# going, and give it a few seconds to come back.
|
||||||
self.reason = f"inputs missing {bad_for:.0f}s"
|
self.reason = f"inputs missing {bad_for:.0f}s"
|
||||||
|
self.why_words = messages.why_waiting(bad_for)
|
||||||
return
|
return
|
||||||
|
|
||||||
claims = []
|
claims = []
|
||||||
@@ -2828,7 +2842,8 @@ class Controller:
|
|||||||
actuator=self.actuator_of("safety")))
|
actuator=self.actuator_of("safety")))
|
||||||
elif not self.inputs_ok:
|
elif not self.inputs_ok:
|
||||||
if self.target != 0.0:
|
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",
|
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
|
||||||
actuator=self.actuator_of("safety")))
|
actuator=self.actuator_of("safety")))
|
||||||
|
|
||||||
@@ -2949,7 +2964,12 @@ class Controller:
|
|||||||
if engaged_now != self._peak_engaged:
|
if engaged_now != self._peak_engaged:
|
||||||
self._peak_engaged = engaged_now
|
self._peak_engaged = engaged_now
|
||||||
if self.peak_guard is not None:
|
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) ----------------------
|
# --- OPT-04: price arbitrage (§10.1 priority 3) ----------------------
|
||||||
# ONE `set` claim at P_ARBITRAGE, and app/arbitrage.py carries the whole
|
# 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.
|
# operator needs - "it started buying at 03:00", not 240 copies.
|
||||||
if self.arb.action != self._arb_action:
|
if self.arb.action != self._arb_action:
|
||||||
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 -----------------------------------------------------
|
# --- maintenance -----------------------------------------------------
|
||||||
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
|
# ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST STAYS SHORT.
|
||||||
@@ -3145,7 +3168,8 @@ class Controller:
|
|||||||
self._maint_shape_suspended = True
|
self._maint_shape_suspended = True
|
||||||
self.log_event("peak at risk - maintenance charge shaping "
|
self.log_event("peak at risk - maintenance charge shaping "
|
||||||
"suspended so the peak claim is not "
|
"suspended so the peak claim is not "
|
||||||
"contradicted")
|
"contradicted",
|
||||||
|
hint=messages.hint("maint_paused"))
|
||||||
else:
|
else:
|
||||||
self._maint_shape_suspended = False
|
self._maint_shape_suspended = False
|
||||||
hi = 0.0
|
hi = 0.0
|
||||||
@@ -3229,7 +3253,8 @@ class Controller:
|
|||||||
)
|
)
|
||||||
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
|
||||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
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.sat_count = decision.sat_count
|
||||||
self.i_w = decision.i_w
|
self.i_w = decision.i_w
|
||||||
self.loop_w = decision.target_w
|
self.loop_w = decision.target_w
|
||||||
@@ -3358,7 +3383,8 @@ class Controller:
|
|||||||
# made by commanding one pack to charge while another discharges.
|
# made by commanding one pack to charge while another discharges.
|
||||||
# `allocator.plan()` carries the argument for each.
|
# `allocator.plan()` carries the argument for each.
|
||||||
if not resolution.allocated:
|
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 = Resolution(0.0, "failsafe", None, False,
|
||||||
resolution.considered,
|
resolution.considered,
|
||||||
unit_bounds=resolution.unit_bounds,
|
unit_bounds=resolution.unit_bounds,
|
||||||
@@ -3394,7 +3420,7 @@ class Controller:
|
|||||||
f"ARBITER: the site target was allocated across "
|
f"ARBITER: the site target was allocated across "
|
||||||
f"{len(resolution.shares)} inverters and this controller has no "
|
f"{len(resolution.shares)} inverters and this controller has no "
|
||||||
f"setpoint entity for {undeliverable} - refusing to command a "
|
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
|
# ⚠️ `shares={}`, NOT THE SHARES IT REFUSED TO DELIVER, AND SINCE
|
||||||
# DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below
|
# DEV-02 THAT IS LOAD-BEARING RATHER THAN TIDY. The write loop below
|
||||||
# commands each inverter `shares.get(name, 0.0)`, so carrying the
|
# 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 ()):
|
for _u, _why in (self.allocation.dropped if self.allocation else ()):
|
||||||
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
|
self.log_event(f"ALLOCATOR: {_u} dropped from the split "
|
||||||
f"({_why}) - the remaining inverter(s) now carry "
|
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:
|
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
|
self._dropped_last = _dropped
|
||||||
# 3b. OPT-09 / IF-04: WATTS NOBODY COULD TAKE ARE NEVER SILENT EITHER.
|
# 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
|
# `unmet_w` is the site target the envelopes could not absorb. It is
|
||||||
@@ -3441,9 +3469,11 @@ class Controller:
|
|||||||
if _unmet:
|
if _unmet:
|
||||||
self.log_event(
|
self.log_event(
|
||||||
f"ALLOCATOR: {_unmet:+d} W of the site target could not be "
|
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:
|
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
|
self._unmet_last = _unmet
|
||||||
if resolution.scope_errors:
|
if resolution.scope_errors:
|
||||||
# A claim reached the arbiter on an actuator its source's scope does
|
# 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
|
# 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
|
# other unit never gets. Loud, every cycle, and it does not change
|
||||||
# the resolution - the arbiter already routed by `actuator`.
|
# 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:
|
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 ------------------------------------------
|
# --- SAFETY-02's arming gate ------------------------------------------
|
||||||
# Was the number about to go on the wire chosen by the grid-following
|
# 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?
|
# 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.following = loop_is_following(resolution, self.loop_w)
|
||||||
self.target = resolution.target_w
|
self.target = resolution.target_w
|
||||||
self.reason = resolution.explain()
|
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 ---------------------
|
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
|
||||||
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
|
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
|
||||||
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
|
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
|
||||||
@@ -3647,20 +3690,20 @@ class Controller:
|
|||||||
self.log_event(
|
self.log_event(
|
||||||
"SHADOW MODE ON - " + shadow.why(
|
"SHADOW MODE ON - " + shadow.why(
|
||||||
self.o, control_started=self.auto)
|
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(
|
_LOG.warning(
|
||||||
"SAFETY-10 SHADOW MODE IS ON. The full decision path runs "
|
"Shadow mode is on, so every inverter stays at %.0f W and "
|
||||||
"and NOTHING is commanded: every inverter is held at "
|
"nothing the add-on decides is sent. %s.", shadow.WIRE_W,
|
||||||
"%+.0f W on the ordinary heartbeat. %s", shadow.WIRE_W,
|
|
||||||
shadow.why(self.o, control_started=self.auto))
|
shadow.why(self.o, control_started=self.auto))
|
||||||
elif not first:
|
elif not first:
|
||||||
self.log_event(
|
self.log_event(
|
||||||
"SHADOW MODE OFF - the battery is being controlled again, "
|
"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(
|
_LOG.warning(
|
||||||
"SAFETY-10 shadow mode is OFF. This controller is "
|
"Shadow mode is off, so the add-on controls the battery "
|
||||||
"commanding the battery again, starting from the "
|
"again, starting from %.0f W.", shadow.WIRE_W)
|
||||||
"%+.0f W that was standing on the wire.", shadow.WIRE_W)
|
|
||||||
if not engaged:
|
if not engaged:
|
||||||
self.shadow_would_w = None
|
self.shadow_would_w = None
|
||||||
self.shadow_sized_from_meter = False
|
self.shadow_sized_from_meter = False
|
||||||
@@ -3693,7 +3736,9 @@ class Controller:
|
|||||||
self._shadow_winner = edge
|
self._shadow_winner = edge
|
||||||
self.log_event(shadow.log_line(
|
self.log_event(shadow.log_line(
|
||||||
would_w=resolution.target_w, reason=resolution.explain(),
|
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
|
self.target = shadow.WIRE_W
|
||||||
for inv in self.inverters:
|
for inv in self.inverters:
|
||||||
inv.target = shadow.WIRE_W
|
inv.target = shadow.WIRE_W
|
||||||
@@ -3771,25 +3816,29 @@ class Controller:
|
|||||||
stamp = self.events[0].split(" ", 1)[0]
|
stamp = self.events[0].split(" ", 1)[0]
|
||||||
self.events[0] = f"{stamp} {row}"
|
self.events[0] = f"{stamp} {row}"
|
||||||
else:
|
else:
|
||||||
self.log_event(row)
|
self.log_event(row, hint=messages.hint(
|
||||||
|
"power_too_high" if bug else "not_keeping_up"))
|
||||||
if not fired:
|
if not fired:
|
||||||
return
|
return
|
||||||
if not self.clamp_wd.bug:
|
if not self.clamp_wd.bug:
|
||||||
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
|
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
|
||||||
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
|
||||||
# it can never replace a BUG SIGNAL notice still on screen.
|
# it can never replace a BUG SIGNAL notice still on screen.
|
||||||
_LOG.warning("SAFETY-03 - %s Engagements so far: %d.",
|
_LOG.warning("The battery gives less than asked: %s Held back %d "
|
||||||
self.clamp_wd.reason, self.clamp_wd.engagements)
|
"times so far.", self.clamp_wd.reason,
|
||||||
|
self.clamp_wd.engagements)
|
||||||
await self.hass.call("persistent_notification", "create",
|
await self.hass.call("persistent_notification", "create",
|
||||||
messages.loop_bound().data())
|
messages.loop_bound().data())
|
||||||
return
|
return
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"SAFETY-03 - %s Silently clipping this is how a winding integrator "
|
"Power request too high, which is a software fault: %s Control is "
|
||||||
"reached 14 768 W on this project before: the clamp makes every "
|
"still running. Cut back %d times so far.",
|
||||||
"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.",
|
|
||||||
self.clamp_wd.reason, self.clamp_wd.engagements)
|
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 -
|
# A fixed notification_id, for the reason check_meter_frozen() gives -
|
||||||
# and a DIFFERENT one from every other alarm this add-on raises, because
|
# 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.
|
# 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
|
was, self._peak_saturated = self._peak_saturated, saturated
|
||||||
if not saturated or was:
|
if not saturated or was:
|
||||||
return
|
return
|
||||||
self.log_event("PEAK GUARD SATURATED - " + g.reason)
|
self.log_event("PEAK GUARD SATURATED - " + g.reason,
|
||||||
|
hint=messages.hint("peak_saturated"))
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"SAFETY-07 - %s. The guard is asking for everything the inverter "
|
"The battery is asked for all it can give, but this 15-minute "
|
||||||
"has and the quarter is still projected over the ceiling: this "
|
"period still goes over the peak limit: %s. Switch off large "
|
||||||
"quarter will set or raise the month's billed capacity peak. The "
|
"loads.", g.reason)
|
||||||
"controller is STILL RUNNING and still shaving; there is nothing "
|
|
||||||
"further it can do. Shed load.", g.reason)
|
|
||||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||||
# is not a frozen meter and is not an out-of-range setpoint.
|
# 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
|
# 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
|
# 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.
|
# 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(
|
_LOG.error(
|
||||||
"SAFETY-24 - %s. The controller is STILL RUNNING and charging is "
|
"The peak guard may not draw the battery lower, so this 15-minute "
|
||||||
"still blocked, but it is not discharging to hold this quarter, so "
|
"period will go over the peak limit: %s. Switch off large loads.",
|
||||||
"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.",
|
|
||||||
g.reason)
|
g.reason)
|
||||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||||
# here - check_meter_frozen() gives the reasoning. A battery at its
|
# 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"))
|
dev_max=self.site_dev_bound("dev_max"))
|
||||||
if not fired:
|
if not fired:
|
||||||
return
|
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(
|
_LOG.error(
|
||||||
"SAFETY-18 - %s. The grid-following loop is regulating against a "
|
"The meter shows the same value while the battery is asked for "
|
||||||
"number that has stopped moving, and nothing in this stack stops "
|
"full power: %s. Control still follows it, so check the meter.",
|
||||||
"it: the meter is still answering, so the age is fresh, the "
|
self.freeze_wd.reason)
|
||||||
"firmware watchdog is fed and the arbiter only bounds the "
|
_LOG.debug(
|
||||||
"magnitude. CHECK THE METER. Sustained for a quarter of an hour "
|
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
||||||
"this sets the month's billed capacity-tariff peak. This is an "
|
"its age is fresh, the firmware watchdog is fed and the arbiter "
|
||||||
"ALARM ONLY - the loop is still running.", self.freeze_wd.reason)
|
"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
|
# ⚠️ A fixed notification_id, so a second episode REPLACES the first
|
||||||
# rather than stacking. An operator who has to dismiss eleven identical
|
# rather than stacking. An operator who has to dismiss eleven identical
|
||||||
# notifications learns to dismiss the twelfth without reading it.
|
# notifications learns to dismiss the twelfth without reading it.
|
||||||
@@ -4168,12 +4216,9 @@ class Controller:
|
|||||||
and standing is not None and standing.from_feed)
|
and standing is not None and standing.from_feed)
|
||||||
if keep:
|
if keep:
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"tariff refresh degraded (%s); KEEPING the plan "
|
"The price update was incomplete (%s), so the plan "
|
||||||
"resolved at %s, %.0f s ago. tariff_at is NOT moved, "
|
"from %s, %.0f s ago, is kept until it is %.0f s old.",
|
||||||
"so it retires %.0f s after that fetch; arbitrage, "
|
plan.words, self.tariff_at,
|
||||||
"where it is armed, stops with it. A kept plan is "
|
|
||||||
"never presented as a current one.", plan.words,
|
|
||||||
self.tariff_at,
|
|
||||||
(now - self.tariff_at).total_seconds(),
|
(now - self.tariff_at).total_seconds(),
|
||||||
TARIFF_MAX_AGE_S)
|
TARIFF_MAX_AGE_S)
|
||||||
else:
|
else:
|
||||||
@@ -4185,9 +4230,9 @@ class Controller:
|
|||||||
# here is something nobody expected, and the plan is kept for
|
# here is something nobody expected, and the plan is kept for
|
||||||
# the same reason and on the same terms.
|
# the same reason and on the same terms.
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"tariff refresh raised (%s); keeping the plan resolved at "
|
"The price update failed (%s), so the plan from %s is kept "
|
||||||
"%s. It retires on its own after %.0f s and arbitrage "
|
"until it is %.0f s old, and trading stops then.",
|
||||||
"stops with it.", err, self.tariff_at, TARIFF_MAX_AGE_S)
|
err, self.tariff_at, TARIFF_MAX_AGE_S)
|
||||||
with contextlib.suppress(asyncio.TimeoutError):
|
with contextlib.suppress(asyncio.TimeoutError):
|
||||||
await asyncio.wait_for(self.stop_event.wait(),
|
await asyncio.wait_for(self.stop_event.wait(),
|
||||||
timeout=TARIFF_REFRESH_S)
|
timeout=TARIFF_REFRESH_S)
|
||||||
@@ -4239,7 +4284,8 @@ class Controller:
|
|||||||
if problems:
|
if problems:
|
||||||
refuse(problems)
|
refuse(problems)
|
||||||
self.refused = 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
|
# Signal, then return WITHOUT entering the loop. amain() wakes
|
||||||
# on this, runs the normal wind-down (which commands 0 W) and
|
# on this, runs the normal wind-down (which commands 0 W) and
|
||||||
# re-raises ConfigRefused afterwards, so the exit code is 78 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 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
|
# 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.
|
# 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)
|
_pk = self.peak.peak(when)
|
||||||
if _pk.is_unreadable:
|
if _pk.is_unreadable:
|
||||||
out.append({"ok": False, "warn": True,
|
out.append({"ok": False, "warn": True, "id": "month_peak",
|
||||||
"text": "capacity-tariff peak: " + _pk.explain()})
|
"text": messages.row_month_peak_unreadable()})
|
||||||
else:
|
else:
|
||||||
# §9.1's "warning entity at 80 %", as a status row rather than
|
# §9.1's "warning entity at 80 %", as a status row rather than
|
||||||
# a second entity - see the constructor on not doubling up.
|
# a second entity - see the constructor on not doubling up.
|
||||||
_frac = self.peak.warning_frac(when) or 0.0
|
_frac = self.peak.warning_frac(when) or 0.0
|
||||||
_ok = _frac < 0.8
|
_ok = _frac < 0.8
|
||||||
out.append({"ok": _ok, "warn": not _ok,
|
out.append({"ok": _ok, "warn": not _ok, "id": "month_peak",
|
||||||
"text": _pk.explain()
|
"text": (messages.row_month_peak_none()
|
||||||
+ f" - {_frac * 100:.0f} % of the "
|
if not _pk.is_observed else
|
||||||
f"{self.peak.cap_w:.0f} W soft limit"
|
messages.row_month_peak(
|
||||||
+ f", {self.peak.recorded} quarter(s) banked"
|
_pk.peak_w,
|
||||||
+ f", on start: {self.peak.restore_state}"})
|
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"
|
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
|
||||||
# are opposite states and a page that spelled them the same way
|
# are opposite states and a page that spelled them the same way
|
||||||
@@ -4715,10 +4768,16 @@ class Controller:
|
|||||||
else ()))})
|
else ()))})
|
||||||
elif _pg.blind:
|
elif _pg.blind:
|
||||||
out.append({"ok": False, "warn": True, "id": "peak_guard",
|
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:
|
else:
|
||||||
out.append({"ok": not _pg.engaged, "warn": _pg.engaged,
|
out.append({"ok": True, "warn": False, "id": "peak_guard",
|
||||||
"id": "peak_guard", "text": _pg.explain()})
|
"text": _pg.explain()})
|
||||||
else:
|
else:
|
||||||
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
|
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the point.
|
||||||
# SAFETY-07 is NOT COVERED on the legacy single-entity path - the
|
# 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.
|
# watching something that cannot happen. Say what is true instead.
|
||||||
out.append({"ok": True, "warn": False, "id": "circulating",
|
out.append({"ok": True, "warn": False, "id": "circulating",
|
||||||
"text": messages.row_one_inverter(
|
"text": messages.row_one_inverter(
|
||||||
"Circulating power check")})
|
messages.CHECK_CIRCULATING)})
|
||||||
else:
|
else:
|
||||||
held = self.circ_wd.held_s(datetime.now(timezone.utc))
|
held = self.circ_wd.held_s(datetime.now(timezone.utc))
|
||||||
out.append({"ok": True, "warn": False, "id": "circulating",
|
out.append({"ok": True, "warn": False, "id": "circulating",
|
||||||
@@ -5079,7 +5138,7 @@ class Controller:
|
|||||||
# the row's first sentence, so it stays a headline.
|
# the row's first sentence, so it stays a headline.
|
||||||
level, banner = "bad", messages.banner_not_ready(bad[0])
|
level, banner = "bad", messages.banner_not_ready(bad[0])
|
||||||
elif not self.auto:
|
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):
|
elif any(i.delivery_wd.tripped for i in self.inverters):
|
||||||
# ⚠️ SAFETY-42, AND IT IS A `warn` BANNER RATHER THAN A `bad` ONE,
|
# ⚠️ 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.
|
# 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,
|
# 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
|
# Battery 0 W" the site is either becalmed or throwing away every
|
||||||
# watt it makes, and only this row separates them.
|
# watt it makes, and only this row separates them.
|
||||||
*(() if not self.pv_roster else ((("PV", w(self.pv_w)),))),
|
*(() if not self.pv_roster else ((("Solar production",
|
||||||
("State of charge", "—" if self.pack_floor_soc is None
|
w(self.pv_w)),))),
|
||||||
|
("Battery level", "—" if self.pack_floor_soc is None
|
||||||
else f"{self.pack_floor_soc:g} %"),
|
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.
|
# SAFETY-10, and it sits BETWEEN the two rows it reconciles.
|
||||||
# "Commanded 0 W" above and a "Why" below that explains a trade is
|
# "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
|
# 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
|
# - the page runs on its own task and `self.peak_guard` has
|
||||||
# moved on. See `Controller.__init__`.
|
# moved on. See `Controller.__init__`.
|
||||||
sized_from_meter=self.shadow_sized_from_meter,
|
sized_from_meter=self.shadow_sized_from_meter,
|
||||||
reason=self.reason)),
|
reason=self.why_words)),
|
||||||
("Why", self.reason),
|
("Why", self.why_words),
|
||||||
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
|
("Maintenance phase", self.maint.phase if len(self.inverters) <= 1
|
||||||
else f"{self.maint.phase} ({self.maint_unit.name})"),
|
else f"{self.maint.phase} ({self.maint_unit.name})"),
|
||||||
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
|
# ⚠️ ONLY WHEN A PACK IS ACTUALLY WAITING, so a one-inverter status
|
||||||
@@ -5195,11 +5255,11 @@ class Controller:
|
|||||||
("Arbitrage", arbitrage_row(
|
("Arbitrage", arbitrage_row(
|
||||||
on=arbitrage.enabled(self.o), decision=self.arb,
|
on=arbitrage.enabled(self.o), decision=self.arb,
|
||||||
decided_at=self.arb_at, cycle_at=self.cycle_at,
|
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 {
|
return {
|
||||||
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
"banner": banner, "level": level, "rows": rows, "checks": checks,
|
||||||
"hint": self.events[0] if self.events else "",
|
"hint": self.hint,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def handle_action(self, what: str):
|
async def handle_action(self, what: str):
|
||||||
@@ -5207,7 +5267,9 @@ class Controller:
|
|||||||
if what == "auto_toggle":
|
if what == "auto_toggle":
|
||||||
self.auto = not self.auto
|
self.auto = not self.auto
|
||||||
self.store.set("auto", 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:
|
if self.auto:
|
||||||
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
|
# ⚠️ THE ONLY WAY OUT OF A SIGN-DISAGREEMENT TRIP, on purpose.
|
||||||
# It clears on a deliberate human start, not on the condition
|
# It clears on a deliberate human start, not on the condition
|
||||||
@@ -5230,7 +5292,9 @@ class Controller:
|
|||||||
inv.store_set("sign_tripped", False)
|
inv.store_set("sign_tripped", False)
|
||||||
inv.store_set("sign_reason", "")
|
inv.store_set("sign_reason", "")
|
||||||
self.log_event(f"sign-disagreement failsafe cleared by "
|
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.
|
# ⚠️ AND THE PRE-DEV-02 FLAT KEYS GO WITH THEM, UNCONDITIONALLY.
|
||||||
# This is what makes `flat True + namespaced False` MEAN
|
# This is what makes `flat True + namespaced False` MEAN
|
||||||
# something, and it is the reason `_grade_store_migration()` can
|
# something, and it is the reason `_grade_store_migration()` can
|
||||||
@@ -5271,7 +5335,8 @@ class Controller:
|
|||||||
# moment a second inverter was configured again.
|
# moment a second inverter was configured again.
|
||||||
if self.site_sign_wd.tripped:
|
if self.site_sign_wd.tripped:
|
||||||
self.log_event("site sign-disagreement failsafe cleared "
|
self.log_event("site sign-disagreement failsafe cleared "
|
||||||
"by operator")
|
"by operator",
|
||||||
|
hint=messages.hint("site_stop_cleared"))
|
||||||
self.site_sign_wd.reset()
|
self.site_sign_wd.reset()
|
||||||
self.store.set("site_sign_tripped", False)
|
self.store.set("site_sign_tripped", False)
|
||||||
self.store.set("site_sign_reason", "")
|
self.store.set("site_sign_reason", "")
|
||||||
@@ -5289,7 +5354,8 @@ class Controller:
|
|||||||
self.circ_wd.reset()
|
self.circ_wd.reset()
|
||||||
self.store.set("circ_tripped", False)
|
self.store.set("circ_tripped", False)
|
||||||
self.store.set("circ_reason", "")
|
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
|
self._circ_announced = False
|
||||||
# SAFETY-42, cleared by the SAME press, and for a reason that is
|
# SAFETY-42, cleared by the SAME press, and for a reason that is
|
||||||
# NOT the one above. The other three latches are cleared here
|
# NOT the one above. The other three latches are cleared here
|
||||||
@@ -5307,7 +5373,10 @@ class Controller:
|
|||||||
for inv in self.inverters:
|
for inv in self.inverters:
|
||||||
if inv.delivery_wd.tripped:
|
if inv.delivery_wd.tripped:
|
||||||
self.log_event(f"not-delivering report on {inv.name} "
|
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.delivery_wd.reset()
|
||||||
inv.store_set("delivery_tripped", False)
|
inv.store_set("delivery_tripped", False)
|
||||||
inv.store_set("delivery_reason", "")
|
inv.store_set("delivery_reason", "")
|
||||||
@@ -5333,14 +5402,18 @@ class Controller:
|
|||||||
# bare `maint_start` with no unit, and inventing a unit parameter
|
# bare `maint_start` with no unit, and inventing a unit parameter
|
||||||
# here would ship half a surface: the page would still have one
|
# here would ship half a surface: the page would still have one
|
||||||
# button and the API would have two meanings.
|
# button and the API would have two meanings.
|
||||||
for msg in self._maint_button(now).force_start(now):
|
_m = self._maint_button(now)
|
||||||
self.log_event(msg)
|
for msg in _m.force_start(now):
|
||||||
|
self.log_event(msg, hint=messages.hint("maintenance",
|
||||||
|
phase=_m.phase))
|
||||||
return {"phase": self.maint.phase}
|
return {"phase": self.maint.phase}
|
||||||
if what == "maint_abort":
|
if what == "maint_abort":
|
||||||
# Aborts the pack that is actually cycling - `_maint_button()` again
|
# Aborts the pack that is actually cycling - `_maint_button()` again
|
||||||
# - and then zeroes the SITE, which is what it always did.
|
# - and then zeroes the SITE, which is what it always did.
|
||||||
for msg in self._maint_button(now).abort(now, "operator"):
|
_m = self._maint_button(now)
|
||||||
self.log_event(msg)
|
for msg in _m.abort(now, "operator"):
|
||||||
|
self.log_event(msg, hint=messages.hint("maintenance",
|
||||||
|
phase=_m.phase))
|
||||||
await self.command_site_zero()
|
await self.command_site_zero()
|
||||||
return {"phase": self.maint.phase}
|
return {"phase": self.maint.phase}
|
||||||
return None
|
return None
|
||||||
@@ -5362,8 +5435,9 @@ def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
|
|||||||
if stop.is_set() or controller.stopping:
|
if stop.is_set() or controller.stopping:
|
||||||
return # the normal wind-down, or run_control()'s refusal path
|
return # the normal wind-down, or run_control()'s refusal path
|
||||||
exc = None if task.cancelled() else task.exception()
|
exc = None if task.cancelled() else task.exception()
|
||||||
_LOG.critical("CONTROL LOOP DIED (%s) - commanding 0 W and exiting with "
|
_LOG.critical("Control stopped unexpectedly (%s), so every inverter is "
|
||||||
"status %d", "cancelled" if task.cancelled() else
|
"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,
|
(repr(exc) if exc else "returned"), EXIT_CONTROL_DIED,
|
||||||
exc_info=exc)
|
exc_info=exc)
|
||||||
died.append(exc)
|
died.append(exc)
|
||||||
@@ -5381,21 +5455,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.
|
Asked together with a 3 s budget: a link may never hold up control start.
|
||||||
"""
|
"""
|
||||||
async def ask(url, field):
|
async def ask(url, field):
|
||||||
|
"""`(value, why)`: the field, or None and a short reason (UX-09)."""
|
||||||
try:
|
try:
|
||||||
async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={
|
async with session.get(url, timeout=DOCS_LINK_TIMEOUT_S, headers={
|
||||||
"Authorization": f"Bearer {token}"}) as r:
|
"Authorization": f"Bearer {token}"}) as r:
|
||||||
body = await r.json(content_type=None)
|
body = await r.json(content_type=None)
|
||||||
data = body.get("data", body) if isinstance(body, dict) else {}
|
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
|
except Exception as err: # noqa: BLE001 - a link, never a stop
|
||||||
_LOG.info("docs link: %s unreadable (%s)", url, err)
|
# A timeout's own text is empty, so the type is always named.
|
||||||
return None
|
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/addons/self/info", "slug"),
|
||||||
ask("http://supervisor/info", "homeassistant"))
|
ask("http://supervisor/info", "homeassistant"))
|
||||||
out = {"slug": slug, "ha_version": ha}
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ per title; the body ends with a link to it.
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
# Severity (STYLE.md section 3). An ALARM may use up to three sentences so
|
# 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.
|
# 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:
|
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:
|
def banner_direction(several: bool) -> str:
|
||||||
if several:
|
if several:
|
||||||
return "An inverter's own power opposes its command. It is at 0 W."
|
return "An inverter's own power went the wrong way. Set to 0 W."
|
||||||
return "Command and meter point opposite ways. Inverter set to 0 W."
|
return "The meter saw power go the wrong way. Inverter at 0 W."
|
||||||
|
|
||||||
|
|
||||||
def banner_not_delivering(units) -> str:
|
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."
|
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:
|
def banner_shadow(latched: bool) -> str:
|
||||||
"""SAFETY-10: first in the cascade, never displaced by another banner.
|
"""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:
|
def row_control(lo_w, hi_w, unit=None) -> str:
|
||||||
def w(v):
|
"""The inverter's own range: charging below 0 W, giving power above it."""
|
||||||
return "?" if v is None else signed_watts(v)
|
head = f"Inverter control{for_unit(unit)}: ready"
|
||||||
return f"Inverter control{for_unit(unit)}: ready, from {w(lo_w)} to {w(hi_w)}"
|
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"{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
|
# 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).
|
# Rows: the direction checks (SAFETY-02/30).
|
||||||
def row_direction_trip(unit=None) -> str:
|
def row_direction_trip(unit=None) -> str:
|
||||||
"""Per inverter. `unit` only at several inverters, graded on its own power."""
|
"""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"
|
what = (f"{unit}'s own power went against what the add-on asked, so it is"
|
||||||
if unit else "Command and meter point opposite ways, so the "
|
if unit else
|
||||||
"inverter is")
|
"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 "
|
return (f"{what} set to 0 W. Find out why, then start control. A restart "
|
||||||
f"does not clear this.")
|
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:
|
def row_site_direction_trip() -> str:
|
||||||
return ("Total command and meter point opposite ways, so every inverter is "
|
return ("Grid power went against the add-on's total request, so every "
|
||||||
"set to 0 W. The cause is the total, not one inverter. Only starting "
|
"inverter is set to 0 W. The cause is the total, not one inverter. "
|
||||||
"control clears this.")
|
"Only starting control clears this.")
|
||||||
|
|
||||||
|
|
||||||
def row_site_direction_check(held_s, hold_s) -> str:
|
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.")
|
"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:
|
def row_circulating_check(now_w, held_s, hold_s) -> str:
|
||||||
return ("Circulating power check: on"
|
if held_s:
|
||||||
+ (f", now {watts(now_w)}" if now_w else "")
|
return (f"{CHECK_CIRCULATING}: {watts(now_w or 0)} for {held_s:.0f} of "
|
||||||
+ (f", for {held_s:.0f} of {seconds(hold_s)}" if held_s else ""))
|
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:
|
def row_one_inverter(check: str) -> str:
|
||||||
@@ -676,6 +692,184 @@ def row_pv_stray(n) -> str:
|
|||||||
f"entity in use")
|
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"
|
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(2, 3, -1500, False)),
|
||||||
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
("row_pv_negative", AMBER, row_pv_negative(1, 1, -1500, True)),
|
||||||
("row_pv_stray", AMBER, row_pv_stray(2)),
|
("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:
|
def setting(key: str, unit: str = "") -> str:
|
||||||
"""`max_w` -> `“Inverter rating” (max_w)`. For one inverter in a list it is
|
"""`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
|
`“Inverter rating” for inverter-b (inverters.max_w)`, so the config panel
|
||||||
|
|||||||
@@ -472,7 +472,7 @@ class P1Ingest:
|
|||||||
"""
|
"""
|
||||||
self.parse_errors += 1
|
self.parse_errors += 1
|
||||||
self.last_error = str(err)
|
self.last_error = str(err)
|
||||||
_LOG.warning("P1 telegram rejected: %s", err)
|
_LOG.warning("Meter reading refused: %s", err)
|
||||||
|
|
||||||
# -- what consumers read -------------------------------------------------
|
# -- what consumers read -------------------------------------------------
|
||||||
def age_s(self) -> float | None:
|
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(
|
problems.append(
|
||||||
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
|
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
|
||||||
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
|
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:
|
elif source == SOURCE_HA_SIGNED:
|
||||||
net = str(opts.get("p1_net_entity", "") or "").strip()
|
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||||
phase_net = [str(e).strip() for e in
|
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:
|
try:
|
||||||
int(port_s)
|
int(port_s)
|
||||||
except ValueError:
|
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.")
|
"its port is not a number.")
|
||||||
try:
|
try:
|
||||||
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
|
# ⚠️ 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)
|
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
problems.append(f"{_S('meter_poll_s')} is "
|
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.")
|
"number.")
|
||||||
else:
|
else:
|
||||||
if poll_s <= 0:
|
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
|
# 0 W cycle, which is fail-safe. The refusal above is for cadences that
|
||||||
# can NEVER be fresh.
|
# can NEVER be fresh.
|
||||||
if poll_s > ingest.max_age_s / 2:
|
if poll_s > ingest.max_age_s / 2:
|
||||||
_LOG.warning("meter_poll_s %.1f leaves no room under meter_max_age_s "
|
_LOG.warning("“Time between meter reads” (%.1f s) is too close to "
|
||||||
"%.1f: one missed poll makes the reading stale. The meter "
|
"“Reading too old after” (%.1f s). Use 5 s and 30 s.",
|
||||||
"updates every ~5 s; 5 s against 30 s is the tested pair.",
|
|
||||||
poll_s, ingest.max_age_s)
|
poll_s, ingest.max_age_s)
|
||||||
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
|
return HomeWizardLocalSource(session, ingest, host, port=port, poll_s=poll_s)
|
||||||
if source == SOURCE_MQTT:
|
if source == SOURCE_MQTT:
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ not need re-testing. Nothing here indexes a phase.
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from . import messages
|
||||||
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
from .arbiter import ACTUATOR_SITE, P_PEAK, Claim
|
||||||
from .control import peak_at_risk
|
from .control import peak_at_risk
|
||||||
from .tariff import QUARTER_S
|
from .tariff import QUARTER_S
|
||||||
@@ -485,17 +486,29 @@ class PeakDecision:
|
|||||||
reason: str = ""
|
reason: str = ""
|
||||||
|
|
||||||
def explain(self) -> 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:
|
if self.floor_limited:
|
||||||
return self.reason
|
return self.reason
|
||||||
if not self.engaged:
|
if not self.engaged:
|
||||||
if self.projected_w is None:
|
if self.projected_w is None:
|
||||||
return f"peak guard idle - {self.reason}"
|
return messages.row_peak_guard_quiet()
|
||||||
return (f"peak guard idle - quarter projects "
|
return messages.row_peak_guard_idle(
|
||||||
f"{self.projected_w:.0f} W under a {self.ceiling_w:.0f} W "
|
self.projected_w, self.ceiling_w,
|
||||||
f"ceiling ({self.ceiling_from})")
|
month_peak=self.ceiling_from == CEILING_MONTH_PEAK)
|
||||||
return self.reason
|
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]:
|
def quarter_ceiling_w(cap_w, reading) -> tuple[float, str]:
|
||||||
"""The average this quarter may not exceed, and where the number came from.
|
"""The average this quarter may not exceed, and where the number came from.
|
||||||
|
|
||||||
|
|||||||
@@ -1314,8 +1314,8 @@ def code_leaks(cls) -> list:
|
|||||||
src = dedent(inspect.getsource(cls))
|
src = dedent(inspect.getsource(cls))
|
||||||
tree = ast.parse(src)
|
tree = ast.parse(src)
|
||||||
except (OSError, TypeError, IndentationError, SyntaxError) as e:
|
except (OSError, TypeError, IndentationError, SyntaxError) as e:
|
||||||
_LOG.warning("could not read %s's source for the framework-key scan "
|
_LOG.warning("Could not read the code of price source %s (%s), so its "
|
||||||
"(%s); the double-counting guard is NOT enforced for it",
|
"double-counting check is skipped.",
|
||||||
getattr(cls, "id", cls), e)
|
getattr(cls, "id", cls), e)
|
||||||
return []
|
return []
|
||||||
for node in ast.walk(tree):
|
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
|
# 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
|
# 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.
|
# source is going away has to be emitted while it still works.
|
||||||
_LOG.warning("the price feed reports deprecated=%r; it still answers, "
|
_LOG.warning("The price feed says it will stop (deprecated=%r), "
|
||||||
"but this endpoint is scheduled to stop",
|
"though it still answers for now.", doc.get("deprecated"))
|
||||||
doc.get("deprecated"))
|
|
||||||
out = []
|
out = []
|
||||||
dropped = []
|
dropped = []
|
||||||
for i, (t, v) in enumerate(zip(times, prices)):
|
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,
|
out.append(RawPoint(start=start, eur_per_kwh=float(v) * scale,
|
||||||
provisional=bool(provisional_default)))
|
provisional=bool(provisional_default)))
|
||||||
if dropped:
|
if dropped:
|
||||||
_LOG.warning("the feed published no price for %d of %d instants "
|
_LOG.warning("The price feed gave no price for %d of %d times (index "
|
||||||
"(index %s); those instants are left OUT of the series, "
|
"%s). Those times are left out, not counted as free.",
|
||||||
"never zero-filled - a missing hour is not a free hour",
|
|
||||||
len(dropped), len(times),
|
len(dropped), len(times),
|
||||||
", ".join(str(i) for i in dropped[:5])
|
", ".join(str(i) for i in dropped[:5])
|
||||||
+ (", ..." if len(dropped) > 5 else ""))
|
+ (", ..." 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()
|
override = str(opts.get("price_feed_url") or "").strip()
|
||||||
if override:
|
if override:
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"price_feed_url is set, so %s is requesting %s exactly as "
|
"price_feed_url is set, so %s asks for %s as given, and "
|
||||||
"given and is adding NO date range to it. The public feed "
|
"tomorrow's prices may stay provisional. Clear it to ask for %s "
|
||||||
"answers a single Brussels day to a request that names no "
|
"to %s.",
|
||||||
"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.",
|
|
||||||
plugin_id, override, *(d.isoformat()
|
plugin_id, override, *(d.isoformat()
|
||||||
for d in brussels_date_range(since, until)))
|
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
|
return override
|
||||||
first, last = brussels_date_range(since, until)
|
first, last = brussels_date_range(since, until)
|
||||||
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
|
return dated_feed_url(base, bzn=bzn, start=first.isoformat(),
|
||||||
@@ -2083,8 +2081,8 @@ def _emit_startup_preview(plugin, pid: str, points) -> None:
|
|||||||
try:
|
try:
|
||||||
lines = list(plugin.startup_preview(points))
|
lines = list(plugin.startup_preview(points))
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
_LOG.warning("%s's startup preview raised (%s: %s); prices are "
|
_LOG.warning("%s could not show its start-up preview (%s: %s). Prices "
|
||||||
"unaffected - this is a log line, not a decision",
|
"are not affected.",
|
||||||
pid, type(e).__name__, e)
|
pid, type(e).__name__, e)
|
||||||
return
|
return
|
||||||
for line in lines:
|
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
|
# 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
|
# 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.
|
# 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 "
|
_LOG.error("price_taxes_profile cannot be used (%s: %s), so no taxes "
|
||||||
"has NO tax component to add - it is not falling back to "
|
"are added to this price update. Fix the table or clear it.",
|
||||||
"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",
|
|
||||||
type(e).__name__, e)
|
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(
|
return PriceRead.no_data(
|
||||||
f"price_taxes_profile cannot be used: {e}")
|
f"price_taxes_profile cannot be used: {e}")
|
||||||
if table is not None:
|
if table is not None:
|
||||||
|
|||||||
@@ -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
|
whole day to carry, and that is the HOUR figure; the point count says how
|
||||||
finely it is priced. Neither answers for the other.
|
finely it is priced. Neither answers for the other.
|
||||||
"""
|
"""
|
||||||
return (f"{day} ({points} point{'' if points == 1 else 's'} "
|
return (f"{day} ({points} price{'' if points == 1 else 's'} "
|
||||||
f"over {hours} h)")
|
f"over {hours} hour{'' if hours == 1 else 's'})")
|
||||||
|
|
||||||
|
|
||||||
def _bad_rows(rec) -> int:
|
def _bad_rows(rec) -> int:
|
||||||
@@ -437,9 +437,9 @@ class PriceHistory:
|
|||||||
# exactly what this handler says, and a third `ValueError` subclass
|
# exactly what this handler says, and a third `ValueError` subclass
|
||||||
# nobody has thought of is how this defect arrived the first time.
|
# nobody has thought of is how this defect arrived the first time.
|
||||||
self.last_error = f"unreadable ({err})"
|
self.last_error = f"unreadable ({err})"
|
||||||
_LOG.warning("price history at %s is %s - starting from empty. "
|
_LOG.warning("Price history at %s is %s, so it starts empty. "
|
||||||
"§8.3 carry-forward has nothing to carry until the "
|
"Missing hours cannot be filled in until a full day "
|
||||||
"next refresh has written a full day",
|
"is saved.",
|
||||||
self.path, self.last_error)
|
self.path, self.last_error)
|
||||||
return
|
return
|
||||||
found = blob.get("version") if isinstance(blob, dict) else None
|
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"which is not a PriceProduct. The optimiser switches on that "
|
||||||
f"enum, so a record that cannot name one cannot be a series")
|
f"enum, so a record that cannot name one cannot be a series")
|
||||||
if bad:
|
if bad:
|
||||||
_LOG.warning("price history: %s in the %s record were dropped; "
|
_LOG.warning("Price history: %s in the %s record were dropped, "
|
||||||
"%d point(s) carried forward", bad, want, len(points))
|
"and %d points were kept.", bad, want, len(points))
|
||||||
return PriceRead.series(points, product,
|
return PriceRead.series(points, product,
|
||||||
str(rec.get("provider") or "price_history"),
|
str(rec.get("provider") or "price_history"),
|
||||||
stamp)
|
stamp)
|
||||||
|
|||||||
@@ -381,8 +381,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
|||||||
"""
|
"""
|
||||||
problems = await problems_for(proposed, hass)
|
problems = await problems_for(proposed, hass)
|
||||||
if problems:
|
if problems:
|
||||||
_LOG.warning("panel REFUSED to save: %d configuration problem(s); "
|
_LOG.warning("The settings page did not save, because of %d "
|
||||||
"nothing written. First: %s", len(problems), problems[0])
|
"problems. The first: %s", len(problems), problems[0])
|
||||||
raise SaveRefused("invalid", problems=problems)
|
raise SaveRefused("invalid", problems=problems)
|
||||||
|
|
||||||
info = await selfcfg.info()
|
info = await selfcfg.info()
|
||||||
@@ -390,8 +390,8 @@ async def save(selfcfg: SelfConfig, proposed: dict, expect: str, *,
|
|||||||
actual = fingerprint(stored)
|
actual = fingerprint(stored)
|
||||||
if actual != expect:
|
if actual != expect:
|
||||||
changed = changed_keys(stored, proposed)
|
changed = changed_keys(stored, proposed)
|
||||||
_LOG.warning("panel REFUSED to save: the stored options changed "
|
_LOG.warning("The settings page did not save, because the settings "
|
||||||
"underneath this page (keys: %s); nothing written",
|
"changed while it was open (keys: %s).",
|
||||||
", ".join(changed) or "none this page also edited")
|
", ".join(changed) or "none this page also edited")
|
||||||
raise SaveRefused("stale", changed=changed, stored=stored)
|
raise SaveRefused("stale", changed=changed, stored=stored)
|
||||||
|
|
||||||
|
|||||||
@@ -318,11 +318,8 @@ WIRE_W = 0.0
|
|||||||
#: a cap that does not bind - and a number invented for the row would be a
|
#: 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.
|
#: second helping of the defect this constant exists to disclose.
|
||||||
SIZED_FROM_METER = (
|
SIZED_FROM_METER = (
|
||||||
"⚠️ AND ITS SIZE CAME FROM THE BILLED-QUARTER METER, WHICH HAS ONLY THE "
|
"This is an upper limit, not a forecast, because the meter did not see "
|
||||||
"HOUSE IN IT WHILE THE BATTERY IS HELD AT 0 W. A live run sizes the same "
|
"the battery move.")
|
||||||
"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")
|
|
||||||
|
|
||||||
|
|
||||||
def mode(opts: dict) -> str:
|
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
|
# discount. What is true in every live state is that what this add-on
|
||||||
# decides reaches the inverter; whether control is RUNNING is the
|
# decides reaches the inverter; whether control is RUNNING is the
|
||||||
# `Stopped` banner's question and it is answered there.
|
# `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)
|
m = mode(opts)
|
||||||
|
live = "what the add-on decides reaches the inverters"
|
||||||
|
inert = "the add-on decides but sends nothing"
|
||||||
if m == "off":
|
if m == "off":
|
||||||
return ("off (shadow_mode: off) - NOT in shadow mode: what this "
|
return f"Off, {live}"
|
||||||
"add-on decides DOES reach the inverter")
|
|
||||||
if m == "on":
|
if m == "on":
|
||||||
return ("on (shadow_mode: on) - decisions are recorded and nothing "
|
return f"On, {inert}"
|
||||||
"is commanded")
|
|
||||||
if control_started:
|
if control_started:
|
||||||
return ("off (shadow_mode: auto, control is started) - NOT in shadow "
|
return f"Off (automatic, control is started), {live}"
|
||||||
"mode: what this add-on decides DOES reach the inverter")
|
return f"On (automatic, control is stopped), {inert}"
|
||||||
return ("on (shadow_mode: auto, control is stopped) - decisions are "
|
|
||||||
"recorded and nothing is commanded")
|
|
||||||
|
|
||||||
|
|
||||||
def words(opts: dict, *, control_started: bool, would_w: float | None,
|
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
|
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.
|
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
|
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
|
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. The ⚠️ below is the STRONGER,
|
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
|
conditional statement about the cycles where the answer can be multiples
|
||||||
out - see this module's header for the measured table.
|
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):
|
if not engaged(opts, control_started=control_started):
|
||||||
return head
|
return head
|
||||||
if would_w is None:
|
if would_w is None:
|
||||||
return (f"{head}; no decision has been reached yet - the inverter is "
|
return (f"{head}. No decision yet, the inverters stay at "
|
||||||
f"held at {WIRE_W:.0f} W")
|
f"{WIRE_W:.0f} W.")
|
||||||
# ⚠️ THE THOUSANDS SEPARATOR IS SWAPPED ON THE NUMBER ALONE. Applied to
|
# ⚠️ 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
|
# 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
|
# half that says WHICH RUNG decided, which is the claim MUTATION.md's
|
||||||
# masking rule says must survive.
|
# masking rule says must survive.
|
||||||
num = f"{float(would_w):+,.0f}".replace(",", " ")
|
num = f"{float(would_w):+,.0f}".replace(",", " ")
|
||||||
out = (f"{head}; ON THIS CYCLE WOULD HAVE COMMANDED {num} W, inverter "
|
out = (f"{head}. This cycle it would ask for {num} W, the inverters stay "
|
||||||
f"held at {WIRE_W:.0f} W")
|
f"at {WIRE_W:.0f} W.")
|
||||||
if sized_from_meter:
|
if sized_from_meter:
|
||||||
out += f" {SIZED_FROM_METER}"
|
out += f" {SIZED_FROM_METER}"
|
||||||
if reason:
|
if reason:
|
||||||
out += f" - {reason}"
|
out += f" Why: {reason}."
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -504,6 +501,6 @@ def problems(opts: dict) -> list:
|
|||||||
text = str("" if raw is None else raw).strip().lower()
|
text = str("" if raw is None else raw).strip().lower()
|
||||||
if text in MODES:
|
if text in MODES:
|
||||||
return []
|
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])
|
+ ", ".join(f"'{m}'" for m in MODES[:-1])
|
||||||
+ f" or '{MODES[-1]}'."]
|
+ f" or '{MODES[-1]}'."]
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ import math
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from . import messages
|
||||||
from .price import (
|
from .price import (
|
||||||
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
MAX_PLAUSIBLE_EUR_KWH, Absence, PricePoint, PriceProduct, PriceRead,
|
||||||
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
Uncertainty, _Outcome, brussels, load_prices, price_option,
|
||||||
@@ -597,11 +598,9 @@ def parse_static_profile(value, *, option: str = "static_profile",
|
|||||||
value = json.loads(text)
|
value = json.loads(text)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{option} is not valid JSON ({e}). Write it on one line with "
|
f"{messages.not_valid(option, e)}. Copy the example from the "
|
||||||
f"a \"periods\" list, for example\n"
|
f"documentation, or leave it empty if you have no fixed "
|
||||||
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
f"tariff.")
|
||||||
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
|
||||||
f"Leave it empty if you have no fixed tariff.")
|
|
||||||
|
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
tz = str(value.get("timezone", "") or "").strip().lower()
|
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
|
for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP
|
||||||
surface said either.
|
surface said either.
|
||||||
"""
|
"""
|
||||||
|
# UX-08: plain words, every figure in its own slot as before.
|
||||||
if not self.has_prices:
|
if not self.has_prices:
|
||||||
return f"no tariff - {self.read.reason}"
|
return f"no prices, {self.read.reason}"
|
||||||
tag = {"provider": self.read.provider, "static": "static_profile",
|
tag = {"provider": "the price feed", "static": "your fixed tariff",
|
||||||
"fallback": "static_profile (feed down)"}.get(self.origin,
|
"fallback": "your fixed tariff, because the feed is down"}.get(
|
||||||
self.origin)
|
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
|
gaps = self.spacings_s
|
||||||
return (f"{self.product.value} from {tag}, "
|
return (f"{what} from {tag}, "
|
||||||
f"{self.n_points} points over {self.hours} h, "
|
f"{self.n_points} prices over {self.hours} hours, "
|
||||||
f"{self.n_firm} firm / {self.n_carried} carried"
|
f"{self.n_firm} published and {self.n_carried} estimated"
|
||||||
+ (f", {'+'.join(str(g) for g in gaps)} s spacing"
|
+ (f", {'+'.join(str(g) for g in gaps)} s apart"
|
||||||
if gaps else "")
|
if gaps else "")
|
||||||
+ (", provisional" if self.provisional else ""))
|
+ (", not final yet" if self.provisional else ""))
|
||||||
|
|
||||||
|
|
||||||
def _whole_hours(points) -> int:
|
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),
|
plan = static_plan(opts, now, now + timedelta(days=MEAN_WINDOW_DAYS),
|
||||||
now=now)
|
now=now)
|
||||||
except ProfileInvalid as e:
|
except ProfileInvalid as e:
|
||||||
_LOG.warning("static_profile is unusable (%s); no cheap window. This "
|
_LOG.warning("static_profile cannot be used (%s), so there is no "
|
||||||
"should have been refused at startup - see validate.py", e)
|
"cheap window. The start-up check should have refused "
|
||||||
return False, f"static_profile unusable: {e}"
|
"it.", e)
|
||||||
|
return False, f"Fixed tariff cannot be used: {e}"
|
||||||
if not plan.has_prices:
|
if not plan.has_prices:
|
||||||
return False, "no static profile"
|
return False, "No fixed tariff set"
|
||||||
cheap = plan.cheap_now(now)
|
cheap = plan.cheap_now(now)
|
||||||
window = plan.window_at(now)
|
window = plan.window_at(now)
|
||||||
price = plan.price_at(now)
|
price = plan.price_at(now)
|
||||||
when = plan.profile.next_change(now)
|
when = plan.profile.next_change(now)
|
||||||
until = "" if when is None else f" until {brussels(when):%H:%M}"
|
until = "" if when is None else f" until {brussels(when):%H:%M}"
|
||||||
kind = ("fixed" if plan.product is PriceProduct.FIXED else "day/night")
|
kind = ("Fixed" if plan.product is PriceProduct.FIXED else "Day/night")
|
||||||
return cheap, (f"{kind}: {window}{until} at {price:.4f} EUR/kWh, "
|
return cheap, (f"{kind} tariff, {window}{until}: {price:.4f} EUR/kWh, "
|
||||||
f"mean {plan.mean_eur_kwh():.4f} over "
|
f"{'cheap' if cheap else 'not cheap'} against the "
|
||||||
f"{MEAN_WINDOW_DAYS} d - "
|
f"{MEAN_WINDOW_DAYS}-day average of "
|
||||||
f"{'CHEAP window' if cheap else 'not a cheap window'}")
|
f"{plan.mean_eur_kwh():.4f}")
|
||||||
|
|
||||||
|
|
||||||
async def resolve_tariff(opts: dict, since: datetime, until: datetime,
|
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)
|
"(%s), so there is no tariff at all", e)
|
||||||
profile = None
|
profile = None
|
||||||
if profile is None:
|
if profile is None:
|
||||||
_LOG.warning("§11.4: %s, and no static_profile is configured to fall "
|
_LOG.warning("%s, and no static_profile is set to fall back to, so "
|
||||||
"back to. The plan is provisional and arbitrage is off; "
|
"prices are provisional and trading is off. Peak "
|
||||||
"self-consumption, peak shaving and reserve management "
|
"limiting and home use go on.", read.reason)
|
||||||
"continue (§6, §10.1).", read.reason)
|
|
||||||
return TariffPlan(read, None, "none")
|
return TariffPlan(read, None, "none")
|
||||||
_LOG.warning("§11.4: %s - falling back to the static profile, plan marked "
|
_LOG.warning("%s, so the static_profile tariff is used instead, marked "
|
||||||
"PROVISIONAL, arbitrage off.", read.reason)
|
"provisional, and trading is off.", read.reason)
|
||||||
points = profile.series(since, until, provisional=True)
|
points = profile.series(since, until, provisional=True)
|
||||||
return TariffPlan(
|
return TariffPlan(
|
||||||
PriceRead.series(points, profile.product, "static_profile", stamp),
|
PriceRead.series(points, profile.product, "static_profile", stamp),
|
||||||
|
|||||||
@@ -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
|
# ⚠️ 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
|
# next start rather than be recorded as finished - the one thing worse
|
||||||
# than a failed migration is a failed migration a later version skips.
|
# than a failed migration is a failed migration a later version skips.
|
||||||
_LOG.error("per-inverter store migration LOST STATE and has not been "
|
_LOG.error("Moving the saved state to one part per inverter lost "
|
||||||
"marked done: %s", rep.explain())
|
"data, so it is not marked done: %s", rep.explain())
|
||||||
return rep
|
return rep
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -647,7 +647,7 @@ def _roster_problems(opts: dict) -> list[str]:
|
|||||||
where = f"inverters[{i}]"
|
where = f"inverters[{i}]"
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
problems.append(
|
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.")
|
"a name, a setpoint entity and an inverter rating.")
|
||||||
continue
|
continue
|
||||||
name = str(entry.get("name") or "").strip()
|
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:
|
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{who} has no usable {S('max_w')}: it is "
|
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.")
|
"watts.")
|
||||||
|
|
||||||
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
# --- (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
|
val = None
|
||||||
if val is None or not (val > 0.0):
|
if val is None or not (val > 0.0):
|
||||||
problems.append(
|
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.")
|
"number. Set it in kWh, or leave it empty on every inverter.")
|
||||||
else:
|
else:
|
||||||
stated.append(who)
|
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.
|
# has answered; it is not called from here because at startup no device has.
|
||||||
max_w = _number(opts, "max_w", 2000.0)
|
max_w = _number(opts, "max_w", 2000.0)
|
||||||
if max_w is None:
|
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.")
|
"a number. Set it in watts.")
|
||||||
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
|
# ⚠️ 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
|
# 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:
|
if phases is None or max_age_s is None:
|
||||||
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
|
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
|
||||||
f"both be numbers. They are "
|
f"both be numbers. They are "
|
||||||
f"{opts.get('meter_phases')!r} and "
|
f"{messages.shown(opts.get('meter_phases'))} and "
|
||||||
f"{opts.get('meter_max_age_s')!r}.")
|
f"{messages.shown(opts.get('meter_max_age_s'))}.")
|
||||||
else:
|
else:
|
||||||
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
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
|
weakest = min(caps) if caps else None
|
||||||
if sign_w is None:
|
if sign_w is None:
|
||||||
problems.append(f"{S('sign_disagree_w')} is "
|
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.")
|
"number. Set it back to 300 W.")
|
||||||
elif weakest is not None and weakest > 0 and sign_w >= weakest:
|
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])
|
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the "
|
f"{S('sign_disagree_w')} is {sign_w:.0f} W, not below "
|
||||||
f"{S('max_w', where)} is {weakest:.0f} W, so this safety stop can "
|
f"{S('max_w', where)} at {weakest:.0f} W, so this safety stop can "
|
||||||
f"never trip. Lower it below {weakest:.0f} W, or raise the rating.")
|
f"never trip. Lower it or raise the rating.")
|
||||||
|
|
||||||
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
|
# ⚠️ 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
|
# 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 caps_ix else "")
|
||||||
if unit_sign_w is None:
|
if unit_sign_w is None:
|
||||||
problems.append(f"{S('unit_sign_disagree_w')} is "
|
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 "
|
"number. Set it back to "
|
||||||
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
|
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
|
||||||
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, but the "
|
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, not below "
|
||||||
f"{S('max_w', unit_where)} is {weakest:.0f} W, so this safety stop "
|
f"{S('max_w', unit_where)} at {weakest:.0f} W, so this safety stop "
|
||||||
f"can never trip. Lower it below {weakest:.0f} W, or raise the "
|
f"can never trip. Lower it or raise the rating.")
|
||||||
"rating.")
|
|
||||||
|
|
||||||
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||||
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
# ⚠️ 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:
|
if charge_w is None:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S('maintenance_charge_w')} is "
|
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.")
|
"it in watts.")
|
||||||
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
||||||
problems.append(
|
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)
|
slew_w = _number(opts, "slew_w", 0.0)
|
||||||
rail = site_rail_w(opts)
|
rail = site_rail_w(opts)
|
||||||
if slew_w is None:
|
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 "
|
"not a number. Set it to 0 to follow the inverter "
|
||||||
"rating.")
|
"rating.")
|
||||||
elif slew_w < 0:
|
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:
|
if soc_floor is None or bms_floor is None:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
|
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"both be numbers. They are {messages.shown(opts.get('peak_soc_floor'))} and "
|
||||||
f"{_raw!r}.")
|
f"{messages.shown(_raw)}.")
|
||||||
elif soc_floor <= bms_floor:
|
elif soc_floor <= bms_floor:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
|
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)
|
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:
|
if integ_w is not None and max_w is not None and integ_w > max_w:
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
|
"integrator_max_w %.0f W is above max_w %.0f W. This is allowed, "
|
||||||
"arbiter still bounds the wire to +-max_w - but the only thing the "
|
"but it only adds overshoot, so 0 is recommended.",
|
||||||
"extra headroom buys is more wrong-direction power to unwind after "
|
|
||||||
"saturation. 0 (follow max_w) is the recommended value.",
|
|
||||||
integ_w, max_w)
|
integ_w, max_w)
|
||||||
|
|
||||||
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
# ⚠️ 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
|
if (disch_w is not None and maint_cap is not None and maint_cap > 0
|
||||||
and disch_w > maint_cap):
|
and disch_w > maint_cap):
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
|
"maintenance_discharge_w %.0f W is above max_w%s %.0f W, so "
|
||||||
"refused - the drain phase SETS the setpoint and the arbiter clamps "
|
"draining runs at %.0f W. Lower it to %.0f W or raise max_w.",
|
||||||
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
|
disch_w, maint_where, maint_cap, maint_cap, maint_cap)
|
||||||
"so nothing is silent and the cycle still completes. But it drains "
|
_LOG.debug(
|
||||||
"at %.0f W, not the %.0f W you configured, and the drain phase has "
|
"Not refused: the drain phase sets the setpoint, the arbiter "
|
||||||
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
|
"clamps it to %.0f W and names the clamp, and the drain phase has "
|
||||||
"surprise.",
|
"a 720 min timeout.", maint_cap)
|
||||||
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
|
|
||||||
maint_cap)
|
|
||||||
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
@@ -1289,10 +1284,9 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
if str(opts.get(k, "") or "").strip()]
|
if str(opts.get(k, "") or "").strip()]
|
||||||
if pid != "off" and ha_price:
|
if pid != "off" and ha_price:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} "
|
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} would "
|
||||||
f"{'is' if len(ha_price) == 1 else 'are'} also set and would win. "
|
f"win over it. Clear {'it' if len(ha_price) == 1 else 'them'}, or "
|
||||||
f"Clear {'it' if len(ha_price) == 1 else 'them'}, or set the price "
|
"set the source to 'off'.")
|
||||||
"source to 'off'.")
|
|
||||||
|
|
||||||
# ⚠️ THE SAME RULE FOR THE SOURCE TARIFF-03 ADDED, AND THIS ONE IS A LIVE
|
# ⚠️ 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`
|
# 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():
|
if ha_price and str(opts.get("static_profile", "") or "").strip():
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S('static_profile')} is set, but {_labels(ha_price)} would win "
|
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
|
# ⚠️ 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
|
# 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):
|
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S(TAXES_PROFILE_OPTION)} is set, so "
|
f"{S(TAXES_PROFILE_OPTION)} is set, so "
|
||||||
f"{S('price_taxes_eur_kwh')} is not used, but it reads {scalar} "
|
f"{S('price_taxes_eur_kwh')} is not used. Set it back to "
|
||||||
f"instead of {shipped}. Set it back to {shipped}, or clear the "
|
f"{shipped} from {scalar}, or clear the table.")
|
||||||
"table.")
|
|
||||||
|
|
||||||
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
||||||
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
|
# 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
|
# observe. §14's warning box governs CONTRADICTIONS, and this is
|
||||||
# not one.
|
# not one.
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"%s carries negative levies %s EUR/kWh. That is accepted, but "
|
"%s has negative levies %s EUR/kWh, which are subtracted from "
|
||||||
"a tax component below zero is usually a sign error: the "
|
"the price. This is allowed, but it is usually a sign error.",
|
||||||
"add-on will SUBTRACT it from the published price in those "
|
TAXES_PROFILE_OPTION, negative)
|
||||||
"hours.", TAXES_PROFILE_OPTION, negative)
|
|
||||||
|
|
||||||
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
||||||
# STARTUP RATHER THAN AT 03:00.
|
# 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
|
# silent loser this module's case (a) is about, so it is said out
|
||||||
# loud rather than left to be discovered from a bill.
|
# loud rather than left to be discovered from a bill.
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"price_formula is filled in but price_provider is %r, so the "
|
"price_formula is filled in, but price_provider is %r, so the "
|
||||||
"coefficients decide NOTHING - only 'custom_formula' reads "
|
"formula is not used. Set price_provider to 'custom_formula' "
|
||||||
"them. Set price_provider to 'custom_formula' to use them, or "
|
"or clear price_formula.", pid)
|
||||||
"clear price_formula so the configuration says what the "
|
|
||||||
"add-on is doing.", pid)
|
|
||||||
if spec is not None and pid == "custom_formula":
|
if spec is not None and pid == "custom_formula":
|
||||||
taxes = _number(opts, "price_taxes_eur_kwh",
|
taxes = _number(opts, "price_taxes_eur_kwh",
|
||||||
PRICE_OPTION_DEFAULTS["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
|
# bounds in `implausible_injection()` are applied, and the
|
||||||
# operator is told which of the two checks did NOT run.
|
# operator is told which of the two checks did NOT run.
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"%s has no \"card\" block, so its coefficients are "
|
"%s has no \"card\" block, so its numbers are not checked "
|
||||||
"UNGRADED: nothing recomputed a printed price from them. "
|
"against a printed price, only their shape (pass-through "
|
||||||
"That is accepted because it is the real market - Eneco "
|
"%.4g, offset %+.5f EUR/kWh).",
|
||||||
"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.",
|
|
||||||
EXPORT_FORMULA_OPTION, xspec.passthrough,
|
EXPORT_FORMULA_OPTION, xspec.passthrough,
|
||||||
xspec.offset_eur_kwh)
|
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:
|
else:
|
||||||
xaged = stale_card(xspec)
|
xaged = stale_card(xspec)
|
||||||
if xaged:
|
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.
|
# every install TARIFF-02 shipped, none of which could have had one.
|
||||||
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"price_provider is %r but static_profile is empty. §11.4's "
|
"price_provider is %r but static_profile is empty, so a price "
|
||||||
"degradation row is 'no price data -> fall back to the static "
|
"feed outage leaves no tariff at all. Fill in static_profile as a "
|
||||||
"profile'; with none configured, a feed outage leaves this site "
|
"fallback.", pid)
|
||||||
"with no tariff at all rather than with a coarse one. Not refused, "
|
|
||||||
"but filling it in is what makes that row work.", pid)
|
|
||||||
|
|
||||||
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
# ⚠️ 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
|
# 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:
|
if thresh is None or thresh < 0:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{S('cycle_cost_threshold_eur')} is "
|
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.")
|
"in EUR/kWh.")
|
||||||
|
|
||||||
if arbitrage_on(opts):
|
if arbitrage_on(opts):
|
||||||
@@ -1655,11 +1642,9 @@ def refuse(problems: list[str]) -> None:
|
|||||||
at all and even the exit status distinguishes the two.
|
at all and even the exit status distinguishes the two.
|
||||||
"""
|
"""
|
||||||
_LOG.error("=" * 72)
|
_LOG.error("=" * 72)
|
||||||
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
|
_LOG.error("The add-on did not start, because its settings have problems.")
|
||||||
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
|
_LOG.error("Nothing is broken. Fix the settings below, then start it again.")
|
||||||
_LOG.error("the add-on will start as soon as the settings below are fixed.")
|
_LOG.error("They are on the add-on's Configuration tab.")
|
||||||
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
|
|
||||||
_LOG.error("Configuration, then start the add-on again.")
|
|
||||||
_LOG.error("-" * 72)
|
_LOG.error("-" * 72)
|
||||||
for i, problem in enumerate(problems, 1):
|
for i, problem in enumerate(problems, 1):
|
||||||
_LOG.error(" %d. %s", i, problem)
|
_LOG.error(" %d. %s", i, problem)
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import logging
|
|||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from . import selfconfig
|
from . import messages, selfconfig
|
||||||
from .config_page import CONFIG_PAGE
|
from .config_page import CONFIG_PAGE
|
||||||
from .configui import changed_keys, field_model, problems_by_field
|
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
|
# 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.
|
# operator cannot see is the exact failure this add-on keeps refusing to ship.
|
||||||
DEVICE_NOTE = (
|
DEVICE_NOTE = (
|
||||||
"%s did not report a range, so the two checks that compare a configured "
|
"%s did not report its power range, so two checks were skipped, here and "
|
||||||
"ceiling against what the device itself accepts were skipped - here and at "
|
"at start. If they fail later, the add-on will not start then.")
|
||||||
"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.")
|
|
||||||
|
|
||||||
|
|
||||||
def build_app(controller, selfcfg=None) -> web.Application:
|
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)
|
_LOG.error("saving options failed: %s", err)
|
||||||
return web.json_response({"error": str(err)}, status=502)
|
return web.json_response({"error": str(err)}, status=502)
|
||||||
controller.log_event("configuration saved from the panel - "
|
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})
|
return web.json_response({"ok": True, **result})
|
||||||
|
|
||||||
async def config_restart(request):
|
async def config_restart(request):
|
||||||
@@ -288,7 +286,8 @@ def build_app(controller, selfcfg=None) -> web.Application:
|
|||||||
except selfconfig.SupervisorUnavailable as err:
|
except selfconfig.SupervisorUnavailable as err:
|
||||||
_LOG.error("restart request failed: %s", err)
|
_LOG.error("restart request failed: %s", err)
|
||||||
return web.json_response({"error": str(err)}, status=502)
|
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})
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
|
|||||||
@@ -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 "
|
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 "
|
"0 W beside them - the caveat NARROWS the claim rather than deleting "
|
||||||
"the record a shadow run exists to produce",
|
"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("+", "") != "")
|
and f"{round(_s47_chg['would_w']):+d}".replace("+", "") != "")
|
||||||
s47check("...and the watts are introduced as what it would have commanded ON "
|
s47check("...and the watts are introduced as what it would ask for THIS CYCLE, "
|
||||||
"THIS CYCLE, in every shadow state and not only the caveated ones - no "
|
"in every shadow state and not only the caveated ones - no number a "
|
||||||
"number a shadow run records is a forecast of a live run",
|
"shadow run records is a forecast of a live run (UX-08: in plain words)",
|
||||||
"ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_row
|
"This cycle it would ask for" in _s47_row
|
||||||
and "ON THIS CYCLE WOULD HAVE COMMANDED" in _s47_first_row)
|
and "This cycle it would ask for" in _s47_first_row)
|
||||||
|
|
||||||
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
|
# --- 3d-7b. THE ACTION LOG, WHICH IS THE RECORD READ AFTERWARDS -----------
|
||||||
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
|
# ⚠️ THE TWO SURFACES ARE GRADED SEPARATELY BECAUSE THEY FAILED SEPARATELY.
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ Read MUTATION.md before you report a survivor.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import io
|
import io
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta, timezone
|
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",
|
"conditions to report and none of them displaces this sentence",
|
||||||
_live_pay["banner"] != _shad_pay["banner"]
|
_live_pay["banner"] != _shad_pay["banner"]
|
||||||
and "Shadow mode" not in _live_pay["banner"])
|
and "Shadow mode" not in _live_pay["banner"])
|
||||||
_slot = re.search(r"WOULD HAVE COMMANDED ([-+][\d ]+) W, inverter held at "
|
_slot = re.search(r"This cycle it would ask for ([-+][\d ]+) W, the inverters "
|
||||||
r"([-\d]+) W", _shad_rows[_ROW])
|
r"stay at ([-\d]+) W", _shad_rows[_ROW])
|
||||||
check("the row answers 'what would it have done' with a NUMBER in its own "
|
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 - "
|
"named slot, and says where the inverter actually is in a second one - "
|
||||||
"two slots in one sentence, so a transposition moves them",
|
"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 "
|
check("...and the second slot is the STANDING command, so the two cannot "
|
||||||
"be read as one number",
|
"be read as one number",
|
||||||
float(_slot.group(2)) == 0.0)
|
float(_slot.group(2)) == 0.0)
|
||||||
check("the row answers 'and why' by carrying the arbiter's own sentence, "
|
check("the row answers 'and why' by carrying the page's own Why line, the "
|
||||||
"rather than a re-derivation of it",
|
"arbiter's answer in plain words (UX-08), rather than a re-derivation",
|
||||||
_shad_c.reason in _shad_rows[_ROW])
|
_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 - "
|
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 "
|
"which is the arrangement that makes the one above a claim about this "
|
||||||
"controller and not about the string",
|
"controller and not about the string",
|
||||||
"NOT in shadow mode" in _live_rows[_ROW]
|
_live_rows[_ROW].startswith("Off")
|
||||||
and "DOES reach the inverter" in _live_rows[_ROW]
|
and "reaches the inverters" in _live_rows[_ROW]
|
||||||
and "WOULD HAVE COMMANDED" not 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
|
# ⚠️ 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
|
# 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))
|
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",
|
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))
|
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",
|
check("...and its ERROR says it is a software fault and that control keeps "
|
||||||
any(x.levelno == logging.ERROR and "14 768 W" in x.getMessage()
|
"running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
|
||||||
for x in log_o))
|
"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 "
|
check("...under the over-rating title, quoting a tick's own asked and sent "
|
||||||
"numbers in that order, with a red status row",
|
"numbers in that order, with a red status row",
|
||||||
clamp_notes(hass_o)
|
clamp_notes(hass_o)
|
||||||
|
|||||||
@@ -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",
|
check("the web runner was cleaned up, i.e. the ordinary shutdown path ran",
|
||||||
["cleanup"] in ev[(fault_at or 0):])
|
["cleanup"] in ev[(fault_at or 0):])
|
||||||
died_line = next((i for i, ln in enumerate(err.splitlines())
|
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())
|
wind_line = next((i for i, ln in enumerate(err.splitlines())
|
||||||
if "shutting down - commanding 0 W" in ln), None)
|
if "shutting down - commanding 0 W" in ln), None)
|
||||||
check("a CRITICAL line names the exception and the exit status",
|
check("a CRITICAL line names the exception and the exit status",
|
||||||
died_line is not None
|
died_line is not None
|
||||||
and "CRITICAL" in err.splitlines()[died_line]
|
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",
|
check("the traceback is logged, down to the frame that raised",
|
||||||
"Traceback (most recent call last)" in err
|
"Traceback (most recent call last)" in err
|
||||||
and f"RuntimeError: {FAULT}" in err and "in cycle" 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")
|
rc2, out2, err2, ev2 = run_child("clean")
|
||||||
print(f" exit status {rc2}; events {ev2}")
|
print(f" exit status {rc2}; events {ev2}")
|
||||||
check("an ordinary stop still exits 0", rc2 == 0)
|
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",
|
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)
|
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]])
|
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}")
|
print(f" exit status {rc3}")
|
||||||
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
|
check("a late-arriving device rating refusal exits 78 (EX_CONFIG), not 70",
|
||||||
rc3 == 78)
|
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:
|
if rc3 != 78:
|
||||||
print(tail(err3))
|
print(tail(err3))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
_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 "
|
check("...and the operator gets a line saying which unit and why, on the same "
|
||||||
"status list the liveness notes use",
|
"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())
|
for n in _cs.liveness_notes())
|
||||||
and _cf.liveness_notes() == [])
|
and _cf.liveness_notes() == [])
|
||||||
check("...and the raw number is still recorded for diagnostics, so the "
|
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)
|
_c57.inputs_bad_since is None and _c57.inputs_ok is True)
|
||||||
_ev57 = [e for e in _c57.events if "STALE" in e]
|
_ev57 = [e for e in _c57.events if "STALE" in e]
|
||||||
_msgs57 = [r.getMessage() for r in _LOGREC]
|
_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]}")
|
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 "
|
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 "
|
"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 "
|
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 "
|
"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",
|
"the register, and it prints the value the register is STILL holding",
|
||||||
"NOTHING HAS BEEN LEARNED ABOUT THE REGISTER" in nth(_rel57, 0)
|
"used again" in nth(_rel57, 0) and "recovered" not in nth(_rel57, 0)
|
||||||
and f"still holding {A_FROZEN_W:+.0f}" 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) ---------------------
|
# --- 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
|
# 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
|
# 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.
|
# 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:]}")
|
print(f" escape clause: ...{nth(_stale57, 0)[-150:]}")
|
||||||
check("⚠️ THE STALE LINE'S PROMISE IS NOW TRUE, AND IT IS PINNED AS AN ORDERED "
|
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 "
|
"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 "
|
"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 "
|
"17 s in the TIME slot and 250 W in the POWER slot; swapped, this goes "
|
||||||
"red",
|
"red",
|
||||||
f"failing that {BOUNDS['batt'].release_s:.0f} s after the standing "
|
f"or {BOUNDS['batt'].release_s:.0f} s after the command drops below "
|
||||||
f"command falls below {BOUNDS['batt'].move_w:.0f} W" in nth(_stale57, 0))
|
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 "
|
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 "
|
"TIME slot, in that order, which is the reverse arrangement of the same "
|
||||||
"two numbers",
|
"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))
|
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 "
|
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",
|
"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"
|
f"rechecked over {BOUNDS['batt'].unchanged_s:.0f} s" in nth(_rel57, 0))
|
||||||
in nth(_rel57, 0))
|
|
||||||
check("⚠️ AND THE DIAGNOSIS HALF IS SAFETY-56'S AND HAS NOW BEEN REWRITTEN, "
|
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 "
|
"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 "
|
"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))
|
present(nth(_stale57, 0))
|
||||||
and "battery-power register is not following its own command"
|
and "battery-power register is not following its own command"
|
||||||
not in _stale57[0]
|
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 "
|
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 "
|
"beside its own value, because after a release the verdict's window and "
|
||||||
"the register's own unchanged time are different facts",
|
"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 _read_unit()", "register is not following its own command"),
|
||||||
("app/main.py checks()", f"{_BATT_ENT} is missing or not numeric"),
|
("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):
|
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 "
|
"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 "
|
"that pointed at a surface present in both states would be worse than "
|
||||||
"the accusation it replaced",
|
"the accusation it replaced",
|
||||||
any("STALE_READING" in r["text"] for r in _c56_ref.checks())
|
any("stopped changing" in r["text"] for r in _c56_ref.checks())
|
||||||
and not any("STALE_READING" in r["text"] for r in _c56_abs.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 "
|
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 "
|
"design choice rather than an omission: the two sentences have no clause "
|
||||||
"in common longer than the word 'entity', because one explains a "
|
"in common longer than the word 'entity', because one explains a "
|
||||||
|
|||||||
@@ -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 "
|
"look at P1 for a fault that is visible only on one inverter's own power "
|
||||||
"sensor",
|
"sensor",
|
||||||
len([r for r in _c3r.checks() if r.get("id") == "direction"]) == 2
|
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"))
|
if r.get("id") == "direction"))
|
||||||
|
|
||||||
# The operator's clear, which is one button and has to reach every latch.
|
# The operator's clear, which is one button and has to reach every latch.
|
||||||
|
|||||||
@@ -1581,9 +1581,11 @@ try:
|
|||||||
_logged, _ = controller(NAMED_OPTS, store=_log_store)
|
_logged, _ = controller(NAMED_OPTS, store=_log_store)
|
||||||
finally:
|
finally:
|
||||||
logging.getLogger("goodwe").removeHandler(_h)
|
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)}")
|
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 "
|
"left the whole suite green, and it is how an operator learns their "
|
||||||
"latch was re-armed rather than never lost",
|
"latch was re-armed rather than never lost",
|
||||||
len(_s44_lines) == 1)
|
len(_s44_lines) == 1)
|
||||||
|
|||||||
@@ -289,20 +289,66 @@ print("\n2. no notification is worded outside app/messages.py")
|
|||||||
APP = HERE / "app"
|
APP = HERE / "app"
|
||||||
|
|
||||||
|
|
||||||
def render(node):
|
# UX-08 2d: a refusal is counted as it is SHOWN. A placeholder that names a
|
||||||
"""A string expression as text, placeholders as {expr}; None otherwise."""
|
# 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):
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||||
return node.value
|
return node.value
|
||||||
if isinstance(node, ast.JoinedStr):
|
if isinstance(node, ast.JoinedStr):
|
||||||
return "".join(v.value if isinstance(v, ast.Constant)
|
out = []
|
||||||
else "{" + ast.unparse(v.value) + "}" for v in node.values)
|
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):
|
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:
|
if a is None and b is None:
|
||||||
return None
|
return None
|
||||||
return (a if a is not None else "{…}") + (b if b is not None else "{…}")
|
return (a if a is not None else "{…}") + (b if b is not None else "{…}")
|
||||||
if isinstance(node, ast.IfExp):
|
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:
|
if a is None and b is None:
|
||||||
return None
|
return None
|
||||||
return (a or "") + " / " + (b or "")
|
return (a or "") + " / " + (b or "")
|
||||||
@@ -346,14 +392,14 @@ LIMITS = { # kind: (words, keys ok, entity ids ok)
|
|||||||
LOG_CALL = re.compile(r"(?:^|\.)(?:_LOG|log|logger|LOG)\.(warning|error|critical|exception)$")
|
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)."""
|
"""Top-level string expressions inside `node` (not their parts)."""
|
||||||
t = render(node)
|
t = render(node, expand)
|
||||||
if t is not None:
|
if t is not None:
|
||||||
yield node, t
|
yield node, t
|
||||||
return
|
return
|
||||||
for child in ast.iter_child_nodes(node):
|
for child in ast.iter_child_nodes(node):
|
||||||
yield from strings_in(child)
|
yield from strings_in(child, expand)
|
||||||
|
|
||||||
|
|
||||||
def collect():
|
def collect():
|
||||||
@@ -363,6 +409,12 @@ def collect():
|
|||||||
if f.name == "messages.py":
|
if f.name == "messages.py":
|
||||||
continue
|
continue
|
||||||
tree = ast.parse(f.read_text(encoding="utf-8"))
|
tree = ast.parse(f.read_text(encoding="utf-8"))
|
||||||
|
CONSTANTS.clear()
|
||||||
|
CONSTANTS.update({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)})
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if isinstance(node, ast.Dict):
|
if isinstance(node, ast.Dict):
|
||||||
for k, v in zip(node.keys, node.values):
|
for k, v in zip(node.keys, node.values):
|
||||||
@@ -387,24 +439,26 @@ def collect():
|
|||||||
out.append(("log", f"{f.name}:{n.lineno}", t))
|
out.append(("log", f"{f.name}:{n.lineno}", t))
|
||||||
elif re.search(r"problems\.(append|extend)$", name):
|
elif re.search(r"problems\.(append|extend)$", name):
|
||||||
for a in node.args:
|
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:
|
if len(words(t)) > 1:
|
||||||
out.append(("refusal", f"{f.name}:{n.lineno}", t))
|
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):
|
def walk(conf, path):
|
||||||
for key, v in (conf or {}).items():
|
for key, v in (conf or {}).items():
|
||||||
if not isinstance(v, dict):
|
if not isinstance(v, dict):
|
||||||
continue
|
continue
|
||||||
if isinstance(v.get("name"), str):
|
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()))
|
v["name"].split("·")[-1].strip()))
|
||||||
if isinstance(v.get("description"), str):
|
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"]))
|
v["description"]))
|
||||||
if isinstance(v.get("fields"), dict):
|
if isinstance(v.get("fields"), dict):
|
||||||
walk(v["fields"], f"{path}{key}.")
|
walk(v["fields"], f"{path}{key}.")
|
||||||
walk(tr.get("configuration"), "")
|
walk(tr.get("configuration"), "")
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -413,7 +467,7 @@ def problems_of(kind, text):
|
|||||||
p = []
|
p = []
|
||||||
if len(words(text)) > lim:
|
if len(words(text)) > lim:
|
||||||
p.append(f"over {lim} words ({len(words(text))})")
|
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")
|
p.append("semicolon")
|
||||||
return p + text_problems(text, keys=keys, entities=ents)
|
return p + text_problems(text, keys=keys, entities=ents)
|
||||||
|
|
||||||
@@ -426,7 +480,7 @@ ALLOW_FILE = HERE / "ux08_allowlist.txt"
|
|||||||
# The allowlist may never grow. The test demands the list be exactly this long,
|
# 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
|
# so deleting lines means lowering this number in the same change, and adding
|
||||||
# one means raising it where a reviewer sees it.
|
# one means raising it where a reviewer sees it.
|
||||||
ALLOWLIST_MAX = 43
|
ALLOWLIST_MAX = 0
|
||||||
|
|
||||||
|
|
||||||
def read_allowlist(path):
|
def read_allowlist(path):
|
||||||
@@ -491,6 +545,30 @@ _own_banner = {"row_direction_trip", "row_site_direction_trip",
|
|||||||
"row_circulating_trip", "row_peak_reserve", "row_power_too_high"}
|
"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
|
_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]
|
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 "
|
check(f"every other red row makes a 'Not ready' banner of at most 12 words "
|
||||||
f"(too long: {_long})", not _long)
|
f"(too long: {_long})", not _long)
|
||||||
|
|
||||||
@@ -555,6 +633,79 @@ check("...and a refusal quotes the label and then the key, so the config "
|
|||||||
"panel still files it under that setting",
|
"panel still files it under that setting",
|
||||||
messages.setting("max_w", "inverter-b")
|
messages.setting("max_w", "inverter-b")
|
||||||
== "“Inverter rating” for inverter-b (inverters.max_w)")
|
== "“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 "
|
print(f"CI: UX-08 allowlist {len(allow)} entries "
|
||||||
+ ", ".join(f"{k} {sum(1 for x in allow if x.startswith(k + ' '))}"
|
+ ", ".join(f"{k} {sum(1 for x in allow if x.startswith(k + ' '))}"
|
||||||
for k in LIMITS))
|
for k in LIMITS))
|
||||||
|
|||||||
@@ -5932,10 +5932,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,
|
_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,
|
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
|
||||||
soc=SOC_OK, soc_floor=SOC_FLOOR)
|
soc=SOC_OK, soc_floor=SOC_FLOOR)
|
||||||
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a "
|
# UX-08 2d: the row is in plain words now, and the pair is still ordered.
|
||||||
f"{_S38_CEIL_W:.0f} W ceiling")
|
from app import messages as _msg38 # noqa: E402
|
||||||
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a "
|
_S38_LITERAL = (f"heading for {_msg38.watts(_S38_PROJ.projected_w)}, under "
|
||||||
f"{_S38_PROJ.projected_w:.0f} W ceiling")
|
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 "
|
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
|
||||||
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
|
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
|
||||||
print(f" {_S38_IDLE.explain()!r}")
|
print(f" {_S38_IDLE.explain()!r}")
|
||||||
@@ -5958,7 +5960,8 @@ check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
|
|||||||
_S38_SWAPPED not in _S38_IDLE.explain())
|
_S38_SWAPPED not in _S38_IDLE.explain())
|
||||||
check("...and the row still names WHERE the ceiling came from, which is the "
|
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 "
|
"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
|
# 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
|
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
|
||||||
|
|||||||
@@ -2189,7 +2189,7 @@ _p = validate_startup_config(
|
|||||||
check("a provider AND the Home Assistant price entities is refused - two price "
|
check("a provider AND the Home Assistant price entities is refused - two price "
|
||||||
"sources on one site", len(_p) == 1)
|
"sources on one site", len(_p) == 1)
|
||||||
check("...and the message says which one actually wins",
|
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.
|
# TARIFF-03 added a second source with the same silent-loser shape.
|
||||||
_ps = validate_startup_config(
|
_ps = validate_startup_config(
|
||||||
vbase(static_profile='{"periods": [{"from":"00:00","to":"24:00",'
|
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)
|
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 "
|
check(f"...and it is refused, by the CARD rule, naming the discrepancy and "
|
||||||
f"the network-charge cause ({_double[:60]!r})",
|
f"the network-charge cause ({_double[:60]!r})",
|
||||||
"does not match its own card" in _double and "the card says" in _double
|
"the card says" in _double
|
||||||
and "(price_taxes_eur_kwh), not in the offset" in _double)
|
and "network charges are not in the offset" in _double)
|
||||||
|
|
||||||
_CORRUPTIONS = {
|
_CORRUPTIONS = {
|
||||||
"VAT left out (vat_pct 0)": spoilt(vat_pct=0),
|
"VAT left out (vat_pct 0)": spoilt(vat_pct=0),
|
||||||
@@ -3447,7 +3447,7 @@ for _name, _blob in sorted(_CORRUPTIONS.items()):
|
|||||||
_why = refusal(_blob)
|
_why = refusal(_blob)
|
||||||
check(f"{_name}: refused, and by the CARD rule rather than by another one "
|
check(f"{_name}: refused, and by the CARD rule rather than by another one "
|
||||||
f"({_why[:48]!r})",
|
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
|
# ⚠️ 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.
|
# "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)))
|
+ CARD_TOLERANCE * 2.0, 6)))
|
||||||
check(f"half a tolerance out passes and twice a tolerance out does not, so "
|
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",
|
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.
|
# 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 "
|
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(
|
why(validate_startup_config(
|
||||||
vbase(price_provider="custom_formula",
|
vbase(price_provider="custom_formula",
|
||||||
price_formula=json.dumps(spoilt(offset=13.0))),
|
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 "
|
check("...and a formula that is not JSON at all is refused with the shape it "
|
||||||
"wanted, rather than with a traceback",
|
"wanted, rather than with a traceback",
|
||||||
why(validate_startup_config(
|
why(validate_startup_config(
|
||||||
vbase(price_provider="custom_formula", price_formula="0.102*spot+1"),
|
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 "
|
check(f"...and a card outside the plausible band is refused too, WITH THE "
|
||||||
f"SHIPPED taxes in place - the flat "
|
f"SHIPPED taxes in place - the flat "
|
||||||
f"{PRICE_OPTION_DEFAULTS['price_taxes_eur_kwh']} must not be able to "
|
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
|
# row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are
|
||||||
# asserted.
|
# asserted.
|
||||||
_T11_SLOTS = {
|
_T11_SLOTS = {
|
||||||
"points": r"(\d+) points over \d+ h",
|
# UX-08 2d: the row is in plain words; every figure keeps its own slot.
|
||||||
"hours": r"\d+ points over (\d+) h",
|
"points": r"(\d+) prices over \d+ hours",
|
||||||
"firm": r"(\d+) firm / \d+ carried",
|
"hours": r"\d+ prices over (\d+) hours",
|
||||||
"carried": r"\d+ firm / (\d+) carried",
|
"firm": r"(\d+) published and \d+ estimated",
|
||||||
"horizon": r"for a (\d+) h horizon",
|
"carried": r"\d+ published and (\d+) estimated",
|
||||||
"age_s": r"fetched (\d+) s ago",
|
"horizon": r"for (\d+) hours ahead",
|
||||||
"spacings": r"([\d+]+) s spacing",
|
"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
|
# ⚠️ 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" -
|
# 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,
|
# WHICH of the two sentences was rendered is graded by inequality below,
|
||||||
@@ -4144,13 +4145,13 @@ def _slot(row, name):
|
|||||||
return derived(_get)
|
return derived(_get)
|
||||||
|
|
||||||
|
|
||||||
_T11_GOOD = ("x (recomputed this tick); provider plan fetched 7 s ago for a "
|
_T11_GOOD = ("x. Price plan from 7 s ago, for 48 hours ahead: day-ahead "
|
||||||
"48 h horizon: dynamic_dayahead from generic_dayahead, "
|
"prices from the price feed, 132 prices over 33 hours, "
|
||||||
"132 points over 33 h, 36 firm / 96 carried, 900+3600 s spacing")
|
"36 published and 96 estimated, 900+3600 s apart")
|
||||||
_T11_SHIPPED_DEFECT = ("x (recomputed this tick); provider plan fetched 7 s "
|
_T11_SHIPPED_DEFECT = ("x. Price plan from 7 s ago, for 48 hours ahead: "
|
||||||
"ago for a 48 h horizon: dynamic_dayahead from "
|
"day-ahead prices from the price feed, 132 prices over "
|
||||||
"generic_dayahead, 132 points over 132 h, "
|
"132 hours, 96 published and 36 estimated, 900+3600 s "
|
||||||
"96 firm / 36 carried, 900+3600 s spacing")
|
"apart")
|
||||||
# The slots divide into the ones a *Tariff*/*Arbitrage* ROW carries and the
|
# 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
|
# 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
|
# 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",
|
f"in the options blob",
|
||||||
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
||||||
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
|
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"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",
|
f"The reason comes from main.py, not from this file",
|
||||||
_T11_ON_HELD[1].reason
|
_T11_ON_HELD[1].why_words
|
||||||
and _T11_ON_HELD[1].reason in _T11_ON_HELD[0]["Arbitrage"])
|
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 "
|
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"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 "
|
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_DROP_STALE is True
|
||||||
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
|
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
|
||||||
check(f"...and it names the reason the cycle stopped "
|
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"still showing, so 'the strategy decided this' and 'the strategy last "
|
||||||
f"decided this' are not one sentence",
|
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))
|
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 "
|
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 "
|
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 "
|
"used, so the two rows describing the same feed can be compared rather "
|
||||||
"than being two conventions on one page",
|
"than being two conventions on one page",
|
||||||
"points over" in _T11_E2E_ROW
|
"prices over" in _T11_E2E_ROW
|
||||||
and "points over" in ("" if isinstance(_T11_E2E, _Raised)
|
and "prices over" in ("" if isinstance(_T11_E2E, _Raised)
|
||||||
else _T11_E2E["Price history"]))
|
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
|
# 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.
|
# minus sign on the offset"), so it left this table.
|
||||||
("a card miss -> what else to check on THIS leg",
|
("a card miss -> what else to check on THIS leg",
|
||||||
spoilt(factor=0.120), "(price_taxes_eur_kwh), not in the offset",
|
spoilt(factor=0.120), "network charges are not in the offset",
|
||||||
"vat_pct is 0 for injection"),
|
"the VAT is 0 for injection"),
|
||||||
("invalid JSON -> WHEN this option is read at all, which is not the "
|
("invalid JSON -> WHEN this option is read at all, which is not the "
|
||||||
"same answer on the two legs: `price_formula` decides nothing unless "
|
"same answer on the two legs: `price_formula` decides nothing unless "
|
||||||
"price_provider is 'custom_formula', and the injection leg is read "
|
"price_provider is 'custom_formula', and the injection leg is read "
|
||||||
"whatever the consumption provider is",
|
"whatever the consumption provider is",
|
||||||
"{not json", "(price_provider) is 'custom_formula'",
|
"{not json", "unless “Dynamic price source” is 'custom_formula'",
|
||||||
"used whatever the price source is"),
|
"only if your injection price follows the market"),
|
||||||
]
|
]
|
||||||
for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER:
|
for _what, _blob, _cons_mark, _inj_mark in _T16_DIFFER:
|
||||||
_a, _b = ((_reason(lambda b=_blob: parse_price_formula(b)),
|
_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"15.55) is refused, naming the option the operator typed "
|
||||||
f"({_T16_SPOILT_CARD[:64]!r})",
|
f"({_T16_SPOILT_CARD[:64]!r})",
|
||||||
_T16_WITH_CARD == ""
|
_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))
|
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION))
|
||||||
check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is "
|
check(f"...and half a tolerance out still passes, so {CARD_TOLERANCE} is "
|
||||||
f"enforced on this leg too rather than 'something'",
|
f"enforced on this leg too rather than 'something'",
|
||||||
|
|||||||
@@ -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"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"holds yesterday, all 24 hours of it' are different facts, and only "
|
||||||
f"the second says the horizon will be full tomorrow morning",
|
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
|
# ⚠️ 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
|
# 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"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"figure that says whether tomorrow's fill has a whole day to carry is "
|
||||||
f"the HOUR one",
|
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 "
|
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"count wearing the hour's name is H1's defect, and this row is where "
|
||||||
f"an operator would have read it",
|
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 "
|
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",
|
f"days it dropped read as days that were simply never written",
|
||||||
isinstance(_w14, str) and (_h14.last_error or "") in _w14
|
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"{_wload!r}: ONE point held, and the row that is not a point named "
|
||||||
f"rather than counted",
|
f"rather than counted",
|
||||||
isinstance(_wload, str)
|
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 "1 malformed point row(s) in" in _wload)
|
||||||
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
|
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
|
||||||
# OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND
|
# 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 "
|
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"held and nothing to report {words_of(_hM2)!r}, so the note clears "
|
||||||
f"itself rather than becoming permanent furniture",
|
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 _hM2.last_error is None)
|
||||||
|
|
||||||
# ...and the shipped path, end to end, through the caller that catches
|
# ...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:
|
# read-only and again on a full tmpfs:
|
||||||
#
|
#
|
||||||
# last_error = 'could not write ([Errno 30] Read-only file system)'
|
# 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 (...)]'
|
# over 24 h) [could not write (...)]'
|
||||||
# yesterday()= series, 24 points
|
# yesterday()= series, 24 points
|
||||||
# ON DISK = {} <- and gone on the next restart
|
# ON DISK = {} <- and gone on the next restart
|
||||||
|
|||||||
@@ -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 "
|
"input; this is instrumentation and a permanent caution on every install "
|
||||||
"that never asked for it is the warning spam the card forbids",
|
"that never asked for it is the warning spam the card forbids",
|
||||||
not pv_rows(c_off)
|
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 "
|
check("...and the whole row list is otherwise the same length, so nothing else "
|
||||||
"moved either", len(_on_rows) == len(_off_rows) + 1)
|
"moved either", len(_on_rows) == len(_off_rows) + 1)
|
||||||
_pv_row = [r["text"] for r in pv_rows(c_on)]
|
_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)}"]
|
_pv_row == [f"Solar production: {messages.signed_watts(3210)}"]
|
||||||
and "sensor." not in _pv_row[0]
|
and "sensor." not in _pv_row[0]
|
||||||
and pv_rows(c_on)[0]["ok"] is True
|
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)
|
c_bad, _, _ = run(cycles=1, states=[state("1234", "%")], **PV_ON)
|
||||||
_bad_row = pv_rows(c_bad)
|
_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"]
|
and c_bad.status_payload()["banner"]
|
||||||
== c_off.status_payload()["banner"])
|
== c_off.status_payload()["banner"])
|
||||||
check("...and the page row renders an absence as an em dash, not as 0 W",
|
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",
|
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 "
|
_many_row == [f"Solar production: {messages.signed_watts(3750)} from 3 "
|
||||||
f"sources"]
|
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)
|
_c_gap, _, _ = run(cycles=1, by_entity=HOLES[E2][0], **PV_MANY)
|
||||||
_gap_row = pv_rows(_c_gap)
|
_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"])
|
== c_off.status_payload()["banner"])
|
||||||
check("...and the page row is an em dash, not a smaller number: the one row an "
|
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",
|
"operator glances at cannot show a partial total either",
|
||||||
("PV", "—") in page_rows(_c_gap)
|
("Solar production", "—") in page_rows(_c_gap)
|
||||||
and ("PV", "3 300 W") not 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
|
# ⚠️ 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
|
# 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)]
|
== [r["text"] for r in pv_rows(c_on)]
|
||||||
== [f"Solar production: {messages.signed_watts(3210)}"]
|
== [f"Solar production: {messages.signed_watts(3210)}"]
|
||||||
and page_rows(_c_one_new)[[r[0] for r in page_rows(_c_one_new)]
|
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 "
|
check("...and a refused read at a roster of one is the one-sensor refusal "
|
||||||
"too, not the roster one - `no reading, <reason>`",
|
"too, not the roster one - `no reading, <reason>`",
|
||||||
[r["text"] for r in pv_rows(run(cycles=1, pv_entities=[E1],
|
[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 "
|
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",
|
"whole of TEL-12 is invisible at the shipped default",
|
||||||
not pv_rows(c_off)
|
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 len(rows_of(_c_many)) == len(rows_of(c_off)) + 1
|
||||||
and run(cycles=1)[1].state_calls == []
|
and run(cycles=1)[1].state_calls == []
|
||||||
and run(cycles=1, pv_entities=[])[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)} "
|
_mix_row == [f"Solar production: {messages.signed_watts(MIX_TOTAL)} "
|
||||||
f"from 3 sources"])
|
f"from 3 sources"])
|
||||||
check("...and the page's PV row is the corrected total",
|
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)
|
_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 "
|
check("a holed roster with a flipped member names the missing source, not "
|
||||||
"the flipped one",
|
"the flipped one",
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class FakeController:
|
|||||||
self.inverters = []
|
self.inverters = []
|
||||||
self.events = []
|
self.events = []
|
||||||
|
|
||||||
def log_event(self, text):
|
def log_event(self, text, hint=None):
|
||||||
self.events.append(text)
|
self.events.append(text)
|
||||||
|
|
||||||
def status_payload(self):
|
def status_payload(self):
|
||||||
@@ -552,7 +552,7 @@ class H:
|
|||||||
async def limits(self, e): return (None, None, None)
|
async def limits(self, e): return (None, None, None)
|
||||||
class C:
|
class C:
|
||||||
o = json.loads(os.environ["OPTS"]); hass = H(); inverters = []
|
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 {}
|
def status_payload(self): return {}
|
||||||
async def handle_action(self, w): raise AssertionError("no")
|
async def handle_action(self, w): raise AssertionError("no")
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
_none.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.0, uncertainty=frozenset()).kind == "refused"
|
||||||
and arbitrage_allowed(_none.read) is False)
|
and arbitrage_allowed(_none.read) is False)
|
||||||
check("...and static_status() says so in words rather than reporting a price",
|
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
|
# ⚠️ 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.
|
# serve it are to invent one or to fail when the clock reaches it.
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from named_absence import given, nth, present
|
from named_absence import given, nth, present
|
||||||
|
|
||||||
|
from app import messages
|
||||||
from app.main import Controller
|
from app.main import Controller
|
||||||
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
|
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
|
||||||
from app.tariff import (
|
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
|
# 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
|
# 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.
|
# 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"]]
|
# UX-08 2d: the rows are in plain words and are found by their `id`. The peak
|
||||||
peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r["text"]]
|
# 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)"))
|
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
|
||||||
check("the status page carries exactly one capacity-tariff peak row",
|
print(" status row: " + (cov_rows[0]["text"] if cov_rows else "(none)"))
|
||||||
len(peak_rows) == 1)
|
check("the status page carries exactly one capacity-tariff peak row, and one "
|
||||||
check("...naming the peak, the soft limit and a coverage figure, all derived "
|
"coverage row beside it",
|
||||||
"from the controller that was actually built",
|
len(peak_rows) == 1 and len(cov_rows) == 1)
|
||||||
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
|
check("...naming the peak and its share of the peak limit, and the coverage "
|
||||||
and f"{given(SHIPPED_CAP_W):.0f} W soft limit" in peak_rows[0]["text"]
|
"figure, all derived from the controller that was actually built",
|
||||||
and "quarter(s) banked" in peak_rows[0]["text"])
|
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,
|
# ⚠️ 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
|
# 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 "
|
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 "
|
"row count taken straight from quarter_blocks, so this pins the gap "
|
||||||
"rather than restating the property that produces it",
|
"rather than restating the property that produces it",
|
||||||
bool(peak_rows)
|
bool(cov_rows)
|
||||||
and f", {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
|
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 "
|
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",
|
f"on a {given(SHIPPED_CAP_W):.0f} W limit",
|
||||||
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
|
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.
|
# The half that matters: the same page with a store that cannot answer.
|
||||||
c2 = built(TMP / "no-such-dir" / "nope.db")
|
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): "
|
print(" status row (unreadable store): "
|
||||||
+ (u_rows[0]["text"] if u_rows else "(none)"))
|
+ (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 "
|
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",
|
"instrument looks exactly like a month with no peak",
|
||||||
bool(u_rows) and "UNREADABLE" in u_rows[0]["text"]
|
bool(u_rows) and "cannot be read" in u_rows[0]["text"]
|
||||||
and "0 %" not in u_rows[0]["text"] and u_rows[0]["warn"])
|
and "%" not in u_rows[0]["text"] and u_rows[0]["warn"])
|
||||||
check("...and the healthy row and the unreadable row are different text",
|
check("...and the healthy row and the unreadable row are different text",
|
||||||
bool(u_rows) and bool(peak_rows)
|
bool(u_rows) and bool(peak_rows)
|
||||||
and u_rows[0]["text"] != peak_rows[0]["text"])
|
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
|
# (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
|
# made the original filter brittle. Not changed here - it is product text with
|
||||||
# other suites reading it.)
|
# 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
|
_later = B + timedelta(days=45) # same fixture, a later billing month
|
||||||
_other_peak = _peak_rows(c.checks(when=_later))
|
_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)"))
|
+ (_other_peak[0]["text"] if _other_peak else "(no peak row)"))
|
||||||
check("asked for a LATER month, the same controller reports that month's "
|
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`",
|
"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"]
|
bool(_other_peak) and "4 998 W" not in _other_peak[0]["text"]
|
||||||
and "no completed quarter recorded yet" 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 "
|
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",
|
"an unreadable store - a month with no quarter is not a broken instrument",
|
||||||
bool(_other_peak) and bool(peak_rows)
|
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"])
|
and _other_peak[0]["text"] != peak_rows[0]["text"])
|
||||||
check("...and it is not FLAGGED, because an empty month is not an exceedance",
|
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"])
|
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)
|
bool(_now_peak) and (bool(peak_rows)
|
||||||
and _now_peak[0]["text"] == peak_rows[0]["text"]
|
and _now_peak[0]["text"] == peak_rows[0]["text"]
|
||||||
if _today_is_fixture_month
|
if _today_is_fixture_month
|
||||||
else "no completed quarter recorded yet"
|
else "none recorded yet"
|
||||||
in _now_peak[0]["text"]))
|
in _now_peak[0]["text"]))
|
||||||
|
|
||||||
# THE MEASUREMENT IS OF DELIVERED POWER (SAFETY-20). Graded on the parsed
|
# 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
|
# 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).
|
# under test must be graded against something that is not itself (habit 2).
|
||||||
_truth_before = len(c13.peak.store.blocks_in(_mon13))
|
_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"]
|
_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 "
|
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",
|
f"count of 0 and a count of the truth are different numbers",
|
||||||
_truth_before == _N13 and _N13 > 1)
|
_truth_before == _N13 and _N13 > 1)
|
||||||
check(f"...and the row says so before the restart",
|
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()
|
c13.peak.store.close()
|
||||||
|
|
||||||
# THE RESTART.
|
# THE RESTART.
|
||||||
c13b = built(_T13)
|
c13b = built(_T13)
|
||||||
_row_after = _peak_rows(c13b.checks())[0]["text"]
|
_row_after = _peak_rows(c13b.checks())[0]["text"]
|
||||||
|
_cov_after = _cov_row(c13b)
|
||||||
_truth_after = len(c13b.peak.store.blocks_in(_mon13))
|
_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, "
|
print(f" rows in quarter_blocks: {_truth_before} before, "
|
||||||
f"{_truth_after} after; the restarted tracker has banked "
|
f"{_truth_after} after; the restarted tracker has banked "
|
||||||
f"{len(c13b.peak._seen)} block(s) of its own through sync()")
|
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 "
|
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 "
|
"half, asserted so that the coverage claim below is not graded against a "
|
||||||
"reading that had already collapsed",
|
"reading that had already collapsed",
|
||||||
_row_before.split(" - ")[0] == _row_after.split(" - ")[0]
|
_row_before == _row_after
|
||||||
and f"{_W13:.0f} W" in _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 "
|
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 "
|
"defect: a coverage figure that resets while the value beside it "
|
||||||
"persists actively misinforms",
|
"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 "
|
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"process-lifetime counter - graded against a COUNT OF ROWS in the "
|
||||||
f"other tier rather than against the property under test",
|
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
|
# ⚠️ 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
|
# checkpointed open block was stale, so `restore()` closed and banked it. The
|
||||||
|
|||||||
@@ -1223,7 +1223,8 @@ def _verdict(opts, token):
|
|||||||
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
|
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
|
||||||
if not ps:
|
if not ps:
|
||||||
return (False, None, None)
|
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)
|
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 "
|
"refusal says WHICH ONE WINS and what to do - not merely that the two "
|
||||||
"are incompatible",
|
"are incompatible",
|
||||||
why(_T17_P_BOTH, "(price_taxes_profile) is set, so")
|
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 "
|
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used. Set it "
|
||||||
"0.1198"))
|
"back to"))
|
||||||
check("...and the SHIPPED scalar beside the table is NOT refused, or every "
|
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 "
|
"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 "
|
"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 "
|
"one is still REFUSED beside the table, naming it - the set of "
|
||||||
"'untouched' figures widened by exactly the old defaults and by nothing "
|
"'untouched' figures widened by exactly the old defaults and by nothing "
|
||||||
"else",
|
"else",
|
||||||
why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads "
|
why(_T17_P_BOTH, "from 0.1198, or clear")
|
||||||
"0.1198")
|
|
||||||
and 0.1198 not in _T18_SUPERSEDED
|
and 0.1198 not in _T18_SUPERSEDED
|
||||||
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
|
and abs(0.1198 - _T18_SHIPPED_TAXES) > 1e-9)
|
||||||
# ⚠️ AND THE REFUSAL SAYS SO, so an operator who HAS typed a number is told
|
# ⚠️ 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
|
# coefficients against arithmetic done with the same coefficients. So the leg
|
||||||
# starts, and the operator is told WHICH check did not run.
|
# starts, and the operator is told WHICH check did not run.
|
||||||
_T16_UNGRADED = [m for lvl, m in _T16_OK_LOG
|
_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]}")
|
print(f" {(_T16_UNGRADED or [''])[0][:118]}")
|
||||||
check("a leg with no card block starts, and says at startup that its "
|
check("a leg with no card block starts, and says at startup that its "
|
||||||
"coefficients are UNGRADED - naming what was checked instead, so the "
|
"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 "
|
"would be noise an operator learns to ignore rather than a report of "
|
||||||
"which check ran",
|
"which check ran",
|
||||||
_T16_WITH_CARD == []
|
_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.
|
# ⚠️ 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 "
|
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",
|
"REFUSED at startup, so the optional block is optional and not inert",
|
||||||
why(_t16_start(price_export_formula=_T16_CONS.replace(
|
why(_t16_start(price_export_formula=_T16_CONS.replace(
|
||||||
'"expect_incl_vat":15.55', '"expect_incl_vat":15.05'))[0],
|
'"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.
|
# ⚠️ 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"
|
_T16_INFO = [m for lvl, m in _T16_OK_LOG if lvl == "INFO"
|
||||||
|
|||||||
@@ -1930,7 +1930,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"]
|
any(ck.get("id") == "site_direction" and not ck["ok"]
|
||||||
and not ck["warn"] and "every inverter" in ck["text"]
|
and not ck["warn"] and "every inverter" in ck["text"]
|
||||||
for ck in _c31.checks())
|
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"])
|
and "inverter-" not in _c31.status_payload()["banner"])
|
||||||
_c31_mode = _c31.o.get("shadow_mode")
|
_c31_mode = _c31.o.get("shadow_mode")
|
||||||
_c31.o["shadow_mode"] = "on"
|
_c31.o["shadow_mode"] = "on"
|
||||||
|
|||||||
@@ -161,16 +161,16 @@ configuration:
|
|||||||
name: "Tariff · Injection price (EUR/kWh)"
|
name: "Tariff · Injection price (EUR/kWh)"
|
||||||
description: "What you are paid per kWh fed into the grid, all in. It may be negative."
|
description: "What you are paid per kWh fed into the grid, all in. It may be negative."
|
||||||
static_profile:
|
static_profile:
|
||||||
name: "Tariff · Fixed or day/night tariff (JSON)"
|
name: "Tariff · Fixed or day/night tariff"
|
||||||
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."
|
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:
|
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."
|
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:
|
price_formula:
|
||||||
name: "Tariff · Your own tariff formula (JSON)"
|
name: "Tariff · Your own tariff formula"
|
||||||
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."
|
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:
|
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."
|
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:
|
price_csv_path:
|
||||||
name: "Tariff · Price CSV file"
|
name: "Tariff · Price CSV file"
|
||||||
@@ -278,14 +278,14 @@ configuration:
|
|||||||
name: "Advanced · Command rounding (W)"
|
name: "Advanced · Command rounding (W)"
|
||||||
description: "The command is rounded to a multiple of this, so noise does not change it every cycle."
|
description: "The command is rounded to a multiple of this, so noise does not change it every cycle."
|
||||||
saturation_w:
|
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."
|
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:
|
saturation_cycles:
|
||||||
name: "Advanced · Cycles at limit before freezing"
|
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."
|
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:
|
integrator_max_w:
|
||||||
name: "Advanced · Error build-up limit (W)"
|
name: "Advanced · Catch-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."
|
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:
|
heartbeat_s:
|
||||||
name: "Advanced · Command refresh interval (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."
|
description: "How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit."
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ configuration:
|
|||||||
description: "Zet dit aan als de sensor van deze omvormer leveren als een negatief getal toont."
|
description: "Zet dit aan als de sensor van deze omvormer leveren als een negatief getal toont."
|
||||||
capacity_kwh:
|
capacity_kwh:
|
||||||
name: "Bruikbare capaciteit (kWh) (optioneel)"
|
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:
|
estop_fitted:
|
||||||
name: "Hardware · RS485-noodstop is geplaatst"
|
name: "Hardware · RS485-noodstop is geplaatst"
|
||||||
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
|
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)"
|
name: "Tarief · Injectievergoeding (EUR/kWh)"
|
||||||
description: "Wat u krijgt per kWh die u op het net zet, alles inbegrepen. Dit mag negatief zijn."
|
description: "Wat u krijgt per kWh die u op het net zet, alles inbegrepen. Dit mag negatief zijn."
|
||||||
static_profile:
|
static_profile:
|
||||||
name: "Tarief · Vast of dag/nacht-tarief (JSON)"
|
name: "Tarief · Vast of dag/nacht-tarief"
|
||||||
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."
|
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:
|
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."
|
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:
|
price_formula:
|
||||||
name: "Tarief · Uw eigen tariefformule (JSON)"
|
name: "Tarief · Uw eigen tariefformule"
|
||||||
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."
|
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:
|
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."
|
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:
|
price_csv_path:
|
||||||
name: "Tarief · CSV-bestand met prijzen"
|
name: "Tarief · CSV-bestand met prijzen"
|
||||||
@@ -259,14 +259,14 @@ configuration:
|
|||||||
name: "Geavanceerd · Afronding van het commando (W)"
|
name: "Geavanceerd · Afronding van het commando (W)"
|
||||||
description: "Het commando wordt afgerond op een veelvoud hiervan, zodat ruis het niet elke cyclus verandert."
|
description: "Het commando wordt afgerond op een veelvoud hiervan, zodat ruis het niet elke cyclus verandert."
|
||||||
saturation_w:
|
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."
|
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:
|
saturation_cycles:
|
||||||
name: "Geavanceerd · Cycli aan grens voor bevriezen"
|
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."
|
description: "Hoeveel cycli na elkaar aan de grens voor de regeling stopt met afwijking opbouwen. Zet dit niet op 1."
|
||||||
integrator_max_w:
|
integrator_max_w:
|
||||||
name: "Geavanceerd · Grens opgebouwde afwijking (W)"
|
name: "Geavanceerd · Inhaalgrens (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."
|
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:
|
heartbeat_s:
|
||||||
name: "Geavanceerd · Commando opnieuw sturen na (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."
|
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."
|
||||||
|
|||||||
@@ -1,50 +1,5 @@
|
|||||||
# UX-08 ratchet (test_messages.py part 3). Each line is a user-facing string that
|
# UX-08 ratchet (test_messages.py part 3). Each line would be a user-facing
|
||||||
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start".
|
# string that does not follow STYLE.md yet: "<kind> <sha1-12 of its text> #
|
||||||
# These are waiting for phase 2d (log); phase 2b emptied the row and banner
|
# where :: start". Phase 2d emptied it: every row, banner, refusal, option text
|
||||||
# kinds and phase 2c the refusal and option kinds. Delete the line when you
|
# and WARNING+ log line now follows STYLE.md, and ALLOWLIST_MAX is 0. Never add
|
||||||
# rewrite the string, because a stale line fails the test. Never add a line for a new string: write the new
|
# a line for a new string: write the new string to STYLE.md instead.
|
||||||
# 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.'
|
|
||||||
|
|||||||
Reference in New Issue
Block a user