UX-08 2c: short refusals, error texts and option texts
Validator refusals name the setting by its label and key, say what is wrong and what to change, and keep the rejected value and limit. Option names and descriptions are short in en and nl; citations moved to DOCS "For installers" and the test_translations citation check now pins the instruction on the tab and the record in DOCS. No rule, key, schema, default or exit code changed. Allowlist 167 -> 43 (log lines only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e1d0970b23
commit
2c516a032c
@@ -6,7 +6,7 @@ This applies to everything a home owner or installer reads: notifications, phone
|
|||||||
- **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. **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). **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. The reasoning goes on a DEBUG line. Log lines stay in English and are not translated.
|
||||||
- **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.
|
||||||
|
|
||||||
|
|||||||
@@ -35,3 +35,12 @@ Every row of the Commissioning list and every banner is now in `app/messages.py`
|
|||||||
4. **One term per setting, matching its label.** Four labels changed instead of the texts, because the texts' words were plainer: “Peak reserve (%)” (was "Peak guard will not discharge below"), “Battery level entity (%)” (was "Battery charge level"), and “Solar production entity / entities, per inverter / Reversed-sign solar entities” (was "PV …"). The Dutch labels already said "Zonneproductie". Option descriptions are phase 2c and were not changed, so some still say "PV".
|
4. **One term per setting, matching its label.** Four labels changed instead of the texts, because the texts' words were plainer: “Peak reserve (%)” (was "Peak guard will not discharge below"), “Battery level entity (%)” (was "Battery charge level"), and “Solar production entity / entities, per inverter / Reversed-sign solar entities” (was "PV …"). The Dutch labels already said "Zonneproductie". Option descriptions are phase 2c and were not changed, so some still say "PV".
|
||||||
5. **The shadow banner and the inverter notes** are now `messages.banner_shadow()` and `row_no_setpoint` / `row_not_controllable` / `row_commands_refused`, so `test_messages.py` checks them. The red shadow banner no longer lists the latches, because 12 words cannot carry them. It says a safety stop stays on, and each latch keeps its own red row, which names it and says how it clears. The colour of a liveness row now comes from `is_live()`, not from matching "NOT COMMANDABLE" in the text.
|
5. **The shadow banner and the inverter notes** are now `messages.banner_shadow()` and `row_no_setpoint` / `row_not_controllable` / `row_commands_refused`, so `test_messages.py` checks them. The red shadow banner no longer lists the latches, because 12 words cannot carry them. It says a safety stop stays on, and each latch keeps its own red row, which names it and says how it clears. The colour of a liveness row now comes from `is_live()`, not from matching "NOT COMMANDABLE" in the text.
|
||||||
6. **DOCS "Notifications" and "Status page"** are written for a home owner, and the card ids, keys and log detail moved to a "For installers" part of each.
|
6. **DOCS "Notifications" and "Status page"** are written for a home owner, and the card ids, keys and log detail moved to a "For installers" part of each.
|
||||||
|
|
||||||
|
# UX-08 phase 2c: refusals, error texts and option texts
|
||||||
|
|
||||||
|
Validator refusals, the parse errors they carry, and every option name and description are now short. No rule, key, schema, default or exit code changed. Over 36 spoiled configurations the old and new validators refuse the same number of times. The allowlist went from 167 entries to 43, and only log lines (phase 2d) remain on it.
|
||||||
|
|
||||||
|
1. **A refusal names the setting by its label and then its key**, as `messages.setting()` writes it: “Inverter rating” (max_w). The key has to stay, because `configui.problems_by_field()` shows a reason beside every key its text names. A test checks each label against en.yaml. A few reasons no longer mention keys they only named in passing, such as `price_markup_eur_kwh` in the withdrawn-provider reason, so the panel shows those reasons beside fewer settings.
|
||||||
|
2. **Citations moved to DOCS.** Option descriptions say "Measured value, leave it unchanged" or similar, and the record behind the value is under "For installers: where the numbers come from". The UX-01 citation check in `test_translations.py` now checks the tab for that instruction and DOCS for the record.
|
||||||
|
3. **What left the refusals:** the shipped default in the peak-reserve reason, the superseded-default note in the levy-table reason (DOCS already covers it), the endpoints of the withdrawn providers (DOCS "Energy price providers"), the plant measurement behind `slew_w` (DOCS, beside the setting), and the leg-specific paragraphs of the tariff-formula errors. Each reason keeps the rejected value, the limit and what to change.
|
||||||
|
4. **Not rewritten:** errors that only a code defect can raise, such as plugin-contract and argument-type checks in `price.py`, `price_forecast.py`, `static_tariff.py`, `arbitrage.py` and `main.py`, which a home owner never reads. INFO lines such as the tariff-formula preview were also left.
|
||||||
|
|||||||
+143
-1
@@ -125,7 +125,7 @@ whole rendered list, in both languages.
|
|||||||
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
| **Order comes from `schema:`** | `App.schema_ui` is `UiOptions(...)(data["schema"])`, so the **schema half** of `config.yaml` decides the order the tab renders in — not the `options:` half |
|
||||||
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
| **There are no section headers** | Add-on options are a flat list: no header primitive, no per-option advanced flag, no units on the input, no sliders. Grouping is approximated by **order** plus the `Group · ` prefix in each label, and by nothing else |
|
||||||
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
| **Groups, in task order** | Hardware → Meter → Limits → Tariff → Maintenance → Safety → Advanced |
|
||||||
| **The description says whether to touch it** | A number that was **derived** names the campaign it came from and says not to change it without re-deriving; a number that is genuinely yours (`max_w`, `peak_cap_w`) says **"THIS IS YOUR NUMBER"**; and `cycle_cost_threshold_eur`, which is neither, says it is an open product question |
|
| **The description says whether to touch it** | One or two plain sentences. A measured or derived value says "leave it unchanged", a number that is genuinely yours (`max_w`, `peak_cap_w`) says "Your own number", and `cycle_cost_threshold_eur` says it is a product choice. Where each number comes from is under [For installers](#for-installers-where-the-numbers-come-from) |
|
||||||
| **Dutch falls back per key** | `translations[your language]?.name \|\| translations["en"]?.name \|\| the raw key`. A key missing from `nl.yaml` degrades to English; a key present but wrong does not degrade at all |
|
| **Dutch falls back per key** | `translations[your language]?.name \|\| translations["en"]?.name \|\| the raw key`. A key missing from `nl.yaml` degrades to English; a key present but wrong does not degrade at all |
|
||||||
| **⚠️ The per-entry keys inside `inverters:` still render raw** | Measured, and negative. A list-of-objects becomes an `object` selector with `multiple: true`, and `ha-selector-object` labels its fields `fields[name].label \|\| name` — the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do **not** reach the screen. The `inverters` label and description themselves do |
|
| **⚠️ The per-entry keys inside `inverters:` still render raw** | Measured, and negative. A list-of-objects becomes an `object` selector with `multiple: true`, and `ha-selector-object` labels its fields `fields[name].label \|\| name` — the converter sets no `label` and no `translation_key`. The `fields:` translations are shipped and validated (they are the shape Supervisor's own schema defines) but on frontend 20250925.1 they do **not** reach the screen. The `inverters` label and description themselves do |
|
||||||
|
|
||||||
@@ -133,6 +133,148 @@ whole rendered list, in both languages.
|
|||||||
option that does not exist, or an option with no translation, fails CI — in
|
option that does not exist, or an option with no translation, fails CI — in
|
||||||
either direction, and the check is mutation-tested both ways.
|
either direction, and the check is mutation-tested both ways.
|
||||||
|
|
||||||
|
### Settings at a glance
|
||||||
|
|
||||||
|
The label and description each setting shows on the Configuration tab, in tab order. `translations/en.yaml` is the source, and `test_translations.py` keeps this table in step with it.
|
||||||
|
|
||||||
|
| Group | Setting | Key | What it does |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Hardware | Inverter setpoint entity | `setpoint_entity` | The ESPHome number entity the add-on writes the inverter's power to. Leave it empty when you fill in the inverter list. |
|
||||||
|
| Hardware | Battery level entity (%) | `soc_entity` | The sensor with the battery level in percent. Use the control board's own reading. |
|
||||||
|
| Hardware | Battery power entity (W) | `batt_entity` | The sensor with this inverter's battery power. Positive must mean the battery is giving power. |
|
||||||
|
| Hardware | Battery power sign is reversed | `batt_invert` | Switch this on only if your battery power sensor shows giving power as a negative number. |
|
||||||
|
| Hardware | Inverter list | `inverters` | Leave it empty if you have one inverter. With two or more, add one entry per inverter, which replaces the inverter settings above. |
|
||||||
|
| Hardware · Inverter list | Name | `inverters.name` | A short, unique name for this inverter, for example inverter-a. |
|
||||||
|
| Hardware · Inverter list | Setpoint entity | `inverters.setpoint_entity` | This inverter's own ESPHome setpoint entity. Two entries may not use the same one. |
|
||||||
|
| Hardware · Inverter list | Inverter rating (W) | `inverters.max_w` | The most power the add-on may ask of this inverter. The site total is the sum of the list. |
|
||||||
|
| Hardware · Inverter list | Battery cut-out level (%) | `inverters.bms_soc_floor` | The level at which this battery's own management system stops giving power. Read it off the battery that is installed. |
|
||||||
|
| Hardware · Inverter list | Battery level entity (%) (optional) | `inverters.soc_entity` | This battery's own level sensor. Leave it empty if this inverter has none. |
|
||||||
|
| Hardware · Inverter list | Battery power entity (W) (optional) | `inverters.batt_entity` | This inverter's own battery power sensor. Leave it empty if it has none. |
|
||||||
|
| Hardware · Inverter list | Battery power sign is reversed (optional) | `inverters.batt_invert` | Switch this on if this inverter's sensor shows giving power as a negative number. |
|
||||||
|
| Hardware · Inverter list | Usable battery capacity (kWh) (optional) | `inverters.capacity_kwh` | This battery's usable capacity from its datasheet. Fill it in on every inverter or on none, and the work is then shared by energy left. |
|
||||||
|
| Hardware | RS485 emergency stop is fitted | `estop_fitted` | Switch this on if the emergency stop is installed. It only changes a warning on the status page. |
|
||||||
|
| Hardware | Solar production entity (W) | `pv_entity` | Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control. |
|
||||||
|
| Hardware | Solar production entities, per inverter (W) | `pv_entities` | Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded. |
|
||||||
|
| Hardware | Reversed-sign solar entities | `pv_invert_entities` | Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up. |
|
||||||
|
| Meter | Where the meter reading comes from | `meter_source` | How the add-on reads your meter. 'off' uses “Grid power entity” below, and only homewizard_local can spot a stuck meter. |
|
||||||
|
| Meter | Grid power entity (W) | `meter_entity` | Used when the meter source is 'off'. Positive must mean drawing from the grid. |
|
||||||
|
| Meter | Grid power sign is reversed | `meter_invert` | Switch this on if your grid sensor shows drawing from the grid as a negative number. |
|
||||||
|
| Meter | Number of phases (1 or 3) | `meter_phases` | Must match your grid connection. Meter readings with a different number of phases are rejected. |
|
||||||
|
| Meter | HomeWizard P1 address | `p1_host` | For homewizard_local only. The meter's address on your network, as host or host:port. |
|
||||||
|
| Meter | Time between meter reads (s) | `meter_poll_s` | For homewizard_local only. The meter updates every 5 s, so lower gains nothing. Keep it well below “Reading too old after”. |
|
||||||
|
| Meter | Reading too old after (s) | `meter_max_age_s` | After this a meter reading counts as missing. The “Missing inputs time limit” starts only after this one runs out. |
|
||||||
|
| Meter | MQTT topic | `meter_mqtt_topic` | For mqtt_p1 only. The topic your meter readings are published on. |
|
||||||
|
| Meter | Grid import entity (W) | `p1_import_entity` | The sensor for power drawn from the grid, never negative. The add-on works out the net value itself. |
|
||||||
|
| Meter | Grid export entity (W) | `p1_export_entity` | The sensor for power fed into the grid, never negative. It pairs with “Grid import entity”. |
|
||||||
|
| Meter | Signed net power entity (W) | `p1_net_entity` | For ha_signed only. One sensor that is positive when drawing from the grid and negative when feeding in. |
|
||||||
|
| Meter | Import entities per phase (L1-L3) | `p1_phase_import_entities` | Optional, in L1, L2, L3 order. Needed for the peak limit on a three-phase connection, one entry per phase. |
|
||||||
|
| Meter | Export entities per phase (L1-L3) | `p1_phase_export_entities` | Optional, in L1, L2, L3 order, next to the import entities per phase. One entry per phase. |
|
||||||
|
| Meter | Net entities per phase (L1-L3) | `p1_phase_net_entities` | For ha_signed only. Optional, in L1, L2, L3 order, signed like “Signed net power entity”, one entry per phase. |
|
||||||
|
| Limits | Inverter rating (W) | `max_w` | Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning. |
|
||||||
|
| Limits | Battery cut-out level (%) | `bms_soc_floor` | The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure. |
|
||||||
|
| Limits | Peak limit (W) | `peak_cap_w` | Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract. |
|
||||||
|
| Limits | 15-minute demand forecast entity (W) | `peak_forecast_entity` | Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit. |
|
||||||
|
| Limits | Peak reserve (%) | `peak_soc_floor` | Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged. |
|
||||||
|
| Tariff | Dynamic price source | `price_provider` | Where prices come from. Choosing a source starts the price fetch, even with trading off. |
|
||||||
|
| Tariff | Supplier markup (EUR/kWh) | `price_markup_eur_kwh` | Your supplier's margin. It is added only when the price source does not already include it. |
|
||||||
|
| Tariff | Distribution, levies and VAT (EUR/kWh) | `price_taxes_eur_kwh` | Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates. |
|
||||||
|
| Tariff | Injection price (EUR/kWh) | `export_price_eur_kwh` | What you are paid per kWh fed into the grid, all in. It may be negative. |
|
||||||
|
| Tariff | Fixed or day/night tariff (JSON) | `static_profile` | Your fixed or day/night contract as one line of JSON, prices all in. Fill it in even on a dynamic contract, as the fallback when the price feed fails. |
|
||||||
|
| Tariff | Day/night levies and VAT (JSON) | `price_taxes_profile` | Leave it empty unless your grid operator charges a different day and night rate, as in Wallonia and Brussels. Set this or the flat figure above, not both. |
|
||||||
|
| Tariff | Your own tariff formula (JSON) | `price_formula` | For custom_formula only. Type the numbers from your supplier's tariff card as one line of JSON, and the add-on checks them against the card. |
|
||||||
|
| Tariff | Your own injection formula (JSON) | `price_export_formula` | Optional. The injection formula from your tariff card, in the same shape as your own tariff formula. Leave it empty to use the flat injection price. |
|
||||||
|
| Tariff | Price CSV file | `price_csv_path` | For manual_csv only. Where in /share the add-on reads your price file. |
|
||||||
|
| Tariff | Price feed URL override | `price_feed_url` | Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day. |
|
||||||
|
| Tariff | Price look-ahead (hours) | `price_horizon_h` | How many hours of prices to ask the source for. |
|
||||||
|
| Tariff | Price request timeout (s) | `price_timeout_s` | How long one request to the price source may take before it is given up. |
|
||||||
|
| Tariff | Current price entity (older) | `price_now_entity` | The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source. |
|
||||||
|
| Tariff | Average price entity (older) | `price_avg_entity` | The average the current price is compared with. Use it only with the current price entity, not with a dynamic price source. |
|
||||||
|
| Tariff | Trade on the price spread | `arbitrage_enabled` | Charge in the cheapest hour and draw the battery down in the dearest, when the spread covers the cost. Turning it off does not turn the price feed off. |
|
||||||
|
| Tariff | Battery cycle cost (EUR/kWh) | `cycle_cost_threshold_eur` | What one full battery cycle costs per kWh. A smaller price spread is not traded, and the value is a product choice, not a measured one. |
|
||||||
|
| Tariff | Trading keeps at least (%) | `arbitrage_soc_floor` | Price trading will not draw the battery below this level. It is separate from the maintenance settings. |
|
||||||
|
| Tariff | Trading charges at most (%) | `arbitrage_soc_ceiling` | Price trading will not charge the battery above this level. |
|
||||||
|
| Maintenance | Run the monthly battery cycle | `maintenance_enabled` | Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default. |
|
||||||
|
| Maintenance | Minimum days between cycles | `maintenance_interval_days` | The shortest gap between two maintenance cycles. |
|
||||||
|
| Maintenance | Start hour | `maintenance_start_hour` | The local hour, 0 to 23, at which a due cycle starts. |
|
||||||
|
| Maintenance | Draw-down rate (W) | `maintenance_discharge_w` | How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer. |
|
||||||
|
| Maintenance | Recharge rate (W) | `maintenance_charge_w` | How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start. |
|
||||||
|
| Maintenance | Draw down to (%) | `maintenance_soc_floor` | The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose. |
|
||||||
|
| Maintenance | Recharge up to (%) | `maintenance_soc_target` | The level the recharge aims for before the hold starts. |
|
||||||
|
| Maintenance | Hold at full for (minutes) | `maintenance_hold_min` | How long the battery stays full so its cells can balance. |
|
||||||
|
| Safety | Wrong-direction threshold (W) | `sign_disagree_w` | A wrong direction counts only when the command and the meter reading both exceed this. Measured value, leave it unchanged. |
|
||||||
|
| Safety | Wrong-direction time (s) | `sign_disagree_s` | How long a wrong direction may last before a safety stop sets every inverter to 0 W. Measured value, leave it unchanged. |
|
||||||
|
| Safety | Per-inverter wrong-direction threshold (W) | `unit_sign_disagree_w` | For two or more inverters: the wrong-direction threshold for each inverter. Derived value, leave it unchanged. |
|
||||||
|
| Safety | Per-inverter wrong-direction time (s) | `unit_sign_disagree_s` | For two or more inverters: how long one inverter may push the wrong way before it is set to 0 W. Derived value, leave it unchanged. |
|
||||||
|
| Safety | Site wrong-direction threshold (W) | `site_sign_disagree_w` | For two or more inverters: the wrong-direction threshold for the whole site. Measured value, leave it unchanged. |
|
||||||
|
| Safety | Site wrong-direction time (s) | `site_sign_disagree_s` | For two or more inverters: how long the whole site may push the wrong way before a safety stop. Derived value, leave it unchanged. |
|
||||||
|
| Safety | Stuck meter alarm after (s) | `meter_freeze_s` | An alarm when the meter repeats one reading this long while the command is at full power, with homewizard_local only. Measured value, leave it unchanged. |
|
||||||
|
| Safety | Opposing inverters threshold (W) | `circulation_w` | For two or more inverters: how much power one may push against another before it counts. Measured value, leave it unchanged. |
|
||||||
|
| Safety | Opposing inverters time (s) | `circulation_hold_s` | For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged. |
|
||||||
|
| Safety | Not delivering alarm after (s) | `delivery_hold_s` | For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged. |
|
||||||
|
| Safety | Shadow mode | `shadow_mode` | The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never. |
|
||||||
|
| Advanced | Start control on boot | `auto_start` | Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart. |
|
||||||
|
| Advanced | Control gain | `gain` | How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it. |
|
||||||
|
| Advanced | Largest change per cycle (W) | `slew_w` | How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended. |
|
||||||
|
| Advanced | Ignore errors below (W) | `deadband_w` | Errors smaller than this are ignored. Keep it small, since any resting point inside it holds. |
|
||||||
|
| Advanced | Meter resting point (W) | `target_grid_w` | What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day. |
|
||||||
|
| Advanced | Command rounding (W) | `step_w` | The command is rounded to a multiple of this, so noise does not change it every cycle. |
|
||||||
|
| Advanced | Inverter at limit gap (W) | `saturation_w` | When an inverter delivers this much less than asked, it counts as at its limit. The per-inverter wrong-direction threshold is half of this. |
|
||||||
|
| Advanced | 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` | Limits how much old error control can carry, and 0 means “Inverter rating”. Do not raise it above the rating. |
|
||||||
|
| Advanced | Command refresh interval (s) | `heartbeat_s` | How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit. |
|
||||||
|
| Advanced | Missing inputs time limit (s) | `stale_input_s` | How long inputs may be missing before every inverter is set to 0 W. With a P1 meter it starts after “Reading too old after”. |
|
||||||
|
| Advanced | Longest time between cycles (s) | `cycle_floor_s` | A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s. |
|
||||||
|
| Advanced | Log level | `log_level` | How much detail the add-on writes to its log. 'debug' and 'trace' are for finding a problem. |
|
||||||
|
|
||||||
|
### For installers: where the numbers come from
|
||||||
|
|
||||||
|
The Configuration tab keeps each description to one or two sentences. The detail that used to sit there is here, one entry per setting. A value marked *measured* or *derived* in its description came from the record named below; change it only after re-deriving it from a new measurement.
|
||||||
|
|
||||||
|
When the add-on refuses to start, each reason names the setting by its label and then its key, for example “Inverter rating” (max_w), and adds the inverter's name when there is a list. The configuration panel shows a reason beside every setting it names.
|
||||||
|
|
||||||
|
- **Battery power entity** (`batt_entity`): on the reference installation this is holding register 0x0518, read by the control board. The wrong-direction checks and the opposing-inverters alarm both grade each inverter against it.
|
||||||
|
- **Inverter list** (`inverters`): empty means the single-inverter settings describe the one inverter you have. Filled in, the list replaces them and nothing is inherited: each entry names its own setpoint entity, rating and cut-out level, so no inverter is judged by another's numbers. “Inverter setpoint entity” must then be empty, and the add-on refuses to start when both are set. Each entry's name keys its own safety stop and records in the add-on's state, so two entries may not share one.
|
||||||
|
- **Usable battery capacity** (`inverters.capacity_kwh`): read only to share the site target by the energy each battery has left rather than by its rating. The reference batteries are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so the two ways of sharing give the work to different batteries. Stated on some entries and not others, the add-on refuses to start.
|
||||||
|
- **Solar production entities** (`pv_entity`, `pv_entities`, `pv_invert_entities`): measurement only, no control behaviour changes. The unit is read off each entity, so a sensor without one is refused rather than guessed. With several entities, one that stops reporting gives no site total at all, because a partial sum looks like a real one. Setting `pv_entities` ignores `pv_entity`.
|
||||||
|
- **Where the meter reading comes from** (`meter_source`): 'off' keeps “Grid power entity”. ha_dsmr follows the DSMR integration, mqtt_p1 reads an MQTT topic, ha_signed reads one signed entity (what a HomeWizard P1 publishes), and homewizard_local polls the meter's own local API. Only homewizard_local can tell "the meter did not answer" from "the value did not change", so the stuck-meter alarm only means something there.
|
||||||
|
- **Grid import and export entities** (`p1_import_entity`, `p1_export_entity`, `p1_net_entity`): the add-on computes net power itself, so the sign convention lives in tested code rather than in a template sensor.
|
||||||
|
- **Number of phases** (`meter_phases`): a reading whose phase count disagrees is rejected and logged as it arrives, so a wrong value shows up at once. The range accepts 2, which is not a real Belgian connection.
|
||||||
|
- **Time between meter reads, Reading too old after** (`meter_poll_s`, `meter_max_age_s`): the add-on refuses to start if the poll interval is at or above the age limit. 30 s is six polls of headroom at the default 5 s. The reading-age limit on its own does not command 0 W. “Missing inputs time limit” starts after it, so the two add up.
|
||||||
|
- **Inverter rating** (`max_w`): your own number, not a derived one. It is not read when the inverter list is filled in, since each entry carries its own and the site limit is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not.
|
||||||
|
- **Battery cut-out level** (`bms_soc_floor`, `inverters.bms_soc_floor`): measured, not chosen. Register 0x0560 reads 10 % on the reference GW5000S-BP. It exists so the add-on can refuse a “Peak reserve” at or below it, which could never take effect. Each inverter entry states its own, because one default for two different batteries protects the shallower one with the deeper one's number.
|
||||||
|
- **Peak limit** (`peak_cap_w`): your own number, from your grid contract. In Belgium it is the capacity-tariff peak your distribution charge is billed on.
|
||||||
|
- **Peak reserve** (`peak_soc_floor`): derived, not picked (SAFETY-24). The reference battery holds 13.8 kWh usable and the worst modelled peak day asks for 878 Wh, or 6.4 %. 20 % sits ten points above the cut-out level and leaves about 1.6 worst days of room. It bounds peak limiting only: ordinary use goes on down to the cut-out level. It must stay above “Battery cut-out level”.
|
||||||
|
- **Dynamic price source** (`price_provider`): selecting a provider is what starts the fetch, every half hour, whether or not trading is on. It may not be combined with the two older price entities. engie_dynamic, totalenergies_dynamic and eneco_eddy are withdrawn and refuse to start: their endpoints never existed. They stay in the list only so such a site gets that explanation.
|
||||||
|
- **Supplier markup** (`price_markup_eur_kwh`): under manual_csv, whose prices are all in, it is ignored and logged as ignored.
|
||||||
|
- **Distribution, levies and VAT** (`price_taxes_eur_kwh`): 0.11736 EUR/kWh, derived in TARIFF-18 from the Eneco residential card for Flanders of September 2026 with 6 % VAT: Fluvius distribution for Antwerpen 5,35 plus excise 4,876 plus green power and CHP levy 1,51 c/kWh. Check it against your own DSO: across the eight Fluvius areas distribution runs from 5,28 to 6,70 c/kWh, so the Flemish total runs from about 0.117 to 0.131. Changing it moves no trading decision, because the same amount lands on every hour. It does move every price shown, and the rule that refuses to draw the battery down into an hour priced below zero. See [What `price_taxes_eur_kwh` is made of](#what-price_taxes_eur_kwh-is-made-of-and-why-yours-is-different-tariff-18).
|
||||||
|
- **Day/night levies and VAT** (`price_taxes_profile`): for Wallonia and Brussels, where distribution has a day and a night rate (ORES 13,27 against 7,39 c/kWh, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10). The figures are levies alone, added to the market price. The add-on refuses to start if it and the flat figure disagree. See [Levies and VAT by time of day](#levies-and-vat-by-time-of-day-price_taxes_profile).
|
||||||
|
- **Injection price** (`export_price_eur_kwh`): one number because no supplier here publishes a dynamic injection curve. In Belgium it is often near zero, and at a sunny midday it can be negative.
|
||||||
|
- **Fixed or day/night tariff** (`static_profile`): the periods must cover every day type exactly once with no gap and no overlap, and a gap is refused rather than filled. A window 22:00-07:00 on mon-fri does not cover Saturday morning, so a weekend row is required. See [Static day/night and fully-fixed tariffs](#static-daynight-and-fully-fixed-tariffs-static_profile).
|
||||||
|
- **Your own tariff and injection formula** (`price_formula`, `price_export_formula`): type the card's numbers in the card's units. The `card` block is required on the tariff formula: the add-on recomputes the card's example price and refuses to start if it does not come out. Injection is VAT-exempt in Belgium, so `vat_pct` there is normally 0. See [Your own tariff formula](#your-own-tariff-formula-custom_formula) and [`price_export_formula`, field by field](#price_export_formula-field-by-field).
|
||||||
|
- **Price feed URL override** (`price_feed_url`): only a host the chosen source declares is accepted. Nothing is added to the URL, not even the date range, so an override often returns one day and the rest of the plan is carried over and excluded from trading. The log warns on every refresh.
|
||||||
|
- **Current and average price entities** (`price_now_entity`, `price_avg_entity`): kept because removing them would take the maintenance cycle's only price signal away from a live site.
|
||||||
|
- **Trade on the price spread** (`arbitrage_enabled`): off as shipped, on purpose, because it moves energy against the meter by design. It needs a price source and a P1 meter, and the add-on refuses combinations that cannot work.
|
||||||
|
- **Battery cycle cost** (`cycle_cost_threshold_eur`): wear plus conversion losses. It is not derived from a measurement: 0.10 is a product decision still open with the owner (OPT-08). At this value a fixed day/night site does not trade, since a realistic Belgian day/night spread is about 0.08 EUR/kWh.
|
||||||
|
- **Trading keeps at least, Trading charges at most** (`arbitrage_soc_floor`, `arbitrage_soc_ceiling`): kept apart from the maintenance levels, so one setting does not couple two unrelated policies.
|
||||||
|
- **Draw-down and recharge rate** (`maintenance_discharge_w`, `maintenance_charge_w`): the draw-down rate above the rating is cut back, not refused. A recharge rate above the rating would ask for more than the limit allows and resolve to 0 W, so maintenance would never charge. That is why the add-on refuses it.
|
||||||
|
- **Draw down to** (`maintenance_soc_floor`): the monthly cycle goes this deep on purpose, while “Peak reserve” is a depth peak limiting never reaches. 11 % against 20 % is why they are two settings.
|
||||||
|
- **Wrong-direction threshold and time** (`sign_disagree_w`, `sign_disagree_s`): measured, not chosen (SAFETY-02). On the captured healthy day (2026-08-23) signs agree on 93.4 % of gated samples, on the fault day (2026-08-20) 0.0-10.5 %, with opposed runs up to 1752 s. The longest legitimate opposed run is 30 s, and the fault day still trips 13 times at 300 s, so 180 s sits in a wide gap. The threshold must stay below “Inverter rating”.
|
||||||
|
- **Per-inverter wrong-direction threshold and time** (`unit_sign_disagree_w`, `unit_sign_disagree_s`): two or more inverters only. Derived from 1890 closed-loop test runs (SAFETY-30) with nothing faulty. 250 W is half the at-limit gap. 85 s is 8.5 times the worst legitimate opposition (10.0 s) and 2.8 times the control board's 30 s link limit.
|
||||||
|
- **Site wrong-direction threshold** (`site_sign_disagree_w`): the single-inverter 300 W carried across deliberately (SAFETY-30), because near zero the sign is noise whatever is behind the one meter.
|
||||||
|
- **Site wrong-direction time** (`site_sign_disagree_s`): derived from the same 1890 runs (SAFETY-30). The worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, so 180 s would false-trip. 325 s is 1.51 times that and inside the 900 s billing period.
|
||||||
|
- **Stuck meter alarm after** (`meter_freeze_s`): measured, not chosen (SAFETY-18). Over 2399 s of this house at its operating point, the longest run without a new reading is 15.4 s, and 300 s is 19.5 times that. It raises an alarm only and does not set 0 W.
|
||||||
|
- **Opposing inverters threshold** (`circulation_w`): measured on 924 closed-loop test runs (SAFETY-31). The worst legitimate opposition drops from 505 s at 100 W to 5 s at 400 W, the lowest value accepted. 700 W is 1.75 times that knee, with margin because the second inverter is still a copy of the first.
|
||||||
|
- **Opposing inverters time** (`circulation_hold_s`): derived (SAFETY-31). 75 s is fifteen control cycles, 15 times the worst legitimate episode, and 2.5 times the control board's 30 s link limit.
|
||||||
|
- **Not delivering alarm after** (`delivery_hold_s`): two or more inverters only. It reports only (SAFETY-42): nothing is commanded differently. Derived from 2640 closed-loop test runs with nothing faulty. The worst legitimate episode is 35.0 s and 185 s is 5.29 times that. The schema allows 30 s, below that, so expect false reports there. A battery at or below its own cut-out level is never reported. Re-derive with admin/goodwe `sim/delivery_window.py --campaign --blag`.
|
||||||
|
- **Shadow mode** (`shadow_mode`): the whole decision path runs and nothing reaches the inverter. The status page and action log show what would have been sent. A safety stop that is already up stays up. See [Shadow mode](#shadow-mode-shadow_mode-safety-10).
|
||||||
|
- **Control gain** (`gain`): measured on real hardware and already at the limit.
|
||||||
|
- **Largest change per cycle** (`slew_w`): 0 means follow the rating: about a ninth of it, derived (SAFETY-39) from a measured ~18 s step response against a 2 s meter tick. A fixed value faster than the rating allows is refused. At 1000 W against a 5000 W rating control swung the meter from 3.4 kW export to 2.8 kW import on 2026-09-05.
|
||||||
|
- **Ignore errors below, Meter resting point** (`deadband_w`, `target_grid_w`): the meter's import counter bills every positive watt, so a resting point at 0 W leaks about half the deadband as billed import, roughly 0.2-0.35 kWh a day at 15 W. -10 W moves that leak to export.
|
||||||
|
- **Inverter at limit gap** (`saturation_w`): the per-inverter wrong-direction threshold is derived as half of it, so moving this moves that argument too.
|
||||||
|
- **Error build-up limit** (`integrator_max_w`): measured on the closed-loop simulator with a 4000 W load dropped to zero: at a limit equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating 1800 W. Lowering it is the useful direction.
|
||||||
|
- **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.
|
||||||
|
|
||||||
### Sources
|
### Sources
|
||||||
|
|
||||||
| option | required | meaning |
|
| option | required | meaning |
|
||||||
|
|||||||
@@ -262,10 +262,8 @@ def threshold_eur_kwh(opts: dict) -> float:
|
|||||||
v = float(price_option(opts, "cycle_cost_threshold_eur"))
|
v = float(price_option(opts, "cycle_cost_threshold_eur"))
|
||||||
if not math.isfinite(v) or v < 0:
|
if not math.isfinite(v) or v < 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
|
f"“Battery cycle cost” (cycle_cost_threshold_eur) is {v!r}. Set "
|
||||||
f"in EUR/kWh, so it is non-negative and finite - a negative "
|
f"it to 0 or more, in EUR/kWh.")
|
||||||
f"threshold is one every spread clears, which is arbitrage with the "
|
|
||||||
f"brake removed.")
|
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ RESOLUTIONS: tuple = ("hourly", "quarter_hourly")
|
|||||||
# index id -> the human name of the series it refers to. One entry today; a
|
# index id -> the human name of the series it refers to. One entry today; a
|
||||||
# second index is a new row here plus a fetch path, not a new grammar.
|
# second index is a new row here plus a fetch path, not a new grammar.
|
||||||
INDEXES: dict = {
|
INDEXES: dict = {
|
||||||
"epex_be": "EPEX BE day-ahead, EUR/MWh",
|
"epex_be": "Belgian day-ahead price, EUR/MWh",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
|
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
|
||||||
@@ -285,87 +285,29 @@ _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 price_provider is 'custom_formula'.",
|
when=("Leave it empty unless “Dynamic price source” (price_provider) "
|
||||||
zero_factor=("every spread is exactly 0, and arbitrage can never "
|
"is 'custom_formula'."),
|
||||||
"clear any threshold - a strategy that silently never "
|
zero_factor=" and trading never finds a price difference",
|
||||||
"fires rather than one that is off"),
|
flat="use “Fixed or day/night tariff” (static_profile) instead",
|
||||||
flat=("that is `static_profile` with a single period covering "
|
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||||
"00:00-24:00 on all days and price_provider 'off'; the add-on "
|
|
||||||
"then says so at startup instead of leaving you to find out "
|
|
||||||
"from a year of unchanged bills"),
|
|
||||||
neg_factor=("No consumption tariff is indexed negatively; if your "
|
|
||||||
"card shows a minus sign it is on the OFFSET (`A`), "
|
|
||||||
"which is allowed here and common on injection"),
|
|
||||||
card_required=True,
|
card_required=True,
|
||||||
card_note=("It is the only thing that can tell a correct pair of "
|
card_note="The add-on needs it to check your two numbers",
|
||||||
"coefficients from a plausible-looking wrong one"),
|
card_causes="that network charges are not in the offset",
|
||||||
card_causes=(
|
|
||||||
"or - the expensive one - network charges, levies or excise "
|
|
||||||
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
|
|
||||||
"card's Tariefformule line is the ENERGY component alone (Eneco "
|
|
||||||
"tabulates network tariffs and levies separately; ENGIE says in "
|
|
||||||
"so many words that the formula excludes network, distribution, "
|
|
||||||
"transport, levies and excise), the add-on adds "
|
|
||||||
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
|
|
||||||
"network charge into `offset` bills you for it twice. That is "
|
|
||||||
"the error this check exists to catch, and it is why it cannot "
|
|
||||||
"be switched off."),
|
|
||||||
),
|
),
|
||||||
EXPORT_FORMULA_OPTION: _Leg(
|
EXPORT_FORMULA_OPTION: _Leg(
|
||||||
option=EXPORT_FORMULA_OPTION,
|
option=EXPORT_FORMULA_OPTION,
|
||||||
what="injection",
|
what="injection",
|
||||||
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
|
|
||||||
# none to copy - the formula is printed with no price beside it. An
|
|
||||||
# example carrying one would invite an operator to compute the figure
|
|
||||||
# themselves, which is the one thing this leg must not encourage.
|
|
||||||
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=("Leave it empty unless your supplier pays you an injection "
|
when=("Fill it in only if your injection price follows the market. It "
|
||||||
"price that moves with the market; a flat one is "
|
"is used whatever the price source is."),
|
||||||
"export_price_eur_kwh. ⚠️ This option is read whatever "
|
zero_factor="",
|
||||||
"price_provider is set to - it is the leg you are PAID on, not "
|
flat="use “Injection price” (export_price_eur_kwh) instead",
|
||||||
"the one you buy on."),
|
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||||
zero_factor=("every hour pays the same, which is a FLAT injection "
|
|
||||||
"price written as a formula. ⚠️ Note this is NOT the "
|
|
||||||
"arbitrage argument the consumption leg gives: no "
|
|
||||||
"strategy in this add-on reads an injection price at "
|
|
||||||
"all, so a flat one here fires nothing and declines "
|
|
||||||
"nothing - it is simply the wrong option"),
|
|
||||||
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
|
|
||||||
"which is what every site used before this option existed and "
|
|
||||||
"which still answers when this one is empty"),
|
|
||||||
neg_factor=("No Belgian injection tariff is indexed negatively - you "
|
|
||||||
"are paid MORE when the market is dearer, not less. The "
|
|
||||||
"minus sign on an injection card is on the OFFSET (`A`): "
|
|
||||||
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
|
|
||||||
"`0,07 x Belpex - 2`, and a negative offset is accepted "
|
|
||||||
"here"),
|
|
||||||
card_required=False,
|
card_required=False,
|
||||||
card_note=(
|
card_note=("It is optional here. Without it the add-on only checks "
|
||||||
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
|
"the size of your two numbers"),
|
||||||
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
|
card_causes="that vat_pct is 0 for injection",
|
||||||
"formula with the reference index and the resulting price beside "
|
|
||||||
"it, which is what makes `price_formula`'s self-check possible. "
|
|
||||||
"Eneco's injection line has NO printed price beside it, so on "
|
|
||||||
"that card there is nothing to grade against and requiring a "
|
|
||||||
"`card` block would make the market's own published formula "
|
|
||||||
"inexpressible - or, worse, invite an operator to compute the "
|
|
||||||
"estimate themselves, at which point the check grades the "
|
|
||||||
"coefficients against arithmetic done with the same coefficients. "
|
|
||||||
"Supply it if YOUR card prints one; when it is absent the add-on "
|
|
||||||
"says at startup that this leg is UNGRADED and the two "
|
|
||||||
"coefficient bounds below are all there is"),
|
|
||||||
card_causes=(
|
|
||||||
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
|
|
||||||
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
|
|
||||||
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
|
|
||||||
"Belgian card the research found, and a 6 typed here because it "
|
|
||||||
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
|
|
||||||
"is added to this leg by the framework: price_taxes_eur_kwh is a "
|
|
||||||
"CONSUMPTION component and is not applied to what you are paid "
|
|
||||||
"for export, so an offset carrying network charges is a "
|
|
||||||
"different error here than it is on price_formula and this "
|
|
||||||
"check cannot tell you which one you made."),
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,16 +430,12 @@ def _finite(value, field: str, option: str = "price_formula") -> float:
|
|||||||
"""
|
"""
|
||||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option}.{field} is {value!r}, which is not a number. "
|
f"{option}.{field} is {value!r}, which is not a number. Write a "
|
||||||
f"Write the coefficient as a bare JSON number - 0.102, not "
|
f"plain number, for example 0.102.")
|
||||||
f"\"0.102\" and not an expression: this provider takes FIXED "
|
|
||||||
f"coefficients and evaluates nothing.")
|
|
||||||
if not math.isfinite(float(value)):
|
if not math.isfinite(float(value)):
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option}.{field} is {value!r}, which is not finite. A NaN "
|
f"{option}.{field} is {value!r}, which is not finite. Write a "
|
||||||
f"or an infinity here makes every price NaN, every price "
|
f"plain number, for example 0.102.")
|
||||||
f"comparison False, and an arbitrage strategy that never fires "
|
|
||||||
f"while reporting that it is armed.")
|
|
||||||
return float(value)
|
return float(value)
|
||||||
|
|
||||||
|
|
||||||
@@ -517,11 +455,8 @@ def _card(raw, leg: _Leg = None) -> tuple:
|
|||||||
if not leg.card_required:
|
if not leg.card_required:
|
||||||
return ("", None, None)
|
return ("", None, None)
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
|
f"{leg.option} has no \"card\" block. {leg.card_note}. Copy "
|
||||||
f"{leg.card_note}: the add-on recomputes your "
|
"the three figures from your tariff card, for example "
|
||||||
"supplier's own printed example from the numbers you typed and "
|
|
||||||
"refuses to start if it does not come out. Copy the three figures "
|
|
||||||
"off the same tariff card as the coefficients: "
|
|
||||||
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
|
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
|
||||||
"\"expect_incl_vat\": 15.55}.")
|
"\"expect_incl_vat\": 15.55}.")
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
@@ -531,16 +466,13 @@ def _card(raw, leg: _Leg = None) -> tuple:
|
|||||||
unknown = sorted(set(raw) - set(_CARD_KEYS))
|
unknown = sorted(set(raw) - set(_CARD_KEYS))
|
||||||
if unknown:
|
if unknown:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{leg.option}.card has no field(s) {unknown}. The fields it "
|
f"{leg.option}.card has unknown field(s) {unknown}. It takes "
|
||||||
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
|
f"only {list(_CARD_KEYS)}.")
|
||||||
f"number the operator believes took effect.")
|
|
||||||
month = str(raw.get("month", "") or "").strip()
|
month = str(raw.get("month", "") or "").strip()
|
||||||
if not _month_date(month):
|
if not _month_date(month):
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
|
f"{leg.option}.card.month is {raw.get('month')!r}. Write the "
|
||||||
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
|
f"month of the card as \"YYYY-MM\", for example \"2026-09\".")
|
||||||
f"not used in any price - it is what lets the add-on tell you "
|
|
||||||
f"your card has gone stale.")
|
|
||||||
return (month,
|
return (month,
|
||||||
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
|
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
|
||||||
leg.option),
|
leg.option),
|
||||||
@@ -594,8 +526,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}). It is a JSON object "
|
f"{option} is not valid JSON ({e}). Write it on one line, for "
|
||||||
f"on one line, e.g.\n"
|
f"example\n"
|
||||||
f" {leg.example}\n"
|
f" {leg.example}\n"
|
||||||
f"{leg.when}")
|
f"{leg.when}")
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
@@ -612,34 +544,21 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
|||||||
unknown = sorted(set(value) - set(_TOP_KEYS))
|
unknown = sorted(set(value) - set(_TOP_KEYS))
|
||||||
if unknown:
|
if unknown:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option} has no field(s) {unknown}. The fields it takes "
|
f"{option} has unknown field(s) {unknown}. It takes only "
|
||||||
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
|
f"{list(_TOP_KEYS)}.")
|
||||||
f"takes an expression: this provider multiplies and adds two "
|
|
||||||
f"FIXED coefficients and evaluates nothing, because two "
|
|
||||||
f"coefficients can be graded against your card's printed price "
|
|
||||||
f"and an arbitrary expression cannot be graded against anything. "
|
|
||||||
f"If your contract needs a shape this cannot express, it needs a "
|
|
||||||
f"new named field here - say which shape.")
|
|
||||||
|
|
||||||
index = str(value.get("index", "") or "").strip()
|
index = str(value.get("index", "") or "").strip()
|
||||||
if index not in INDEXES:
|
if index not in INDEXES:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option}.index is {value.get('index')!r}. The reference "
|
f"{option}.index is {value.get('index')!r}. The add-on can only "
|
||||||
f"series this add-on can fetch are "
|
f"fetch "
|
||||||
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
|
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}.")
|
||||||
f"If your card names a different index, the add-on cannot get it "
|
|
||||||
f"and guessing which series your supplier meant would price your "
|
|
||||||
f"battery against the wrong curve.")
|
|
||||||
|
|
||||||
resolution = str(value.get("resolution", "") or "").strip()
|
resolution = str(value.get("resolution", "") or "").strip()
|
||||||
if resolution not in RESOLUTIONS:
|
if resolution not in RESOLUTIONS:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option}.resolution is {value.get('resolution')!r}; it "
|
f"{option}.resolution is {value.get('resolution')!r}. Choose one "
|
||||||
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
|
f"of {list(RESOLUTIONS)}, as your tariff card says.")
|
||||||
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
|
|
||||||
f"a day; a contract written against the HOURLY series is priced "
|
|
||||||
f"on the mean of each hour's quarters. Which one your contract "
|
|
||||||
f"uses is on your card and cannot be inferred from the feed.")
|
|
||||||
|
|
||||||
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
|
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
|
||||||
# factor-1000 class with a friendly face: the add-on would silently assume
|
# factor-1000 class with a friendly face: the add-on would silently assume
|
||||||
@@ -648,12 +567,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
|||||||
units = str(value.get("units", "") or "").strip()
|
units = str(value.get("units", "") or "").strip()
|
||||||
if units not in UNITS:
|
if units not in UNITS:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
|
f"{option}.units is {value.get('units')!r}. Choose one of "
|
||||||
f"there is no default, on purpose. It must be one of "
|
f"{sorted(UNITS)}, as printed beside the formula on your card.")
|
||||||
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
|
|
||||||
f"and two suppliers use two different conventions for the same "
|
|
||||||
f"shape, so assuming one would be a factor of 100 or 1000 on "
|
|
||||||
f"every hour with nothing able to notice.")
|
|
||||||
|
|
||||||
factor = _finite(value.get("factor"), "factor", option)
|
factor = _finite(value.get("factor"), "factor", option)
|
||||||
offset = _finite(value.get("offset"), "offset", option)
|
offset = _finite(value.get("offset"), "offset", option)
|
||||||
@@ -667,10 +582,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
|||||||
# the operator at it rather than let them build it by accident.
|
# the operator at it rather than let them build it by accident.
|
||||||
if factor == 0.0:
|
if factor == 0.0:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option}.factor is 0, so the index would not enter the "
|
f"{option}.factor is 0, so every hour gets the same {leg.what} "
|
||||||
"price at all: every hour of every day comes out at "
|
f"price{leg.zero_factor}. For one flat price, {leg.flat}.")
|
||||||
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
|
|
||||||
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
|
|
||||||
|
|
||||||
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
|
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
|
||||||
# the dearest hour becomes the cheapest one, so the battery is told to
|
# the dearest hour becomes the cheapest one, so the battery is told to
|
||||||
@@ -678,22 +591,16 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
|||||||
# mispriced site, it is a site actively trading against itself.
|
# mispriced site, it is a site actively trading against itself.
|
||||||
if factor < 0.0:
|
if factor < 0.0:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
|
f"{option}.factor is {factor:g}, which turns the market upside "
|
||||||
f"inverts the market: the dearest hour of the day comes out as "
|
f"down. {leg.neg_factor}.")
|
||||||
f"the cheapest one, so the battery would charge at the evening "
|
|
||||||
f"peak and discharge at 03:00 - trading against itself on every "
|
|
||||||
f"cycle. {leg.neg_factor}.")
|
|
||||||
|
|
||||||
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
|
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
|
||||||
# and some cards are already quoted VAT-inclusive. The bound exists for the
|
# and some cards are already quoted VAT-inclusive. The bound exists for the
|
||||||
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
|
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
|
||||||
if not 0.0 <= vat_pct <= 30.0:
|
if not 0.0 <= vat_pct <= 30.0:
|
||||||
raise FormulaInvalid(
|
raise FormulaInvalid(
|
||||||
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
|
f"{option}.vat_pct is {vat_pct:g}. Write a percentage between 0 "
|
||||||
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
|
f"and 30, for example 6.")
|
||||||
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
|
|
||||||
f"injection is VAT-exempt, and a card that already quotes "
|
|
||||||
f"VAT-inclusive coefficients needs no second multiplication.")
|
|
||||||
|
|
||||||
month, card_index, card_expect = _card(value.get("card"), leg)
|
month, card_index, card_expect = _card(value.get("card"), leg)
|
||||||
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
|
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
|
||||||
@@ -777,27 +684,11 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
|||||||
exists at all.
|
exists at all.
|
||||||
"""
|
"""
|
||||||
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
||||||
ratio = (got / spec.card_expect_incl_vat
|
|
||||||
if spec.card_expect_incl_vat else float("inf"))
|
|
||||||
leg = _LEGS[spec.option]
|
leg = _LEGS[spec.option]
|
||||||
return (
|
return (
|
||||||
f"{spec.option} does not reproduce its own card, so the add-on will "
|
f"{spec.option} does not match its own card: it gives {got:.4f} "
|
||||||
f"NOT start on it. Your contract's arithmetic is "
|
f"{spec.units} and the card says {spec.card_expect_incl_vat:g}. Check "
|
||||||
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
|
f"the units, the VAT and {leg.card_causes}.")
|
||||||
f"on the card's own index that is "
|
|
||||||
f"({spec.factor:g} x {spec.card_index_eur_per_mwh:g} + "
|
|
||||||
f"{spec.offset:g}) x (1 + {spec.vat_pct:g}/100) = {got:.4f} "
|
|
||||||
f"{spec.units}, but card.expect_incl_vat says "
|
|
||||||
f"{spec.card_expect_incl_vat:g} - OUT BY {delta:+.4f} {spec.units} "
|
|
||||||
f"(ratio {ratio:.4g}; the tolerance is {CARD_TOLERANCE}, which is the "
|
|
||||||
f"last digit a card prints). One of the two sides is wrong and the "
|
|
||||||
f"add-on cannot tell which, so it refuses rather than price a battery "
|
|
||||||
f"on it. The usual causes, in the order they happen: the coefficients "
|
|
||||||
f"and the expected price are in DIFFERENT units (ratio 10, 100 or "
|
|
||||||
f"1000 - `units` must be the one the formula line is printed in); VAT "
|
|
||||||
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
|
|
||||||
f"digit; the two sides copied off DIFFERENT cards or different "
|
|
||||||
f"months; {leg.card_causes}")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -852,20 +743,10 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
|
|||||||
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
|
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
|
||||||
return None
|
return None
|
||||||
return (
|
return (
|
||||||
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
|
f"{spec.option} gives {energy:.5f} EUR/kWh for energy and "
|
||||||
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
|
f"{all_in:.5f} EUR/kWh in total on its own card, outside the "
|
||||||
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
|
f"plausible band of {PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} "
|
||||||
f"price_taxes_eur_kwh is added, which is outside the plausible band "
|
f"EUR/kWh. Check \"units\" against the unit beside your formula.")
|
||||||
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh for a Belgian "
|
|
||||||
f"retail tariff. The card self-check passed, so the coefficients and "
|
|
||||||
f"the printed price AGREE - they simply agree on a number no Belgian "
|
|
||||||
f"bill carries, which is what a whole card read in the wrong units "
|
|
||||||
f"looks like (a factor of 10, 100 or 1000 applied to BOTH sides at "
|
|
||||||
f"once cancels out of that check and cannot cancel out of this one). "
|
|
||||||
f"Check `units` against the unit printed beside your formula. "
|
|
||||||
f"⚠️ This bounds the CARD'S OWN POINT, not every hour: a live hour may "
|
|
||||||
f"legitimately fall outside the band, and a negative day-ahead hour - "
|
|
||||||
f"the best charging opportunity of the year - is never refused.")
|
|
||||||
|
|
||||||
|
|
||||||
def implausible_injection(spec: FormulaSpec):
|
def implausible_injection(spec: FormulaSpec):
|
||||||
@@ -914,36 +795,16 @@ def implausible_injection(spec: FormulaSpec):
|
|||||||
"""
|
"""
|
||||||
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
|
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
|
||||||
return (
|
return (
|
||||||
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
|
f"{spec.option}.factor passes through {spec.passthrough:.4g}x the "
|
||||||
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
|
f"market price, outside {PASSTHROUGH_LO}-{PASSTHROUGH_HI}x. Check "
|
||||||
f"wholesale price - outside the plausible "
|
f"\"units\" ({spec.units!r}) against the unit beside your "
|
||||||
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
|
f"formula.")
|
||||||
f"Belgian injection formula pays a share of the market price a "
|
|
||||||
f"little under or about equal to it: Eneco's "
|
|
||||||
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
|
|
||||||
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
|
|
||||||
f"factor typed in the wrong units looks like - check `units` "
|
|
||||||
f"({spec.units!r}) against the unit printed beside your formula, "
|
|
||||||
f"because the card self-check cannot see a factor of 10, 100 or "
|
|
||||||
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
|
|
||||||
f"COEFFICIENT and not the price: an injection price below zero is "
|
|
||||||
f"accepted and is the real market - Eneco's goes negative under "
|
|
||||||
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
|
|
||||||
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
|
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
|
||||||
return (
|
return (
|
||||||
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
|
f"{spec.option}.offset is {spec.offset:g} {spec.units}, which is "
|
||||||
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
|
f"{spec.offset_eur_kwh:+.5f} EUR/kWh and larger than "
|
||||||
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
|
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh either way. Check its "
|
||||||
f"injection tariff in either direction. The published ones are "
|
f"unit.")
|
||||||
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
|
|
||||||
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
|
|
||||||
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
|
|
||||||
f"cause is the offset typed in a different unit from `units` "
|
|
||||||
f"({spec.units!r}), or a consumption offset copied onto the "
|
|
||||||
f"injection leg: they are different numbers on the same card. "
|
|
||||||
f"⚠️ Network charges and levies do not belong here either - "
|
|
||||||
f"nothing is added to what you are PAID for export, so an offset "
|
|
||||||
f"carrying them is simply wrong rather than double-counted.")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -957,15 +818,9 @@ def stale_card(spec: FormulaSpec, today=None):
|
|||||||
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
|
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
|
||||||
return None
|
return None
|
||||||
return (
|
return (
|
||||||
f"{spec.option}'s card is dated {spec.card_month}, which is "
|
f"{spec.option}'s card is dated {spec.card_month}, "
|
||||||
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
|
f"{(now - dated).days} days ago. The add-on still starts, but if your "
|
||||||
f"has been renewed or rolled over since then, the coefficients here "
|
f"contract changed since, copy the new figures from your supplier.")
|
||||||
f"are last year's and every price this add-on computes is wrong by "
|
|
||||||
f"however far they moved - and the card self-check cannot see it, "
|
|
||||||
f"because it grades your coefficients against the card you typed, not "
|
|
||||||
f"the card against your current contract. Not refused: ENGIE freezes "
|
|
||||||
f"coefficients for the contract's duration, so an old card is often "
|
|
||||||
f"exactly right. Worth re-reading your klantenzone once.")
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -798,3 +798,51 @@ def samples() -> list:
|
|||||||
meter_frozen(600),
|
meter_frozen(600),
|
||||||
meter_frozen(90),
|
meter_frozen(90),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- refusals (UX-08 phase 2c) --------------------------------------------------
|
||||||
|
# A refusal names a setting by its label on the Configuration tab and then by its
|
||||||
|
# key, because an installer editing in YAML needs the key. The label is the
|
||||||
|
# en.yaml name without its group prefix and unit; test_messages.py checks that.
|
||||||
|
LABELS = {
|
||||||
|
"setpoint_entity": "Inverter setpoint entity",
|
||||||
|
"max_w": "Inverter rating",
|
||||||
|
"bms_soc_floor": "Battery cut-out level",
|
||||||
|
"capacity_kwh": "Usable battery capacity",
|
||||||
|
"inverters": "Inverter list",
|
||||||
|
"name": "Name",
|
||||||
|
"meter_source": "Where the meter reading comes from",
|
||||||
|
"meter_phases": "Number of phases",
|
||||||
|
"p1_host": "HomeWizard P1 address",
|
||||||
|
"meter_poll_s": "Time between meter reads",
|
||||||
|
"meter_max_age_s": "Reading too old after",
|
||||||
|
"meter_mqtt_topic": "MQTT topic",
|
||||||
|
"p1_import_entity": "Grid import entity",
|
||||||
|
"p1_export_entity": "Grid export entity",
|
||||||
|
"p1_net_entity": "Signed net power entity",
|
||||||
|
"p1_phase_net_entities": "Net entities per phase",
|
||||||
|
"peak_soc_floor": "Peak reserve",
|
||||||
|
"price_provider": "Dynamic price source",
|
||||||
|
"price_taxes_eur_kwh": "Distribution, levies and VAT",
|
||||||
|
"static_profile": "Fixed or day/night tariff",
|
||||||
|
"price_taxes_profile": "Day/night levies and VAT",
|
||||||
|
"price_formula": "Your own tariff formula",
|
||||||
|
"price_export_formula": "Your own injection formula",
|
||||||
|
"price_csv_path": "Price CSV file",
|
||||||
|
"price_now_entity": "Current price entity",
|
||||||
|
"price_avg_entity": "Average price entity",
|
||||||
|
"arbitrage_enabled": "Trade on the price spread",
|
||||||
|
"cycle_cost_threshold_eur": "Battery cycle cost",
|
||||||
|
"arbitrage_soc_floor": "Trading keeps at least",
|
||||||
|
"arbitrage_soc_ceiling": "Trading charges at most",
|
||||||
|
"maintenance_charge_w": "Recharge rate",
|
||||||
|
"sign_disagree_w": "Wrong-direction threshold",
|
||||||
|
"unit_sign_disagree_w": "Per-inverter wrong-direction threshold",
|
||||||
|
"slew_w": "Largest change per cycle",
|
||||||
|
"shadow_mode": "Shadow mode",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def setting(key: str, unit: str = "") -> str:
|
||||||
|
"""`max_w` -> `“Inverter rating” (max_w)`, plus ` for inverter-b` in a list."""
|
||||||
|
return f"“{LABELS[key]}” ({key})" + (f" for {unit}" if unit else "")
|
||||||
|
|||||||
+31
-23
@@ -47,6 +47,10 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from . import messages
|
||||||
|
|
||||||
|
_S = messages.setting # a refusal names the setting by its label (UX-08)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
except ImportError: # pragma: no cover - container always has it
|
except ImportError: # pragma: no cover - container always has it
|
||||||
@@ -180,7 +184,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
|
|||||||
# already applied a sign we are about to apply again - reject it rather
|
# already applied a sign we are about to apply again - reject it rather
|
||||||
# than silently double-signing the control loop.
|
# than silently double-signing the control loop.
|
||||||
if imp < 0 or exp < 0:
|
if imp < 0 or exp < 0:
|
||||||
raise P1Error(f"unsigned registers cannot be negative (import={imp:g} export={exp:g})")
|
raise P1Error(f"import and export readings cannot be negative "
|
||||||
|
f"(import={imp:g} export={exp:g})")
|
||||||
|
|
||||||
per_phase = per_phase_import = None
|
per_phase = per_phase_import = None
|
||||||
if phase_import_w is not None or phase_export_w is not None:
|
if phase_import_w is not None or phase_export_w is not None:
|
||||||
@@ -188,8 +193,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
|
|||||||
pe = list(phase_export_w or [0.0] * len(pi))
|
pe = list(phase_export_w or [0.0] * len(pi))
|
||||||
if len(pi) != phases or len(pe) != phases:
|
if len(pi) != phases or len(pe) != phases:
|
||||||
raise P1Error(
|
raise P1Error(
|
||||||
f"phase count mismatch: telegram has {len(pi)} import / {len(pe)} export "
|
f"the meter sent {len(pi)} import and {len(pe)} export phases, "
|
||||||
f"phases, meter_phases is {phases}")
|
f"but {_S('meter_phases')} is {phases}")
|
||||||
vals = [_watts(a, f"L{i + 1} import") - _watts(b, f"L{i + 1} export")
|
vals = [_watts(a, f"L{i + 1} import") - _watts(b, f"L{i + 1} export")
|
||||||
for i, (a, b) in enumerate(zip(pi, pe))]
|
for i, (a, b) in enumerate(zip(pi, pe))]
|
||||||
per_phase = tuple(vals)
|
per_phase = tuple(vals)
|
||||||
@@ -1160,37 +1165,37 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
|||||||
exp = str(opts.get("p1_export_entity", "") or "").strip()
|
exp = str(opts.get("p1_export_entity", "") or "").strip()
|
||||||
if not imp or not exp:
|
if not imp or not exp:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
|
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
|
||||||
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
|
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
|
||||||
|
f"are {imp or '<blank>'!r} and {exp or '<blank>'!r}.")
|
||||||
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
|
||||||
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
|
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
|
||||||
if not net:
|
if not net:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
|
f"{_S('meter_source')} is {SOURCE_HA_SIGNED!r}, so it needs "
|
||||||
"(sensor.p1_sample_age_s would otherwise be announced with "
|
f"{_S('p1_net_entity')}.")
|
||||||
"nothing feeding it)")
|
|
||||||
if phase_net and len(phase_net) != phases:
|
if phase_net and len(phase_net) != phases:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"p1_phase_net_entities has {len(phase_net)} entities but "
|
f"{_S('p1_phase_net_entities')} has {len(phase_net)} entities, "
|
||||||
f"meter_phases is {phases} - every telegram would be rejected "
|
f"but {_S('meter_phases')} is {phases}. Make them match.")
|
||||||
"on the phase-count check")
|
|
||||||
elif source == SOURCE_MQTT:
|
elif source == SOURCE_MQTT:
|
||||||
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
|
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
|
||||||
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
|
problems.append(f"{_S('meter_source')} is {SOURCE_MQTT!r}, so it "
|
||||||
|
f"needs {_S('meter_mqtt_topic')}.")
|
||||||
elif source == SOURCE_HOMEWIZARD:
|
elif source == SOURCE_HOMEWIZARD:
|
||||||
host, port_s = homewizard_host_port(opts)
|
host, port_s = homewizard_host_port(opts)
|
||||||
if not host:
|
if not host:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
|
f"{_S('meter_source')} is {SOURCE_HOMEWIZARD!r}, so it needs "
|
||||||
"own address, e.g. 192.168.2.250)")
|
f"{_S('p1_host')}, the meter's address on your network.")
|
||||||
if port_s:
|
if port_s:
|
||||||
try:
|
try:
|
||||||
int(port_s)
|
int(port_s)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
|
problems.append(f"{_S('p1_host')} is {opts.get('p1_host')!r}, and "
|
||||||
"unparseable port")
|
"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
|
||||||
# cadence of 0 is a misconfiguration that must be reported, not
|
# cadence of 0 is a misconfiguration that must be reported, not
|
||||||
@@ -1198,23 +1203,26 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
|||||||
raw = opts.get("meter_poll_s", 5)
|
raw = opts.get("meter_poll_s", 5)
|
||||||
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"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
|
problems.append(f"{_S('meter_poll_s')} is "
|
||||||
|
f"{opts.get('meter_poll_s')!r}, which is not a "
|
||||||
|
"number.")
|
||||||
else:
|
else:
|
||||||
if poll_s <= 0:
|
if poll_s <= 0:
|
||||||
problems.append("meter_poll_s must be positive")
|
problems.append(f"{_S('meter_poll_s')} must be above 0.")
|
||||||
# ⚠️ The age can never be fresher than the poll interval. At or past
|
# ⚠️ The age can never be fresher than the poll interval. At or past
|
||||||
# max_age_s every reading is stale before its successor arrives, so
|
# max_age_s every reading is stale before its successor arrives, so
|
||||||
# the controller would sit permanently on missing inputs while the
|
# the controller would sit permanently on missing inputs while the
|
||||||
# meter is perfectly healthy - refuse it rather than ship that.
|
# meter is perfectly healthy - refuse it rather than ship that.
|
||||||
elif poll_s >= max_age_s:
|
elif poll_s >= max_age_s:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
|
f"{_S('meter_poll_s')} is {poll_s:.1f}, not below "
|
||||||
f"{max_age_s:.1f} - every reading would go stale before the "
|
f"{_S('meter_max_age_s')} at {max_age_s:.1f}. Poll more "
|
||||||
"next poll")
|
"often, or every reading goes stale.")
|
||||||
else:
|
else:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"meter_source {source!r} is not one of "
|
f"{_S('meter_source')} is {source!r}. Choose one of: off, "
|
||||||
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
|
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD))
|
||||||
|
+ ".")
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1453,9 +1453,8 @@ def _validate_raw(points, caps):
|
|||||||
if abs(p.eur_per_kwh) > MAX_PLAUSIBLE_EUR_KWH:
|
if abs(p.eur_per_kwh) > MAX_PLAUSIBLE_EUR_KWH:
|
||||||
raise SourceUnavailable(
|
raise SourceUnavailable(
|
||||||
f"point at {p.start.isoformat()} is {p.eur_per_kwh} EUR/kWh, "
|
f"point at {p.start.isoformat()} is {p.eur_per_kwh} EUR/kWh, "
|
||||||
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH} - the usual cause is a feed "
|
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH}, so the feed is probably in "
|
||||||
f"in EUR/MWh being read as EUR/kWh, which is a factor of 1000 "
|
f"EUR/MWh")
|
||||||
f"and would make every hour look like an emergency")
|
|
||||||
u = p.start.astimezone(timezone.utc)
|
u = p.start.astimezone(timezone.utc)
|
||||||
if seen is not None and u <= seen:
|
if seen is not None and u <= seen:
|
||||||
raise SourceUnavailable(
|
raise SourceUnavailable(
|
||||||
@@ -1708,18 +1707,13 @@ def _points_from_columnar_json(body, expect_unit, scale,
|
|||||||
if len(times) != len(prices):
|
if len(times) != len(prices):
|
||||||
raise SourceUnavailable(
|
raise SourceUnavailable(
|
||||||
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
|
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
|
||||||
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
|
f"{COLUMNAR_PRICE_KEY} has {len(prices)}, so the feed is cut short "
|
||||||
f"The short array would truncate the series silently, and a price "
|
f"and none of it is used")
|
||||||
f"series missing its last hours is one missing the evening peak - "
|
|
||||||
f"traded on as if complete, where no data at all would have "
|
|
||||||
f"switched arbitrage off (§11.4)")
|
|
||||||
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
|
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
|
||||||
if unit != _columnar_unit(expect_unit):
|
if unit != _columnar_unit(expect_unit):
|
||||||
raise SourceUnavailable(
|
raise SourceUnavailable(
|
||||||
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
|
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
|
||||||
f"{expect_unit!r}. This provider converts with a fixed factor of "
|
f"{expect_unit!r}, so its prices cannot be converted")
|
||||||
f"{scale}, so a changed unit is a factor of 1000 on every price "
|
|
||||||
f"with nothing downstream able to notice it")
|
|
||||||
if doc.get("deprecated"):
|
if doc.get("deprecated"):
|
||||||
# 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
|
||||||
@@ -2011,11 +2005,9 @@ class CustomFormula(PriceProvider):
|
|||||||
raise SourceUnavailable(f"price_formula: {e}")
|
raise SourceUnavailable(f"price_formula: {e}")
|
||||||
if spec is None:
|
if spec is None:
|
||||||
raise SourceUnavailable(
|
raise SourceUnavailable(
|
||||||
"price_provider is 'custom_formula' but price_formula is "
|
"“Dynamic price source” (price_provider) is 'custom_formula', "
|
||||||
"empty, so there are no coefficients to price anything with. "
|
"but “Your own tariff formula” (price_formula) is empty. "
|
||||||
"validate.py refuses this configuration at startup; reaching "
|
"Restart the add-on to check the settings again")
|
||||||
"it here means the options blob changed under a running "
|
|
||||||
"add-on")
|
|
||||||
url = dayahead_feed_url(self.id, opts, since, until,
|
url = dayahead_feed_url(self.id, opts, since, until,
|
||||||
self.FEED_BASE, self.BZN)
|
self.FEED_BASE, self.BZN)
|
||||||
check_egress(url, self.capabilities())
|
check_egress(url, self.capabilities())
|
||||||
|
|||||||
@@ -504,9 +504,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"shadow_mode is {raw!r}, which is not one of "
|
return [f"{messages.setting('shadow_mode')} is {raw!r}. Choose "
|
||||||
+ ", ".join(f"'{m}'" for m in MODES)
|
+ ", ".join(f"'{m}'" for m in MODES[:-1])
|
||||||
+ ". 'auto' leaves the battery alone while control is stopped and "
|
+ f" or '{MODES[-1]}'."]
|
||||||
"commands it once control is started; 'on' records every "
|
|
||||||
"decision and commands nothing at all; 'off' is ordinary "
|
|
||||||
"control."]
|
|
||||||
|
|||||||
@@ -262,9 +262,8 @@ def _minutes(value, field: str) -> int:
|
|||||||
parts = text.split(":")
|
parts = text.split(":")
|
||||||
if len(parts) != 2:
|
if len(parts) != 2:
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{field} is {value!r}; it must be \"HH:MM\" local Belgian time, "
|
f"{field} is {value!r}. Write it as \"HH:MM\" in Belgian time, "
|
||||||
f"e.g. \"22:00\". A bare hour or a decimal is refused rather than "
|
f"for example \"22:00\".")
|
||||||
f"guessed at - 7.5 could be 07:30 or 07:50.")
|
|
||||||
try:
|
try:
|
||||||
h, m = int(parts[0]), int(parts[1])
|
h, m = int(parts[0]), int(parts[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -272,9 +271,8 @@ def _minutes(value, field: str) -> int:
|
|||||||
f"integers, e.g. \"07:00\"")
|
f"integers, e.g. \"07:00\"")
|
||||||
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
|
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{field} is {value!r}, which is not a time of day. Hours run 0-23, "
|
f"{field} is {value!r}, which is not a time of day. Use 00:00 to "
|
||||||
f"minutes 0-59; \"24:00\" is accepted only as an END meaning "
|
f"23:59, or \"24:00\" as an end time.")
|
||||||
f"midnight at the end of the day.")
|
|
||||||
return h * 60 + m
|
return h * 60 + m
|
||||||
|
|
||||||
|
|
||||||
@@ -286,11 +284,8 @@ def _days(value, field: str) -> frozenset:
|
|||||||
"""
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{field} is missing. Name the days this period applies to - "
|
f"{field} is missing. Name the days, for example \"mon-fri\", "
|
||||||
f"\"mon-fri\", \"sat,sun\" or \"all\". It is required rather than "
|
f"\"sat,sun\" or \"all\".")
|
||||||
f"defaulted to every day, because a Belgian night tariff that "
|
|
||||||
f"silently covered the weekend too would misprice 48 h a week with "
|
|
||||||
f"nothing reporting it.")
|
|
||||||
tokens = []
|
tokens = []
|
||||||
if isinstance(value, (list, tuple)):
|
if isinstance(value, (list, tuple)):
|
||||||
for item in value:
|
for item in value:
|
||||||
@@ -367,10 +362,8 @@ def _price(value, field: str, means: str = None) -> float:
|
|||||||
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
|
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
|
||||||
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
|
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. The "
|
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. "
|
||||||
f"usual cause is a figure in cents or in EUR/MWh - 34 instead of "
|
f"Write the price in EUR/kWh, for example 0.34 and not 34.")
|
||||||
f"0.34 - which would make every hour look like an emergency. Same "
|
|
||||||
f"bound the fetched feeds are held to (price.py `_validate_raw`).")
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -422,9 +415,8 @@ class StaticProfile:
|
|||||||
self.option = option
|
self.option = option
|
||||||
if not self.periods:
|
if not self.periods:
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{option} has no periods. A profile with no periods is "
|
f"{option} has no periods. Add at least one, or leave the "
|
||||||
f"not a fixed tariff, it is an absent one - leave the option "
|
f"setting empty.")
|
||||||
f"empty to say that on purpose (§11.4 then applies).")
|
|
||||||
_check_tiling(self.periods, option=option)
|
_check_tiling(self.periods, option=option)
|
||||||
|
|
||||||
# -- what kind of tariff this is, DERIVED --------------------------------
|
# -- what kind of tariff this is, DERIVED --------------------------------
|
||||||
@@ -558,10 +550,7 @@ def _raise_gap(weekday, start, end, option="static_profile"):
|
|||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{option} leaves {DAY_NAMES[weekday]} "
|
f"{option} leaves {DAY_NAMES[weekday]} "
|
||||||
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
|
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
|
||||||
f"price. Refused rather than defaulted: an invented price is one an "
|
f"price. Add a period that covers them.")
|
||||||
f"optimiser would trade on, and §11.4 already says what a missing price "
|
|
||||||
f"means. Add a period covering it - remember a window written "
|
|
||||||
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
|
|
||||||
|
|
||||||
|
|
||||||
def _raise_overlap(weekday, over, end, periods, option="static_profile"):
|
def _raise_overlap(weekday, over, end, periods, option="static_profile"):
|
||||||
@@ -569,9 +558,8 @@ def _raise_overlap(weekday, over, end, periods, option="static_profile"):
|
|||||||
names = [p.name for p in periods if p.covers(weekday, start)]
|
names = [p.name for p in periods if p.covers(weekday, start)]
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
|
f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
|
||||||
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
|
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Make the "
|
||||||
f"one hour means one of them silently wins on list order. Narrow one of "
|
f"periods stop overlapping.")
|
||||||
f"the windows or split the day selector.")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_static_profile(value, *, option: str = "static_profile",
|
def parse_static_profile(value, *, option: str = "static_profile",
|
||||||
@@ -609,28 +597,23 @@ 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}). It is a JSON object "
|
f"{option} is not valid JSON ({e}). Write it on one line with "
|
||||||
f"with a \"periods\" list, on one line, e.g.\n"
|
f"a \"periods\" list, for example\n"
|
||||||
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
||||||
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
||||||
f"Leave it empty for a dynamic-only or price-free site.")
|
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()
|
||||||
if tz not in SUPPORTED_TZ:
|
if tz not in SUPPORTED_TZ:
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{option}.timezone is {value.get('timezone')!r}. Only "
|
f"{option}.timezone is {value.get('timezone')!r}. Only "
|
||||||
f"Europe/Brussels is supported: the shipped image carries no tz "
|
f"Europe/Brussels is supported.")
|
||||||
f"database (see app/price.py), so any other zone could not be "
|
|
||||||
f"honoured and would be silently treated as Brussels - which is "
|
|
||||||
f"wrong by an hour for part of the year.")
|
|
||||||
rows = value.get("periods")
|
rows = value.get("periods")
|
||||||
if rows is None:
|
if rows is None:
|
||||||
raise ProfileInvalid(
|
raise ProfileInvalid(
|
||||||
f"{option} has no \"periods\" list. Even a fully-fixed "
|
f"{option} has no \"periods\" list. A fixed price is one "
|
||||||
f"tariff is written as one period covering 00:00-24:00 all days "
|
f"period from 00:00 to 24:00 on all days.")
|
||||||
f"- one shape for both products, so nothing downstream branches "
|
|
||||||
f"on which kind of site this is.")
|
|
||||||
else:
|
else:
|
||||||
rows = value
|
rows = value
|
||||||
|
|
||||||
|
|||||||
+140
-297
@@ -322,6 +322,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from . import messages
|
||||||
from .arbitrage import enabled as arbitrage_on
|
from .arbitrage import enabled as arbitrage_on
|
||||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||||
from .custom_formula import (
|
from .custom_formula import (
|
||||||
@@ -571,6 +572,17 @@ def _unit_label(opts: dict, index: int) -> str:
|
|||||||
return f" ({name or f'inverters[{index}]'})"
|
return f" ({name or f'inverters[{index}]'})"
|
||||||
|
|
||||||
|
|
||||||
|
def S(key: str, where: str = "") -> str:
|
||||||
|
"""A setting as a refusal names it; `where` is `_unit_label()`'s " (name)"."""
|
||||||
|
return messages.setting(key, where.strip().strip("()"))
|
||||||
|
|
||||||
|
|
||||||
|
def _labels(keys, joiner: str = "and") -> str:
|
||||||
|
"""Several settings, each with its key: the config panel files a refusal
|
||||||
|
under every key it names (`configui.problems_by_field()`)."""
|
||||||
|
return f" {joiner} ".join(S(k) for k in keys)
|
||||||
|
|
||||||
|
|
||||||
def _roster_problems(opts: dict) -> list[str]:
|
def _roster_problems(opts: dict) -> list[str]:
|
||||||
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
|
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
|
||||||
what it says.
|
what it says.
|
||||||
@@ -625,11 +637,9 @@ def _roster_problems(opts: dict) -> list[str]:
|
|||||||
if raw is None or raw == "" or raw == []:
|
if raw is None or raw == "" or raw == []:
|
||||||
return problems
|
return problems
|
||||||
if not isinstance(raw, (list, tuple)):
|
if not isinstance(raw, (list, tuple)):
|
||||||
return [f"inverters is {type(raw).__name__} {raw!r}, and it must be a "
|
return [f"{S('inverters')} is {type(raw).__name__} {raw!r}, not a list. "
|
||||||
"LIST of inverter objects - one per inverter, each naming its "
|
"Add one entry per inverter, or leave it empty for a single "
|
||||||
"own name, setpoint_entity and max_w. A single inverter needs "
|
"inverter."]
|
||||||
"no list at all: leave `inverters` empty and the flat "
|
|
||||||
"setpoint_entity/soc_entity/batt_entity/max_w keys describe it."]
|
|
||||||
|
|
||||||
entries = roster_entries(opts)
|
entries = roster_entries(opts)
|
||||||
seen: dict = {}
|
seen: dict = {}
|
||||||
@@ -637,45 +647,31 @@ 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} is {type(entry).__name__} {entry!r}, and every entry "
|
f"{where} in the “Inverter list” is {entry!r}. Each entry needs "
|
||||||
"must be an object with at least name, setpoint_entity and "
|
"a name, a setpoint entity and an inverter rating.")
|
||||||
"max_w.")
|
|
||||||
continue
|
continue
|
||||||
name = str(entry.get("name") or "").strip()
|
name = str(entry.get("name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{where} has no name. Every inverter needs one: the Store "
|
f"{where} in the “Inverter list” has no name. Give every "
|
||||||
"namespaces this unit's SAFETY-02 latch, its heartbeat and its "
|
"inverter a short name, for example inverter-a.")
|
||||||
"write bookkeeping under it, so two unnamed units would share "
|
|
||||||
"one namespace and a trip caused by one would latch the other. "
|
|
||||||
"Any short identifier works - inverter-a, inverter-b, garage.")
|
|
||||||
elif name in seen:
|
elif name in seen:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{where} and {seen[name]} are both called {name!r}. The Store "
|
f"{where} and {seen[name]} are both called {name!r}. Give every "
|
||||||
"keys every per-unit latch under the unit's name, so two units "
|
"inverter its own name.")
|
||||||
"with one name share one SAFETY-02 latch and one heartbeat "
|
|
||||||
"deadline - a trip caused by either would stop both, and an "
|
|
||||||
"operator clearing it would clear both. Give them different "
|
|
||||||
"names.")
|
|
||||||
else:
|
else:
|
||||||
seen[name] = where
|
seen[name] = where
|
||||||
who = f"{where} ({name})" if name else where
|
who = f"{where} ({name})" if name else where
|
||||||
if not str(entry.get("setpoint_entity") or "").strip():
|
if not str(entry.get("setpoint_entity") or "").strip():
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{who} has no setpoint_entity, so this add-on cannot command "
|
f"{who} has no {S('setpoint_entity')}, so the add-on cannot "
|
||||||
"it. It would sit in the roster, take a share of the site "
|
"control it. Set its ESPHome number entity, or remove the "
|
||||||
"target every cycle and deliver none of it, while the status "
|
"entry.")
|
||||||
"page showed a site running at full strength. Give it the "
|
|
||||||
"ESPHome number entity that carries ITS setpoint, or remove "
|
|
||||||
"the entry.")
|
|
||||||
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 max_w (got {entry.get('max_w')!r}). It is "
|
f"{who} has no usable {S('max_w')}: it is "
|
||||||
"the supervised cap on what may be written to THIS inverter, it "
|
f"{entry.get('max_w')!r}. Set this inverter's own rating in "
|
||||||
"is not shared with any other unit and it is deliberately not "
|
"watts.")
|
||||||
"inherited from the flat max_w - a rating borrowed from a "
|
|
||||||
"sibling is exactly what DEV-02 removed. State this unit's own "
|
|
||||||
"rating in 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 ---
|
||||||
stated, blank = [], []
|
stated, blank = [], []
|
||||||
@@ -694,35 +690,21 @@ 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 capacity_kwh {raw!r}, which is not a usable number "
|
f"{who} has {S('capacity_kwh')} {raw!r}, which is not a usable "
|
||||||
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
|
"number. Set it in kWh, or leave it empty on every inverter.")
|
||||||
"read to weight the split of the site target by ENERGY headroom "
|
|
||||||
"rather than by power rating; a zero or an unparseable value is "
|
|
||||||
"a pack the split would quietly stop preferring while the box "
|
|
||||||
"still shows a value. State it in kWh, or leave it empty on "
|
|
||||||
"every entry.")
|
|
||||||
else:
|
else:
|
||||||
stated.append(who)
|
stated.append(who)
|
||||||
if stated and blank:
|
if stated and blank:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
|
f"{S('capacity_kwh')} is set on {', '.join(stated)} but empty on "
|
||||||
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
|
f"{', '.join(blank)}. Set it on every inverter, or on none.")
|
||||||
"split weights one pack's energy headroom against another's, so a "
|
|
||||||
"pack measured in kWh beside a sibling that weighs a dimensionless "
|
|
||||||
"1.0 is not a comparison - the add-on answers 'no opinion' for the "
|
|
||||||
"whole roster and the value you filled in changes nothing. State a "
|
|
||||||
"capacity for every inverter, or for none of them.")
|
|
||||||
|
|
||||||
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
flat_sp = str(opts.get("setpoint_entity") or "").strip()
|
||||||
if entries and flat_sp:
|
if entries and flat_sp:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"setpoint_entity is set to {flat_sp} AND an inverters list of "
|
f"{S('setpoint_entity')} is set to {flat_sp}, but the "
|
||||||
f"{len(entries)} is declared. The flat setpoint_entity/soc_entity/"
|
f"{S('inverters')} has {len(entries)} entries. Move that inverter "
|
||||||
"batt_entity/max_w keys are the SINGLE-inverter shape and are not "
|
"into the list and clear this setting.")
|
||||||
"read at all once a roster exists, so this config declares an "
|
|
||||||
"actuator that nothing will ever write - a site running quietly "
|
|
||||||
"smaller than its own configuration. Move that inverter into the "
|
|
||||||
"list as its own entry and clear setpoint_entity.")
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
@@ -760,17 +742,14 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
|||||||
name = getattr(unit, "name", entity)
|
name = getattr(unit, "name", entity)
|
||||||
if dev_max is not None and cap > float(dev_max):
|
if dev_max is not None and cap > float(dev_max):
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{name}: max_w is {cap:.0f} W but {entity} accepts at most "
|
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts at "
|
||||||
f"{float(dev_max):.0f} W - THE DEVICE WINS, so the {cap:.0f} W "
|
f"most {float(dev_max):.0f} W. Lower this inverter's rating to "
|
||||||
f"configured for this inverter would silently never happen. "
|
f"{float(dev_max):.0f} W or less.")
|
||||||
f"Lower this entry's max_w to {float(dev_max):.0f} W or less.")
|
|
||||||
elif dev_min is not None and -cap < float(dev_min):
|
elif dev_min is not None and -cap < float(dev_min):
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{name}: max_w is {cap:.0f} W, so this controller may command "
|
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts no "
|
||||||
f"{-cap:.0f} W, but {entity} accepts no less than "
|
f"less than {float(dev_min):.0f} W when charging. Lower this "
|
||||||
f"{float(dev_min):.0f} W - THE DEVICE WINS on the charge side. "
|
f"inverter's rating to {abs(float(dev_min)):.0f} W or less.")
|
||||||
f"Lower this entry's max_w to {abs(float(dev_min)):.0f} W or "
|
|
||||||
"less.")
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
@@ -803,11 +782,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
writers = actuator_writers(opts)
|
writers = actuator_writers(opts)
|
||||||
if not writers:
|
if not writers:
|
||||||
problems.append(
|
problems.append(
|
||||||
"setpoint_entity is not set, so this add-on has no actuator: every "
|
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
|
||||||
"write is a no-op, the device's own rating is never read, and the "
|
"inverter. Set it to the ESPHome number entity for the setpoint.")
|
||||||
"ESP32 heartbeat that holds off the layer-1 failsafe never happens. "
|
|
||||||
"It would run, look healthy, and move no energy at all. Set it to "
|
|
||||||
"the ESPHome number entity that carries the setpoint.")
|
|
||||||
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
|
# SAFETY-28. The §1.5 non-goal, graded on the writers rather than on the
|
||||||
# count of option keys. Read `actuator_writers()` for why the old check went
|
# count of option keys. Read `actuator_writers()` for why the old check went
|
||||||
# green on the very config this one is here to refuse.
|
# green on the very config this one is here to refuse.
|
||||||
@@ -822,15 +798,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
for actuator, who in sorted(writers.items()):
|
for actuator, who in sorted(writers.items()):
|
||||||
if len(who) > 1:
|
if len(who) > 1:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{len(who)} configured writers can reach the one actuator "
|
f"{len(who)} settings point at the same inverter entity "
|
||||||
f"{actuator}: {', '.join(who)}. specs §1.5 makes any "
|
f"{actuator}: {', '.join(who)}. Give each inverter its own "
|
||||||
"configuration in which two writers can reach one actuator a "
|
"setpoint entity.")
|
||||||
"non-goal - two independent setpoints on one inverter is the "
|
|
||||||
"arbitration problem this add-on refuses to have, and at "
|
|
||||||
"runtime it is a meter-emulation target being written twice a "
|
|
||||||
"second by two strategies that cannot see each other. Give "
|
|
||||||
"each actuator exactly one writer, or point the second one at "
|
|
||||||
"its own entity.")
|
|
||||||
|
|
||||||
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
|
# --- 9. CFG-04's roster, graded BEFORE anything reads a per-unit number --
|
||||||
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
|
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
|
||||||
@@ -861,7 +831,8 @@ 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"max_w {opts.get('max_w')!r} is not a number")
|
problems.append(f"{S('max_w')} is {opts.get('max_w')!r}, which is not "
|
||||||
|
"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
|
||||||
# validate_startup_config() is now also sim/inject.py's entry point and can
|
# validate_startup_config() is now also sim/inject.py's entry point and can
|
||||||
@@ -872,24 +843,19 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
# nonsense config it is.
|
# nonsense config it is.
|
||||||
elif max_w <= 0:
|
elif max_w <= 0:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"max_w is {max_w:.0f} W and must be positive - it is a magnitude, "
|
f"{S('max_w')} is {max_w:.0f} W. Set it above 0 W.")
|
||||||
"not a signed setpoint. At or below zero the arbiter's supervised "
|
|
||||||
f"limit becomes lo={-max_w:.0f} / hi={max_w:.0f}, which is a "
|
|
||||||
"contradiction it resolves to 0 W, so the controller would look "
|
|
||||||
"like it was tripping rather than misconfigured.")
|
|
||||||
elif dev_max is not None and max_w > dev_max:
|
elif dev_max is not None and max_w > dev_max:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"max_w is {max_w:.0f} W but {opts.get('setpoint_entity') or 'the setpoint device'} accepts at "
|
f"{S('max_w')} is {max_w:.0f} W, but "
|
||||||
f"most {dev_max:.0f} W - THE DEVICE WINS, so the {max_w:.0f} W you "
|
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts at "
|
||||||
f"configured would silently never happen. Lower max_w to "
|
f"most {dev_max:.0f} W. Lower the rating to {dev_max:.0f} W or "
|
||||||
f"{dev_max:.0f} W or less, or point setpoint_entity at a device that "
|
"less.")
|
||||||
f"can take {max_w:.0f} W.")
|
|
||||||
elif dev_min is not None and -max_w < dev_min:
|
elif dev_min is not None and -max_w < dev_min:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"max_w is {max_w:.0f} W, so this controller may command "
|
f"{S('max_w')} is {max_w:.0f} W, but "
|
||||||
f"{-max_w:.0f} W, but {opts.get('setpoint_entity') or 'the setpoint device'} accepts no less "
|
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts no "
|
||||||
f"than {dev_min:.0f} W - THE DEVICE WINS on the charge side. Lower "
|
f"less than {dev_min:.0f} W when charging. Lower the rating to "
|
||||||
f"max_w to {abs(dev_min):.0f} W or less.")
|
f"{abs(dev_min):.0f} W or less.")
|
||||||
|
|
||||||
# --- 3. a meter that can actually read -----------------------------------
|
# --- 3. a meter that can actually read -----------------------------------
|
||||||
# ⚠️ Not a second copy of these rules - p1.py owns them, because
|
# ⚠️ Not a second copy of these rules - p1.py owns them, because
|
||||||
@@ -898,9 +864,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
phases = _number(opts, "meter_phases", 1.0)
|
phases = _number(opts, "meter_phases", 1.0)
|
||||||
max_age_s = _number(opts, "meter_max_age_s", 30.0)
|
max_age_s = _number(opts, "meter_max_age_s", 30.0)
|
||||||
if phases is None or max_age_s is None:
|
if phases is None or max_age_s is None:
|
||||||
problems.append("meter_phases and meter_max_age_s must both be numbers "
|
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
|
||||||
f"(got {opts.get('meter_phases')!r} / "
|
f"both be numbers. They are "
|
||||||
f"{opts.get('meter_max_age_s')!r})")
|
f"{opts.get('meter_phases')!r} and "
|
||||||
|
f"{opts.get('meter_max_age_s')!r}.")
|
||||||
else:
|
else:
|
||||||
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
||||||
|
|
||||||
@@ -921,15 +888,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
if (is_enabled(opts) and max_age_s is not None
|
if (is_enabled(opts) and max_age_s is not None
|
||||||
and max_age_s > FIRMWARE_INGEST_VETO_S):
|
and max_age_s > FIRMWARE_INGEST_VETO_S):
|
||||||
problems.append(
|
problems.append(
|
||||||
f"meter_max_age_s is {max_age_s:.0f} s but the ESP32 firmware vetoes "
|
f"{S('meter_max_age_s')} is {max_age_s:.0f} s, but the control "
|
||||||
f"any P1 sample at {FIRMWARE_INGEST_VETO_S:.0f} s and forces the "
|
f"board stops the inverter after {FIRMWARE_INGEST_VETO_S:.0f} s. "
|
||||||
"layer-1 failsafe - THE FIRMWARE WINS, and it wins silently. This "
|
f"Set it to {FIRMWARE_INGEST_VETO_S:.0f} s or less.")
|
||||||
f"add-on would treat a {max_age_s - 1:.0f} s reading as fresh and "
|
|
||||||
"keep commanding, while the inverter sat at 0 W with a healthy-"
|
|
||||||
"looking meter on the status page. Lower meter_max_age_s to "
|
|
||||||
f"{FIRMWARE_INGEST_VETO_S:.0f} s or less (30 s is the tested value), "
|
|
||||||
"or rebuild the firmware with a matching max_age_s and update "
|
|
||||||
"validate.FIRMWARE_INGEST_VETO_S.")
|
|
||||||
|
|
||||||
# --- 5. a sign watchdog that can actually fire ---------------------------
|
# --- 5. a sign watchdog that can actually fire ---------------------------
|
||||||
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
|
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
|
||||||
@@ -956,19 +917,15 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
caps = [cap for _, cap in caps_ix]
|
caps = [cap for _, cap in caps_ix]
|
||||||
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"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
|
problems.append(f"{S('sign_disagree_w')} is "
|
||||||
|
f"{opts.get('sign_disagree_w')!r}, which is not a "
|
||||||
|
"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"sign_disagree_w is {sign_w:.0f} W but max_w{where} is "
|
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the "
|
||||||
f"{weakest:.0f} W, so "
|
f"{S('max_w', where)} is {weakest:.0f} W, so this check can never "
|
||||||
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
|
f"trip. Lower it below {weakest:.0f} W, or raise the rating.")
|
||||||
"arbiter never lets a command exceed max_w, and the watchdog needs "
|
|
||||||
f"a command over {sign_w:.0f} W. It would sit there reporting "
|
|
||||||
"'armed' on the status page while being incapable of tripping - the "
|
|
||||||
f"one runaway failure mode this system has actually observed. Lower "
|
|
||||||
f"sign_disagree_w below {weakest:.0f} W (300 W is the measured "
|
|
||||||
"default), or raise max_w.")
|
|
||||||
|
|
||||||
# ⚠️ 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
|
||||||
@@ -1014,20 +971,16 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||||
if caps_ix else "")
|
if caps_ix else "")
|
||||||
if unit_sign_w is None:
|
if unit_sign_w is None:
|
||||||
problems.append(f"unit_sign_disagree_w "
|
problems.append(f"{S('unit_sign_disagree_w')} is "
|
||||||
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
|
f"{opts.get('unit_sign_disagree_w')!r}, which is not a "
|
||||||
|
"number. Set it back to "
|
||||||
|
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
|
||||||
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
|
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, but the "
|
||||||
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
f"{S('max_w', unit_where)} is {weakest:.0f} W, so this check can "
|
||||||
"watchdog can never fire: no inverter is ever commanded past max_w, "
|
f"never trip. Lower it below {weakest:.0f} W, or raise the "
|
||||||
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
|
"rating.")
|
||||||
"inverters that is the guard which catches a unit doing the "
|
|
||||||
"OPPOSITE of what it was told while its sibling covers for it at "
|
|
||||||
"the meter - the case the site-level watchdog is structurally "
|
|
||||||
f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W "
|
|
||||||
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
|
|
||||||
"measured default), or raise max_w.")
|
|
||||||
|
|
||||||
# --- 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
|
||||||
@@ -1100,18 +1053,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
maint_where = _unit_label(opts, maint_index)
|
maint_where = _unit_label(opts, maint_index)
|
||||||
if charge_w is None:
|
if charge_w is None:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
|
f"{S('maintenance_charge_w')} is "
|
||||||
|
f"{opts.get('maintenance_charge_w')!r}, which is not a number. Set "
|
||||||
|
"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(
|
||||||
f"maintenance_charge_w is {charge_w:.0f} W but max_w{maint_where} is "
|
f"{S('maintenance_charge_w')} is {charge_w:.0f} W, but the "
|
||||||
f"{maint_cap:.0f} W, so a maintenance charge in a cheap window can "
|
f"{S('max_w', maint_where)} is {maint_cap:.0f} W, so the monthly "
|
||||||
"never happen: the shaping asks the arbiter for at most "
|
f"recharge cannot run. Lower it to {maint_cap:.0f} W or less.")
|
||||||
f"{-charge_w:.0f} W while the supervised limit allows no less than "
|
|
||||||
f"{-maint_cap:.0f} W, which is a contradiction it resolves to 0 W - "
|
|
||||||
"THE CEILING WINS, and what you would see is an ARBITER "
|
|
||||||
"CONTRADICTION line that reads like a bug rather than like these "
|
|
||||||
f"two numbers. Lower maintenance_charge_w to {maint_cap:.0f} W or "
|
|
||||||
f"less, or raise max_w to at least {charge_w:.0f} W.")
|
|
||||||
|
|
||||||
# --- 10. a slew rate the plant can actually follow -----------------------
|
# --- 10. a slew rate the plant can actually follow -----------------------
|
||||||
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
|
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
|
||||||
@@ -1140,34 +1089,19 @@ 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"slew_w {opts.get('slew_w')!r} is not a number")
|
problems.append(f"{S('slew_w')} is {opts.get('slew_w')!r}, which is "
|
||||||
|
"not a number. Set it to 0 to follow the inverter "
|
||||||
|
"rating.")
|
||||||
elif slew_w < 0:
|
elif slew_w < 0:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
|
f"{S('slew_w')} is {slew_w:.0f} W and cannot be negative. Set it to "
|
||||||
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
|
"0 to follow the inverter rating.")
|
||||||
elif slew_w and rail is not None and rail > 0:
|
elif slew_w and rail is not None and rail > 0:
|
||||||
bound = SLEW_RAIL_FRAC * rail
|
bound = SLEW_RAIL_FRAC * rail
|
||||||
if slew_w > bound + 1.0:
|
if slew_w > bound + 1.0:
|
||||||
ticks = rail / slew_w
|
|
||||||
problems.append(
|
problems.append(
|
||||||
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
|
f"{S('slew_w')} is {slew_w:.0f} W, too fast for a {rail:.0f} W "
|
||||||
f"so the loop crosses its whole output range in {ticks:.1f} "
|
f"site. Set it to 0 or to at most {bound:.0f} W.")
|
||||||
f"cycles - {ticks * LOOP_TICK_S:.0f} s at the {LOOP_TICK_S:.0f} s "
|
|
||||||
f"tick this site was measured running - while the plant takes "
|
|
||||||
f"{PLANT_SETTLE_S:.0f} s to settle (goodwe/NOTES.md §5.10, a "
|
|
||||||
"1.78 kW step). The controller would command its entire range "
|
|
||||||
"before it could observe its answer to the first step, which is "
|
|
||||||
"not a slow loop, it is a loop that MANUFACTURES the error it "
|
|
||||||
"then chases: measured on this system 2026-09-05, a 3.4 kW "
|
|
||||||
"export drove the setpoint 380 -> 5000 W in five ticks, the "
|
|
||||||
"battery to -5154 W past a 5000 VA rating, and the grid from "
|
|
||||||
"3.4 kW of export to 2.8 kW of import. The stated limit - the "
|
|
||||||
"command moves at most slew_w per cycle, so it cannot jump "
|
|
||||||
"ahead of the inverter - is untrue at this rail; THE PLANT "
|
|
||||||
f"WINS, and it has no status row. Set slew_w to 0, which means "
|
|
||||||
f"'follow the rail' and resolves to {bound:.0f} W here and "
|
|
||||||
"moves on its own if the rail does, or write a value at or "
|
|
||||||
f"below {bound:.0f} W. Lower is always accepted.")
|
|
||||||
|
|
||||||
# --- 7. two price sources on one site ------------------------------------
|
# --- 7. two price sources on one site ------------------------------------
|
||||||
problems.extend(_price_problems(opts))
|
problems.extend(_price_problems(opts))
|
||||||
@@ -1198,22 +1132,15 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
|||||||
where = _unit_label(opts, _ix)
|
where = _unit_label(opts, _ix)
|
||||||
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"peak_soc_floor and bms_soc_floor{where} must both be numbers "
|
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
|
||||||
f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})")
|
f"both be numbers. They are {opts.get('peak_soc_floor')!r} and "
|
||||||
|
f"{_raw!r}.")
|
||||||
elif soc_floor <= bms_floor:
|
elif soc_floor <= bms_floor:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
|
||||||
f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
f"{S('bms_soc_floor', where)} of {bms_floor:.0f} %. Raise the "
|
||||||
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
f"reserve above {bms_floor:.0f} %, or correct the cut-out "
|
||||||
"the pack's protection rather than yours. The guard would report a "
|
"level.")
|
||||||
"SoC floor on its status page while being incapable of ever being "
|
|
||||||
"the thing that stops the discharge, and the symptom of that is a "
|
|
||||||
"battery repeatedly sitting at the BMS limit with no record of why "
|
|
||||||
f"- which looks exactly like the SoC drift the maintenance cycle "
|
|
||||||
f"exists to correct. Raise peak_soc_floor above {bms_floor:.0f} % "
|
|
||||||
f"(the shipped default is "
|
|
||||||
f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %), or correct "
|
|
||||||
f"bms_soc_floor{where} if it does not match this pack.")
|
|
||||||
|
|
||||||
# --- warnings: real, but not worth refusing over -------------------------
|
# --- warnings: real, but not worth refusing over -------------------------
|
||||||
integ = opts.get("integrator_max_w")
|
integ = opts.get("integrator_max_w")
|
||||||
@@ -1306,43 +1233,19 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
# fall back to, the site has had no tariff at all - a worse state, and
|
# fall back to, the site has had no tariff at all - a worse state, and
|
||||||
# one this message must not paper over.
|
# one this message must not paper over.
|
||||||
been_running = (
|
been_running = (
|
||||||
f"the static day/night tariff in static_profile"
|
f"the {S('static_profile')}"
|
||||||
if str(opts.get("static_profile", "") or "").strip()
|
if str(opts.get("static_profile", "") or "").strip()
|
||||||
else "NOTHING AT ALL - static_profile is empty too, so there was "
|
else "no tariff at all")
|
||||||
"no tariff to fall back to either")
|
|
||||||
problems.append(
|
problems.append(
|
||||||
f"price_provider is {pid!r}, which WAS a shipped plugin and has "
|
f"{S('price_provider')} is {pid!r}, which was withdrawn and never "
|
||||||
f"been WITHDRAWN (TARIFF-07). This is not a typo and you did not "
|
f"fetched a price. This site ran on {been_running}. Choose "
|
||||||
f"mistype it - the add-on offered this id, accepted it, and has "
|
"'generic_dayahead', 'manual_csv' or 'off'.")
|
||||||
f"said nothing at every startup since. "
|
|
||||||
f"⚠️ IT HAS NEVER FETCHED A PRICE, HERE OR ANYWHERE: "
|
|
||||||
f"{RETIRED_PROVIDERS[pid]}. Every fetch it ever made ended in "
|
|
||||||
f"SourceUnavailable, and §11.4 turns that into a SILENT fall back "
|
|
||||||
f"- so what this site has actually been running since the day the "
|
|
||||||
f"id was set is {been_running}. Nothing you could see would have "
|
|
||||||
f"differed from a working provider that simply never found a cheap "
|
|
||||||
f"hour, which is why you are being told now rather than left on "
|
|
||||||
f"it. WHAT TO DO: set price_provider to 'generic_dayahead' for the "
|
|
||||||
f"wholesale EPEX BE day-ahead curve, to which the add-on adds your "
|
|
||||||
f"price_markup_eur_kwh and price_taxes_eur_kwh - that APPROXIMATES "
|
|
||||||
f"a Belgian dynamic tariff and cannot reproduce your supplier's "
|
|
||||||
f"B x spot + A formula, so the level will be off even when the "
|
|
||||||
f"shape is right; or 'manual_csv' to enter all-in prices you "
|
|
||||||
f"compute yourself; or 'off' to run on static_profile "
|
|
||||||
f"deliberately. There is deliberately NO alias to your old "
|
|
||||||
f"supplier: aliasing would need your contract's own two "
|
|
||||||
f"coefficients, which live behind your klantenzone login and are "
|
|
||||||
f"in no public index, so the add-on would have to invent the "
|
|
||||||
f"numbers it prices your battery against.")
|
|
||||||
return problems # nothing below can be judged against a dead id
|
return problems # nothing below can be judged against a dead id
|
||||||
|
|
||||||
if pid != "off" and pid not in PROVIDERS:
|
if pid != "off" and pid not in PROVIDERS:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"price_provider is {pid!r}, which is not a shipped plugin. The "
|
f"{S('price_provider')} is {pid!r}, which does not exist. Choose "
|
||||||
f"ids that exist are: off, {', '.join(sorted(PROVIDERS))}. "
|
f"one of: off, {', '.join(sorted(PROVIDERS))}.")
|
||||||
"Refused rather than defaulted, per the §14 warning box - a "
|
|
||||||
"silent fall back to 'off' would look exactly like a working "
|
|
||||||
"provider that happens never to find a cheap hour.")
|
|
||||||
return problems # nothing below can be judged against an unknown id
|
return problems # nothing below can be judged against an unknown id
|
||||||
|
|
||||||
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
|
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
|
||||||
@@ -1386,19 +1289,10 @@ 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"price_provider is {pid!r} AND {' and '.join(ha_price)} "
|
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} "
|
||||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
f"{'is' if len(ha_price) == 1 else 'are'} also set and would win. "
|
||||||
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
f"Clear {'it' if len(ha_price) == 1 else 'them'}, or set the price "
|
||||||
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
"source to 'off'.")
|
||||||
"reads, and read_inputs() never looks at the other source once "
|
|
||||||
"they are set. The provider series is consumed - "
|
|
||||||
"app/static_tariff.py builds a tariff plan and an arbitrage "
|
|
||||||
"verdict, and app/arbitrage.py trades on it off a background "
|
|
||||||
"refresh - but the `cheap` flag those entities own is a separate "
|
|
||||||
"decision they would silently keep. Clear "
|
|
||||||
"price_now_entity and price_avg_entity to use the "
|
|
||||||
f"{pid!r} plugin, or set price_provider to 'off' to keep the "
|
|
||||||
"Home Assistant sensors.")
|
|
||||||
|
|
||||||
# ⚠️ 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`
|
||||||
@@ -1407,14 +1301,9 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
# day/night tariff has no way to see that it lost.
|
# day/night tariff has no way to see that it lost.
|
||||||
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"static_profile is set AND {' and '.join(ha_price)} "
|
f"{S('static_profile')} and {_labels(ha_price)} are both set, and "
|
||||||
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
|
"only the Home Assistant price entities would count. Clear one of "
|
||||||
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
|
"the two.")
|
||||||
"ENTITIES WIN: main.py reads them first and never looks at the "
|
|
||||||
"static profile, so the day/night tariff you typed would decide "
|
|
||||||
"nothing and nothing would say so. Clear price_now_entity and "
|
|
||||||
"price_avg_entity to use static_profile, or clear static_profile "
|
|
||||||
"to keep the Home Assistant sensors.")
|
|
||||||
|
|
||||||
# ⚠️ 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
|
||||||
@@ -1427,7 +1316,7 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
profile = parse_static_profile(raw_profile)
|
profile = parse_static_profile(raw_profile)
|
||||||
except ProfileInvalid as e:
|
except ProfileInvalid as e:
|
||||||
profile = None
|
profile = None
|
||||||
problems.append(f"static_profile cannot be used: {e}")
|
problems.append(f"“{messages.LABELS['static_profile']}” cannot be used. {e}")
|
||||||
if profile is not None and profile.product is not None \
|
if profile is not None and profile.product is not None \
|
||||||
and len(set(profile.prices)) == 1:
|
and len(set(profile.prices)) == 1:
|
||||||
_LOG.info(
|
_LOG.info(
|
||||||
@@ -1452,7 +1341,7 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
try:
|
try:
|
||||||
taxes_table = parse_taxes_profile(raw_taxes_profile)
|
taxes_table = parse_taxes_profile(raw_taxes_profile)
|
||||||
except ProfileInvalid as e:
|
except ProfileInvalid as e:
|
||||||
problems.append(f"{TAXES_PROFILE_OPTION} cannot be used: {e}")
|
problems.append(f"“{messages.LABELS[TAXES_PROFILE_OPTION]}” cannot be used. {e}")
|
||||||
|
|
||||||
if taxes_table is not None:
|
if taxes_table is not None:
|
||||||
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
|
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
|
||||||
@@ -1482,21 +1371,13 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||||
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
||||||
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||||
superseded = ", ".join(
|
|
||||||
str(u) for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
|
||||||
if scalar is not None \
|
if scalar is not None \
|
||||||
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"{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is "
|
f"{S(TAXES_PROFILE_OPTION)} is set, so "
|
||||||
f"{scalar} rather than its shipped {shipped}. THE TABLE WINS: "
|
f"{S('price_taxes_eur_kwh')} is not used, but it reads {scalar} "
|
||||||
f"the add-on reads the per-period figures and never looks at "
|
f"instead of {shipped}. Set it back to {shipped}, or clear the "
|
||||||
f"the scalar, so the number you typed there would decide "
|
"table.")
|
||||||
f"nothing and nothing would say so. Set price_taxes_eur_kwh "
|
|
||||||
f"back to {shipped}, or clear {TAXES_PROFILE_OPTION} to keep "
|
|
||||||
f"the flat figure. (A scalar still sitting on a SUPERSEDED "
|
|
||||||
f"default - {superseded} - is accepted and NOT refused: "
|
|
||||||
f"that is a number this add-on wrote into your options "
|
|
||||||
f"blob, not one you typed.)")
|
|
||||||
|
|
||||||
# ⚠️ 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
|
||||||
@@ -1511,13 +1392,8 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
if abs(p.eur_per_kwh) >= 1.0})
|
if abs(p.eur_per_kwh) >= 1.0})
|
||||||
if steep:
|
if steep:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian "
|
f"{S(TAXES_PROFILE_OPTION)} has {steep} EUR/kWh, which looks "
|
||||||
f"levies and distribution run about 0.05-0.25 EUR/kWh, so "
|
"like cents. Divide the figures by 100, or clear this setting.")
|
||||||
f"these are almost certainly the CENT figures off the tariff "
|
|
||||||
f"card (ORES prints `13,27`, which is 0.1327 EUR/kWh) typed "
|
|
||||||
f"without the conversion - a factor of 100 that would make "
|
|
||||||
f"every hour look like an emergency. Divide by 100, or clear "
|
|
||||||
f"the option.")
|
|
||||||
negative = sorted({p.eur_per_kwh for p in taxes_table.periods
|
negative = sorted({p.eur_per_kwh for p in taxes_table.periods
|
||||||
if p.eur_per_kwh < 0})
|
if p.eur_per_kwh < 0})
|
||||||
if negative:
|
if negative:
|
||||||
@@ -1567,25 +1443,15 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
or isinstance(raw_formula, dict)
|
or isinstance(raw_formula, dict)
|
||||||
if pid == "custom_formula" and not has_formula:
|
if pid == "custom_formula" and not has_formula:
|
||||||
problems.append(
|
problems.append(
|
||||||
"price_provider is 'custom_formula' but price_formula is empty, "
|
f"{S('price_provider')} is 'custom_formula', but "
|
||||||
"so the add-on has no coefficients to price anything with. Every "
|
f"{S('price_formula')} is empty. Copy the formula from your "
|
||||||
"fetch would return no-price-data and §11.4 would fall back "
|
"supplier's tariff card, or choose 'generic_dayahead'.")
|
||||||
"silently - which is the shape of defect this provider exists to "
|
|
||||||
"remove, arrived at by leaving one field blank. Copy the "
|
|
||||||
"Tariefformule line off your supplier's tariff card, e.g. "
|
|
||||||
"{\"index\":\"epex_be\",\"resolution\":\"hourly\","
|
|
||||||
"\"factor\":0.102,\"offset\":1.00,"
|
|
||||||
"\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,"
|
|
||||||
"\"card\":{\"month\":\"2026-09\","
|
|
||||||
"\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}} "
|
|
||||||
"- see DOCS.md. Or set price_provider to 'generic_dayahead' for "
|
|
||||||
"the wholesale curve with a flat markup.")
|
|
||||||
elif has_formula:
|
elif has_formula:
|
||||||
try:
|
try:
|
||||||
spec = parse_price_formula(raw_formula)
|
spec = parse_price_formula(raw_formula)
|
||||||
except FormulaInvalid as e:
|
except FormulaInvalid as e:
|
||||||
spec = None
|
spec = None
|
||||||
problems.append(f"price_formula cannot be used: {e}")
|
problems.append(f"“{messages.LABELS['price_formula']}” cannot be used. {e}")
|
||||||
if spec is not None and pid != "custom_formula":
|
if spec is not None and pid != "custom_formula":
|
||||||
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
|
# ⚠️ A WARNING AND NOT A REFUSAL. This is not two price sources
|
||||||
# fighting over one flag - it is one that is simply not wired to
|
# fighting over one flag - it is one that is simply not wired to
|
||||||
@@ -1639,7 +1505,7 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
xspec = parse_export_formula(raw_export)
|
xspec = parse_export_formula(raw_export)
|
||||||
except FormulaInvalid as e:
|
except FormulaInvalid as e:
|
||||||
xspec = None
|
xspec = None
|
||||||
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
|
problems.append(f"“{messages.LABELS[EXPORT_FORMULA_OPTION]}” cannot be used. {e}")
|
||||||
if xspec is not None:
|
if xspec is not None:
|
||||||
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
|
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
|
||||||
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
|
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
|
||||||
@@ -1732,62 +1598,39 @@ def _price_problems(opts: dict) -> list[str]:
|
|||||||
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
|
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
|
||||||
if thresh is None or thresh < 0:
|
if thresh is None or thresh < 0:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"cycle_cost_threshold_eur is "
|
f"{S('cycle_cost_threshold_eur')} is "
|
||||||
f"{opts.get('cycle_cost_threshold_eur')!r} and must be a "
|
f"{opts.get('cycle_cost_threshold_eur')!r}. Set it to 0 or more, "
|
||||||
f"non-negative number of EUR/kWh - it is what one round trip COSTS "
|
"in EUR/kWh.")
|
||||||
f"(§10.1). A negative threshold is one that every spread clears, "
|
|
||||||
f"including a spread of nothing, which is arbitrage with the brake "
|
|
||||||
f"removed rather than arbitrage tuned aggressively.")
|
|
||||||
|
|
||||||
if arbitrage_on(opts):
|
if arbitrage_on(opts):
|
||||||
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
|
if pid == "off" and not str(opts.get("static_profile", "") or "").strip():
|
||||||
problems.append(
|
problems.append(
|
||||||
"arbitrage_enabled is on but price_provider is 'off' and "
|
f"{S('arbitrage_enabled')} is on, but the site has no prices. "
|
||||||
"static_profile is empty, so this site has NO PRICES AT ALL. "
|
f"Set {_labels(['price_provider', 'static_profile'], 'or')}, "
|
||||||
"§10.1's priority 3 is 'charge cheap, discharge expensive' and "
|
"or turn trading off.")
|
||||||
"there is nothing here to tell cheap from expensive, so the "
|
|
||||||
"strategy would refuse on every cycle forever while the "
|
|
||||||
"configuration said it was running. Configure a price_provider "
|
|
||||||
"or a static_profile, or turn arbitrage_enabled off.")
|
|
||||||
if not is_enabled(opts):
|
if not is_enabled(opts):
|
||||||
problems.append(
|
problems.append(
|
||||||
"arbitrage_enabled is on but meter_source is 'off', so there "
|
f"{S('arbitrage_enabled')} is on, but no meter is set in "
|
||||||
"is no P1 telegram stream, the capacity-tariff peak guard "
|
f"{S('meter_source')}. Choose a meter, or turn trading off.")
|
||||||
"never runs, and nothing measures the 15-minute average this "
|
|
||||||
"strategy's charging would land in. §9.1 gives peak shaving "
|
|
||||||
"priority over arbitrage; an unmeasured peak cannot be shaved, "
|
|
||||||
"so arbitrage would be deliberately creating the billed "
|
|
||||||
"quantity with the one guard that limits it switched off. "
|
|
||||||
"app/arbitrage.py refuses to trade in that state anyway - this "
|
|
||||||
"refuses the configuration that asks it to. Set meter_source, "
|
|
||||||
"or turn arbitrage_enabled off.")
|
|
||||||
floor = _number(opts, "arbitrage_soc_floor",
|
floor = _number(opts, "arbitrage_soc_floor",
|
||||||
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
|
||||||
ceiling = _number(opts, "arbitrage_soc_ceiling",
|
ceiling = _number(opts, "arbitrage_soc_ceiling",
|
||||||
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
|
||||||
if floor is None or ceiling is None:
|
if floor is None or ceiling is None:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"arbitrage_soc_floor {opts.get('arbitrage_soc_floor')!r} and "
|
f"{S('arbitrage_soc_floor')} and {S('arbitrage_soc_ceiling')} "
|
||||||
f"arbitrage_soc_ceiling {opts.get('arbitrage_soc_ceiling')!r} "
|
"must both be numbers, in %.")
|
||||||
f"must both be numbers - they are percentages of pack charge.")
|
|
||||||
elif floor >= ceiling:
|
elif floor >= ceiling:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"arbitrage_soc_floor is {floor:g} % and arbitrage_soc_ceiling "
|
f"{S('arbitrage_soc_floor')} is {floor:g} %, not below "
|
||||||
f"is {ceiling:g} %, so there is no band left between them: "
|
f"{S('arbitrage_soc_ceiling')} at {ceiling:g} %. Make the lower "
|
||||||
f"every state of charge is either at or under the floor (no "
|
"limit the smaller number.")
|
||||||
f"discharge) or at or over the ceiling (no charge), and the "
|
|
||||||
f"strategy can never act. The floor is the reserve it may not "
|
|
||||||
f"spend and the ceiling is the headroom it may not overfill, "
|
|
||||||
f"so the floor must be the SMALLER number.")
|
|
||||||
|
|
||||||
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
if pid == "manual_csv" and not str(opts.get("price_csv_path", "") or "").strip():
|
||||||
problems.append(
|
problems.append(
|
||||||
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
f"{S('price_provider')} is 'manual_csv', but "
|
||||||
"the offline fallback has no file to read. Every fetch would "
|
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
|
||||||
"return no-price-data with a filesystem error as its reason - "
|
"choose 'off'.")
|
||||||
"which is §11.4 behaviour arrived at by accident rather than by "
|
|
||||||
"configuration. Point it at the CSV, or set price_provider to "
|
|
||||||
"'off', which says the same thing on purpose.")
|
|
||||||
|
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|||||||
@@ -2609,7 +2609,7 @@ check("arbitrage on with NO price source at all is refused - a strategy that "
|
|||||||
"must refuse on every cycle forever looks exactly like one that is "
|
"must refuse on every cycle forever looks exactly like one that is "
|
||||||
"running and finding nothing",
|
"running and finding nothing",
|
||||||
why(validate_startup_config(vbase(arbitrage_enabled=True, **P1_ON),
|
why(validate_startup_config(vbase(arbitrage_enabled=True, **P1_ON),
|
||||||
-5000, 5000), "NO PRICES AT ALL"))
|
-5000, 5000), "the site has no prices"))
|
||||||
check("arbitrage on with meter_source off is refused - §9.1 puts peak shaving "
|
check("arbitrage on with meter_source off is refused - §9.1 puts peak shaving "
|
||||||
"above arbitrage and an unmeasured peak cannot be shaved",
|
"above arbitrage and an unmeasured peak cannot be shaved",
|
||||||
why(validate_startup_config(
|
why(validate_startup_config(
|
||||||
@@ -2620,7 +2620,7 @@ check("a floor at or above the ceiling is refused - there is no band left "
|
|||||||
why(validate_startup_config(
|
why(validate_startup_config(
|
||||||
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
|
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
|
||||||
arbitrage_soc_floor=90, arbitrage_soc_ceiling=90, **P1_ON),
|
arbitrage_soc_floor=90, arbitrage_soc_ceiling=90, **P1_ON),
|
||||||
-5000, 5000), "no band left"))
|
-5000, 5000), "Make the lower limit the smaller number"))
|
||||||
check("...and the same numbers the right way round start",
|
check("...and the same numbers the right way round start",
|
||||||
validate_startup_config(
|
validate_startup_config(
|
||||||
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
|
vbase(arbitrage_enabled=True, price_provider="generic_dayahead",
|
||||||
@@ -2629,7 +2629,7 @@ check("...and the same numbers the right way round start",
|
|||||||
check("a NEGATIVE cycle_cost_threshold_eur is refused even with arbitrage OFF "
|
check("a NEGATIVE cycle_cost_threshold_eur is refused even with arbitrage OFF "
|
||||||
"- it is a cost, and a negative one is a threshold every spread clears",
|
"- it is a cost, and a negative one is a threshold every spread clears",
|
||||||
why(validate_startup_config(vbase(cycle_cost_threshold_eur=-0.01),
|
why(validate_startup_config(vbase(cycle_cost_threshold_eur=-0.01),
|
||||||
-5000, 5000), "non-negative")
|
-5000, 5000), "Set it to 0 or more")
|
||||||
and raises(lambda: threshold_eur_kwh({"cycle_cost_threshold_eur": -1}),
|
and raises(lambda: threshold_eur_kwh({"cycle_cost_threshold_eur": -1}),
|
||||||
ValueError))
|
ValueError))
|
||||||
# Rule 7's wording, which OPT-04 had to update because it named what main.py
|
# Rule 7's wording, which OPT-04 had to update because it named what main.py
|
||||||
|
|||||||
@@ -1629,7 +1629,7 @@ check("⚠️ AC 5: two inverters naming the SAME setpoint_entity are REFUSED. "
|
|||||||
"life of this add-on because the schema had exactly one such key; this "
|
"life of this add-on because the schema had exactly one such key; this "
|
||||||
"is the first configuration in which it is expressible, and it is the "
|
"is the first configuration in which it is expressible, and it is the "
|
||||||
"first time that guarantee has had to be a check",
|
"first time that guarantee has had to be a check",
|
||||||
_why(_p_same, "one actuator") and _why(_p_same, "number.sp_a"))
|
_why(_p_same, "point at the same inverter entity") and _why(_p_same, "number.sp_a"))
|
||||||
check("...and case-folded, so the hazard cannot be smuggled past on "
|
check("...and case-folded, so the hazard cannot be smuggled past on "
|
||||||
"NUMBER.SP_A versus number.sp_a - which is exactly how it would be "
|
"NUMBER.SP_A versus number.sp_a - which is exactly how it would be "
|
||||||
"written by someone copying an entity id out of the HA UI",
|
"written by someone copying an entity id out of the HA UI",
|
||||||
@@ -1653,7 +1653,7 @@ check("a flat setpoint_entity left set BESIDE a declared roster is refused. It "
|
|||||||
"names an actuator the roster does not, so nothing would ever write it - "
|
"names an actuator the roster does not, so nothing would ever write it - "
|
||||||
"a site running quietly smaller than its own configuration, which is the "
|
"a site running quietly smaller than its own configuration, which is the "
|
||||||
"'silently a different config' outcome this card ranks below a refusal",
|
"'silently a different config' outcome this card ranks below a refusal",
|
||||||
_why(_p_flat, "AND an inverters list"))
|
_why(_p_flat, "but the “Inverter list” (inverters) has"))
|
||||||
_p_dup = validate_startup_config(_opts(setpoint_entity="", inverters=[
|
_p_dup = validate_startup_config(_opts(setpoint_entity="", inverters=[
|
||||||
dict(name="twin", setpoint_entity="number.sp_a", max_w=A_RATING),
|
dict(name="twin", setpoint_entity="number.sp_a", max_w=A_RATING),
|
||||||
dict(name="twin", setpoint_entity="number.sp_b",
|
dict(name="twin", setpoint_entity="number.sp_b",
|
||||||
@@ -1666,13 +1666,14 @@ for _what, _bad, _needle in (
|
|||||||
("with no name at all",
|
("with no name at all",
|
||||||
dict(setpoint_entity="number.sp_b", max_w=3000), "has no name"),
|
dict(setpoint_entity="number.sp_b", max_w=3000), "has no name"),
|
||||||
("this controller cannot command",
|
("this controller cannot command",
|
||||||
dict(name="inverter-b", max_w=3000), "has no setpoint_entity"),
|
dict(name="inverter-b", max_w=3000),
|
||||||
|
"has no “Inverter setpoint entity” (setpoint_entity)"),
|
||||||
("with no rating",
|
("with no rating",
|
||||||
dict(name="inverter-b", setpoint_entity="number.sp_b"),
|
dict(name="inverter-b", setpoint_entity="number.sp_b"),
|
||||||
"no usable max_w"),
|
"no usable “Inverter rating” (max_w)"),
|
||||||
("whose rating is not a number",
|
("whose rating is not a number",
|
||||||
dict(name="inverter-b", setpoint_entity="number.sp_b", max_w="lots"),
|
dict(name="inverter-b", setpoint_entity="number.sp_b", max_w="lots"),
|
||||||
"no usable max_w")):
|
"no usable “Inverter rating” (max_w)")):
|
||||||
check(f"an entry {_what} is refused, and the refusal says which entry and "
|
check(f"an entry {_what} is refused, and the refusal says which entry and "
|
||||||
f"which key ({_needle!r}) rather than that something is wrong",
|
f"which key ({_needle!r}) rather than that something is wrong",
|
||||||
_why(validate_startup_config(_opts(setpoint_entity="",
|
_why(validate_startup_config(_opts(setpoint_entity="",
|
||||||
@@ -1680,7 +1681,7 @@ for _what, _bad, _needle in (
|
|||||||
check("`inverters` that is not a list at all is refused with the shape it "
|
check("`inverters` that is not a list at all is refused with the shape it "
|
||||||
"should have been, rather than crashing inside build_roster()",
|
"should have been, rather than crashing inside build_roster()",
|
||||||
_why(validate_startup_config(_opts(inverters="number.sp_b")),
|
_why(validate_startup_config(_opts(inverters="number.sp_b")),
|
||||||
"must be a\nLIST".replace("\n", " ")))
|
"(inverters) is str 'number.sp_b', not a list"))
|
||||||
|
|
||||||
# --- the permissive readers INSIDE validate.py --------------------------------
|
# --- the permissive readers INSIDE validate.py --------------------------------
|
||||||
# ⚠️ RUN BOTH WAYS, WHICH IS MUTATION.md's RULE. A check that only asserts the
|
# ⚠️ RUN BOTH WAYS, WHICH IS MUTATION.md's RULE. A check that only asserts the
|
||||||
@@ -1738,8 +1739,8 @@ check("the fixture actually desynchronises the two lists, or the two checks "
|
|||||||
check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
|
check("⚠️ RULE 5 NAMES THE UNIT THE NUMBER CAME OFF. The weakest cap is "
|
||||||
"inverter-c's 1000 W; it sits at caps index 1, and `_unit_label(opts, 1)` "
|
"inverter-c's 1000 W; it sits at caps index 1, and `_unit_label(opts, 1)` "
|
||||||
"is inverter-B. The refusal says inverter-c",
|
"is inverter-B. The refusal says inverter-c",
|
||||||
_why(_p_skew, "max_w (inverter-c) is 1000 W")
|
_why(_p_skew, "(max_w) for inverter-c is 1000 W")
|
||||||
and not _why(_p_skew, "max_w (inverter-b) is 1000 W"))
|
and not _why(_p_skew, "(max_w) for inverter-b is 1000 W"))
|
||||||
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
|
# ⚠️ MAINT-05 MOVED RULE 6's SUBJECT, AND THIS CHECK MOVED WITH IT. It used to
|
||||||
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
|
# assert the refusal named inverter-b, because rule 6 graded the FIRST entry
|
||||||
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
|
# with a usable cap - `Controller.maint_unit` was `inverters[0]` and the shaping
|
||||||
@@ -1753,15 +1754,15 @@ check("⚠️ RULE 6 TOO, AND SINCE MAINT-05 IT IS THE WEAKEST PACK IT NAMES. "
|
|||||||
"inverter-c's 1000 W sits at caps index 1, and `_unit_label(opts, 1)` is "
|
"inverter-c's 1000 W sits at caps index 1, and `_unit_label(opts, 1)` is "
|
||||||
"inverter-B - so a caps index leaking into a roster index would say "
|
"inverter-B - so a caps index leaking into a roster index would say "
|
||||||
"inverter-b here. The refusal says inverter-c, with inverter-c's number",
|
"inverter-b here. The refusal says inverter-c, with inverter-c's number",
|
||||||
_why(_p_skew, "maintenance_charge_w is 3000 W but max_w (inverter-c) is "
|
_why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” (max_w) for "
|
||||||
"1000 W")
|
"inverter-c is 1000 W")
|
||||||
and not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
|
and not _why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” (max_w) for "
|
||||||
"(inverter-b)"))
|
"inverter-b"))
|
||||||
check("...and it is no longer graded against the FIRST usable cap at all, which "
|
check("...and it is no longer graded against the FIRST usable cap at all, which "
|
||||||
"is the behaviour change MAINT-05 makes here: inverter-b's 2000 W was the "
|
"is the behaviour change MAINT-05 makes here: inverter-b's 2000 W was the "
|
||||||
"pre-card answer and appears nowhere in the maintenance refusal now",
|
"pre-card answer and appears nowhere in the maintenance refusal now",
|
||||||
not _why(_p_skew, "maintenance_charge_w is 3000 W but max_w "
|
not _why(_p_skew, "(maintenance_charge_w) is 3000 W, but the “Inverter rating” (max_w) for "
|
||||||
"(inverter-b) is 2000 W"))
|
"inverter-b is 2000 W"))
|
||||||
check("...and the counterfactual, so this is not a check that would pass on a "
|
check("...and the counterfactual, so this is not a check that would pass on a "
|
||||||
"validator that named nothing: the OLD expressions still evaluate, on "
|
"validator that named nothing: the OLD expressions still evaluate, on "
|
||||||
"this same config, to the wrong names",
|
"this same config, to the wrong names",
|
||||||
@@ -1784,7 +1785,7 @@ check("⚠️ A READER THAT SILENTLY PINNED INVERTER #1, IN main.py. run_control
|
|||||||
"unit's max_w was graded against the device it is written to, and the "
|
"unit's max_w was graded against the device it is written to, and the "
|
||||||
"skip was silent. The per-unit form catches B against B's own ESPHome",
|
"skip was silent. The per-unit form catches B against B's own ESPHome",
|
||||||
len(_p_rng) == 1 and _why(_p_rng, "inverter-b")
|
len(_p_rng) == 1 and _why(_p_rng, "inverter-b")
|
||||||
and _why(_p_rng, "THE DEVICE WINS"))
|
and _why(_p_rng, "Lower this inverter's rating to"))
|
||||||
check("...and it says INVERTER-B, not 'the setpoint device' - a site with "
|
check("...and it says INVERTER-B, not 'the setpoint device' - a site with "
|
||||||
"three inverters and one bad rating needs to be told which one",
|
"three inverters and one bad rating needs to be told which one",
|
||||||
_why(_p_rng, "number.sp_b") and not _why(_p_rng, "inverter-a"))
|
_why(_p_rng, "number.sp_b") and not _why(_p_rng, "inverter-a"))
|
||||||
|
|||||||
@@ -413,6 +413,8 @@ 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
|
||||||
|
p.append("semicolon")
|
||||||
return p + text_problems(text, keys=keys, entities=ents)
|
return p + text_problems(text, keys=keys, entities=ents)
|
||||||
|
|
||||||
|
|
||||||
@@ -424,7 +426,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 = 167
|
ALLOWLIST_MAX = 43
|
||||||
|
|
||||||
|
|
||||||
def read_allowlist(path):
|
def read_allowlist(path):
|
||||||
@@ -527,6 +529,32 @@ check(f"no allowlist entry is stale, so the list only shrinks "
|
|||||||
check(f"the allowlist is exactly its cap ({len(allow)} entries, cap "
|
check(f"the allowlist is exactly its cap ({len(allow)} entries, cap "
|
||||||
f"{ALLOWLIST_MAX}): lower the cap when you delete lines",
|
f"{ALLOWLIST_MAX}): lower the cap when you delete lines",
|
||||||
len(allow) == ALLOWLIST_MAX)
|
len(allow) == ALLOWLIST_MAX)
|
||||||
|
|
||||||
|
# A refusal names a setting by its Configuration-tab label (UX-08 2c). The
|
||||||
|
# catalogue's labels must be the en.yaml names without group prefix and unit.
|
||||||
|
_TR = TL.parse_translation_file(HERE / "translations" / "en.yaml")["configuration"]
|
||||||
|
|
||||||
|
|
||||||
|
def _label_stems(key):
|
||||||
|
names = [c[key]["name"] for c in (_TR, _TR["inverters"]["fields"])
|
||||||
|
if isinstance(c.get(key), dict)]
|
||||||
|
out = set()
|
||||||
|
for n in names:
|
||||||
|
n = n.split("·")[-1].strip()
|
||||||
|
while re.search(r"\s*\([^()]*\)$", n):
|
||||||
|
n = re.sub(r"\s*\([^()]*\)$", "", n)
|
||||||
|
out.add(n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_drift = sorted(f"{k}: {v!r}" for k, v in messages.LABELS.items()
|
||||||
|
if v not in _label_stems(k))
|
||||||
|
check(f"every label a refusal quotes is the setting's name in en.yaml "
|
||||||
|
f"(drifted: {_drift})", not _drift)
|
||||||
|
check("...and a refusal quotes the label and then the key, so the config "
|
||||||
|
"panel still files it under that setting",
|
||||||
|
messages.setting("max_w", "inverter-b")
|
||||||
|
== "“Inverter rating” (max_w) for inverter-b")
|
||||||
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))
|
||||||
@@ -567,6 +595,11 @@ check("an entity id in an alarm is refused", bool(body_problems(
|
|||||||
"Check sensor.battery_power now.", messages.ALARM)))
|
"Check sensor.battery_power now.", messages.ALARM)))
|
||||||
check("...but allowed in a refusal", not problems_of(
|
check("...but allowed in a refusal", not problems_of(
|
||||||
"refusal", "Check sensor.battery_power in Developer Tools."))
|
"refusal", "Check sensor.battery_power in Developer Tools."))
|
||||||
|
check("a semicolon in a refusal or an option text is refused",
|
||||||
|
bool(problems_of("refusal", "It is wrong; fix it."))
|
||||||
|
and bool(problems_of("option-desc", "It is wrong; fix it.")))
|
||||||
|
check("a label that drifted from en.yaml is caught",
|
||||||
|
"Inverter power" not in _label_stems("max_w"))
|
||||||
check("an option key in an alarm is refused", bool(body_problems(
|
check("an option key in an alarm is refused", bool(body_problems(
|
||||||
"Lower peak_soc_floor.", messages.ALARM)))
|
"Lower peak_soc_floor.", messages.ALARM)))
|
||||||
check("a message that lost its Details line is refused",
|
check("a message that lost its Details line is refused",
|
||||||
|
|||||||
@@ -2019,12 +2019,13 @@ check("...and so is a floor EXACTLY AT it, which is the interesting case: equal
|
|||||||
"it leaves none", len(equal) == 1)
|
"it leaves none", len(equal) == 1)
|
||||||
check("...while one point above is accepted, so this refuses the disarmed case "
|
check("...while one point above is accepted, so this refuses the disarmed case "
|
||||||
"only and not 'close to the limit'", above == [])
|
"only and not 'close to the limit'", above == [])
|
||||||
check("...and the refusal NAMES BOTH NUMBERS and the shipped default, so the "
|
# UX-08 2c: the shipped default left the sentence (it is on the option's
|
||||||
"operator can act on it without reading the source",
|
# description). The two numbers the operator acts on stay.
|
||||||
|
check("...and the refusal NAMES BOTH NUMBERS, so the operator can act on it "
|
||||||
|
"without reading the source",
|
||||||
len(below) == 1
|
len(below) == 1
|
||||||
and f"{BMS_FLOOR:.0f} %" in below[0]
|
and f"{BMS_FLOOR:.0f} %" in below[0]
|
||||||
and f"{BMS_FLOOR - 5:.0f} %" in below[0]
|
and f"{BMS_FLOOR - 5:.0f} %" in below[0])
|
||||||
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in below[0])
|
|
||||||
check("...and a non-numeric floor is refused rather than defaulted, for the "
|
check("...and a non-numeric floor is refused rather than defaulted, for the "
|
||||||
"reason every read in this file is: a value that cannot be parsed is not "
|
"reason every read in this file is: a value that cannot be parsed is not "
|
||||||
"an observation of anything",
|
"an observation of anything",
|
||||||
@@ -3894,13 +3895,12 @@ check("⚠️ AC 3. Rule 8 grades EVERY unit: a roster whose SECOND pack has a "
|
|||||||
"inverter-b. Under the flat key this config STARTED, and the guard would "
|
"inverter-b. Under the flat key this config STARTED, and the guard would "
|
||||||
"then have commanded a forced discharge that pack B's BMS - not the "
|
"then have commanded a forced discharge that pack B's BMS - not the "
|
||||||
"operator's floor - is the thing that stops",
|
"operator's floor - is the thing that stops",
|
||||||
len(_r_second) == 1 and "(inverter-b)" in _msg(_r_second)
|
len(_r_second) == 1 and " for inverter-b" in _msg(_r_second)
|
||||||
and "(inverter-a)" not in _msg(_r_second))
|
and " for inverter-a" not in _msg(_r_second))
|
||||||
check("...and it names BOTH numbers and the shipped default, so the operator "
|
check("...and it names BOTH numbers, so the operator can act on it without "
|
||||||
"can act on it without reading the source",
|
"reading the source",
|
||||||
f"{B_PACK_FLOOR_DEEP_SIM:.0f} %" in _msg(_r_second)
|
f"{B_PACK_FLOOR_DEEP_SIM:.0f} %" in _msg(_r_second)
|
||||||
and f"{SOC_FLOOR:.0f} %" in _msg(_r_second)
|
and f"{SOC_FLOOR:.0f} %" in _msg(_r_second))
|
||||||
and f"{PEAK_OPTION_DEFAULTS['peak_soc_floor']:.0f} %" in _msg(_r_second))
|
|
||||||
# The mirror. A grader hardwired to index 1 - or one that reports the LAST
|
# The mirror. A grader hardwired to index 1 - or one that reports the LAST
|
||||||
# offender - passes the check above and fails this one.
|
# offender - passes the check above and fails this one.
|
||||||
_r_first = rule8(validate_startup_config(
|
_r_first = rule8(validate_startup_config(
|
||||||
@@ -3908,8 +3908,8 @@ _r_first = rule8(validate_startup_config(
|
|||||||
check("...and with the SAME two numbers in the OTHER ORDER it names "
|
check("...and with the SAME two numbers in the OTHER ORDER it names "
|
||||||
"inverter-a instead. The pair of checks is what makes this an index "
|
"inverter-a instead. The pair of checks is what makes this an index "
|
||||||
"check: one of them goes red for any hardwired position",
|
"check: one of them goes red for any hardwired position",
|
||||||
len(_r_first) == 1 and "(inverter-a)" in _msg(_r_first)
|
len(_r_first) == 1 and " for inverter-a" in _msg(_r_first)
|
||||||
and "(inverter-b)" not in _msg(_r_first))
|
and " for inverter-b" not in _msg(_r_first))
|
||||||
# And both at once, because a loop that `break`s on the first offender reports
|
# And both at once, because a loop that `break`s on the first offender reports
|
||||||
# one refusal for a config with two.
|
# one refusal for a config with two.
|
||||||
_r_both = rule8(validate_startup_config(
|
_r_both = rule8(validate_startup_config(
|
||||||
@@ -3917,8 +3917,8 @@ _r_both = rule8(validate_startup_config(
|
|||||||
check("...and a roster where BOTH packs offend is refused TWICE, once per unit "
|
check("...and a roster where BOTH packs offend is refused TWICE, once per unit "
|
||||||
"and each naming its own - so an operator with two wrong packs is not "
|
"and each naming its own - so an operator with two wrong packs is not "
|
||||||
"told about one of them and left to discover the other on the next boot",
|
"told about one of them and left to discover the other on the next boot",
|
||||||
len(_r_both) == 2 and "(inverter-a)" in _msg(_r_both, 0)
|
len(_r_both) == 2 and " for inverter-a" in _msg(_r_both, 0)
|
||||||
and "(inverter-b)" in _msg(_r_both, 1))
|
and " for inverter-b" in _msg(_r_both, 1))
|
||||||
# A missing per-entry cut-out is refused BY NAME rather than defaulted.
|
# A missing per-entry cut-out is refused BY NAME rather than defaulted.
|
||||||
_r_missing = rule8(validate_startup_config(_roster32(A_PACK_FLOOR, None)))
|
_r_missing = rule8(validate_startup_config(_roster32(A_PACK_FLOOR, None)))
|
||||||
print(f" entry with no bms_soc_floor -> {_msg(_r_missing)[:120] or '(none)'}...")
|
print(f" entry with no bms_soc_floor -> {_msg(_r_missing)[:120] or '(none)'}...")
|
||||||
@@ -3927,7 +3927,7 @@ check("...and an entry that states NO `bms_soc_floor` is refused by name rather
|
|||||||
"cut-out this add-on invented for a battery it has not been told about, "
|
"cut-out this add-on invented for a battery it has not been told about, "
|
||||||
"which is the same wrong number the flat key produced. See "
|
"which is the same wrong number the flat key produced. See "
|
||||||
"DECISIONS/SAFETY-32-per-pack-bms-floor.md",
|
"DECISIONS/SAFETY-32-per-pack-bms-floor.md",
|
||||||
len(_r_missing) == 1 and "(inverter-b)" in _msg(_r_missing)
|
len(_r_missing) == 1 and " for inverter-b" in _msg(_r_missing)
|
||||||
and "must both be numbers" in _msg(_r_missing))
|
and "must both be numbers" in _msg(_r_missing))
|
||||||
|
|
||||||
# --- 12f. nothing is inherited, with the flat key SET AND DIFFERENT ----------
|
# --- 12f. nothing is inherited, with the flat key SET AND DIFFERENT ----------
|
||||||
@@ -5601,7 +5601,7 @@ MUT.append(("an unstated per-entry cut-out defaulted to 0 %",
|
|||||||
|
|
||||||
# 19. rule 8 grading only the first unit, i.e. the flat rule left in place.
|
# 19. rule 8 grading only the first unit, i.e. the flat rule left in place.
|
||||||
MUT.append(("rule 8 grading only the FIRST unit",
|
MUT.append(("rule 8 grading only the FIRST unit",
|
||||||
_r_second != [] and "(inverter-b)" in _msg(_r_second),
|
_r_second != [] and " for inverter-b" in _msg(_r_second),
|
||||||
"part 12e: 'a roster whose SECOND pack has a cut-out above "
|
"part 12e: 'a roster whose SECOND pack has a cut-out above "
|
||||||
"peak_soc_floor is refused'. ⚠️ The offender is the SECOND entry "
|
"peak_soc_floor is refused'. ⚠️ The offender is the SECOND entry "
|
||||||
"for exactly this row"))
|
"for exactly this row"))
|
||||||
@@ -5609,7 +5609,7 @@ MUT.append(("rule 8 grading only the FIRST unit",
|
|||||||
# 20. rule 8 naming a hardwired unit - CFG-04's rule 5/6 bug, which is why
|
# 20. rule 8 naming a hardwired unit - CFG-04's rule 5/6 bug, which is why
|
||||||
# `unit_bms_floors_indexed()` carries the roster index.
|
# `unit_bms_floors_indexed()` carries the roster index.
|
||||||
MUT.append(("rule 8's refusal naming a hardwired inverter",
|
MUT.append(("rule 8's refusal naming a hardwired inverter",
|
||||||
"(inverter-b)" in _msg(_r_second) and "(inverter-a)" in _msg(_r_first),
|
" for inverter-b" in _msg(_r_second) and " for inverter-a" in _msg(_r_first),
|
||||||
"part 12e: the two orders. Either hardwired index passes one of "
|
"part 12e: the two orders. Either hardwired index passes one of "
|
||||||
"them and fails the other"))
|
"them and fails the other"))
|
||||||
|
|
||||||
|
|||||||
@@ -2123,7 +2123,7 @@ check("...and so does a configured provider on its own",
|
|||||||
-5000, 5000) == [])
|
-5000, 5000) == [])
|
||||||
check("an unknown provider id is refused rather than defaulted to 'off'",
|
check("an unknown provider id is refused rather than defaulted to 'off'",
|
||||||
why(validate_startup_config(vbase(price_provider="nordpool"), -5000, 5000),
|
why(validate_startup_config(vbase(price_provider="nordpool"), -5000, 5000),
|
||||||
"not a shipped plugin"))
|
"which does not exist"))
|
||||||
|
|
||||||
# ⚠️ TARIFF-07: A RETIRED ID IS REFUSED BY A DIFFERENT MESSAGE, AND EVERY CHECK
|
# ⚠️ TARIFF-07: A RETIRED ID IS REFUSED BY A DIFFERENT MESSAGE, AND EVERY CHECK
|
||||||
# HERE GRADES THE REASON RATHER THAN THE VERDICT. The failure this section
|
# HERE GRADES THE REASON RATHER THAN THE VERDICT. The failure this section
|
||||||
@@ -2144,26 +2144,21 @@ check(f"every retired id is refused at startup {sorted(_retired)}",
|
|||||||
len(_retired) == 3 and all(len(v) == 1 for v in _retired.values()))
|
len(_retired) == 3 and all(len(v) == 1 for v in _retired.values()))
|
||||||
check("...and NOT by the unknown-id message, which would tell an operator they "
|
check("...and NOT by the unknown-id message, which would tell an operator they "
|
||||||
"mistyped an id this add-on offered them and accepted for months",
|
"mistyped an id this add-on offered them and accepted for months",
|
||||||
all("not a shipped plugin" not in v[0] for v in _retired.values())
|
all("which does not exist" not in v[0] for v in _retired.values())
|
||||||
and "WITHDRAWN" not in _unknown[0])
|
and "was withdrawn" not in _unknown[0])
|
||||||
check("...and each message NAMES ITS OWN ID, so the operator can tell it is "
|
check("...and each message NAMES ITS OWN ID, so the operator can tell it is "
|
||||||
"about their configuration and not a generic notice",
|
"about their configuration and not a generic notice",
|
||||||
all(repr(pid) in v[0] for pid, v in _retired.items()))
|
all(repr(pid) in v[0] for pid, v in _retired.items()))
|
||||||
check("...and (a) says the plugin never fetched a price, naming the endpoint "
|
# UX-08 2c: the endpoints that never existed are in DOCS.md under "Energy price
|
||||||
"that does not exist",
|
# providers"; the refusal keeps what the operator acts on.
|
||||||
all("NEVER FETCHED A PRICE" in v[0] for v in _retired.values())
|
check("...and (a) says the plugin never fetched a price",
|
||||||
and "api.engie.be/dynamic/be" in _retired["engie_dynamic"][0]
|
all("never fetched a price" in v[0] for v in _retired.values()))
|
||||||
and "api.totalenergies.be/prices/dynamic"
|
|
||||||
in _retired["totalenergies_dynamic"][0]
|
|
||||||
and "api.eneco.be/eddy/prices" in _retired["eneco_eddy"][0])
|
|
||||||
check("...and (b) says what the site has ACTUALLY been running instead - the "
|
check("...and (b) says what the site has ACTUALLY been running instead - the "
|
||||||
"half §11.4 made invisible",
|
"half §11.4 made invisible",
|
||||||
all("SILENT" in v[0] and "static_profile" in v[0]
|
all("This site ran on" in v[0] for v in _retired.values()))
|
||||||
for v in _retired.values()))
|
check("...and (c) says what to do, naming a live plugin",
|
||||||
check("...and (c) says what to do, naming a live plugin and refusing to "
|
|
||||||
"pretend an alias could exist",
|
|
||||||
all("generic_dayahead" in v[0] and "manual_csv" in v[0]
|
all("generic_dayahead" in v[0] and "manual_csv" in v[0]
|
||||||
and "NO alias" in v[0] for v in _retired.values()))
|
for v in _retired.values()))
|
||||||
|
|
||||||
# ⚠️ AND THE SENTENCE CHANGES WITH THE SITE. "You have been on the static
|
# ⚠️ AND THE SENTENCE CHANGES WITH THE SITE. "You have been on the static
|
||||||
# profile" is FALSE where no profile was ever set, and that site is in a WORSE
|
# profile" is FALSE where no profile was ever set, and that site is in a WORSE
|
||||||
@@ -2177,11 +2172,12 @@ _with_profile = validate_startup_config(
|
|||||||
_without = _retired["eneco_eddy"][0]
|
_without = _retired["eneco_eddy"][0]
|
||||||
check("a retired id on a site WITH a static profile is told it has been "
|
check("a retired id on a site WITH a static profile is told it has been "
|
||||||
"running that profile",
|
"running that profile",
|
||||||
"the static day/night tariff in static_profile" in _with_profile)
|
"ran on the “Fixed or day/night tariff” (static_profile)"
|
||||||
|
in _with_profile)
|
||||||
check("...and a site WITHOUT one is told it has been running nothing at all, "
|
check("...and a site WITHOUT one is told it has been running nothing at all, "
|
||||||
"which is a different and worse state",
|
"which is a different and worse state",
|
||||||
"NOTHING AT ALL" in _without
|
"ran on no tariff at all" in _without
|
||||||
and "the static day/night tariff in static_profile" not in _without)
|
and "(static_profile)" not in _without)
|
||||||
check("...so the two renderings are not the same sentence",
|
check("...so the two renderings are not the same sentence",
|
||||||
_with_profile != _without)
|
_with_profile != _without)
|
||||||
|
|
||||||
@@ -2193,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, "HOME ASSISTANT ENTITIES WIN"))
|
why(_p, "are also set and would win"))
|
||||||
# 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",'
|
||||||
@@ -2202,7 +2198,7 @@ _ps = validate_startup_config(
|
|||||||
-5000, 5000)
|
-5000, 5000)
|
||||||
check("a static_profile AND the Home Assistant price entities is refused too - "
|
check("a static_profile AND the Home Assistant price entities is refused too - "
|
||||||
"main.py reads one of them for `cheap` and the loser is silent",
|
"main.py reads one of them for `cheap` and the loser is silent",
|
||||||
len(_ps) == 1 and why(_ps, "HOME ASSISTANT"))
|
len(_ps) == 1 and why(_ps, "Home Assistant price entities would count"))
|
||||||
check("the Home Assistant entities alone still start, so nothing is taken away "
|
check("the Home Assistant entities alone still start, so nothing is taken away "
|
||||||
"from a live install",
|
"from a live install",
|
||||||
validate_startup_config(vbase(price_now_entity="sensor.now",
|
validate_startup_config(vbase(price_now_entity="sensor.now",
|
||||||
@@ -2945,10 +2941,10 @@ check("...and BOTH translations tell an operator which switch does which - "
|
|||||||
"the provider option says it is what starts the fetch, and the trading "
|
"the provider option says it is what starts the fetch, and the trading "
|
||||||
"option says turning it off does not turn the feed off. One language "
|
"option says turning it off does not turn the feed off. One language "
|
||||||
"updated and not the other is how this surface rots",
|
"updated and not the other is how this surface rots",
|
||||||
"STARTS THE PRICE FETCH" in _T10_TR["en"]
|
"starts the price fetch" in _T10_TR["en"]
|
||||||
and "DOES NOT TURN THE PRICE FEED OFF" in _T10_TR["en"]
|
and "does not turn the price feed off" in _T10_TR["en"]
|
||||||
and "HET OPHALEN VAN PRIJZEN START" in _T10_TR["nl"]
|
and "start het ophalen van prijzen" in _T10_TR["nl"]
|
||||||
and "ZET DE PRIJSFEED NIET UIT" in _T10_TR["nl"])
|
and "zet de prijsfeed niet uit" in _T10_TR["nl"])
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -3431,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 reproduce its own card" in _double and "OUT BY" in _double
|
"does not match its own card" in _double and "the card says" in _double
|
||||||
and "folded into `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),
|
||||||
@@ -3451,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 reproduce its own card" in _why and "OUT BY" in _why)
|
"does not match its own card" in _why and "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.
|
||||||
@@ -3463,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 reproduce its own card" in _outside)
|
_inside == "" and "does not match its own card" 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 "
|
||||||
@@ -3569,32 +3565,31 @@ check("...and the hourly series' coverage is still DERIVED from the "
|
|||||||
print()
|
print()
|
||||||
print("15e. every refusal in the table, and the two that are WARNINGS")
|
print("15e. every refusal in the table, and the two that are WARNINGS")
|
||||||
_TABLE = {
|
_TABLE = {
|
||||||
"units absent": (spoilt(units=""), "units", "is REQUIRED"),
|
"units absent": (spoilt(units=""), "units", "Choose one of"),
|
||||||
"units not one of the enumerated ones":
|
"units not one of the enumerated ones":
|
||||||
(spoilt(units="cents"), "units", "must be one of"),
|
(spoilt(units="cents"), "units", "Choose one of"),
|
||||||
"factor == 0 (every spread becomes 0, arbitrage never fires)":
|
"factor == 0 (every spread becomes 0, arbitrage never fires)":
|
||||||
(spoilt(factor=0), "factor", "static_profile"),
|
(spoilt(factor=0), "factor", "static_profile"),
|
||||||
"factor < 0 (the dearest hour becomes the cheapest)":
|
"factor < 0 (the dearest hour becomes the cheapest)":
|
||||||
(spoilt(factor=-0.102), "factor", "inverts the market"),
|
(spoilt(factor=-0.102), "factor", "turns the market upside down"),
|
||||||
"vat_pct above 30 (the 6 / 1.06 / 106 confusion)":
|
"vat_pct above 30 (the 6 / 1.06 / 106 confusion)":
|
||||||
(spoilt(vat_pct=106), "vat_pct", "between 0 and 30"),
|
(spoilt(vat_pct=106), "vat_pct", "between 0 and 30"),
|
||||||
"vat_pct below 0": (spoilt(vat_pct=-6), "vat_pct", "between 0 and 30"),
|
"vat_pct below 0": (spoilt(vat_pct=-6), "vat_pct", "between 0 and 30"),
|
||||||
"a non-numeric coefficient": (spoilt(factor="0.102"), "factor",
|
"a non-numeric coefficient": (spoilt(factor="0.102"), "factor",
|
||||||
"not a number"),
|
"not a number"),
|
||||||
"a coefficient that is an EXPRESSION rather than a number":
|
"a coefficient that is an EXPRESSION rather than a number":
|
||||||
(spoilt(factor="0.1*x"), "factor", "evaluates nothing"),
|
(spoilt(factor="0.1*x"), "factor", "Write a plain number"),
|
||||||
"a non-finite coefficient": (spoilt(offset=float("inf")), "offset",
|
"a non-finite coefficient": (spoilt(offset=float("inf")), "offset",
|
||||||
"not finite"),
|
"not finite"),
|
||||||
"an unknown index": (spoilt(index="belpex_h"), "index", "cannot get it"),
|
"an unknown index": (spoilt(index="belpex_h"), "index", "can only fetch"),
|
||||||
"an unknown resolution": (spoilt(resolution="daily"), "resolution",
|
"an unknown resolution": (spoilt(resolution="daily"), "resolution",
|
||||||
"must be one of"),
|
"Choose one of"),
|
||||||
"an unknown top-level field (which is how 'expr' is answered)":
|
"an unknown top-level field (which is how 'expr' is answered)":
|
||||||
(spoilt(expr="0.102*spot+1"), "expr", "NO field that takes an "
|
(spoilt(expr="0.102*spot+1"), "expr", "It takes only"),
|
||||||
"expression"),
|
|
||||||
"an unknown field inside the card": ({**ENECO_CARD,
|
"an unknown field inside the card": ({**ENECO_CARD,
|
||||||
"card": dict(ENECO_CARD["card"],
|
"card": dict(ENECO_CARD["card"],
|
||||||
vat="6")},
|
vat="6")},
|
||||||
"vat", "no field(s)"),
|
"vat", "unknown field(s)"),
|
||||||
}
|
}
|
||||||
for _name, (_blob, _names, _needle) in sorted(_TABLE.items()):
|
for _name, (_blob, _names, _needle) in sorted(_TABLE.items()):
|
||||||
_why = refusal(_blob)
|
_why = refusal(_blob)
|
||||||
@@ -3649,7 +3644,7 @@ check("...and it is warned about, naming the month and saying why it is not "
|
|||||||
"refused",
|
"refused",
|
||||||
"2019-01" in (stale_card(parse_price_formula(json.dumps(_old_card)))
|
"2019-01" in (stale_card(parse_price_formula(json.dumps(_old_card)))
|
||||||
or "")
|
or "")
|
||||||
and "Not refused" in (stale_card(
|
and "still starts" in (stale_card(
|
||||||
parse_price_formula(json.dumps(_old_card))) or ""))
|
parse_price_formula(json.dumps(_old_card))) or ""))
|
||||||
check("...while a card from this month draws no warning, so the rule is the "
|
check("...while a card from this month draws no warning, so the rule is the "
|
||||||
"AGE and not the presence of the field",
|
"AGE and not the presence of the field",
|
||||||
@@ -3850,13 +3845,13 @@ check("custom_formula with NO formula is refused, naming the option and "
|
|||||||
"showing the shape",
|
"showing the shape",
|
||||||
why(validate_startup_config(vbase(price_provider="custom_formula"),
|
why(validate_startup_config(vbase(price_provider="custom_formula"),
|
||||||
-5000, 5000),
|
-5000, 5000),
|
||||||
"price_formula is empty"))
|
"(price_formula) is empty"))
|
||||||
check("a formula that does not reproduce its card is refused AT STARTUP, "
|
check("a formula that does not reproduce its card is refused AT STARTUP, "
|
||||||
"with the discrepancy named",
|
"with the discrepancy named",
|
||||||
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 reproduce its own card"))
|
-5000, 5000), "does not match its own card"))
|
||||||
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(
|
||||||
@@ -5362,18 +5357,17 @@ check("...and none of those rows is vacuous: every one really did refuse on "
|
|||||||
_T16_DIFFER = [
|
_T16_DIFFER = [
|
||||||
("factor 0 -> where a FLAT price of THIS leg lives",
|
("factor 0 -> where a FLAT price of THIS leg lives",
|
||||||
spoilt(factor=0.0), "static_profile", "export_price_eur_kwh"),
|
spoilt(factor=0.0), "static_profile", "export_price_eur_kwh"),
|
||||||
("a negative factor -> which market it inverts",
|
# UX-08 2c: a negative factor gets one sentence on both legs now ("put the
|
||||||
spoilt(factor=-0.102), "No consumption tariff",
|
# minus sign on the offset"), so it left this table.
|
||||||
"No Belgian injection tariff"),
|
("a card miss -> what else to check on THIS leg",
|
||||||
("a card miss -> what a wrong `offset` costs on THIS leg",
|
spoilt(factor=0.120), "network charges are not in the offset",
|
||||||
spoilt(factor=0.120), "bills you for it twice",
|
"vat_pct is 0 for injection"),
|
||||||
"nothing is added to this leg"),
|
|
||||||
("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", "unless price_provider is 'custom_formula'",
|
"{not json", "(price_provider) is 'custom_formula'",
|
||||||
"read whatever price_provider is set to"),
|
"used whatever the price source is"),
|
||||||
]
|
]
|
||||||
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)),
|
||||||
@@ -5490,10 +5484,10 @@ check(f"a factor 100x out is refused BY THE PASS-THROUGH RUNG, naming the "
|
|||||||
f"share of the wholesale price it would pay ({_T16_PT_WHY[:60]!r})",
|
f"share of the wholesale price it would pay ({_T16_PT_WHY[:60]!r})",
|
||||||
"passes through" in _T16_PT_WHY
|
"passes through" in _T16_PT_WHY
|
||||||
and f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x" in _T16_PT_WHY
|
and f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x" in _T16_PT_WHY
|
||||||
and "|offset| above" not in _T16_PT_WHY)
|
and "larger than" not in _T16_PT_WHY)
|
||||||
check(f"an offset 100x out is refused BY THE OFFSET RUNG, naming the EUR/kWh "
|
check(f"an offset 100x out is refused BY THE OFFSET RUNG, naming the EUR/kWh "
|
||||||
f"it works out at ({_T16_OFF_WHY[:60]!r})",
|
f"it works out at ({_T16_OFF_WHY[:60]!r})",
|
||||||
"|offset| above" in _T16_OFF_WHY
|
"larger than" in _T16_OFF_WHY
|
||||||
and "passes through" not in _T16_OFF_WHY)
|
and "passes through" not in _T16_OFF_WHY)
|
||||||
check(f"...and the WHOLE card 100x out - which trips both - is refused, with "
|
check(f"...and the WHOLE card 100x out - which trips both - is refused, with "
|
||||||
f"the pass-through rung answering first ({_T16_BOTH_WHY[:44]!r})",
|
f"the pass-through rung answering first ({_T16_BOTH_WHY[:44]!r})",
|
||||||
@@ -5555,9 +5549,8 @@ 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 reproduce its own card" in _T16_SPOILT_CARD
|
and "does not match its own card" in _T16_SPOILT_CARD
|
||||||
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION)
|
and _T16_SPOILT_CARD.startswith(EXPORT_FORMULA_OPTION))
|
||||||
and f"the tolerance is {CARD_TOLERANCE}" in _T16_SPOILT_CARD)
|
|
||||||
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'",
|
||||||
_reason(lambda: parse_export_formula(json.dumps(spoilt(
|
_reason(lambda: parse_export_formula(json.dumps(spoilt(
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ check("the stored options are byte-identical to what they were",
|
|||||||
check("...and the refusal carries the validator's OWN sentence, unedited",
|
check("...and the refusal carries the validator's OWN sentence, unedited",
|
||||||
refused.problems == validate_startup_config(bad, None, None))
|
refused.problems == validate_startup_config(bad, None, None))
|
||||||
check("...which is the sentence that names the key and says what it costs",
|
check("...which is the sentence that names the key and says what it costs",
|
||||||
any("setpoint_entity is not set" in p for p in refused.problems))
|
any("(setpoint_entity) is empty" in p for p in refused.problems))
|
||||||
|
|
||||||
_good = base()
|
_good = base()
|
||||||
res = run(selfconfig.save(sup, _good, fp))
|
res = run(selfconfig.save(sup, _good, fp))
|
||||||
@@ -316,7 +316,7 @@ _probs, _ = run(selfconfig.problems_and_ranges(dict(_live, max_w=""), None))
|
|||||||
check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the "
|
check("⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the "
|
||||||
"rail collapsing to the 2000 default under an explicit 400 W slew, so "
|
"rail collapsing to the 2000 default under an explicit 400 W slew, so "
|
||||||
"the box cannot be emptied into a quietly halved rail",
|
"the box cannot be emptied into a quietly halved rail",
|
||||||
any("site rail of 2000 W" in p for p in _probs))
|
any("for a 2000 W site" in p for p in _probs))
|
||||||
|
|
||||||
# HALF TWO: the backstop cannot report "no problems" for a config it could not
|
# HALF TWO: the backstop cannot report "no problems" for a config it could not
|
||||||
# compose. Returning [] there enables Save - a crash became a green.
|
# compose. Returning [] there enables Save - a crash became a green.
|
||||||
@@ -430,7 +430,7 @@ _probs = run(selfconfig.problems_for(base(max_w=4000), FakeHass(_ranges)))
|
|||||||
_by, _un = configui.problems_by_field(_probs, ALL_KEYS)
|
_by, _un = configui.problems_by_field(_probs, ALL_KEYS)
|
||||||
check("the device-range rule is REACHED from the panel - the setpoint entity's "
|
check("the device-range rule is REACHED from the panel - the setpoint entity's "
|
||||||
"own range is read, so rule 1 fires here exactly as it does at startup",
|
"own range is read, so rule 1 fires here exactly as it does at startup",
|
||||||
any("THE DEVICE WINS" in p for p in _probs))
|
any("Lower the rating to" in p for p in _probs))
|
||||||
check("...and it lands on `max_w`, which is the field the operator has to "
|
check("...and it lands on `max_w`, which is the field the operator has to "
|
||||||
"change", "max_w" in _by)
|
"change", "max_w" in _by)
|
||||||
# ⚠️ THE COLLISION, CHECKED HERE AND NOT ONLY IN THE PROBE. `sign_disagree_w`
|
# ⚠️ THE COLLISION, CHECKED HERE AND NOT ONLY IN THE PROBE. `sign_disagree_w`
|
||||||
@@ -467,7 +467,7 @@ _p2, _unread2 = run(selfconfig.problems_and_ranges(base(max_w=4000),
|
|||||||
FakeHass(_ranges)))
|
FakeHass(_ranges)))
|
||||||
check("...and with the same device answering, the same config IS refused - "
|
check("...and with the same device answering, the same config IS refused - "
|
||||||
"which is what makes the line above a skip rather than a pass",
|
"which is what makes the line above a skip rather than a pass",
|
||||||
any("THE DEVICE WINS" in p for p in _p2) and _unread2 == [])
|
any("Lower the rating to" in p for p in _p2) and _unread2 == [])
|
||||||
check("the note is about the config UNDER TEST, not about the running roster: "
|
check("the note is about the config UNDER TEST, not about the running roster: "
|
||||||
"a newly typed entity that does not answer is the one named",
|
"a newly typed entity that does not answer is the one named",
|
||||||
run(selfconfig.problems_and_ranges(
|
run(selfconfig.problems_and_ranges(
|
||||||
@@ -495,8 +495,9 @@ _leaked = sorted(k for k in ALL_KEYS
|
|||||||
_script))
|
_script))
|
||||||
check(f"the page's script names NOT ONE option key - so it cannot be rendering "
|
check(f"the page's script names NOT ONE option key - so it cannot be rendering "
|
||||||
f"a field list of its own. Leaked: {_leaked}", not _leaked)
|
f"a field list of its own. Leaked: {_leaked}", not _leaked)
|
||||||
_phrases = ["THE DEVICE WINS", "has no actuator", "two writers",
|
_phrases = ["Lower the rating to", "cannot control the inverter",
|
||||||
"setpoint_entity is not set", "would silently never happen"]
|
"point at the same inverter entity", "(setpoint_entity) is empty",
|
||||||
|
"accepts at most"]
|
||||||
_copied = sorted(p for p in _phrases
|
_copied = sorted(p for p in _phrases
|
||||||
for f, text in _ui_files.items() if p in text)
|
for f, text in _ui_files.items() if p in text)
|
||||||
check(f"no phrase out of a validate.py refusal appears anywhere in the three "
|
check(f"no phrase out of a validate.py refusal appears anywhere in the three "
|
||||||
|
|||||||
@@ -850,7 +850,7 @@ check("a good static_profile starts",
|
|||||||
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
|
check("a gapped one is REFUSED at startup, not at 22:00 on the day it bites",
|
||||||
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
||||||
-5000, 5000),
|
-5000, 5000),
|
||||||
"static_profile cannot be used"))
|
"“Fixed or day/night tariff” cannot be used"))
|
||||||
check("...and the refusal names the uncovered range",
|
check("...and the refusal names the uncovered range",
|
||||||
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
why(validate_startup_config(vbase(static_profile=json.dumps(_gap)),
|
||||||
-5000, 5000), "22:00-23:00"))
|
-5000, 5000), "22:00-23:00"))
|
||||||
@@ -859,7 +859,7 @@ check("static_profile alongside the Home Assistant price entities is refused - "
|
|||||||
why(validate_startup_config(
|
why(validate_startup_config(
|
||||||
vbase(static_profile=json.dumps(TWO_PERIOD),
|
vbase(static_profile=json.dumps(TWO_PERIOD),
|
||||||
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
|
price_now_entity="sensor.now", price_avg_entity="sensor.avg"),
|
||||||
-5000, 5000), "HOME ASSISTANT"))
|
-5000, 5000), "Home Assistant price entities would count"))
|
||||||
check("...and the Home Assistant pair ALONE still starts, so nothing is taken "
|
check("...and the Home Assistant pair ALONE still starts, so nothing is taken "
|
||||||
"away from a live install",
|
"away from a live install",
|
||||||
validate_startup_config(vbase(price_now_entity="sensor.now",
|
validate_startup_config(vbase(price_now_entity="sensor.now",
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ check("baseline: the bare options object is CLEARED (exit 0)",
|
|||||||
code == 0 and "CLEARED for deploy" in out)
|
code == 0 and "CLEARED for deploy" in out)
|
||||||
code, out = gate(dump("bare-bad.json", dict(BARE, slew_w=5000)))
|
code, out = gate(dump("bare-bad.json", dict(BARE, slew_w=5000)))
|
||||||
check("baseline: the bare object with slew_w 5000 is REFUSED by the validator",
|
check("baseline: the bare object with slew_w 5000 is REFUSED by the validator",
|
||||||
code == 1 and "slew_w is 5000 W" in out)
|
code == 1 and "(slew_w) is 5000 W" in out)
|
||||||
|
|
||||||
for label, doc in (("wrapped", ha_apps_info(BARE)),
|
for label, doc in (("wrapped", ha_apps_info(BARE)),
|
||||||
("wrapped, slew_w 5000", ha_apps_info(dict(BARE, slew_w=5000)))):
|
("wrapped, slew_w 5000", ha_apps_info(dict(BARE, slew_w=5000)))):
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ the sentence that says WHETHER TO TOUCH THE VALUE, because the tab presents a
|
|||||||
constant derived from 1890 closed-loop test runs and the operator's own inverter
|
constant derived from 1890 closed-loop test runs and the operator's own inverter
|
||||||
rating as the same kind of field.
|
rating as the same kind of field.
|
||||||
|
|
||||||
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE
|
⚠️ WHAT THIS FILE CANNOT SEE, SAID FIRST. It grades COVERAGE and PROVENANCE -
|
||||||
CITATIONS - that every option has a name and a description in both languages,
|
that every option has a name and a description in both languages, that no
|
||||||
that no translation names an option that does not exist, and that each derived
|
translation names an option that does not exist, that each derived constant's
|
||||||
constant's English description cites the record it was derived from. It does NOT
|
English description says to leave it, and that DOCS.md's "For installers"
|
||||||
|
entry for it names the record it came from (moved there by UX-08). It does NOT
|
||||||
grade whether a sentence is true, whether the Dutch is good Dutch, or whether
|
grade whether a sentence is true, whether the Dutch is good Dutch, or whether
|
||||||
the result looks right on screen. The last of those is measured, not asserted,
|
the result looks right on screen. The last of those is measured, not asserted,
|
||||||
by `tools/supervisor_shape_probe.py` section F, which loads these files with
|
by `tools/supervisor_shape_probe.py` section F, which loads these files with
|
||||||
@@ -107,83 +108,95 @@ check("...and no Dutch string was left as a copy of the English one, which is "
|
|||||||
== TR["nl"]["configuration"][k]["description"]])
|
== TR["nl"]["configuration"][k]["description"]])
|
||||||
|
|
||||||
print("\n3. ⭐ the description says WHETHER TO TOUCH IT")
|
print("\n3. ⭐ the description says WHETHER TO TOUCH IT")
|
||||||
# ⚠️ THE HIGHEST-VALUE ITEM IN THE CARD, AND THE ONE A COVERAGE COUNT CANNOT
|
# The value of this surface is the sentence that says whether a number is a
|
||||||
# REACH. Sixty-odd descriptions that all say "the threshold, in watts" would
|
# knob. Since UX-08 phase 2c the tab keeps that sentence short ("Measured value,
|
||||||
# pass part 2 completely. What makes this surface safe is that a DERIVED
|
# leave it unchanged.") and the record it came from lives in DOCS.md under
|
||||||
# constant names the campaign it came from and says the value is not a knob,
|
# "For installers: where the numbers come from", where UX-01's citations moved.
|
||||||
# while a number that really is the operator's says so too.
|
# So each derived key is pinned twice:
|
||||||
#
|
# its English description tells the reader what to do, and its DOCS entry names
|
||||||
# ⚠️ AND THIS IS A TEXT CHECK, WHICH IS WEAK ON PURPOSE AND IS SAID TO BE. It
|
# the record. A text check, weak on purpose: it cannot know a citation is TRUE,
|
||||||
# cannot know whether a citation is TRUE; it can only know that the citation is
|
# only that deleting it, or the instruction, goes red.
|
||||||
# there and names the right record, so that deleting the provenance - or
|
DOCS = (HERE / "DOCS.md").read_text(encoding="utf-8")
|
||||||
# renaming the card it points at - goes red instead of shipping. The table is
|
_inst = DOCS.split("\n### For installers: where the numbers come from\n", 1)
|
||||||
# written out rather than derived because there is nothing in config.yaml that
|
INSTALLERS = _inst[-1].split("\n#", 1)[0] if len(_inst) == 2 else ""
|
||||||
# distinguishes a derived constant from an operator's number; that distinction
|
check("DOCS.md has the 'For installers: where the numbers come from' section",
|
||||||
# is the whole of what this card had to add.
|
bool(INSTALLERS.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def docs_entry(key):
|
||||||
|
"""The DOCS bullet that names `key` in backticks, lower-cased."""
|
||||||
|
hits = [b for b in INSTALLERS.split("\n- ") if f"`{key}`" in b]
|
||||||
|
return hits[0].lower() if hits else ""
|
||||||
|
|
||||||
|
|
||||||
|
LEAVE_M, LEAVE_D = "Measured value, leave it unchanged", \
|
||||||
|
"Derived value, leave it unchanged"
|
||||||
|
# key: (what the English description must say, what its DOCS entry must name)
|
||||||
DERIVED = {
|
DERIVED = {
|
||||||
"sign_disagree_w": ("SAFETY-02", "MEASURED"),
|
"sign_disagree_w": (LEAVE_M, ("SAFETY-02", "measured")),
|
||||||
"sign_disagree_s": ("SAFETY-02", "MEASURED"),
|
"sign_disagree_s": (LEAVE_M, ("SAFETY-02", "measured")),
|
||||||
"unit_sign_disagree_w": ("SAFETY-30", "1890"),
|
"unit_sign_disagree_w": (LEAVE_D, ("SAFETY-30", "1890")),
|
||||||
"unit_sign_disagree_s": ("SAFETY-30", "1890"),
|
"unit_sign_disagree_s": (LEAVE_D, ("SAFETY-30", "1890")),
|
||||||
"site_sign_disagree_w": ("SAFETY-30", "CARRIED"),
|
"site_sign_disagree_w": (LEAVE_M, ("SAFETY-30", "carried")),
|
||||||
"site_sign_disagree_s": ("SAFETY-30", "1890"),
|
"site_sign_disagree_s": (LEAVE_D, ("SAFETY-30", "1890")),
|
||||||
"meter_freeze_s": ("SAFETY-18", "MEASURED"),
|
"meter_freeze_s": (LEAVE_M, ("SAFETY-18", "measured")),
|
||||||
"circulation_w": ("SAFETY-31", "924"),
|
"circulation_w": (LEAVE_M, ("SAFETY-31", "924")),
|
||||||
"circulation_hold_s": ("SAFETY-31", "DERIVED"),
|
"circulation_hold_s": (LEAVE_D, ("SAFETY-31", "derived")),
|
||||||
# SAFETY-42. Two needles rather than one: the campaign it was derived from,
|
# SAFETY-42: the alarm changes nothing, and both surfaces say so.
|
||||||
# and the word that says what it does NOT do. An operator who reads
|
"delivery_hold_s": (LEAVE_D, ("SAFETY-42", "derived from",
|
||||||
# "not-delivering report" and assumes the add-on has handled it is the
|
"reports only")),
|
||||||
# failure this alarm's whole wording is written against.
|
"peak_soc_floor": (LEAVE_D, ("SAFETY-24", "derived")),
|
||||||
"delivery_hold_s": ("SAFETY-42", "DERIVED FROM", "REPORTS ONLY"),
|
"bms_soc_floor": ("Read it off your battery", ("0x0560", "measured")),
|
||||||
"peak_soc_floor": ("SAFETY-24", "DERIVED"),
|
"integrator_max_w": ("Do not raise it above", ("measured",)),
|
||||||
"bms_soc_floor": ("0x0560", "MEASURED"),
|
"gain": ("do not raise it", ("measured on",)),
|
||||||
"integrator_max_w": ("MEASURED", "Do not raise it"),
|
# SAFETY-39: 0 is not "off", it follows the rating.
|
||||||
"gain": ("MEASURED ON", "do not raise it"),
|
"slew_w": ("0 follows", ("SAFETY-39", "derived", "0 means follow")),
|
||||||
# SAFETY-39. `slew_w` joins the table because it stopped being an
|
# TARIFF-18: where the figure came from, that your DSO differs, and that
|
||||||
# operator's convenience and became a DERIVED value the moment it was
|
# changing it moves no trade.
|
||||||
# tied to the rating: 0 resolves to about a ninth of `max_w`, from an
|
"price_taxes_eur_kwh": ("Check it against your own grid operator",
|
||||||
# ~18 s measured step response. It is the one entry here whose value is
|
("TARIFF-18", "your own dso",
|
||||||
# a RATIO, so the description has to say what it follows as well as
|
"moves no trading decision")),
|
||||||
# where the ratio came from - a citation alone would leave an operator
|
|
||||||
# reading "0" on the tab with no idea it is not "off".
|
|
||||||
"slew_w": ("SAFETY-39", "DERIVED", "0 MEANS FOLLOW"),
|
|
||||||
# TARIFF-18. `price_taxes_eur_kwh` joins the table for `slew_w`'s reason:
|
|
||||||
# it stopped being a figure somebody picked and became one SUMMED FROM
|
|
||||||
# NAMED CARD ROWS. Three needles, because three different things about it
|
|
||||||
# can go wrong on the Configuration tab and only the first is provenance:
|
|
||||||
# where the number came from, that the operator's own DSO gives a
|
|
||||||
# different one, and that changing it moves no trade - which is the claim
|
|
||||||
# an operator is most likely to assume the opposite of when a number
|
|
||||||
# doubles.
|
|
||||||
"price_taxes_eur_kwh": ("TARIFF-18", "YOUR OWN DSO",
|
|
||||||
"MOVES NO TRADING DECISION"),
|
|
||||||
}
|
}
|
||||||
_uncited = []
|
_uncited = []
|
||||||
for key, needles in sorted(DERIVED.items()):
|
for key, (says, needles) in sorted(DERIVED.items()):
|
||||||
desc = TR["en"]["configuration"][key]["description"]
|
if says not in TR["en"]["configuration"][key]["description"]:
|
||||||
|
_uncited.append(f"{key} description does not say {says!r}")
|
||||||
for needle in needles:
|
for needle in needles:
|
||||||
if needle not in desc:
|
if needle.lower() not in docs_entry(key):
|
||||||
_uncited.append(f"{key} does not cite {needle!r}")
|
_uncited.append(f"{key}: DOCS does not name {needle!r}")
|
||||||
print(f" {len(DERIVED)} derived constants must cite their campaign; "
|
print(f" {len(DERIVED)} derived constants; {len(_uncited)} problem(s)")
|
||||||
f"{len(_uncited)} do not")
|
check(f"every derived constant's description says what to do with it, and its "
|
||||||
check(f"every derived constant's English description names WHERE THE NUMBER "
|
f"DOCS entry names where the number came from. Problems: {_uncited}",
|
||||||
f"CAME FROM, so an operator reading the tab can tell it apart from their "
|
not _uncited)
|
||||||
f"own inverter rating. Uncited: {_uncited}", not _uncited)
|
|
||||||
# The other direction, and it matters as much: a number that IS the operator's
|
|
||||||
# must not be dressed up as derived, or the labels stop meaning anything.
|
|
||||||
_theirs = ["max_w", "peak_cap_w"]
|
_theirs = ["max_w", "peak_cap_w"]
|
||||||
check("...and the numbers that really are the OPERATOR'S say so instead of "
|
check("...and the numbers that really are the OPERATOR'S say so instead - "
|
||||||
"carrying a citation - `max_w` and `peak_cap_w` are the two the card "
|
"`max_w` and `peak_cap_w` start 'Your own number', here and in DOCS",
|
||||||
"names, and both read 'THIS IS YOUR NUMBER'",
|
all(TR["en"]["configuration"][k]["description"].startswith(
|
||||||
all("THIS IS YOUR NUMBER" in TR["en"]["configuration"][k]["description"]
|
"Your own number") and "your own number" in docs_entry(k)
|
||||||
for k in _theirs))
|
for k in _theirs))
|
||||||
check("...and the one threshold that is NOT derived from a measurement admits "
|
check("...and the one threshold that is NOT derived from a measurement admits "
|
||||||
"it rather than borrowing the others' authority - "
|
"it - `cycle_cost_threshold_eur` is a product choice, and DOCS names the "
|
||||||
"`cycle_cost_threshold_eur` is an open product question (OPT-08)",
|
"open question (OPT-08)",
|
||||||
"NOT DERIVED FROM A MEASUREMENT"
|
"product choice, not a measured one"
|
||||||
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
|
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"]
|
||||||
and "OPT-08"
|
and "opt-08" in docs_entry("cycle_cost_threshold_eur")
|
||||||
in TR["en"]["configuration"]["cycle_cost_threshold_eur"]["description"])
|
and "not derived from a measurement"
|
||||||
|
in docs_entry("cycle_cost_threshold_eur"))
|
||||||
|
# DOCS.md's "Settings at a glance" shows the tab's text. It must not drift.
|
||||||
|
_glance = DOCS.split("\n### Settings at a glance\n", 1)[-1].split("\n#", 1)[0]
|
||||||
|
_rows = {}
|
||||||
|
for line in _glance.splitlines():
|
||||||
|
cells = [c.strip() for c in line.strip().strip("|").split("|")]
|
||||||
|
if len(cells) == 4 and cells[2].startswith("`"):
|
||||||
|
_rows[cells[2].strip("`")] = (cells[1], cells[3])
|
||||||
|
_drift = []
|
||||||
|
for key in ORDER:
|
||||||
|
conf = TR["en"]["configuration"][key]
|
||||||
|
want = (conf["name"].split(" · ")[-1], conf["description"])
|
||||||
|
if _rows.get(key) != want:
|
||||||
|
_drift.append(key)
|
||||||
|
check(f"DOCS.md 'Settings at a glance' shows every option's English label and "
|
||||||
|
f"description exactly as the tab does. Drifted: {_drift}", not _drift)
|
||||||
|
|
||||||
print("\n4. task ordering, and the labels that carry it")
|
print("\n4. task ordering, and the labels that carry it")
|
||||||
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced
|
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST - no section headers, no per-option advanced
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ p = validate_startup_config(base(max_w=8000), -5000, 5000)
|
|||||||
check("max_w above the device maximum is refused", len(p) == 1)
|
check("max_w above the device maximum is refused", len(p) == 1)
|
||||||
check("...and the message names the configured value", why(p, "8000"))
|
check("...and the message names the configured value", why(p, "8000"))
|
||||||
check("...and the device's value", why(p, "5000"))
|
check("...and the device's value", why(p, "5000"))
|
||||||
check("...and says which one wins", why(p, "THE DEVICE WINS"))
|
check("...and says what to lower it to", why(p, "Lower the rating to 5000 W"))
|
||||||
check("max_w exactly at the device maximum is fine",
|
check("max_w exactly at the device maximum is fine",
|
||||||
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
|
validate_startup_config(base(max_w=5000), -5000, 5000) == [])
|
||||||
check("max_w under it is fine",
|
check("max_w under it is fine",
|
||||||
@@ -85,7 +85,7 @@ check("max_w under it is fine",
|
|||||||
# The charge side is a separate rail and an asymmetric device is legal.
|
# The charge side is a separate rail and an asymmetric device is legal.
|
||||||
p = validate_startup_config(base(max_w=4000), -2500, 5000)
|
p = validate_startup_config(base(max_w=4000), -2500, 5000)
|
||||||
check("a max_w the device cannot take on the CHARGE side is refused too",
|
check("a max_w the device cannot take on the CHARGE side is refused too",
|
||||||
len(p) == 1 and why(p, "charge side"))
|
len(p) == 1 and why(p, "when charging"))
|
||||||
check("...naming the device's floor", why(p, "-2500"))
|
check("...naming the device's floor", why(p, "-2500"))
|
||||||
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
|
# ⚠️ Not guessed at: the entity may simply not have booted yet. run_control()
|
||||||
# re-runs this the moment the real rating arrives.
|
# re-runs this the moment the real rating arrives.
|
||||||
@@ -122,7 +122,7 @@ check("...and rule 2 REFUSES that whole configuration at startup, which is why "
|
|||||||
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
|
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
|
||||||
check("...on the rating side specifically, not incidentally through some other "
|
check("...on the rating side specifically, not incidentally through some other "
|
||||||
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
|
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
|
||||||
"THE DEVICE WINS"))
|
"Lower the rating to"))
|
||||||
check("...and the case the refusal CANNOT reach - the rating unread because "
|
check("...and the case the refusal CANNOT reach - the rating unread because "
|
||||||
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
|
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
|
||||||
"answer had to be a runtime status row and not a startup gate",
|
"answer had to be a runtime status row and not a startup gate",
|
||||||
@@ -136,9 +136,9 @@ print("2. an actuator to write to")
|
|||||||
for blank in ("", " ", None):
|
for blank in ("", " ", None):
|
||||||
p = validate_startup_config(base(setpoint_entity=blank), None, None)
|
p = validate_startup_config(base(setpoint_entity=blank), None, None)
|
||||||
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
|
check(f"setpoint_entity {blank!r} is refused", len(p) == 1)
|
||||||
check("...and the message says why it is dangerous rather than untidy",
|
check("...and the message says what it costs: nothing is controlled",
|
||||||
why(validate_startup_config(base(setpoint_entity=""), None, None),
|
why(validate_startup_config(base(setpoint_entity=""), None, None),
|
||||||
"heartbeat"))
|
"cannot control the inverter"))
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# specs §1.5: TWO WRITERS ON ONE ACTUATOR, AND WHAT ACTUALLY GRADES IT
|
# specs §1.5: TWO WRITERS ON ONE ACTUATOR, AND WHAT ACTUALLY GRADES IT
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -181,7 +181,7 @@ check("...and the replacement SEES it, because it counts writers per ACTUATOR "
|
|||||||
and len(_census["number.goodwe_setpoint"]) == 3)
|
and len(_census["number.goodwe_setpoint"]) == 3)
|
||||||
_hazard_problems = validate_startup_config(_PER_INVERTER_HAZARD, -5000, 5000)
|
_hazard_problems = validate_startup_config(_PER_INVERTER_HAZARD, -5000, 5000)
|
||||||
check("AC 3: and the config is REFUSED, where today it starts",
|
check("AC 3: and the config is REFUSED, where today it starts",
|
||||||
why(_hazard_problems, "one actuator"))
|
why(_hazard_problems, "point at the same inverter entity"))
|
||||||
check("...and the refusal names the actuator and every writer aimed at it, so "
|
check("...and the refusal names the actuator and every writer aimed at it, so "
|
||||||
"an operator can see WHICH declarations to remove rather than being told "
|
"an operator can see WHICH declarations to remove rather than being told "
|
||||||
"only that something is wrong",
|
"only that something is wrong",
|
||||||
@@ -243,7 +243,8 @@ check("...and entity ids are folded to lower case, so a second writer cannot "
|
|||||||
"be smuggled past the census on Number.X versus number.x",
|
"be smuggled past the census on Number.X versus number.x",
|
||||||
why(validate_startup_config(base(inverters=[{"setpoint_entity":
|
why(validate_startup_config(base(inverters=[{"setpoint_entity":
|
||||||
"NUMBER.GOODWE_SETPOINT"}]),
|
"NUMBER.GOODWE_SETPOINT"}]),
|
||||||
-5000, 5000), "one actuator"))
|
-5000, 5000),
|
||||||
|
"point at the same inverter entity"))
|
||||||
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
|
# ⚠️ THIS CHECK CHANGED SHAPE IN CFG-04, AND THE REASON IS A DECISION RATHER
|
||||||
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
|
# THAN AN ADJUSTMENT. It used to assert that a blank per-inverter entity left
|
||||||
# the whole config ACCEPTED - "blank is how an operator leaves a slot
|
# the whole config ACCEPTED - "blank is how an operator leaves a slot
|
||||||
@@ -265,7 +266,8 @@ check("...but the ENTRY is refused all the same, by rule 9 and not by the "
|
|||||||
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
|
why(validate_startup_config(base(inverters=[{"name": "inverter-b",
|
||||||
"max_w": 2000,
|
"max_w": 2000,
|
||||||
"setpoint_entity": " "}]),
|
"setpoint_entity": " "}]),
|
||||||
-5000, 5000), "has no setpoint_entity"))
|
-5000, 5000),
|
||||||
|
"has no “Inverter setpoint entity” (setpoint_entity)"))
|
||||||
|
|
||||||
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
|
# --- OPT-09: `capacity_kwh`, which is OPTIONAL but not PARTIAL ---------------
|
||||||
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
|
# ⚠️ RULE 9 GAINED TWO CLAUSES AND BOTH REFUSE A CONFIG THAT WOULD OTHERWISE
|
||||||
@@ -303,9 +305,9 @@ check("⚠️ AND A PARTIAL ROSTER IS REFUSED - stated on one entry and left emp
|
|||||||
"weighing a dimensionless 1.0 is not a comparison, so the key would "
|
"weighing a dimensionless 1.0 is not a comparison, so the key would "
|
||||||
"silently change nothing at all",
|
"silently change nothing at all",
|
||||||
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
why(validate_startup_config(_with_cap(15.36, None), -5000, 5000),
|
||||||
"not PARTIAL")
|
"Set it on every inverter, or on none")
|
||||||
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
|
and why(validate_startup_config(_with_cap(None, 20.48), -5000, 5000),
|
||||||
"not PARTIAL"))
|
"Set it on every inverter, or on none"))
|
||||||
check("...and the refusal names WHICH entry is which, because an operator with "
|
check("...and the refusal names WHICH entry is which, because an operator with "
|
||||||
"three inverters told 'one of them is wrong' is sent to the wrong "
|
"three inverters told 'one of them is wrong' is sent to the wrong "
|
||||||
"cupboard",
|
"cupboard",
|
||||||
@@ -339,7 +341,7 @@ check("a phase list disagreeing with meter_phases is refused",
|
|||||||
why(validate_startup_config(base(meter_source="ha_signed",
|
why(validate_startup_config(base(meter_source="ha_signed",
|
||||||
p1_net_entity="sensor.n", meter_phases=3,
|
p1_net_entity="sensor.n", meter_phases=3,
|
||||||
p1_phase_net_entities=["a", "b"]),
|
p1_phase_net_entities=["a", "b"]),
|
||||||
-5000, 5000), "phase-count"))
|
-5000, 5000), "Make them match"))
|
||||||
check("mqtt_p1 with no topic is refused",
|
check("mqtt_p1 with no topic is refused",
|
||||||
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
|
why(validate_startup_config(base(meter_source="mqtt_p1"), -5000, 5000),
|
||||||
"meter_mqtt_topic"))
|
"meter_mqtt_topic"))
|
||||||
@@ -349,7 +351,7 @@ check("ha_dsmr with only half its entities is refused",
|
|||||||
"p1_export_entity"))
|
"p1_export_entity"))
|
||||||
check("a misspelt meter_source is refused rather than silently ignored",
|
check("a misspelt meter_source is refused rather than silently ignored",
|
||||||
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
|
why(validate_startup_config(base(meter_source="ha_signd"), -5000, 5000),
|
||||||
"is not one of"))
|
"Choose one of"))
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
print("4. meter_max_age_s against the firmware's compile-time veto")
|
print("4. meter_max_age_s against the firmware's compile-time veto")
|
||||||
@@ -361,7 +363,8 @@ p = validate_startup_config(base(**hw, meter_max_age_s=120), -5000, 5000)
|
|||||||
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
|
check("a meter_max_age_s above the firmware veto is refused", len(p) == 1)
|
||||||
check("...naming the configured value", why(p, "120"))
|
check("...naming the configured value", why(p, "120"))
|
||||||
check("...naming the firmware's value", why(p, "30"))
|
check("...naming the firmware's value", why(p, "30"))
|
||||||
check("...and saying which one wins", why(p, "THE FIRMWARE WINS"))
|
check("...and saying that the control board stops the inverter first",
|
||||||
|
why(p, "control board stops the inverter after 30 s"))
|
||||||
check("the tested pair 30/30 is accepted",
|
check("the tested pair 30/30 is accepted",
|
||||||
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
|
validate_startup_config(base(**hw, meter_max_age_s=30), -5000, 5000) == [])
|
||||||
check("one second above it is refused",
|
check("one second above it is refused",
|
||||||
@@ -391,7 +394,7 @@ p = validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
|||||||
check("maintenance_charge_w above max_w is refused", len(p) == 1)
|
check("maintenance_charge_w above max_w is refused", len(p) == 1)
|
||||||
check("...and the message names the charge rate", why(p, "2500"))
|
check("...and the message names the charge rate", why(p, "2500"))
|
||||||
check("...and the ceiling", why(p, "2000"))
|
check("...and the ceiling", why(p, "2000"))
|
||||||
check("...and says which one wins", why(p, "THE CEILING WINS"))
|
check("...and says what it blocks", why(p, "monthly recharge cannot run"))
|
||||||
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
|
# ⚠️ Equality is REACHABLE, not a near miss: lo=-2000, hi=-2000 is the single
|
||||||
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
|
# point -2000 W. Refusing it would be exactly the trivia that gets a validator
|
||||||
# deleted, and it is the boundary the rule is written `>` for.
|
# deleted, and it is the boundary the rule is written `>` for.
|
||||||
@@ -407,7 +410,8 @@ check("below it starts",
|
|||||||
check("...and it is refused with maintenance_enabled off, as shipped",
|
check("...and it is refused with maintenance_enabled off, as shipped",
|
||||||
why(validate_startup_config(
|
why(validate_startup_config(
|
||||||
base(max_w=2000, maintenance_charge_w=2500,
|
base(max_w=2000, maintenance_charge_w=2500,
|
||||||
maintenance_enabled=False), -5000, 5000), "THE CEILING WINS"))
|
maintenance_enabled=False), -5000, 5000),
|
||||||
|
"monthly recharge cannot run"))
|
||||||
|
|
||||||
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
|
# ⚠️ MAINT-05: AND AT TWO PACKS IT IS THE WEAKEST CAP, NOT THE FIRST ENTRY'S.
|
||||||
# The comment this replaces in validate.py said, in as many words, that it took
|
# The comment this replaces in validate.py said, in as many words, that it took
|
||||||
@@ -448,11 +452,11 @@ check("⚠️ A CHARGE RATE THE SMALLEST PACK CANNOT TAKE IS REFUSED, even thoug
|
|||||||
"recalibrated, so its cap could not contradict anything; it is now, and "
|
"recalibrated, so its cap could not contradict anything; it is now, and "
|
||||||
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
|
"its cheap-window charge would ask for hi=-3000 against lo=-2000 - the "
|
||||||
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
|
"`lo > hi` contradiction, 0 W, on the pack nobody was watching",
|
||||||
len(_p_two) == 1 and why(_p_two, "max_w (inverter-b) is 2000 W"))
|
len(_p_two) == 1 and why(_p_two, "(max_w) for inverter-b is 2000 W"))
|
||||||
check("...and the message names the pack the number came off, not the first "
|
check("...and the message names the pack the number came off, not the first "
|
||||||
"entry - an operator told to lower a number on the wrong device is being "
|
"entry - an operator told to lower a number on the wrong device is being "
|
||||||
"sent to the wrong device by the message that exists to stop that",
|
"sent to the wrong device by the message that exists to stop that",
|
||||||
not why(_p_two, "max_w (inverter-a)"))
|
not why(_p_two, "(max_w) for inverter-a"))
|
||||||
check("...while the same rate against two packs that can both take it starts, "
|
check("...while the same rate against two packs that can both take it starts, "
|
||||||
"so this is a comparison and not a blanket refusal of rosters",
|
"so this is a comparison and not a blanket refusal of rosters",
|
||||||
[p for p in validate_startup_config(
|
[p for p in validate_startup_config(
|
||||||
@@ -463,7 +467,7 @@ check("...while the same rate against two packs that can both take it starts, "
|
|||||||
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
|
check("...and at a ROSTER OF ONE `min()` over one cap is that cap: the same "
|
||||||
"message, the same boundary, the same refusal the flat config always got",
|
"message, the same boundary, the same refusal the flat config always got",
|
||||||
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
why(validate_startup_config(base(max_w=2000, maintenance_charge_w=2500),
|
||||||
-5000, 5000), "THE CEILING WINS")
|
-5000, 5000), "monthly recharge cannot run")
|
||||||
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
and validate_startup_config(base(max_w=2000, maintenance_charge_w=2000),
|
||||||
-5000, 5000) == [])
|
-5000, 5000) == [])
|
||||||
|
|
||||||
@@ -539,17 +543,15 @@ check("⭐ RESTORING THE 2026-09-05 PAIR - slew_w 1000 at max_w 5000 - IS REFUSE
|
|||||||
"It crosses the whole output range in 10 s against a plant measured at "
|
"It crosses the whole output range in 10 s against a plant measured at "
|
||||||
"18 s, which is not a fast loop, it is a loop that commands its entire "
|
"18 s, which is not a fast loop, it is a loop that commands its entire "
|
||||||
"range before it can observe its answer to the first step",
|
"range before it can observe its answer to the first step",
|
||||||
len(_broken) == 1 and why(_broken, "slew_w is 1000 W"))
|
len(_broken) == 1 and why(_broken, "(slew_w) is 1000 W"))
|
||||||
check("...and the refusal says WHICH RAIL it graded against and HOW LONG the "
|
check("...and the refusal says WHICH RAIL it graded against, because 'slew_w "
|
||||||
"crossing takes, because 'slew_w is too high' sends an operator to lower "
|
"is too high' sends an operator to lower a number when raising max_w or "
|
||||||
"a number when raising max_w or setting 0 are both correct answers too",
|
"setting 0 are both correct answers too",
|
||||||
why(_broken, "site rail of 5000 W")
|
why(_broken, "for a 5000 W site"))
|
||||||
and why(_broken, "crosses its whole output range in 5.0 cycles"))
|
# UX-08 2c: the plant measurement the rule is graded against (the ~18 s step
|
||||||
check("...and it names the plant measurement it is graded against, so the "
|
# response) is in DOCS.md "For installers", beside slew_w, not in the refusal.
|
||||||
"number is traceable to a step response rather than to a preference",
|
|
||||||
why(_broken, "goodwe/NOTES.md §5.10"))
|
|
||||||
check("...and it says what to DO, including the setting that cannot go stale",
|
check("...and it says what to DO, including the setting that cannot go stale",
|
||||||
why(_broken, "Set slew_w to 0") and why(_broken, "556 W"))
|
why(_broken, "Set it to 0") and why(_broken, "556 W"))
|
||||||
|
|
||||||
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
|
# THE BOUNDARY. Equality is the DERIVED VALUE, not a violation - at exactly
|
||||||
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
|
# `SLEW_RAIL_FRAC * rail` the loop takes precisely the plant's settling time to
|
||||||
@@ -600,7 +602,7 @@ check("slew_w 0 - FOLLOW THE RAIL - is never refused at any rating, which it "
|
|||||||
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
|
check("a negative slew_w is refused as nonsense rather than treated as 0 - it "
|
||||||
"is a magnitude, and 0 already has a meaning here",
|
"is a magnitude, and 0 already has a meaning here",
|
||||||
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
|
why(validate_startup_config(dict(_R5000, slew_w=-400), -5000, 5000),
|
||||||
"must not be negative"))
|
"cannot be negative"))
|
||||||
|
|
||||||
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
|
# ⭐ THE ROSTER, WHICH IS THE WHOLE REASON THIS GRADES A SUM. `slew_w` is not a
|
||||||
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
|
# per-unit quantity: main.py builds `Tuning(max_w=self.site_max_w)` and
|
||||||
@@ -649,7 +651,7 @@ check("...so the same explicit slew_w that is REFUSED at a 5000 W rail is "
|
|||||||
check("...and a slew that outruns even the doubled rail is still refused there, "
|
check("...and a slew that outruns even the doubled rail is still refused there, "
|
||||||
"so the roster path is not simply un-graded",
|
"so the roster path is not simply un-graded",
|
||||||
why(validate_startup_config(_roster(slew_w=2000)),
|
why(validate_startup_config(_roster(slew_w=2000)),
|
||||||
"site rail of 9600 W"))
|
"for a 9600 W site"))
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
print("the SHIPPED defaults must pass the rule they are governed by (AC3)")
|
||||||
@@ -777,9 +779,10 @@ print("max_w must be a positive magnitude")
|
|||||||
# sim/inject.py's entry point and can be handed any dict at all.
|
# sim/inject.py's entry point and can be handed any dict at all.
|
||||||
for bad in (0, -1, -2000):
|
for bad in (0, -1, -2000):
|
||||||
p = validate_startup_config(base(max_w=bad), -5000, 5000)
|
p = validate_startup_config(base(max_w=bad), -5000, 5000)
|
||||||
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "must be positive"))
|
check(f"max_w {bad} is refused", len(p) == 1 and why(p, "Set it above 0 W"))
|
||||||
check("...and the message explains it would read as a trip, not a mistake",
|
check("...and the message names the setting by its label and its key",
|
||||||
why(validate_startup_config(base(max_w=0), -5000, 5000), "contradiction"))
|
why(validate_startup_config(base(max_w=0), -5000, 5000),
|
||||||
|
"“Inverter rating” (max_w) is 0 W"))
|
||||||
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
|
# ⚠️ SAFETY-02 changed what this case has to say, and the change is real rather
|
||||||
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
|
# than bookkeeping: with a 1 W ceiling the default 300 W sign-disagreement
|
||||||
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
|
# threshold is unreachable, so rule 5 refuses - correctly, because the watchdog
|
||||||
@@ -792,7 +795,7 @@ check("the smallest sane max_w still starts",
|
|||||||
unit_sign_disagree_w=0.5,
|
unit_sign_disagree_w=0.5,
|
||||||
maintenance_charge_w=1), -5000, 5000) == [])
|
maintenance_charge_w=1), -5000, 5000) == [])
|
||||||
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
check("...but that same 1 W ceiling with the DEFAULT sign threshold is refused",
|
||||||
why(validate_startup_config(base(max_w=1), -5000, 5000), "never fire"))
|
why(validate_startup_config(base(max_w=1), -5000, 5000), "can never trip"))
|
||||||
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
|
# ⚠️ SAFETY-30 ADDED A SECOND WAY TO DISARM A SIGN WATCHDOG BY CONFIGURATION,
|
||||||
# and the fixture above now has to name BOTH thresholds - which is the check
|
# and the fixture above now has to name BOTH thresholds - which is the check
|
||||||
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
|
# doing its job rather than a bookkeeping edit. `unit_sign_disagree_w` bounds a
|
||||||
@@ -1188,9 +1191,9 @@ def _roster_n(caps, **over):
|
|||||||
# The three rules that reduce over `unit_caps_indexed()`, each as
|
# The three rules that reduce over `unit_caps_indexed()`, each as
|
||||||
# (option name, the token its refusal is recognised by).
|
# (option name, the token its refusal is recognised by).
|
||||||
_CAP_RULES = (
|
_CAP_RULES = (
|
||||||
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
|
("sign_disagree_w", "(sign_disagree_w) is"),
|
||||||
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
|
("unit_sign_disagree_w", "(unit_sign_disagree_w) is"),
|
||||||
("maintenance_charge_w", "maintenance charge in a cheap window"),
|
("maintenance_charge_w", "monthly recharge cannot run"),
|
||||||
)
|
)
|
||||||
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
|
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
|
||||||
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
|
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
|
||||||
@@ -1220,7 +1223,7 @@ 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"max_w \(([^)]+)\) is (\d+) W", ps[0])
|
m = re.search(r"\(max_w\) for (\S+) is (\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)
|
||||||
|
|
||||||
|
|
||||||
@@ -1330,7 +1333,7 @@ check("...and at a ROSTER OF ONE every candidate that still has a member to "
|
|||||||
and {n for n, v in _at_one.items() if v is None}
|
and {n for n, v in _at_one.items() if v is None}
|
||||||
== {"min(caps[1:])", "min(caps[:-1])"}
|
== {"min(caps[1:])", "min(caps[:-1])"}
|
||||||
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
|
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
|
||||||
"SAFETY-02 sign-disagreement")[0])
|
"(sign_disagree_w) is")[0])
|
||||||
|
|
||||||
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
|
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
|
||||||
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
|
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
|
||||||
@@ -1342,7 +1345,7 @@ check("...and at a ROSTER OF ONE every candidate that still has a member to "
|
|||||||
_leftover = _roster_n([_SMALL, _SMALL],
|
_leftover = _roster_n([_SMALL, _SMALL],
|
||||||
**dict(_QUIET, unit_sign_disagree_w=_RATE))
|
**dict(_QUIET, unit_sign_disagree_w=_RATE))
|
||||||
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
|
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
|
||||||
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
|
_lv = _verdict(_leftover, "(unit_sign_disagree_w) is")
|
||||||
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
|
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
|
||||||
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
|
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
|
||||||
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
|
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
|
||||||
@@ -1358,7 +1361,7 @@ check("...and the over-refusal in the OTHER direction is gone too: a roster of "
|
|||||||
"reach. It starts",
|
"reach. It starts",
|
||||||
_verdict(_roster_n([_BIG, _BIG],
|
_verdict(_roster_n([_BIG, _BIG],
|
||||||
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
|
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
|
||||||
"SAFETY-30 per-inverter sign-disagreement")
|
"(unit_sign_disagree_w) is")
|
||||||
== (False, None, None))
|
== (False, None, None))
|
||||||
check("...and the flat single-inverter config it always refused is refused "
|
check("...and the flat single-inverter config it always refused is refused "
|
||||||
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
|
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
|
||||||
@@ -1411,7 +1414,7 @@ print(f" {(_T17_P_GAP or [''])[0][:120]}")
|
|||||||
check("a table that leaves 00:00-07:00 unpriced is REFUSED, by the same tiling "
|
check("a table that leaves 00:00-07:00 unpriced is REFUSED, by the same tiling "
|
||||||
"rule static_profile has - and the refusal names the KEY and the HOURS, "
|
"rule static_profile has - and the refusal names the KEY and the HOURS, "
|
||||||
"not merely that something was wrong",
|
"not merely that something was wrong",
|
||||||
why(_T17_P_GAP, "price_taxes_profile cannot be used:")
|
why(_T17_P_GAP, "“Day/night levies and VAT” cannot be used.")
|
||||||
and why(_T17_P_GAP, "leaves mon 00:00-07:00 with no period"))
|
and why(_T17_P_GAP, "leaves mon 00:00-07:00 with no period"))
|
||||||
check("...and it is refused for THAT reason and not as a side effect of "
|
check("...and it is refused for THAT reason and not as a side effect of "
|
||||||
"something else this fixture happens to break: exactly one problem",
|
"something else this fixture happens to break: exactly one problem",
|
||||||
@@ -1427,9 +1430,9 @@ print(f" {(_T17_P_BOTH or [''])[0][:120]}")
|
|||||||
check("the table AND a non-default price_taxes_eur_kwh is refused, and the "
|
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 AND price_taxes_eur_kwh is "
|
why(_T17_P_BOTH, "(price_taxes_profile) is set, so")
|
||||||
"0.1198")
|
and why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads "
|
||||||
and why(_T17_P_BOTH, "THE TABLE WINS"))
|
"0.1198"))
|
||||||
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 "
|
||||||
@@ -1479,19 +1482,17 @@ 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_profile is set AND price_taxes_eur_kwh is "
|
why(_T17_P_BOTH, "(price_taxes_eur_kwh) is not used, but it reads "
|
||||||
"0.1198")
|
"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
|
||||||
# both which figure to return to and that the old default is not the problem.
|
# both which figure to return to and that the old default is not the problem.
|
||||||
check("...and the refusal names the SHIPPED default to return to AND says the "
|
# UX-08 2c: that a superseded default is accepted is said in DOCS.md "For
|
||||||
"superseded default is accepted, so the sentence is actionable on both "
|
# installers" now. The refusal keeps the one figure the operator acts on.
|
||||||
"sides of the upgrade",
|
check("...and the refusal names the SHIPPED default to return to",
|
||||||
bool(_T18_SUPERSEDED)
|
bool(_T18_SUPERSEDED)
|
||||||
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}")
|
and why(_T17_P_BOTH, f"back to {_T18_SHIPPED_TAXES}"))
|
||||||
and why(_T17_P_BOTH, "SUPERSEDED")
|
|
||||||
and all(why(_T17_P_BOTH, str(u)) for u in _T18_SUPERSEDED))
|
|
||||||
check("...and the same non-default scalar WITHOUT the table still starts, "
|
check("...and the same non-default scalar WITHOUT the table still starts, "
|
||||||
"which is what makes the line above about the PAIR rather than about "
|
"which is what makes the line above about the PAIR rather than about "
|
||||||
"0.1198 being disliked",
|
"0.1198 being disliked",
|
||||||
@@ -1531,18 +1532,18 @@ print(f" ORES : {(_T17_P_ORES or [''])[0][:110]}")
|
|||||||
print(f" Sibelga : {(_T17_P_SIB or [''])[0][:110]}")
|
print(f" Sibelga : {(_T17_P_SIB or [''])[0][:110]}")
|
||||||
check("ORES's card typed in CENTS is refused by the PARSER's bound, and the "
|
check("ORES's card typed in CENTS is refused by the PARSER's bound, and the "
|
||||||
"sentence is the parser's - `beyond +-10.0`, naming the row",
|
"sentence is the parser's - `beyond +-10.0`, naming the row",
|
||||||
why(_T17_P_ORES, "price_taxes_profile cannot be used:")
|
why(_T17_P_ORES, "“Day/night levies and VAT” cannot be used.")
|
||||||
and why(_T17_P_ORES, "beyond +-10.0"))
|
and why(_T17_P_ORES, "beyond +-10.0"))
|
||||||
check("...and SIBELGA's whole card - 9,39 and 7,10, BOTH under that bound - is "
|
check("...and SIBELGA's whole card - 9,39 and 7,10, BOTH under that bound - is "
|
||||||
"refused by THIS rung instead, naming both bands and the factor. Without "
|
"refused by THIS rung instead, naming both bands and the factor. Without "
|
||||||
"it a Brussels site would price a 9.39 EUR/kWh levy and start",
|
"it a Brussels site would price a 9.39 EUR/kWh levy and start",
|
||||||
why(_T17_P_SIB, "price_taxes_profile carries [7.1, 9.39] EUR/kWh")
|
why(_T17_P_SIB, "(price_taxes_profile) has [7.1, 9.39] EUR/kWh")
|
||||||
and why(_T17_P_SIB, "factor of 100"))
|
and why(_T17_P_SIB, "Divide the figures by 100"))
|
||||||
check("...and the two refusals are DIFFERENT sentences, so the rungs cannot "
|
check("...and the two refusals are DIFFERENT sentences, so the rungs cannot "
|
||||||
"mask each other: deleting the units rung leaves the Sibelga line red "
|
"mask each other: deleting the units rung leaves the Sibelga line red "
|
||||||
"while the ORES line goes on passing for the parser's reason",
|
"while the ORES line goes on passing for the parser's reason",
|
||||||
_T17_P_ORES != _T17_P_SIB
|
_T17_P_ORES != _T17_P_SIB
|
||||||
and not why(_T17_P_ORES, "factor of 100")
|
and not why(_T17_P_ORES, "Divide the figures by 100")
|
||||||
and not why(_T17_P_SIB, "beyond +-10.0"))
|
and not why(_T17_P_SIB, "beyond +-10.0"))
|
||||||
check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
|
check("...and BOTH cards divided by 100 start, which is habit 7: the refusals "
|
||||||
"above are about the units and not about Brussels or Wallonia",
|
"above are about the units and not about Brussels or Wallonia",
|
||||||
@@ -1639,13 +1640,13 @@ check("PRECONDITION: each of those two blobs differs from the accepted "
|
|||||||
== _T16_ENECO)
|
== _T16_ENECO)
|
||||||
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
|
check("a factor 100x out is REFUSED at startup, by the pass-through rung, "
|
||||||
"naming the option the operator typed",
|
"naming the option the operator typed",
|
||||||
why(_T16_P_PT, "price_export_formula cannot be used:")
|
why(_T16_P_PT, "“Your own injection formula” cannot be used.")
|
||||||
and why(_T16_P_PT, "passes through")
|
and why(_T16_P_PT, "passes through")
|
||||||
and not why(_T16_P_PT, "|offset| above"))
|
and not why(_T16_P_PT, "larger than"))
|
||||||
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
|
check("...and an offset 100x out is refused by the OFFSET rung, so the two "
|
||||||
"rungs are graded on their reasons and cannot mask each other",
|
"rungs are graded on their reasons and cannot mask each other",
|
||||||
why(_T16_P_OFF, "price_export_formula cannot be used:")
|
why(_T16_P_OFF, "“Your own injection formula” cannot be used.")
|
||||||
and why(_T16_P_OFF, "|offset| above")
|
and why(_T16_P_OFF, "larger than")
|
||||||
and not why(_T16_P_OFF, "passes through"))
|
and not why(_T16_P_OFF, "passes through"))
|
||||||
check("...and a malformed blob is refused as a malformed blob, naming the "
|
check("...and a malformed blob is refused as a malformed blob, naming the "
|
||||||
"field rather than 'invalid configuration'",
|
"field rather than 'invalid configuration'",
|
||||||
@@ -1688,7 +1689,7 @@ 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 reproduce its own card"))
|
"price_export_formula does not match its own card"))
|
||||||
|
|
||||||
# ⚠️ 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"
|
||||||
@@ -1736,7 +1737,7 @@ for _v in _S10_BAD:
|
|||||||
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
|
_p = validate_startup_config(base(shadow_mode=_v), -5000, 5000)
|
||||||
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
|
check(f"shadow_mode={_v!r} is REFUSED, and the refusal SPELLS the key so "
|
||||||
"configui.problems_by_field() can file it under the right control",
|
"configui.problems_by_field() can file it under the right control",
|
||||||
why(_p, "shadow_mode is") and why(_p, "'auto'")
|
why(_p, "(shadow_mode) is") and why(_p, "'auto'")
|
||||||
and why(_p, "'on'") and why(_p, "'off'"))
|
and why(_p, "'on'") and why(_p, "'off'"))
|
||||||
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
|
for _v in ("auto", "on", "off", "AUTO", " on ", "Auto "):
|
||||||
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "
|
check(f"shadow_mode={_v!r} starts - the three values and the tolerant "
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ r5 = run_gate(addon3, root3, "0.7.0", opts_file(bad))
|
|||||||
check("refuses a config the validator rejects", r5.returncode == 1)
|
check("refuses a config the validator rejects", r5.returncode == 1)
|
||||||
check("...and warns it is exit 78", "78" in r5.stdout)
|
check("...and warns it is exit 78", "78" in r5.stdout)
|
||||||
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
|
check("...and warns the ingress panel goes too", "ingress panel" in r5.stdout)
|
||||||
check("...and prints the validator's own words", "slew_w is 1000 W" in r5.stdout)
|
check("...and prints the validator's own words", "(slew_w) is 1000 W" in r5.stdout)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
|
print("⭐ HISTORY WITH MORE THAN ONE BUMP - THE FIXTURE THE FIRST DRAFT LACKED")
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 300},
|
|||||||
check("300 W under a 2000 W ceiling is accepted", p == [])
|
check("300 W under a 2000 W ceiling is accepted", p == [])
|
||||||
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
|
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 2000},
|
||||||
-5000, 5000)
|
-5000, 5000)
|
||||||
check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
|
check("a threshold AT max_w is refused", len(p) == 1 and "can never trip" in p[0])
|
||||||
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
|
# ⚠️ maintenance_charge_w is named here only to keep this case about the SIGN
|
||||||
# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W
|
# rule. MAINT-04 added a second rule against the same ceiling, and a 500 W
|
||||||
# max_w is also below the 2000 W shipped maintenance charge - so without this
|
# max_w is also below the 2000 W shipped maintenance charge - so without this
|
||||||
@@ -162,7 +162,7 @@ check("a threshold AT max_w is refused", len(p) == 1 and "never fire" in p[0])
|
|||||||
# `len(p) == 1` below would be measuring the interaction rather than this rule.
|
# `len(p) == 1` below would be measuring the interaction rather than this rule.
|
||||||
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000,
|
p = validate_startup_config({**base, "max_w": 500, "sign_disagree_w": 3000,
|
||||||
"maintenance_charge_w": 500}, -5000, 5000)
|
"maintenance_charge_w": 500}, -5000, 5000)
|
||||||
check("a threshold above max_w is refused", len(p) == 1 and "never fire" in p[0])
|
check("a threshold above max_w is refused", len(p) == 1 and "can never trip" in p[0])
|
||||||
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
|
p = validate_startup_config({**base, "max_w": 2000, "sign_disagree_w": 1999},
|
||||||
-5000, 5000)
|
-5000, 5000)
|
||||||
check("one watt under the ceiling is reachable, so accepted", p == [])
|
check("one watt under the ceiling is reachable, so accepted", p == [])
|
||||||
|
|||||||
@@ -34,269 +34,267 @@
|
|||||||
# is approximated by ORDER plus the `Group · ` prefix in each label. Do not
|
# is approximated by ORDER plus the `Group · ` prefix in each label. Do not
|
||||||
# promise the operator visual sections; there are none.
|
# promise the operator visual sections; there are none.
|
||||||
#
|
#
|
||||||
# ⚠️ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM. The Configuration tab
|
# Names and descriptions follow STYLE.md (UX-08): a short label with its unit,
|
||||||
# presents a number read off 1890 closed-loop test runs and the operator's own
|
# and one or two plain sentences. A measured or derived value says "leave it
|
||||||
# inverter rating as the same kind of field. The descriptions are the only place
|
# unchanged", and where it came from is in DOCS.md under "For installers:
|
||||||
# that difference can be stated, so a derived value names its campaign and says
|
# where the numbers come from".
|
||||||
# "do not change without re-deriving", and a value that is genuinely the
|
|
||||||
# operator's says so too.
|
|
||||||
configuration:
|
configuration:
|
||||||
setpoint_entity:
|
setpoint_entity:
|
||||||
name: "Hardware · Inverter setpoint entity"
|
name: "Hardware · Inverter setpoint entity"
|
||||||
description: "The ESPHome number entity this add-on writes the power setpoint to, normally number.*_goodwe_setpoint_w. This is the only thing the add-on commands. Leave it empty if you fill in the inverter list below: a site with a roster must not also name one loose actuator, and the add-on refuses to start when both are set."
|
description: "The ESPHome number entity the add-on writes the inverter's power to. Leave it empty when you fill in the inverter list."
|
||||||
soc_entity:
|
soc_entity:
|
||||||
name: "Hardware · Battery level entity (%)"
|
name: "Hardware · Battery level entity (%)"
|
||||||
description: "The sensor that reports the pack's state of charge in percent. Use the ESP32's own reading, not a figure computed somewhere else. The maintenance cycle and every charge-level floor read this."
|
description: "The sensor with the battery level in percent. Use the control board's own reading."
|
||||||
batt_entity:
|
batt_entity:
|
||||||
name: "Hardware · Battery power entity (W)"
|
name: "Hardware · Battery power entity (W)"
|
||||||
description: "The sensor that reports this inverter's own battery power, positive while discharging. On this installation that is holding register 0x0518, read by the ESP32. The sign watchdog and the circulating-power alarm both grade the inverter against this reading."
|
description: "The sensor with this inverter's battery power. Positive must mean the battery is giving power."
|
||||||
batt_invert:
|
batt_invert:
|
||||||
name: "Hardware · Battery power sign is reversed"
|
name: "Hardware · Battery power sign is reversed"
|
||||||
description: "Only switch this on if your battery power sensor reports discharging as a negative number. The add-on expects positive to mean discharging."
|
description: "Switch this on only if your battery power sensor shows giving power as a negative number."
|
||||||
inverters:
|
inverters:
|
||||||
name: "Hardware · Inverter list (leave empty if you have one inverter)"
|
name: "Hardware · Inverter list"
|
||||||
description: "Leave this empty unless the site really has more than one inverter. EMPTY DOES NOT MEAN 'not configured yet': it means the four entity settings above, together with the inverter rating and the battery cut-out under Limits, describe the one inverter you have. That is what every existing installation reads, and nobody has to touch anything. Filling this in REPLACES those settings instead of extending them, and nothing is inherited: each entry has to name its own actuator, its own rating and its own battery cut-out, so that no inverter is ever judged against another unit's numbers. The flat setpoint entity above must then be left empty."
|
description: "Leave it empty if you have one inverter. With two or more, add one entry per inverter, which replaces the inverter settings above."
|
||||||
fields:
|
fields:
|
||||||
name:
|
name:
|
||||||
name: "Name"
|
name: "Name"
|
||||||
description: "A short identifier for this inverter, for example inverter-a. It gives this unit its own namespace for its safety latch, heartbeat and write bookkeeping in the add-on's state file, so two units may not share one."
|
description: "A short, unique name for this inverter, for example inverter-a."
|
||||||
setpoint_entity:
|
setpoint_entity:
|
||||||
name: "Setpoint entity"
|
name: "Setpoint entity"
|
||||||
description: "This inverter's own ESPHome number.*_goodwe_setpoint_w. Two entries may not name the same one - two writers on one actuator is refused at startup."
|
description: "This inverter's own ESPHome setpoint entity. Two entries may not use the same one."
|
||||||
max_w:
|
max_w:
|
||||||
name: "Inverter rating (W)"
|
name: "Inverter rating (W)"
|
||||||
description: "The supervised ceiling on what may be written to THIS inverter; the site's ceiling is the sum over the list. Required of every entry: an entry without one would have to borrow a rating, and judging one inverter against another unit's numbers is exactly the fault this per-entry list exists to remove."
|
description: "The most power the add-on may ask of this inverter. The site total is the sum of the list."
|
||||||
bms_soc_floor:
|
bms_soc_floor:
|
||||||
name: "Battery cut-out level (%)"
|
name: "Battery cut-out level (%)"
|
||||||
description: "The percent at which THIS pack's own battery management system cuts out. Read it off the pack that is actually installed (register 0x0560) before that pack is ever commanded; do not copy another unit's figure. Required of every entry, deliberately: an optional one would be a defaulted one, and one default for two batteries of different models protects the shallower pack with the deeper pack's number."
|
description: "The level at which this battery's own management system stops giving power. Read it off the battery that is installed."
|
||||||
soc_entity:
|
soc_entity:
|
||||||
name: "Battery level entity (%) (optional)"
|
name: "Battery level entity (%) (optional)"
|
||||||
description: "This pack's own state of charge. Leave it empty if this unit reports none - that is what it means, and it never falls back to another unit's sensor."
|
description: "This battery's own level sensor. Leave it empty if this inverter has none."
|
||||||
batt_entity:
|
batt_entity:
|
||||||
name: "Battery power entity (W) (optional)"
|
name: "Battery power entity (W) (optional)"
|
||||||
description: "This unit's own battery power, positive while discharging. Leave it empty if this unit reports none; nothing is inherited from another entry."
|
description: "This inverter's own battery power sensor. Leave it empty if it has none."
|
||||||
batt_invert:
|
batt_invert:
|
||||||
name: "Battery power sign is reversed (optional)"
|
name: "Battery power sign is reversed (optional)"
|
||||||
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
|
description: "Switch this on if this inverter's sensor shows giving power as a negative number."
|
||||||
capacity_kwh:
|
capacity_kwh:
|
||||||
name: "Usable battery capacity (kWh) (optional)"
|
name: "Usable battery capacity (kWh) (optional)"
|
||||||
description: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or its label. It is read for one purpose: dividing the site's target between the inverters by how much ENERGY each pack has left rather than by how many watts it is rated for. The two packs at the reference site are 5000 W with 15.36 kWh and 4600 W with 20.48 kWh, so those two ways of dividing the work hand it to different batteries, and the energy one is the question worth asking. Optional: leave it empty and the add-on has no opinion, dividing the target evenly exactly as it did before. State it on EVERY inverter or on none of them - a pack measured in kWh next to one that is not is not a comparison, and the add-on refuses to start rather than ignore half of what you filled in."
|
description: "This battery's usable capacity from its datasheet. Fill it in on every inverter or on none, and the work is then shared by energy left."
|
||||||
estop_fitted:
|
estop_fitted:
|
||||||
name: "Hardware · RS485 emergency stop is fitted"
|
name: "Hardware · RS485 emergency stop is fitted"
|
||||||
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
|
description: "Switch this on if the emergency stop is installed. It only changes a warning on the status page."
|
||||||
pv_entity:
|
pv_entity:
|
||||||
name: "Hardware · Solar production entity (W)"
|
name: "Hardware · Solar production entity (W)"
|
||||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Point it at your solar inverter's own power sensor (any brand Home Assistant reads; it does not have to be on the RS485 bus) and the add-on records production beside the grid and battery figures, so a day of unstored surplus can be told apart from a day with no sun. POSITIVE MUST MEAN PRODUCING - if yours reads negative while producing, list it under 'sign is reversed' below. The unit is read from the entity itself - W, kW and the rest of Home Assistant's power units - so a sensor with no unit is refused rather than guessed at, and the status page says so. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
description: "Optional. Your solar inverter's power sensor, positive while producing. The add-on records it and does not use it for control."
|
||||||
pv_entities:
|
pv_entities:
|
||||||
name: "Hardware · Solar production entities, per inverter (W)"
|
name: "Hardware · Solar production entities, per inverter (W)"
|
||||||
description: "Optional, and MEASUREMENT ONLY - it changes no control behaviour whatsoever. Use this instead of the single entity above when your roof has SEVERAL solar inverters: add one row per inverter, pointing at each one's own power sensor, and the add-on records the SITE total. Each entity's own unit is read, so a kW inverter beside a W one adds up correctly. ⚠️ SETTING THIS IGNORES THE SINGLE ENTITY ABOVE - clear that one when you fill this in, or you will see a caution on the status page saying so. ⚠️ AND IF ANY ONE OF THESE STOPS REPORTING THERE IS NO SITE TOTAL: the sensor goes to 'unknown' and the history records a gap, rather than publishing the sum of the ones that did answer. Two inverters out of three add up to a smaller number that looks exactly like a real one, and a week later that reads as 'there was less sun than there was' - which is the one conclusion this recording exists to test. The status page says which one is missing, by its place in this list. Leave it empty and nothing is read, nothing is published and nothing is shown."
|
description: "Optional. Use one row per solar inverter instead of “Solar production entity” when you have several. If one stops reporting, no total is recorded."
|
||||||
pv_invert_entities:
|
pv_invert_entities:
|
||||||
name: "Hardware · Reversed-sign solar entities"
|
name: "Hardware · Reversed-sign solar entities"
|
||||||
description: "Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a power clamp in the PV circuit - read NEGATIVE while producing and a few watts POSITIVE at night. Add each such entity here, spelled exactly as it is above, and its sign is flipped before it is added up; leave the others out. The status page marks every flipped entity. History recorded before you set this keeps the old sign."
|
description: "Optional. Add each solar entity above that reads negative while producing, spelled the same way. Its sign is flipped before adding up."
|
||||||
meter_source:
|
meter_source:
|
||||||
name: "Meter · Where the meter reading comes from"
|
name: "Meter · Where the meter reading comes from"
|
||||||
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
|
description: "How the add-on reads your meter. 'off' uses “Grid power entity” below, and only homewizard_local can spot a stuck meter."
|
||||||
meter_entity:
|
meter_entity:
|
||||||
name: "Meter · Grid power entity (W)"
|
name: "Meter · Grid power entity (W)"
|
||||||
description: "The net grid power sensor, used when the meter source is 'off'. POSITIVE MUST MEAN DRAWING FROM THE GRID. Get this the wrong way round and the loop drives the battery in the wrong direction; use the sign switch below rather than a template sensor."
|
description: "Used when the meter source is 'off'. Positive must mean drawing from the grid."
|
||||||
meter_invert:
|
meter_invert:
|
||||||
name: "Meter · Grid power sign is reversed"
|
name: "Meter · Grid power sign is reversed"
|
||||||
description: "Switch this on if your grid sensor reports drawing from the grid as a negative number."
|
description: "Switch this on if your grid sensor shows drawing from the grid as a negative number."
|
||||||
meter_phases:
|
meter_phases:
|
||||||
name: "Meter · Number of phases (1 or 3)"
|
name: "Meter · Number of phases (1 or 3)"
|
||||||
description: "Must match your connection. A telegram whose phase count disagrees is rejected as it arrives and logged, so a wrong value shows up straight away as 'no telegrams accepted' rather than as a quietly wrong number. The range accepts 2, but that is not a real Belgian connection."
|
description: "Must match your grid connection. Meter readings with a different number of phases are rejected."
|
||||||
p1_host:
|
p1_host:
|
||||||
name: "Meter · HomeWizard P1 address"
|
name: "Meter · HomeWizard P1 address"
|
||||||
description: "For meter source homewizard_local only: the meter's own address on your network, as host or host:port, for example 192.168.2.250."
|
description: "For homewizard_local only. The meter's address on your network, as host or host:port."
|
||||||
meter_poll_s:
|
meter_poll_s:
|
||||||
name: "Meter · Seconds between meter polls"
|
name: "Meter · Time between meter reads (s)"
|
||||||
description: "For meter source homewizard_local only. 5 s is the meter's own update rate, so there is nothing to gain below it. It must stay well under the staleness limit below - the reading is never fresher than this interval - and the add-on refuses to start if it is at or above that limit."
|
description: "For homewizard_local only. The meter updates every 5 s, so lower gains nothing. Keep it well below “Reading too old after”."
|
||||||
meter_max_age_s:
|
meter_max_age_s:
|
||||||
name: "Meter · A reading counts as stale after (s)"
|
name: "Meter · Reading too old after (s)"
|
||||||
description: "Beyond this the grid figure counts as missing. On its own it does not command 0 W: the 'inputs missing' timer under Advanced starts only after this one has expired, and the two stack. 30 s is six polls of headroom at the default poll interval."
|
description: "After this a meter reading counts as missing. The “Missing inputs time limit” starts only after this one runs out."
|
||||||
meter_mqtt_topic:
|
meter_mqtt_topic:
|
||||||
name: "Meter · MQTT topic"
|
name: "Meter · MQTT topic"
|
||||||
description: "For meter source mqtt_p1 only: the topic the P1 readings are published on."
|
description: "For mqtt_p1 only. The topic your meter readings are published on."
|
||||||
p1_import_entity:
|
p1_import_entity:
|
||||||
name: "Meter · Import register entity (W)"
|
name: "Meter · Grid import entity (W)"
|
||||||
description: "The UNSIGNED consumption register. Do not point this at a signed template sensor: the add-on works out net power itself as import minus export, so the sign convention lives in tested code rather than in unreviewed YAML."
|
description: "The sensor for power drawn from the grid, never negative. The add-on works out the net value itself."
|
||||||
p1_export_entity:
|
p1_export_entity:
|
||||||
name: "Meter · Export register entity (W)"
|
name: "Meter · Grid export entity (W)"
|
||||||
description: "The UNSIGNED injection register, the counterpart of the import register above. Same rule: no signed template sensor."
|
description: "The sensor for power fed into the grid, never negative. It pairs with “Grid import entity”."
|
||||||
p1_net_entity:
|
p1_net_entity:
|
||||||
name: "Meter · Signed net power entity (W)"
|
name: "Meter · Signed net power entity (W)"
|
||||||
description: "For meter source ha_signed only. ONE signed sensor: positive is drawn from the grid, negative is fed back into it. Do not split it into two template sensors - the add-on does that split itself, so that it is tested."
|
description: "For ha_signed only. One sensor that is positive when drawing from the grid and negative when feeding in."
|
||||||
p1_phase_import_entities:
|
p1_phase_import_entities:
|
||||||
name: "Meter · Per-phase import entities (L1, L2, L3)"
|
name: "Meter · Import entities per phase (L1-L3)"
|
||||||
description: "Optional, in L1..L3 order. Needed for the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases above."
|
description: "Optional, in L1, L2, L3 order. Needed for the peak limit on a three-phase connection, one entry per phase."
|
||||||
p1_phase_export_entities:
|
p1_phase_export_entities:
|
||||||
name: "Meter · Per-phase export entities (L1, L2, L3)"
|
name: "Meter · Export entities per phase (L1-L3)"
|
||||||
description: "Optional, in L1..L3 order, alongside the per-phase import entities above. The number of entries must equal the number of phases."
|
description: "Optional, in L1, L2, L3 order, next to the import entities per phase. One entry per phase."
|
||||||
p1_phase_net_entities:
|
p1_phase_net_entities:
|
||||||
name: "Meter · Per-phase signed net entities (L1, L2, L3)"
|
name: "Meter · Net entities per phase (L1-L3)"
|
||||||
description: "For meter source ha_signed only. Optional, in L1..L3 order, each signed the same way as the net entity above. Same job as the per-phase import and export entities: the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases."
|
description: "For ha_signed only. Optional, in L1, L2, L3 order, signed like “Signed net power entity”, one entry per phase."
|
||||||
max_w:
|
max_w:
|
||||||
name: "Limits · Inverter rating (W)"
|
name: "Limits · Inverter rating (W)"
|
||||||
description: "THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may ever be commanded, in watts. Set it to what your inverter is actually rated for, start low and raise it once the site has been commissioned. It is not read at all when the inverter list is filled in - each entry then carries its own, and the site ceiling is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not."
|
description: "Your own number: the most power the add-on may ever ask of the inverter. Start low and raise it after commissioning."
|
||||||
bms_soc_floor:
|
bms_soc_floor:
|
||||||
name: "Limits · Battery cut-out level (%)"
|
name: "Limits · Battery cut-out level (%)"
|
||||||
description: "MEASURED, not chosen: the percent at which the pack's OWN battery management system stops discharging, whatever this add-on is set to. Register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure. It is here so the add-on can refuse a capacity-guard floor at or below it: such a floor could never bind, the BMS would win first, and the status page would report an energy backstop that does not exist. Not read when the inverter list is filled in - each entry then states its own pack's cut-out."
|
description: "The level where the battery's own management system stops giving power. Read it off your battery rather than copying a figure."
|
||||||
peak_cap_w:
|
peak_cap_w:
|
||||||
name: "Limits · Capacity-tariff target (W)"
|
name: "Limits · Peak limit (W)"
|
||||||
description: "THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tries to stay under. Take it from your grid contract - in Belgium it is the capacity-tariff peak your distribution charge is billed on."
|
description: "Your own number: the 15-minute average power the add-on keeps the grid under. Take it from your grid contract."
|
||||||
peak_forecast_entity:
|
peak_forecast_entity:
|
||||||
name: "Limits · Quarter-hour demand forecast entity (W)"
|
name: "Limits · 15-minute demand forecast entity (W)"
|
||||||
description: "Optional. A forecast of the current billing quarter's average demand. Leave it empty and there is no capacity cap at all."
|
description: "Optional. A forecast of this 15-minute period's average power. Leave it empty and there is no peak limit."
|
||||||
peak_soc_floor:
|
peak_soc_floor:
|
||||||
name: "Limits · Peak reserve (%)"
|
name: "Limits · Peak reserve (%)"
|
||||||
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
|
description: "Peak limiting will not draw the battery below this level, but ordinary use can. Derived value, leave it unchanged."
|
||||||
price_provider:
|
price_provider:
|
||||||
name: "Tariff · Dynamic price source"
|
name: "Tariff · Dynamic price source"
|
||||||
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH - not the trading switch further down. With a source selected the add-on fetches the feed every half hour, checks it, records the day and shows what it holds on the Price history row, whether or not price trading is armed. On its own it still moves no energy: trading is the separate switch, and the cheap window the monthly maintenance cycle uses comes from the fixed or day/night tariff below. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed. ⚠️ THREE IDS IN THIS LIST ARE WITHDRAWN AND WILL NOT START: engie_dynamic, totalenergies_dynamic and eneco_eddy. Their endpoints do not exist and never did, so those plugins never once fetched a price - a site set to one of them has silently been running on the fixed or day/night tariff below, or on nothing, since the day it was set. They are still offered here only so that such a site gets that explanation at startup instead of an unreadable schema error. Choose generic_dayahead for the wholesale curve with a flat markup, custom_formula to enter your own contract's B x index + A coefficients off your tariff card, manual_csv for a file you write yourself, or off."
|
description: "Where prices come from. Choosing a source starts the price fetch, even with trading off."
|
||||||
price_markup_eur_kwh:
|
price_markup_eur_kwh:
|
||||||
name: "Tariff · Supplier markup (EUR/kWh)"
|
name: "Tariff · Supplier markup (EUR/kWh)"
|
||||||
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under manual_csv, whose figures are all-in, it is ignored, and logged as ignored, never applied twice."
|
description: "Your supplier's margin. It is added only when the price source does not already include it."
|
||||||
price_taxes_eur_kwh:
|
price_taxes_eur_kwh:
|
||||||
name: "Tariff · Distribution, levies and VAT (EUR/kWh)"
|
name: "Tariff · Distribution, levies and VAT (EUR/kWh)"
|
||||||
description: "Everything on your bill that is not the energy itself - distribution, levies, excise and VAT - per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them. ⚠️ THIS NUMBER IS NOT A PREFERENCE AND IT IS NOT YOUR INVERTER'S. It ships at 0.11736, DERIVED in TARIFF-18 from the Eneco residential card for Flanders of September 2026 including 6% VAT: Fluvius distribution for Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. The energiebijdrage is 0 since 1 August 2026 - it was merged into the excise, so a figure copied from an older guide would count it twice. ⚠️ CHECK IT AGAINST YOUR OWN DSO. Only the distribution row moves: across the eight Fluvius DSOs it runs from 5,28 c/kWh in Midden-Vlaanderen to 6,70 in West-Vlaanderen, so the Flemish total runs from about 0.117 to 0.131. In Wallonia or Brussels the distribution tariff has a separate day and night rate and does not belong in this field at all - use the levies-by-time-of-day option below instead. ⚠️ CHANGING IT MOVES NO TRADING DECISION: the same amount is added to every hour, so it cancels out of both the cheapest hour and the spread. What it does move is every price you are shown, and the rule that refuses to discharge into an hour whose all-in price is below zero."
|
description: "Grid costs, levies and VAT per kWh, preset for Flanders. Check it against your own grid operator's rates."
|
||||||
export_price_eur_kwh:
|
export_price_eur_kwh:
|
||||||
name: "Tariff · Injection price (EUR/kWh)"
|
name: "Tariff · Injection price (EUR/kWh)"
|
||||||
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
|
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 (one line of JSON)"
|
name: "Tariff · Fixed or day/night tariff (JSON)"
|
||||||
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
|
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."
|
||||||
price_taxes_profile:
|
price_taxes_profile:
|
||||||
name: "Tariff · Levies and VAT by time of day (one line of JSON)"
|
name: "Tariff · Day/night levies and VAT (JSON)"
|
||||||
description: "Leave this empty unless your network tariff really does charge a different day and night rate - in Flanders it does not, Fluvius distribution is one rate, and the flat figure above is correct. In Wallonia and Brussels it does: ORES charges 13,27 c/kWh by day against 7,39 at night, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10. Written as a single line of JSON in exactly the same shape as the fixed or day/night tariff above, with the same rule that the periods must cover every day type once with no gap and no overlap. ⚠️ THE NUMBERS HERE ARE THE LEVIES ALONE, NOT A FULL PRICE - the add-on adds them to whatever your price source publishes, exactly as it adds the flat figure. Why it matters: a flat figure is the same in every hour, so it moves every hour equally and cannot change which hour is cheapest - and a night network rate is cheap in the same hours the market is cheap, so flattening it makes the add-on think the day is flatter than it is and turn down battery cycles that would have paid for themselves. Set this or the flat figure above, not both: the add-on refuses to start if the two disagree."
|
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 (one line of JSON)"
|
name: "Tariff · Your own tariff formula (JSON)"
|
||||||
description: "For price source custom_formula only: your own contract's two coefficients, written as a single line of JSON. Your supplier's tariff card prints the formula as B x index + A - those two numbers exist only on your card, there is no public index of them anywhere, and you are the only person who can read them. Type what the card prints, IN THE UNITS THE CARD PRINTS: every unit conversion and the VAT step are the add-on's job, because those are the two places a human silently loses a factor of 10, 100 or 1.06. Example: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ THE card BLOCK IS REQUIRED AND IT IS WHAT MAKES THIS SAFE: your card also prints the reference index it assumed and the price that comes out, so the add-on recomputes that price from your numbers and REFUSES TO START if it does not come out, telling you by how much. That catches a wrong unit, VAT left out or counted twice, a transposed digit, and the expensive one - network charges folded into the offset, which the add-on would then bill you for a second time. ⚠️ AND ITS LIMIT, IN THE SAME BREATH: it checks your coefficients against your card, not your card against reality, so two numbers copied off the wrong product agree with each other and pass. Fixed numbers only - no expression of any kind is evaluated, and an unrecognised field is refused rather than ignored."
|
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."
|
||||||
price_export_formula:
|
price_export_formula:
|
||||||
name: "Tariff · Your own INJECTION formula (one line of JSON)"
|
name: "Tariff · Your own injection formula (JSON)"
|
||||||
description: "Optional, and only if your supplier pays you a price that moves with the market. Your tariff card has a SECOND formula for what you are paid for the electricity you export, and it is not the first one with a minus in front: Eneco prints `0,1 x BELPEX-H - 1,188` c/kWh for injection against `0,102 x ...` for consumption, and Aspiravi `0,07 x Belpex - 2`. Written in exactly the same shape as your own tariff formula above. ⚠️ INJECTION IS EXEMPT FROM VAT in Belgium while consumption is at 6%, so vat_pct here is normally 0 - that is the one field people copy across from the other formula and get wrong. ⚠️ THE PRICE THIS GIVES CAN BE NEGATIVE, and that is not an error: Eneco pays below zero whenever BELPEX-H is under 11,88 EUR/MWh, which is the middle of a sunny day - you can be charged to export. The add-on accepts that rather than hiding it. The card block is OPTIONAL here and required above, because Eneco prints the injection formula with NO example price beside it and there would be nothing to check your numbers against; if your card does print one, fill it in and the add-on will refuse to start unless your numbers reproduce it. ⚠️ WHAT THIS DOES NOT DO: no part of the add-on trades on an injection price today - it charges cheap to avoid buying dear, and both sides of that comparison are consumption prices. This is what you are shown, and what a future export strategy would read. Leave it empty and the flat injection price above is used, which is what every installation does now."
|
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"
|
||||||
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
|
description: "For manual_csv only. Where in /share the add-on reads your price file."
|
||||||
price_feed_url:
|
price_feed_url:
|
||||||
name: "Tariff · Price feed URL override"
|
name: "Tariff · Price feed URL override"
|
||||||
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere. WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN: the add-on adds nothing to it, not even the date range it normally asks its own feed for, so an override gets whatever horizon that URL returns - often a single day, with the rest of the plan carried over from yesterday and excluded from price arbitrage. A warning in the log says so on every price refresh. Clear this option to get the full horizon back."
|
description: "Leave it empty to use the source's own feed. An override is sent exactly as typed and often returns only one day."
|
||||||
price_horizon_h:
|
price_horizon_h:
|
||||||
name: "Tariff · How far ahead to fetch prices (hours)"
|
name: "Tariff · Price look-ahead (hours)"
|
||||||
description: "How many hours of prices to ask the source for."
|
description: "How many hours of prices to ask the source for."
|
||||||
price_timeout_s:
|
price_timeout_s:
|
||||||
name: "Tariff · Price request timeout (s)"
|
name: "Tariff · Price request timeout (s)"
|
||||||
description: "How long a single request to the price source may take before it is given up on."
|
description: "How long one request to the price source may take before it is given up."
|
||||||
price_now_entity:
|
price_now_entity:
|
||||||
name: "Tariff · Current price entity (older price path)"
|
name: "Tariff · Current price entity (older)"
|
||||||
description: "The older way of getting a price, kept because retiring it would take the maintenance cycle's only price signal away from a live installation. The add-on reads this and the average below and derives exactly one thing from them - whether now is cheaper than average - which only the maintenance cycle's cheap-window charging uses. It may not be combined with a dynamic price source."
|
description: "The older way to get a price, used only to charge in a cheap window during maintenance. Do not combine it with a dynamic price source."
|
||||||
price_avg_entity:
|
price_avg_entity:
|
||||||
name: "Tariff · Average price entity (older price path)"
|
name: "Tariff · Average price entity (older)"
|
||||||
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
|
description: "The average the current price is compared with. Use it only with the current price entity, not with a dynamic price source."
|
||||||
arbitrage_enabled:
|
arbitrage_enabled:
|
||||||
name: "Tariff · Trade on the price spread"
|
name: "Tariff · Trade on the price spread"
|
||||||
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. ⚠️ LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF. A price source selected above is fetched, checked and recorded either way; this switch decides only whether the add-on ACTS on those prices. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
|
description: "Charge in the cheapest hour and draw the battery down in the dearest, when the spread covers the cost. Turning it off does not turn the price feed off."
|
||||||
cycle_cost_threshold_eur:
|
cycle_cost_threshold_eur:
|
||||||
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
|
name: "Tariff · Battery cycle cost (EUR/kWh)"
|
||||||
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
|
description: "What one full battery cycle costs per kWh. A smaller price spread is not traded, and the value is a product choice, not a measured one."
|
||||||
arbitrage_soc_floor:
|
arbitrage_soc_floor:
|
||||||
name: "Tariff · Trading may not discharge below (%)"
|
name: "Tariff · Trading keeps at least (%)"
|
||||||
description: "The reserve price trading may not spend. Deliberately not the same key as the maintenance floor: pointing a trading strategy at the monthly balancing cycle's numbers would couple two unrelated policies through one setting."
|
description: "Price trading will not draw the battery below this level. It is separate from the maintenance settings."
|
||||||
arbitrage_soc_ceiling:
|
arbitrage_soc_ceiling:
|
||||||
name: "Tariff · Trading may not charge above (%)"
|
name: "Tariff · Trading charges at most (%)"
|
||||||
description: "The headroom price trading may not fill. The counterpart of the trading floor above, and kept separate from the maintenance target for the same reason."
|
description: "Price trading will not charge the battery above this level."
|
||||||
maintenance_enabled:
|
maintenance_enabled:
|
||||||
name: "Maintenance · Run the monthly battery cycle"
|
name: "Maintenance · Run the monthly battery cycle"
|
||||||
description: "A deliberate deep discharge followed by a full recharge and a hold, so the battery management system can recalibrate its charge estimate and balance the cells. Off by default."
|
description: "Once a month, empty the battery deeply, charge it full and hold it there, so its level reading stays accurate. Off by default."
|
||||||
maintenance_interval_days:
|
maintenance_interval_days:
|
||||||
name: "Maintenance · Minimum days between cycles"
|
name: "Maintenance · Minimum days between cycles"
|
||||||
description: "The shortest gap between two maintenance cycles."
|
description: "The shortest gap between two maintenance cycles."
|
||||||
maintenance_start_hour:
|
maintenance_start_hour:
|
||||||
name: "Maintenance · Hour of day a due cycle starts"
|
name: "Maintenance · Start hour"
|
||||||
description: "Local hour, 0 to 23, at which a cycle that has become due begins."
|
description: "The local hour, 0 to 23, at which a due cycle starts."
|
||||||
maintenance_discharge_w:
|
maintenance_discharge_w:
|
||||||
name: "Maintenance · Discharge rate (W)"
|
name: "Maintenance · Draw-down rate (W)"
|
||||||
description: "How fast the cycle drains the pack; the surplus is exported. Above the inverter rating it is CLAMPED to the rating rather than refused - the cycle still completes, just more slowly, and the status page says so. It ships at 2000 to match the shipped rating; raise the rating first, then this."
|
description: "How fast the cycle empties the battery. Above “Inverter rating” it is cut back to the rating, and the cycle just takes longer."
|
||||||
maintenance_charge_w:
|
maintenance_charge_w:
|
||||||
name: "Maintenance · Recharge rate (W)"
|
name: "Maintenance · Recharge rate (W)"
|
||||||
description: "⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses to start otherwise. Above it, the cheap-window charge shaping asks for more than the supervised limit allows, the arbiter resolves that contradiction to 0 W, and maintenance would silently never charge in a cheap window on a default installation. It is also capped again by whatever headroom the peak guard leaves."
|
description: "How fast the cycle recharges the battery. It must be at or below “Inverter rating”, or the add-on will not start."
|
||||||
maintenance_soc_floor:
|
maintenance_soc_floor:
|
||||||
name: "Maintenance · Discharge down to (%)"
|
name: "Maintenance · Draw down to (%)"
|
||||||
description: "The depth the monthly recalibration deliberately goes to, once a month, on purpose. ⚠️ THIS IS NOT THE PEAK GUARD'S FLOOR: that one is the depth a money-saving discharge may never reach on any day. The right values genuinely differ - 11 here against 20 there - which is why they are two separate settings and must not be merged."
|
description: "The level the monthly cycle empties the battery to. It is not the “Peak reserve”, and the two differ on purpose."
|
||||||
maintenance_soc_target:
|
maintenance_soc_target:
|
||||||
name: "Maintenance · Recharge up to (%)"
|
name: "Maintenance · Recharge up to (%)"
|
||||||
description: "The level the recharge phase aims for before the hold begins."
|
description: "The level the recharge aims for before the hold starts."
|
||||||
maintenance_hold_min:
|
maintenance_hold_min:
|
||||||
name: "Maintenance · Hold at full for (minutes)"
|
name: "Maintenance · Hold at full for (minutes)"
|
||||||
description: "How long to sit at the target so the battery management system can finish balancing the cells."
|
description: "How long the battery stays full so its cells can balance."
|
||||||
sign_disagree_w:
|
sign_disagree_w:
|
||||||
name: "Safety · Sign watchdog threshold (W) — measured, do not change"
|
name: "Safety · Wrong-direction threshold (W)"
|
||||||
description: "MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and the grid reading must exceed this before their opposite signs count as a disagreement at all. On the captured healthy day (2026-08-23) sign agreement over the gated samples is 93.4 %; on the captured fault day (2026-08-20) the clusters run 0.0-10.5 % with opposed runs up to 1752 s. There is no reason to move this without a new capture that says so. It must stay below the inverter rating or the watchdog can never fire, and the add-on refuses that configuration."
|
description: "A wrong direction counts only when the command and the meter reading both exceed this. Measured value, leave it unchanged."
|
||||||
sign_disagree_s:
|
sign_disagree_s:
|
||||||
name: "Safety · Sign watchdog hold (s) — measured, do not change"
|
name: "Safety · Wrong-direction time (s)"
|
||||||
description: "MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter must stay opposed, continuously, before the add-on commands 0 W and latches until an operator starts control again. 180 s sits in the middle of a very wide measured gap: the longest legitimate opposed run on the healthy day is 30 s, while the fault day still trips 13 times at 300 s. Do not change it without a new capture."
|
description: "How long a wrong direction may last before a safety stop sets every inverter to 0 W. Measured value, leave it unchanged."
|
||||||
unit_sign_disagree_w:
|
unit_sign_disagree_w:
|
||||||
name: "Safety · Per-inverter sign threshold (W) — derived, do not change"
|
name: "Safety · Per-inverter wrong-direction threshold (W)"
|
||||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothing at all. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS on the two-master rig (SAFETY-30), in which nothing was faulty, swept across the whole range this add-on accepts for loop gain and slew rate. 250 W is exactly half the saturation threshold, so the smallest divergence this can see is precisely the gap at which the control law itself already says the inverter is not following. Do not change it without re-deriving it. It must stay below the inverter rating or it can never fire."
|
description: "For two or more inverters: the wrong-direction threshold for each inverter. Derived value, leave it unchanged."
|
||||||
unit_sign_disagree_s:
|
unit_sign_disagree_s:
|
||||||
name: "Safety · Per-inverter sign hold (s) — derived, do not change"
|
name: "Safety · Per-inverter wrong-direction time (s)"
|
||||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs produce (10.0 s), 2.8 times the firmware's own 30 s link veto, and under half the 180 s the site-wide check takes. Do not change it without re-deriving it."
|
description: "For two or more inverters: how long one inverter may push the wrong way before it is set to 0 W. Derived value, leave it unchanged."
|
||||||
site_sign_disagree_w:
|
site_sign_disagree_w:
|
||||||
name: "Safety · Site-wide sign threshold (W) — measured, do not change"
|
name: "Safety · Site wrong-direction threshold (W)"
|
||||||
description: "TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter threshold, CARRIED ACROSS DELIBERATELY (SAFETY-30): the argument behind it does not depend on how many inverters there are - near zero the sign is noise, the deadband is 15 W, and there is one meter whatever is behind it. Do not change it without a capture that says so."
|
description: "For two or more inverters: the wrong-direction threshold for the whole site. Measured value, leave it unchanged."
|
||||||
site_sign_disagree_s:
|
site_sign_disagree_s:
|
||||||
name: "Safety · Site-wide sign hold (s) — derived, do not change"
|
name: "Safety · Site wrong-direction time (s)"
|
||||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30), and deliberately NOT carried across from the single-inverter 180 s. On those runs the worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, because the command has to travel the whole site rail at a slew rate that is not the site's - so carrying 180 s across would have shipped a false trip reachable at the lowest gain and slew with an ordinary 2.2 kW load reversal. 325 s is 1.51 times that, still inside the 900 s billing quarter, and still inside every episode of the captured fault day. Do not change it without re-deriving it."
|
description: "For two or more inverters: how long the whole site may push the wrong way before a safety stop. Derived value, leave it unchanged."
|
||||||
meter_freeze_s:
|
meter_freeze_s:
|
||||||
name: "Safety · Frozen-meter alarm after (s) — measured, do not change"
|
name: "Safety · Stuck meter alarm after (s)"
|
||||||
description: "MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answering with an UNCHANGED reading, while the command stands at the rail, before the add-on raises an alarm. Over 2399 s of this house at its settled operating point - 1194 polls, 735 of them repeating the previous reading - the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that and still inside the 900 s billing quarter. ALARM ONLY: it does not command 0 W and does not latch. ⚠️ Only the homewizard_local meter source can see this at all; on every other source the status page says NOT COVERED rather than armed. Do not change it without re-deriving it."
|
description: "An alarm when the meter repeats one reading this long while the command is at full power, with homewizard_local only. Measured value, leave it unchanged."
|
||||||
circulation_w:
|
circulation_w:
|
||||||
name: "Safety · Circulating-power threshold (W) — measured, do not change"
|
name: "Safety · Opposing inverters threshold (W)"
|
||||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identically zero. Two inverters pushing against each other net out to zero at the meter and are invisible to every other guard in this add-on. MEASURED ON 924 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-31): the worst legitimate opposition collapses from 505 s at a 100 W threshold to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below - under it, ordinary operation holds the sensor over the threshold for minutes and nothing shorter than the billing quarter could tell that from a fault. 700 W is 1.75 times the knee, and the extra margin is there because the second inverter's ramp and lag are still copied from the first one rather than measured. Do not change it without re-deriving it."
|
description: "For two or more inverters: how much power one may push against another before it counts. Measured value, leave it unchanged."
|
||||||
circulation_hold_s:
|
circulation_hold_s:
|
||||||
name: "Safety · Circulating-power hold (s) — derived, do not change"
|
name: "Safety · Opposing inverters time (s)"
|
||||||
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
|
description: "For two or more inverters: how long they may push against each other before a safety stop. Derived value, leave it unchanged."
|
||||||
delivery_hold_s:
|
delivery_hold_s:
|
||||||
name: "Safety · Not-delivering report after (s) — derived, do not change"
|
name: "Safety · Not delivering alarm after (s)"
|
||||||
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus a one-inverter arm as the control, which reports nothing at any threshold): the worst legitimate episode those runs produce is 35.0 s, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 5.29 times that. ⚠️ The schema lets you set this as low as 30 s, which is BELOW that measured 35.0 s - at 30 s you should expect reports on legitimate saturation. The second inverter in those tests is a COPY OF THE FIRST, because the second inverter does not exist yet: what a unit four times slower would produce (75.0 s) is a sensitivity study on that idea, not a measurement of any real second inverter. Do not change it without re-deriving it - and it can be re-derived now: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
|
description: "For two or more inverters: how long one may fall short of its share before an alarm, which changes nothing. Derived value, leave it unchanged."
|
||||||
shadow_mode:
|
shadow_mode:
|
||||||
name: "Safety · Shadow mode — run every decision, command nothing"
|
name: "Safety · Shadow mode"
|
||||||
description: "Runs the whole decision path — strategies, arbiter, clamps and guards — and sends NOTHING to the inverter. Every inverter is held at 0 W, refreshed on the ordinary heartbeat, and the status page says plainly that the battery is not being controlled; the decision it WOULD have commanded, and why, is on the status page and in the action log. This is how you watch a site for a day before letting it trade. 'auto' (the shipped value) means shadow mode while control is stopped and ordinary control once you press Start — so a NEW install observes before it ever commands anything, and an install that is already controlling is not changed by one watt. 'on' means shadow mode always, whatever the Start / stop button says: that is the setting to pick when you want the loop and the strategies running on a live site with nothing reaching the battery. 'off' is ordinary control. ⚠️ While this is on the battery is NOT being regulated — the grid is not held at zero, no arbitrage trade is executed and no capacity peak is shaved. ⚠️ A failsafe latch that was already up stays up: shadow mode does not clear it, and the banner names it."
|
description: "The add-on makes every decision but keeps every inverter at 0 W. 'auto' does this while control is stopped, 'on' always, 'off' never."
|
||||||
auto_start:
|
auto_start:
|
||||||
name: "Advanced · Start controlling automatically on boot"
|
name: "Advanced · Start control on boot"
|
||||||
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
|
description: "Off by default. Leave it off until the site is commissioned, so someone presses Start after each restart."
|
||||||
gain:
|
gain:
|
||||||
name: "Advanced · Loop gain"
|
name: "Advanced · Control gain"
|
||||||
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
|
description: "How much of the remaining error control corrects each cycle. Measured on real hardware, do not raise it."
|
||||||
slew_w:
|
slew_w:
|
||||||
name: "Advanced · Largest change per cycle (W)"
|
name: "Advanced · Largest change per cycle (W)"
|
||||||
description: "How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW THE INVERTER RATING and is the recommended setting: the limit becomes about a ninth of the rating, so the loop takes the plant's own MEASURED settling time to cross its whole output range and the number cannot be left behind when the rating changes. DERIVED (SAFETY-39) from an ~18 s measured step response against a 2 s meter tick. A fixed number is accepted and is never silently reduced, but a number faster than the rating allows is refused at startup - at 1000 W against a 5000 W rating this loop drove the meter from 3.4 kW of export to 2.8 kW of import on 2026-09-05. Lower is always accepted."
|
description: "How far the command may move in one cycle. 0 follows “Inverter rating” and is recommended."
|
||||||
deadband_w:
|
deadband_w:
|
||||||
name: "Advanced · Deadband (W)"
|
name: "Advanced · Ignore errors below (W)"
|
||||||
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
|
description: "Errors smaller than this are ignored. Keep it small, since any resting point inside it holds."
|
||||||
target_grid_w:
|
target_grid_w:
|
||||||
name: "Advanced · Resting point at the meter (W)"
|
name: "Advanced · Meter resting point (W)"
|
||||||
description: "What the meter should sit at when nothing is happening; negative means a slight export. -10 W is deliberate: resting at 0 W leaks about half the deadband in billed import all day - roughly 0.2 to 0.35 kWh at a 15 W deadband - and biasing the rest point below zero moves that leak into the export register, which is not billed. It costs about this many watts of given-away export, so keep it small."
|
description: "What the meter should show when nothing happens. A slight export avoids paying for tiny imports all day."
|
||||||
step_w:
|
step_w:
|
||||||
name: "Advanced · Setpoint quantisation (W)"
|
name: "Advanced · Command rounding (W)"
|
||||||
description: "The setpoint is rounded to a multiple of this before it is written, so the inverter is not handed a new number for every watt of noise."
|
description: "The command is rounded to a multiple of this, so noise does not change it every cycle."
|
||||||
saturation_w:
|
saturation_w:
|
||||||
name: "Advanced · Saturation threshold (W)"
|
name: "Advanced · Inverter at limit gap (W)"
|
||||||
description: "The gap between what was commanded and what the inverter delivers that counts as 'this inverter is at a limit'. ⚠️ The per-inverter sign threshold under Safety is derived as exactly half of this, so moving this moves that measurement's argument with it."
|
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 · Saturated cycles before freezing (count)"
|
name: "Advanced · Cycles at limit before freezing"
|
||||||
description: "How many consecutive saturated cycles before the loop stops winding up. ⚠️ Do not set this to 1: a single noisy cycle would then freeze the loop."
|
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 · Integrator bound (W, 0 means the same as the rating)"
|
name: "Advanced · Error build-up limit (W)"
|
||||||
description: "Caps how much stale error can be waiting to unwind when the sign flips. MEASURED on the closed-loop simulator with a 4000 W load dropped to zero: at a bound equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating it is 1800 W. ⚠️ Do not raise it above the inverter rating - the output clamp already bounds what reaches the wire, so extra headroom buys nothing but wrong-direction watts after every saturation event. Lowering it below the rating is the useful direction, and the only reason this is a separate setting."
|
description: "Limits how much old error control can carry, and 0 means “Inverter rating”. Do not raise it above the rating."
|
||||||
heartbeat_s:
|
heartbeat_s:
|
||||||
name: "Advanced · Setpoint refresh interval (s)"
|
name: "Advanced · Command refresh interval (s)"
|
||||||
description: "How often the setpoint is rewritten even when it has not changed. It must stay well under the firmware's own watchdog, which vetoes a link that has been quiet for 30 s."
|
description: "How often the command is sent again even when unchanged. Keep it well under the control board's own 30 s limit."
|
||||||
stale_input_s:
|
stale_input_s:
|
||||||
name: "Advanced · Command 0 W after inputs missing for (s)"
|
name: "Advanced · Missing inputs time limit (s)"
|
||||||
description: "How long the inputs may be missing before the add-on commands 0 W. With a P1 meter this clock starts only AFTER the meter staleness limit has already expired, so the two stack: the total is longer than this number on its own."
|
description: "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”."
|
||||||
cycle_floor_s:
|
cycle_floor_s:
|
||||||
name: "Advanced · Longest gap between control cycles (s)"
|
name: "Advanced · Longest time between cycles (s)"
|
||||||
description: "A deadline on the gap between cycles, reset by every cycle. On a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than about 10 s, and keep it under the meter staleness limit - past that there is no reading left to evaluate anyway."
|
description: "A deadline between control cycles that a healthy meter never reaches. Raise it only for a meter slower than about 10 s."
|
||||||
log_level:
|
log_level:
|
||||||
name: "Advanced · Log level"
|
name: "Advanced · Log level"
|
||||||
description: "How much detail the add-on writes to its log. 'info' is the shipped setting; 'debug' and 'trace' are for diagnosing a problem and are noisy."
|
description: "How much detail the add-on writes to its log. 'debug' and 'trace' are for finding a problem."
|
||||||
|
|||||||
@@ -16,267 +16,266 @@
|
|||||||
# volgorde van het `schema:`-blok van config.yaml; de volgorde hieronder is
|
# volgorde van het `schema:`-blok van config.yaml; de volgorde hieronder is
|
||||||
# alleen een spiegel daarvan, zodat beide bestanden gelijk lezen.
|
# alleen een spiegel daarvan, zodat beide bestanden gelijk lezen.
|
||||||
#
|
#
|
||||||
# ⚠️ AFGELEIDE CONSTANTEN VERMELDEN WAAR ZE VANDAAN KOMEN. Het tabblad toont een
|
# Namen en beschrijvingen volgen STYLE.md (UX-08): een kort label met de
|
||||||
# getal dat uit 1890 gesloten-lustests komt en het omvormervermogen van de
|
# eenheid, en een of twee eenvoudige zinnen. Waar een gemeten of afgeleide
|
||||||
# eigenaar als hetzelfde soort veld. De beschrijving is de enige plek waar dat
|
# waarde vandaan komt, staat in DOCS.md onder "For installers".
|
||||||
# verschil gezegd kan worden.
|
|
||||||
configuration:
|
configuration:
|
||||||
setpoint_entity:
|
setpoint_entity:
|
||||||
name: "Hardware · Setpoint-entiteit van de omvormer"
|
name: "Hardware · Setpoint-entiteit van de omvormer"
|
||||||
description: "De ESPHome number-entiteit waar deze add-on het vermogen naartoe schrijft, meestal number.*_goodwe_setpoint_w. Dit is het enige wat de add-on aanstuurt. Laat leeg als u hieronder de lijst met omvormers invult: een installatie met een lijst mag niet óók één losse aansturing hebben, en de add-on start dan niet op."
|
description: "De ESPHome-entiteit waar de add-on het vermogen van de omvormer naartoe schrijft. Laat dit leeg als u de lijst met omvormers invult."
|
||||||
soc_entity:
|
soc_entity:
|
||||||
name: "Hardware · Entiteit met het laadniveau van de batterij (%)"
|
name: "Hardware · Entiteit laadniveau (%)"
|
||||||
description: "De sensor die de laadtoestand van de batterij in procent doorgeeft. Gebruik de meting van de ESP32 zelf, geen waarde die elders berekend wordt. De onderhoudscyclus en alle ondergrenzen voor het laadniveau lezen deze sensor."
|
description: "De sensor met het laadniveau van de batterij in procent. Gebruik de meting van de stuurprint zelf."
|
||||||
batt_entity:
|
batt_entity:
|
||||||
name: "Hardware · Entiteit met het batterijvermogen (W)"
|
name: "Hardware · Entiteit batterijvermogen (W)"
|
||||||
description: "De sensor die het batterijvermogen van deze omvormer doorgeeft, positief bij ontladen. Op deze installatie is dat holding register 0x0518, uitgelezen door de ESP32. Zowel de tekenbewaking als het alarm voor rondpompend vermogen beoordelen de omvormer aan de hand van deze meting."
|
description: "De sensor met het batterijvermogen van deze omvormer. Positief moet betekenen dat de batterij stroom levert."
|
||||||
batt_invert:
|
batt_invert:
|
||||||
name: "Hardware · Teken van het batterijvermogen omkeren"
|
name: "Hardware · Teken batterijvermogen omkeren"
|
||||||
description: "Zet dit alleen aan als uw sensor ontladen als een negatief getal doorgeeft. De add-on gaat ervan uit dat positief ontladen betekent."
|
description: "Zet dit alleen aan als uw sensor het leveren van stroom als een negatief getal toont."
|
||||||
inverters:
|
inverters:
|
||||||
name: "Hardware · Lijst met omvormers (leeg laten bij één omvormer)"
|
name: "Hardware · Lijst met omvormers"
|
||||||
description: "Laat dit leeg tenzij er echt meer dan één omvormer staat. LEEG BETEKENT NIET 'nog niet ingesteld': het betekent dat de vier entiteiten hierboven, samen met het omvormervermogen en de uitschakelgrens van de batterij onder Grenzen, de ene omvormer beschrijven die u hebt. Dat is wat elke bestaande installatie leest, zonder dat iemand iets hoeft aan te passen. Vult u de lijst wel in, dan VERVANGT die de instellingen hierboven in plaats van ze aan te vullen, en wordt er niets overgenomen: elk item moet zijn eigen aansturing, zijn eigen vermogen en zijn eigen uitschakelgrens vermelden, zodat geen enkele omvormer ooit beoordeeld wordt op de cijfers van een andere. De losse setpoint-entiteit hierboven moet dan leeg blijven."
|
description: "Laat dit leeg als u één omvormer hebt. Hebt u er meer, voeg dan per omvormer een item toe. De lijst vervangt dan de instellingen hierboven."
|
||||||
fields:
|
fields:
|
||||||
name:
|
name:
|
||||||
name: "Naam"
|
name: "Naam"
|
||||||
description: "Een korte aanduiding voor deze omvormer, bijvoorbeeld inverter-a. Ze geeft deze omvormer een eigen naamruimte voor haar veiligheidsvergrendeling, hartslag en schrijfadministratie in het statusbestand van de add-on, dus twee omvormers mogen er niet één delen."
|
description: "Een korte, unieke naam voor deze omvormer, bijvoorbeeld inverter-a."
|
||||||
setpoint_entity:
|
setpoint_entity:
|
||||||
name: "Setpoint-entiteit"
|
name: "Setpoint-entiteit"
|
||||||
description: "De eigen ESPHome-entiteit number.*_goodwe_setpoint_w van deze omvormer. Twee items mogen niet dezelfde entiteit noemen - twee schrijvers op één aansturing wordt bij het opstarten geweigerd."
|
description: "De eigen ESPHome-setpoint-entiteit van deze omvormer. Twee items mogen niet dezelfde gebruiken."
|
||||||
max_w:
|
max_w:
|
||||||
name: "Vermogen van deze omvormer (W)"
|
name: "Vermogen van deze omvormer (W)"
|
||||||
description: "De bewaakte bovengrens van wat er naar DEZE omvormer geschreven mag worden; het plafond van de installatie is de som over de lijst. Verplicht bij elk item: een item zonder deze waarde zou een vermogen moeten lenen, en een omvormer beoordelen op de cijfers van een andere is precies de fout die deze lijst per omvormer moet wegnemen."
|
description: "Het hoogste vermogen dat de add-on aan deze omvormer mag vragen. Het totaal van de installatie is de som van de lijst."
|
||||||
bms_soc_floor:
|
bms_soc_floor:
|
||||||
name: "Uitschakelniveau van de batterij (%)"
|
name: "Uitschakelniveau van de batterij (%)"
|
||||||
description: "Het percentage waarbij het batterijmanagementsysteem van DEZE batterij uitschakelt. Lees dit af van de batterij die er werkelijk staat (register 0x0560) voordat die batterij ooit aangestuurd wordt; neem geen waarde van een andere omvormer over. Bewust verplicht bij elk item: optioneel maken zou neerkomen op een standaardwaarde, en één standaardwaarde voor twee verschillende batterijen beschermt de kleinste batterij met het getal van de grootste."
|
description: "Het niveau waarop het beheersysteem van deze batterij stopt met leveren. Lees het af van de batterij die er staat."
|
||||||
soc_entity:
|
soc_entity:
|
||||||
name: "Entiteit met het laadniveau (%) (optioneel)"
|
name: "Entiteit laadniveau (%) (optioneel)"
|
||||||
description: "Het laadniveau van deze batterij zelf. Laat leeg als deze omvormer dat niet doorgeeft - dat is wat het betekent, en er wordt nooit teruggevallen op de sensor van een andere omvormer."
|
description: "De eigen sensor voor het laadniveau van deze batterij. Laat leeg als deze omvormer er geen heeft."
|
||||||
batt_entity:
|
batt_entity:
|
||||||
name: "Entiteit met het batterijvermogen (W) (optioneel)"
|
name: "Entiteit batterijvermogen (W) (optioneel)"
|
||||||
description: "Het batterijvermogen van deze omvormer zelf, positief bij ontladen. Laat leeg als deze omvormer dat niet doorgeeft; er wordt niets van een ander item overgenomen."
|
description: "De eigen sensor voor het batterijvermogen van deze omvormer. Laat leeg als die er niet is."
|
||||||
batt_invert:
|
batt_invert:
|
||||||
name: "Teken van het batterijvermogen omkeren (optioneel)"
|
name: "Teken batterijvermogen omkeren (optioneel)"
|
||||||
description: "Alleen voor dit item: zet dit aan als de sensor van deze omvormer ontladen als een negatief getal doorgeeft."
|
description: "Zet dit aan als de sensor van deze omvormer leveren als een negatief getal toont."
|
||||||
capacity_kwh:
|
capacity_kwh:
|
||||||
name: "Bruikbare capaciteit van de batterij (kWh) (optioneel)"
|
name: "Bruikbare capaciteit (kWh) (optioneel)"
|
||||||
description: "De bruikbare capaciteit van DEZE batterij in kilowattuur, van het datablad of het typeplaatje. Ze wordt voor één ding gelezen: het verdelen van de doelwaarde van de installatie over de omvormers volgens hoeveel ENERGIE elke batterij nog heeft, in plaats van volgens het vermogen waarvoor ze gebouwd is. De twee batterijen op de referentiesite zijn 5000 W met 15,36 kWh en 4600 W met 20,48 kWh, dus die twee manieren van verdelen wijzen het werk aan verschillende batterijen toe, en de energieverdeling is de juiste vraag. Optioneel: laat leeg en de add-on heeft geen voorkeur en verdeelt gelijk, precies zoals voordien. Vul ze in bij ELKE omvormer of bij geen enkele - een batterij in kWh naast een batterij zonder waarde is geen vergelijking, en de add-on start liever niet op dan de helft van wat u invulde te negeren."
|
description: "De bruikbare capaciteit van deze batterij, van het datablad. Vul ze bij elke omvormer in of bij geen enkele. Het werk wordt dan verdeeld volgens de energie die over is."
|
||||||
estop_fitted:
|
estop_fitted:
|
||||||
name: "Hardware · RS485-noodstop is geplaatst"
|
name: "Hardware · RS485-noodstop is geplaatst"
|
||||||
description: "Of de RS485-noodstop fysiek geïnstalleerd is. Dit stuurt alleen de waarschuwingsbalk op de statuspagina aan en verandert niets aan de regeling."
|
description: "Zet dit aan als de noodstop geïnstalleerd is. Het verandert alleen een waarschuwing op de statuspagina."
|
||||||
pv_entity:
|
pv_entity:
|
||||||
name: "Hardware · Zonneproductie-entiteit (W)"
|
name: "Hardware · Entiteit zonneproductie (W)"
|
||||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Verwijs naar de vermogenssensor van je omvormer (elk merk dat Home Assistant kan lezen; hij hoeft niet op de RS485-bus te zitten) en de add-on registreert de productie naast de meter- en batterijwaarden, zodat een dag met niet-opgeslagen overschot te onderscheiden is van een dag zonder zon. POSITIEF MOET PRODUCEREN BETEKENEN - leest de uwe negatief tijdens productie, zet hem dan ook bij 'teken omgekeerd' hieronder. De eenheid wordt van de entiteit zelf gelezen - W, kW en de overige vermogenseenheden van Home Assistant - dus een sensor zonder eenheid wordt geweigerd in plaats van geraden, en de statuspagina zegt waarom. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
description: "Optioneel. De vermogenssensor van uw zonne-omvormer, positief tijdens productie. De add-on houdt ze bij en regelt er niet mee."
|
||||||
pv_entities:
|
pv_entities:
|
||||||
name: "Hardware · Zonneproductie-entiteiten, één per omvormer (W)"
|
name: "Hardware · Entiteiten zonneproductie, per omvormer (W)"
|
||||||
description: "Optioneel, en UITSLUITEND EEN METING - het verandert niets aan de regeling. Gebruik dit in plaats van de losse entiteit hierboven wanneer uw dak MEERDERE zonne-omvormers heeft: voeg één rij per omvormer toe, elk verwijzend naar haar eigen vermogenssensor, en de add-on registreert het totaal van de installatie. De eenheid van elke entiteit wordt afzonderlijk gelezen, dus een omvormer in kW naast een in W telt correct op. ⚠️ ALS U DIT INVULT WORDT DE LOSSE ENTITEIT HIERBOVEN GENEGEERD - maak die leeg, anders verschijnt er een waarschuwing op de statuspagina die dat meldt. ⚠️ EN ALS ÉÉN VAN DEZE ENTITEITEN NIETS MEER DOORGEEFT, IS ER GEEN TOTAAL: de sensor gaat naar 'unknown' en de geschiedenis bewaart een gat, in plaats van de som van de entiteiten die wél antwoordden te publiceren. Twee omvormers van de drie tellen op tot een kleiner getal dat precies op een echt getal lijkt, en een week later leest dat als 'er was minder zon dan er was' - net de conclusie die deze registratie moet kunnen testen. De statuspagina zegt welke ontbreekt, met zijn plaats in deze lijst. Laat leeg en er wordt niets gelezen, niets gepubliceerd en niets getoond."
|
description: "Optioneel. Eén rij per zonne-omvormer, in plaats van de losse entiteit hierboven, als u er meerdere hebt. Valt er één weg, dan wordt er geen totaal bijgehouden."
|
||||||
pv_invert_entities:
|
pv_invert_entities:
|
||||||
name: "Hardware · Zonneproductie-entiteiten met omgekeerd teken"
|
name: "Hardware · Zonne-entiteiten met omgekeerd teken"
|
||||||
description: "Optioneel, UITSLUITEND EEN METING. Sommige sensoren - meestal een kWh-meter of een stroomtang in het PV-circuit - lezen NEGATIEF tijdens productie en 's nachts enkele watt POSITIEF. Zet elke zulke entiteit hier, exact gespeld zoals hierboven, en haar teken wordt omgedraaid vóór het optellen; laat de andere weg. De statuspagina markeert elke omgedraaide entiteit. Geschiedenis van vóór deze instelling behoudt het oude teken."
|
description: "Optioneel. Zet hier elke zonne-entiteit van hierboven die negatief leest tijdens productie, precies zo gespeld. Het teken wordt omgedraaid voor het optellen."
|
||||||
meter_source:
|
meter_source:
|
||||||
name: "Meter · Waar de meterstand vandaan komt"
|
name: "Meter · Waar de meterstand vandaan komt"
|
||||||
description: "'off' houdt de losse Home Assistant-entiteit hieronder aan, waar een bestaande installatie op draait tot u iets anders kiest. ha_dsmr luistert mee met de DSMR-integratie; mqtt_p1 leest een MQTT-topic; ha_signed leest één entiteit met teken, wat een HomeWizard P1 publiceert; homewizard_local bevraagt de lokale API van de meter zelf en slaat Home Assistant helemaal over. ⚠️ Alleen homewizard_local kan 'de meter heeft niet geantwoord' onderscheiden van 'de waarde is niet veranderd', en is dus de enige bron waarop het alarm voor een vastgelopen meter iets betekent."
|
description: "Hoe de add-on uw meter uitleest. Met 'off' gebruikt ze de entiteit netvermogen hieronder. Alleen homewizard_local merkt een vastgelopen meter op."
|
||||||
meter_entity:
|
meter_entity:
|
||||||
name: "Meter · Entiteit met het netvermogen (W)"
|
name: "Meter · Entiteit netvermogen (W)"
|
||||||
description: "De sensor met het netto netvermogen, gebruikt wanneer de meterbron 'off' is. POSITIEF MOET AFNAME VAN HET NET BETEKENEN. Staat dit omgekeerd, dan stuurt de regeling de batterij de verkeerde kant op; gebruik daarvoor de tekenschakelaar hieronder en geen template-sensor."
|
description: "Gebruikt als de meterbron 'off' is. Positief moet afname van het net betekenen."
|
||||||
meter_invert:
|
meter_invert:
|
||||||
name: "Meter · Teken van het netvermogen omkeren"
|
name: "Meter · Teken netvermogen omkeren"
|
||||||
description: "Zet dit aan als uw netsensor afname van het net als een negatief getal doorgeeft."
|
description: "Zet dit aan als uw netsensor afname als een negatief getal toont."
|
||||||
meter_phases:
|
meter_phases:
|
||||||
name: "Meter · Aantal fasen (1 of 3)"
|
name: "Meter · Aantal fasen (1 of 3)"
|
||||||
description: "Moet overeenkomen met uw aansluiting. Een telegram met een ander aantal fasen wordt bij binnenkomst geweigerd en gelogd, zodat een verkeerde waarde meteen zichtbaar wordt als 'geen telegrammen aanvaard' in plaats van als een stil verkeerd getal. Het bereik aanvaardt 2, maar dat is geen echte Belgische aansluiting."
|
description: "Moet overeenkomen met uw aansluiting. Meterstanden met een ander aantal fasen worden geweigerd."
|
||||||
p1_host:
|
p1_host:
|
||||||
name: "Meter · Adres van de HomeWizard P1"
|
name: "Meter · Adres van de HomeWizard P1"
|
||||||
description: "Alleen voor meterbron homewizard_local: het adres van de meter zelf op uw netwerk, als host of host:poort, bijvoorbeeld 192.168.2.250."
|
description: "Alleen voor homewizard_local. Het adres van de meter op uw netwerk, als host of host:poort."
|
||||||
meter_poll_s:
|
meter_poll_s:
|
||||||
name: "Meter · Seconden tussen twee uitlezingen"
|
name: "Meter · Tijd tussen twee uitlezingen (s)"
|
||||||
description: "Alleen voor meterbron homewizard_local. 5 s is het tempo waarin de meter zelf ververst, dus lager instellen levert niets op. Deze waarde moet ruim onder de verouderingsgrens hieronder blijven - de meting is nooit verser dan dit interval - en de add-on start niet op als ze eraan gelijk is of erboven ligt."
|
description: "Alleen voor homewizard_local. De meter ververst elke 5 s, lager levert niets op. Houd dit ruim onder de grens voor een te oude meting."
|
||||||
meter_max_age_s:
|
meter_max_age_s:
|
||||||
name: "Meter · Een meting is verouderd na (s)"
|
name: "Meter · Meting te oud na (s)"
|
||||||
description: "Daarna geldt de netwaarde als ontbrekend. Op zichzelf stuurt dit nog geen 0 W aan: de teller 'invoer ontbreekt' onder Geavanceerd begint pas te lopen nadat deze verstreken is, en de twee stapelen. 30 s is zes uitlezingen speling bij het standaard uitleesinterval."
|
description: "Daarna telt een meterstand als ontbrekend. De tijdslimiet voor ontbrekende invoer begint pas daarna te lopen."
|
||||||
meter_mqtt_topic:
|
meter_mqtt_topic:
|
||||||
name: "Meter · MQTT-topic"
|
name: "Meter · MQTT-topic"
|
||||||
description: "Alleen voor meterbron mqtt_p1: het topic waarop de P1-metingen gepubliceerd worden."
|
description: "Alleen voor mqtt_p1. Het topic waarop uw meterstanden verschijnen."
|
||||||
p1_import_entity:
|
p1_import_entity:
|
||||||
name: "Meter · Entiteit van het afnameregister (W)"
|
name: "Meter · Entiteit afname (W)"
|
||||||
description: "Het afnameregister ZONDER teken. Wijs dit niet aan een template-sensor mét teken toe: de add-on berekent het nettovermogen zelf als afname min injectie, zodat de tekenafspraak in geteste code zit en niet in ongecontroleerde YAML."
|
description: "De sensor voor afname van het net, nooit negatief. De add-on berekent het nettovermogen zelf."
|
||||||
p1_export_entity:
|
p1_export_entity:
|
||||||
name: "Meter · Entiteit van het injectieregister (W)"
|
name: "Meter · Entiteit injectie (W)"
|
||||||
description: "Het injectieregister ZONDER teken, de tegenhanger van het afnameregister hierboven. Zelfde regel: geen template-sensor mét teken."
|
description: "De sensor voor injectie in het net, nooit negatief. Hoort bij de entiteit afname hierboven."
|
||||||
p1_net_entity:
|
p1_net_entity:
|
||||||
name: "Meter · Entiteit met het nettovermogen mét teken (W)"
|
name: "Meter · Entiteit nettovermogen met teken (W)"
|
||||||
description: "Alleen voor meterbron ha_signed. ÉÉN sensor mét teken: positief is afname van het net, negatief is injectie. Splits die niet in twee template-sensoren op - de add-on doet die splitsing zelf, juist zodat ze getest is."
|
description: "Alleen voor ha_signed. Eén sensor die positief is bij afname en negatief bij injectie."
|
||||||
p1_phase_import_entities:
|
p1_phase_import_entities:
|
||||||
name: "Meter · Afname-entiteiten per fase (L1, L2, L3)"
|
name: "Meter · Afname-entiteiten per fase (L1-L3)"
|
||||||
description: "Optioneel, in de volgorde L1..L3. Nodig voor de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen hierboven."
|
description: "Optioneel, in de volgorde L1, L2, L3. Nodig voor de piekgrens bij een driefasige aansluiting, één item per fase."
|
||||||
p1_phase_export_entities:
|
p1_phase_export_entities:
|
||||||
name: "Meter · Injectie-entiteiten per fase (L1, L2, L3)"
|
name: "Meter · Injectie-entiteiten per fase (L1-L3)"
|
||||||
description: "Optioneel, in de volgorde L1..L3, naast de afname-entiteiten per fase hierboven. Het aantal items moet gelijk zijn aan het aantal fasen."
|
description: "Optioneel, in de volgorde L1, L2, L3, naast de afname-entiteiten per fase. Eén item per fase."
|
||||||
p1_phase_net_entities:
|
p1_phase_net_entities:
|
||||||
name: "Meter · Netto-entiteiten per fase mét teken (L1, L2, L3)"
|
name: "Meter · Netto-entiteiten per fase (L1-L3)"
|
||||||
description: "Alleen voor meterbron ha_signed. Optioneel, in de volgorde L1..L3, elk met hetzelfde teken als de netto-entiteit hierboven. Zelfde rol als de afname- en injectie-entiteiten per fase: de kwartierpiek van het capaciteitstarief op een driefasige aansluiting. Het aantal items moet gelijk zijn aan het aantal fasen."
|
description: "Alleen voor ha_signed. Optioneel, in de volgorde L1, L2, L3, met hetzelfde teken als de netto-entiteit. Eén item per fase."
|
||||||
max_w:
|
max_w:
|
||||||
name: "Grenzen · Vermogen van de omvormer (W)"
|
name: "Grenzen · Vermogen van de omvormer (W)"
|
||||||
description: "DIT IS UW GETAL, geen afgeleide waarde: de harde bovengrens van wat er ooit gestuurd mag worden, in watt. Zet het op wat uw omvormer werkelijk aankan, begin laag en verhoog het pas na de indienstname. Het wordt helemaal niet gelezen zodra de lijst met omvormers is ingevuld - elk item draagt dan zijn eigen waarde en het plafond van de installatie is hun som. Verschillende veiligheidsdrempels moeten eronder blijven; de add-on start niet op als er één niet aan voldoet."
|
description: "Uw eigen getal: het hoogste vermogen dat de add-on ooit aan de omvormer mag vragen. Begin laag en verhoog het na de indienstname."
|
||||||
bms_soc_floor:
|
bms_soc_floor:
|
||||||
name: "Grenzen · Uitschakelniveau van de batterij (%)"
|
name: "Grenzen · Uitschakelniveau van de batterij (%)"
|
||||||
description: "GEMETEN, niet gekozen: het percentage waarbij het batterijmanagementsysteem van de batterij ZELF stopt met ontladen, wat u hier ook instelt. Register 0x0560 leest 10 op de referentie-GW5000S-BP. Lees deze waarde af van de batterij die er werkelijk staat in plaats van dit getal over te nemen. Ze staat hier zodat de add-on een ondergrens voor de piekbewaking op of onder deze waarde kan weigeren: zo'n ondergrens kan nooit werken, het BMS wint altijd eerst, en de statuspagina zou een reserve tonen die niet bestaat. Wordt niet gelezen zodra de lijst met omvormers is ingevuld - elk item vermeldt dan de uitschakelgrens van zijn eigen batterij."
|
description: "Het niveau waarop het beheersysteem van de batterij zelf stopt met leveren. Lees het af van uw batterij in plaats van een getal over te nemen."
|
||||||
peak_cap_w:
|
peak_cap_w:
|
||||||
name: "Grenzen · Streefwaarde capaciteitstarief (W)"
|
name: "Grenzen · Piekgrens (W)"
|
||||||
description: "DIT IS UW GETAL: het gemiddelde kwartiervermogen waar de piekbewaking onder probeert te blijven. Neem het over uit uw netcontract - in België is dit de kwartierpiek waarop het capaciteitstarief van uw distributiefactuur berekend wordt."
|
description: "Uw eigen getal: het gemiddelde vermogen per kwartier waaronder de add-on de afname houdt. Neem het over uit uw netcontract."
|
||||||
peak_forecast_entity:
|
peak_forecast_entity:
|
||||||
name: "Grenzen · Entiteit met de kwartierprognose (W)"
|
name: "Grenzen · Entiteit kwartierprognose (W)"
|
||||||
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende factuurkwartier. Laat u dit leeg, dan is er helemaal geen piekbegrenzing."
|
description: "Optioneel. Een prognose van het gemiddelde vermogen in het lopende kwartier. Laat u dit leeg, dan is er geen piekgrens."
|
||||||
peak_soc_floor:
|
peak_soc_floor:
|
||||||
name: "Grenzen · Piekreserve (%)"
|
name: "Grenzen · Piekreserve (%)"
|
||||||
description: "AFGELEID, niet gekozen (SAFETY-24): de referentiebatterij heeft 13,8 kWh bruikbaar en de zwaarste doorgerekende afvlakdag vraagt 878 Wh, oftewel 6,4 %, dus 20 % ligt tien punten boven de uitschakelgrens van de batterij zelf en laat de bewaking nog ongeveer 1,6 van haar zwaarste waargenomen dagen speling. ⚠️ DIT BEGRENST ALLEEN DE PIEKBEWAKING. Het is geen reserve voor de hele batterij: gewoon eigenverbruik ontlaadt er dwars doorheen en gaat door tot aan de uitschakelgrens van de batterij, en dit getal verhogen verandert daar geen tiende procent aan. Het moet strikt boven de uitschakelgrens van de batterij blijven, anders start de add-on niet op. Verander het niet zonder de afleiding opnieuw te maken."
|
description: "Het afvlakken van de piek laat de batterij niet onder dit niveau zakken, gewoon verbruik wel. Afgeleide waarde, laat ze ongewijzigd."
|
||||||
price_provider:
|
price_provider:
|
||||||
name: "Tarief · Bron van dynamische prijzen"
|
name: "Tarief · Bron van dynamische prijzen"
|
||||||
description: "Waar day-ahead- of realtimeprijzen vandaan komen. 'off' is de standaardinstelling en betekent dat deze installatie op het vaste tarief hieronder draait, zonder handel. ⚠️ DIT AANZETTEN IS WAT HET OPHALEN VAN PRIJZEN START - niet de handelsschakelaar verderop. Met een bron ingesteld haalt de add-on de feed elk half uur op, controleert die, bewaart de dag en toont op de rij Prijsgeschiedenis wat er bewaard is, of prijshandel nu aan staat of niet. Op zichzelf verplaatst dat nog geen energie: handelen is de aparte schakelaar, en het goedkope venster dat de maandelijkse onderhoudscyclus gebruikt komt uit het vaste of dag/nacht-tarief hieronder. Het mag niet gecombineerd worden met de twee oude Home Assistant-prijsentiteiten verderop: twee prijsbronnen op één installatie, waarvan er stilzwijgend één wint, wordt bij het opstarten geweigerd in plaats van schijnbaar actief gelaten. ⚠️ DRIE IDS IN DEZE LIJST ZIJN INGETROKKEN EN STARTEN NIET MEER: engie_dynamic, totalenergies_dynamic en eneco_eddy. Hun endpoints bestaan niet en hebben nooit bestaan, dus die plugins hebben nooit één prijs opgehaald - een installatie die op zo'n bron stond, draait sindsdien stilzwijgend op het vaste of dag/nacht-tarief hieronder, of op niets. Ze staan hier alleen nog zodat zo'n installatie bij het opstarten die uitleg krijgt in plaats van een onleesbare schemafout. Kies generic_dayahead voor de groothandelscurve met een vaste marge, custom_formula om de coëfficiënten B x index + A van uw eigen contract van uw tariefkaart over te typen, manual_csv voor een bestand dat u zelf schrijft, of off."
|
description: "Waar de prijzen vandaan komen. Een bron kiezen start het ophalen van prijzen, ook als handelen uit staat."
|
||||||
price_markup_eur_kwh:
|
price_markup_eur_kwh:
|
||||||
name: "Tarief · Marge van de leverancier (EUR/kWh)"
|
name: "Tarief · Marge van de leverancier (EUR/kWh)"
|
||||||
description: "De marge van uw energieleverancier. De add-on telt die ALLEEN op wanneer de gekozen prijsbron ze niet al bevat - bij manual_csv, waarvan de bedragen alles inbegrepen zijn, wordt ze genegeerd, en dat wordt ook gelogd, zodat ze nooit dubbel wordt aangerekend."
|
description: "De marge van uw leverancier. Ze wordt alleen opgeteld als de prijsbron ze nog niet bevat."
|
||||||
price_taxes_eur_kwh:
|
price_taxes_eur_kwh:
|
||||||
name: "Tarief · Distributie, heffingen en btw (EUR/kWh)"
|
name: "Tarief · Distributie, heffingen en btw (EUR/kWh)"
|
||||||
description: "Alles op uw factuur dat niet de energie zelf is - distributie, heffingen, accijns en btw - per kWh, opgeteld volgens dezelfde regel als de marge: alleen wanneer de gekozen prijsbron ze nog niet bevat. ⚠️ DIT GETAL IS GEEN VOORKEUR EN HET IS NIET DAT VAN UW OMVORMER. Het staat standaard op 0.11736, AFGELEID in TARIFF-18 van de Eneco-kaart voor residentiele klanten in Vlaanderen van september 2026, inclusief 6% btw: Fluvius-distributie voor Antwerpen 5,35 plus bijzondere accijns 4,876 plus bijdrage groene stroom en WKK 1,51 c/kWh. De energiebijdrage is sinds 1 augustus 2026 nul - ze is opgegaan in de accijns, dus een bedrag uit een oudere gids telt ze dubbel. ⚠️ CONTROLEER HET BIJ UW EIGEN DISTRIBUTIENETBEHEERDER. Alleen de distributieregel verschilt: over de acht Fluvius-netgebieden loopt die van 5,28 c/kWh in Midden-Vlaanderen tot 6,70 in West-Vlaanderen, zodat het Vlaamse totaal ongeveer van 0.117 tot 0.131 loopt. In Wallonie en Brussel heeft het distributietarief een apart dag- en nachttarief en hoort het hier helemaal niet thuis - gebruik dan de optie voor heffingen per tijdstip hieronder. ⚠️ DIT WIJZIGEN VERANDERT GEEN ENKELE HANDELSBESLISSING: hetzelfde bedrag komt bij elk uur, dus het valt weg uit zowel het goedkoopste uur als het prijsverschil. Wat het wel verandert is elke prijs die u te zien krijgt, en de regel die weigert te ontladen in een uur waarvan de totaalprijs onder nul ligt."
|
description: "Netkosten, heffingen en btw per kWh, ingesteld voor Vlaanderen. Controleer het bij uw eigen netbeheerder."
|
||||||
export_price_eur_kwh:
|
export_price_eur_kwh:
|
||||||
name: "Tarief · Injectievergoeding (EUR/kWh)"
|
name: "Tarief · Injectievergoeding (EUR/kWh)"
|
||||||
description: "Wat u krijgt voor een kWh die u op het net zet, alles inbegrepen. Eén getal en geen reeks: geen enkele leverancier hier publiceert een dynamische injectiecurve. In België ligt dit vaak dicht bij nul, wat op zich al sterk pleit voor eigenverbruik in plaats van injectie. De waarde mag negatief zijn - op een zomerse middag betaalt u soms om te injecteren."
|
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 (één regel JSON)"
|
name: "Tarief · Vast of dag/nacht-tarief (JSON)"
|
||||||
description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief."
|
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."
|
||||||
price_taxes_profile:
|
price_taxes_profile:
|
||||||
name: "Tarief · Heffingen en btw per dagdeel (één regel JSON)"
|
name: "Tarief · Heffingen en btw dag/nacht (JSON)"
|
||||||
description: "Laat dit leeg tenzij uw netbeheerder werkelijk een ander dag- en nachttarief aanrekent - in Vlaanderen is dat niet zo, de Fluvius-distributie is één tarief, en het vaste bedrag hierboven klopt. In Wallonië en Brussel is dat wél zo: ORES rekent 13,27 c/kWh overdag tegen 7,39 's nachts, AIESH 15,17 tegen 8,21, Sibelga 9,39 tegen 7,10. Geschreven als één regel JSON in precies dezelfde vorm als het vaste of dag/nacht-tarief hierboven, met dezelfde regel dat de periodes elk dagtype één keer moeten bedekken zonder gat en zonder overlap. ⚠️ DE BEDRAGEN HIER ZIJN ALLEEN DE HEFFINGEN, GEEN VOLLEDIGE PRIJS - de add-on telt ze op bij wat uw prijsbron publiceert, net zoals bij het vaste bedrag. Waarom het uitmaakt: een vast bedrag is in elk uur hetzelfde, verschuift dus elk uur evenveel en kan niet veranderen welk uur het goedkoopste is - en een nachttarief van het net is goedkoop in dezelfde uren waarin de markt goedkoop is, dus door het plat te slaan lijkt de dag vlakker dan hij is en weigert de add-on batterijcycli die zichzelf zouden terugverdienen. Vul dit in óf het vaste bedrag hierboven, niet allebei: de add-on weigert te starten als de twee elkaar tegenspreken."
|
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 (één regel JSON)"
|
name: "Tarief · Uw eigen tariefformule (JSON)"
|
||||||
description: "Alleen voor prijsbron custom_formula: de twee coëfficiënten van uw eigen contract, als één regel JSON. De tariefkaart van uw leverancier drukt de formule af als B x index + A - die twee getallen staan alleen op uw kaart, er bestaat nergens een publieke lijst van, en u bent de enige die ze kan lezen. Typ wat de kaart afdrukt, IN DE EENHEID DIE DE KAART GEBRUIKT: elke eenheidsomrekening en de btw-stap zijn het werk van de add-on, want dat zijn precies de twee plaatsen waar een mens stilzwijgend een factor 10, 100 of 1,06 verliest. Voorbeeld: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ HET BLOK card IS VERPLICHT EN MAAKT DIT VEILIG: uw kaart vermeldt ook de referentie-index die ze veronderstelt en de prijs die eruit volgt, dus de add-on berekent die prijs opnieuw uit uw getallen en START NIET als het resultaat niet klopt - met vermelding van het verschil. Dat vangt een verkeerde eenheid, btw die ontbreekt of dubbel geteld is, een omgewisseld cijfer, en de dure fout: nettarieven die in de offset verwerkt zitten en die de add-on er dan nog een tweede keer bovenop zou rekenen. ⚠️ EN DE GRENS DAARVAN, IN DEZELFDE ZIN: ze toetst uw coëfficiënten aan uw kaart, niet uw kaart aan de werkelijkheid - twee getallen van het verkeerde product kloppen onderling en komen er dus door. Alleen vaste getallen: er wordt geen enkele expressie geëvalueerd, en een onbekend veld wordt geweigerd in plaats van genegeerd."
|
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."
|
||||||
price_export_formula:
|
price_export_formula:
|
||||||
name: "Tarief · Uw eigen INJECTIEformule (één regel JSON)"
|
name: "Tarief · Uw eigen injectieformule (JSON)"
|
||||||
description: "Optioneel, en alleen als uw leverancier u een prijs betaalt die met de markt meebeweegt. Op uw tariefkaart staat een TWEEDE formule voor wat u krijgt voor de stroom die u injecteert, en dat is niet de eerste met een minteken ervoor: Eneco drukt `0,1 x BELPEX-H - 1,188` c/kWh af voor injectie tegenover `0,102 x ...` voor afname, en Aspiravi `0,07 x Belpex - 2`. Geschreven in precies dezelfde vorm als uw eigen tariefformule hierboven. ⚠️ INJECTIE IS IN BELGIË VRIJGESTELD VAN BTW terwijl afname op 6% staat, dus vat_pct is hier normaal 0 - dat is het ene veld dat men van de andere formule overneemt en verkeerd invult. ⚠️ DE PRIJS DIE HIERUIT VOLGT KAN NEGATIEF ZIJN, en dat is geen fout: Eneco betaalt onder nul zodra BELPEX-H lager is dan 11,88 EUR/MWh, en dat is het midden van een zonnige dag - u kunt moeten betalen om te injecteren. De add-on aanvaardt dat in plaats van het te verbergen. Het blok card is hier OPTIONEEL en hierboven verplicht, omdat Eneco de injectieformule afdrukt ZONDER voorbeeldprijs ernaast en er dan niets is om uw getallen aan te toetsen; drukt uw kaart er wél een af, vul ze dan in en de add-on start niet als uw getallen die prijs niet reproduceren. ⚠️ WAT DIT NIET DOET: geen enkel onderdeel van de add-on handelt vandaag op een injectieprijs - hij laadt goedkoop op om duur te vermijden, en beide zijden van die vergelijking zijn afnameprijzen. Dit is wat u te zien krijgt, en wat een toekomstige exportstrategie zou lezen. Laat het leeg en de vaste injectieprijs hierboven wordt gebruikt, zoals elke installatie nu doet."
|
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"
|
||||||
description: "Alleen voor prijsbron manual_csv: waar in /share de add-on uw eigen prijsbestand leest."
|
description: "Alleen voor manual_csv. Waar in /share de add-on uw prijsbestand leest."
|
||||||
price_feed_url:
|
price_feed_url:
|
||||||
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
name: "Tarief · Afwijkende URL voor de prijsfeed"
|
||||||
description: "Laat leeg om de eigen feed van de gekozen bron te gebruiken. Alleen een host die die bron zelf opgeeft, wordt aanvaard, zodat dit niet zomaar ergens naar kan verwijzen. WAT U HIER TYPT WORDT LETTERLIJK VERSTUURD: de add-on voegt er niets aan toe, ook niet het datumbereik dat ze normaal aan haar eigen feed vraagt. Een afwijkende URL levert dus de horizon op die die URL zelf teruggeeft - vaak één dag, waarna de rest van het plan uit de prijzen van gisteren wordt overgenomen en buiten de prijsarbitrage valt. Bij elke prijsvernieuwing verschijnt daarover een waarschuwing in het logboek. Maak dit veld leeg om de volledige horizon terug te krijgen."
|
description: "Laat leeg om de eigen feed van de bron te gebruiken. Een afwijkende URL wordt letterlijk verstuurd en geeft vaak maar één dag terug."
|
||||||
price_horizon_h:
|
price_horizon_h:
|
||||||
name: "Tarief · Hoe ver vooruit prijzen ophalen (uren)"
|
name: "Tarief · Prijzen vooruit (uren)"
|
||||||
description: "Hoeveel uur aan prijzen er bij de bron opgevraagd wordt."
|
description: "Hoeveel uur aan prijzen de add-on bij de bron opvraagt."
|
||||||
price_timeout_s:
|
price_timeout_s:
|
||||||
name: "Tarief · Time-out voor een prijsaanvraag (s)"
|
name: "Tarief · Time-out prijsaanvraag (s)"
|
||||||
description: "Hoe lang één aanvraag bij de prijsbron mag duren voordat ze wordt opgegeven."
|
description: "Hoe lang één aanvraag bij de prijsbron mag duren voor ze wordt opgegeven."
|
||||||
price_now_entity:
|
price_now_entity:
|
||||||
name: "Tarief · Entiteit met de huidige prijs (oudere prijsweg)"
|
name: "Tarief · Entiteit huidige prijs (oud)"
|
||||||
description: "De oudere manier om aan een prijs te komen, behouden omdat ze weghalen de onderhoudscyclus op een draaiende installatie zijn enige prijssignaal zou afnemen. De add-on leest deze entiteit en het gemiddelde hieronder en leidt er precies één ding uit af - of het nu goedkoper is dan gemiddeld - wat alleen gebruikt wordt om tijdens de onderhoudscyclus in een goedkoop venster te laden. Mag niet gecombineerd worden met een dynamische prijsbron."
|
description: "De oudere manier om een prijs te krijgen, alleen gebruikt om tijdens onderhoud goedkoop te laden. Niet combineren met een dynamische prijsbron."
|
||||||
price_avg_entity:
|
price_avg_entity:
|
||||||
name: "Tarief · Entiteit met de gemiddelde prijs (oudere prijsweg)"
|
name: "Tarief · Entiteit gemiddelde prijs (oud)"
|
||||||
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen zinvol samen met de entiteit hierboven en, net als die, niet te combineren met een dynamische prijsbron."
|
description: "Het gemiddelde waarmee de huidige prijs vergeleken wordt. Alleen samen met de entiteit huidige prijs, niet met een dynamische prijsbron."
|
||||||
arbitrage_enabled:
|
arbitrage_enabled:
|
||||||
name: "Tarief · Handelen op het prijsverschil"
|
name: "Tarief · Handelen op het prijsverschil"
|
||||||
description: "Laden in het goedkoopste gepubliceerde uur en ontladen in het duurste, maar alleen wanneer het verschil de kosten van de volledige cyclus dekt. STAAT UIT in de standaardinstelling, met opzet: dit is de eerste strategie in de add-on die bewust energie tegen de meter in verplaatst, dus ze wordt bewust aangezet of helemaal niet. ⚠️ DIT UIT LATEN STAAN ZET DE PRIJSFEED NIET UIT. Een prijsbron die hierboven gekozen is, wordt hoe dan ook opgehaald, gecontroleerd en bewaard; deze schakelaar bepaalt alleen of de add-on op die prijzen HANDELT. Ze heeft zowel een prijsbron als een P1-meter nodig, en de add-on weigert combinaties die niet kunnen werken in plaats van ze schijnbaar actief te laten."
|
description: "Laden in het goedkoopste uur en de batterij laten leveren in het duurste, als het verschil de kosten dekt. Dit uitzetten zet de prijsfeed niet uit."
|
||||||
cycle_cost_threshold_eur:
|
cycle_cost_threshold_eur:
|
||||||
name: "Tarief · Kostprijs van één volledige laad/ontlaadcyclus (EUR/kWh)"
|
name: "Tarief · Kosten van een batterijcyclus (EUR/kWh)"
|
||||||
description: "Wat één volledige cyclus per kWh kost: slijtage van de batterij plus omzettingsverliezen. Een prijsverschil dat hier onder ligt, kost geld en levensduur en wordt dus niet verhandeld. ⚠️ DIT GETAL IS NIET AFGELEID UIT EEN METING. 0,10 is een productbeslissing en staat nog open bij de eigenaar (OPT-08). Bij deze waarde handelt een installatie met een vast dag/nachttarief helemaal niet - het realistische Belgische dag/nachtverschil is ongeveer 0,08 EUR/kWh - en dat is bij dit getal het juiste antwoord, geen fout."
|
description: "Wat één volledige batterijcyclus per kWh kost. Een kleiner prijsverschil wordt niet verhandeld. De waarde is een productkeuze, geen meting."
|
||||||
arbitrage_soc_floor:
|
arbitrage_soc_floor:
|
||||||
name: "Tarief · Handel ontlaadt niet onder (%)"
|
name: "Tarief · Handel houdt minstens (%)"
|
||||||
description: "De reserve die de prijshandel niet mag aanspreken. Bewust een andere instelling dan de ondergrens van het onderhoud: een handelsstrategie op de cijfers van de maandelijkse balanceercyclus richten, zou twee ongerelateerde keuzes aan één getal koppelen."
|
description: "Prijshandel laat de batterij niet onder dit niveau zakken. Dit staat los van de onderhoudsinstellingen."
|
||||||
arbitrage_soc_ceiling:
|
arbitrage_soc_ceiling:
|
||||||
name: "Tarief · Handel laadt niet boven (%)"
|
name: "Tarief · Handel laadt hoogstens (%)"
|
||||||
description: "De ruimte die de prijshandel niet mag volmaken. De tegenhanger van de ondergrens hierboven, en om dezelfde reden losgehouden van het onderhoudsdoel."
|
description: "Prijshandel laadt de batterij niet boven dit niveau."
|
||||||
maintenance_enabled:
|
maintenance_enabled:
|
||||||
name: "Onderhoud · Maandelijkse batterijcyclus uitvoeren"
|
name: "Onderhoud · Maandelijkse batterijcyclus"
|
||||||
description: "Een bewuste diepe ontlading, gevolgd door volledig opladen en een rustperiode op vol, zodat het batterijmanagementsysteem zijn schatting van het laadniveau kan herijken en de cellen kan balanceren. Staat standaard uit."
|
description: "Eén keer per maand de batterij diep leegmaken, volladen en vol houden, zodat het laadniveau juist blijft. Staat standaard uit."
|
||||||
maintenance_interval_days:
|
maintenance_interval_days:
|
||||||
name: "Onderhoud · Minimum aantal dagen tussen cycli"
|
name: "Onderhoud · Minimum dagen tussen cycli"
|
||||||
description: "De kortste tijd tussen twee onderhoudscycli."
|
description: "De kortste tijd tussen twee onderhoudscycli."
|
||||||
maintenance_start_hour:
|
maintenance_start_hour:
|
||||||
name: "Onderhoud · Uur waarop een geplande cyclus start"
|
name: "Onderhoud · Startuur"
|
||||||
description: "Lokaal uur, 0 tot 23, waarop een cyclus die aan de beurt is begint."
|
description: "Het lokale uur, 0 tot 23, waarop een geplande cyclus begint."
|
||||||
maintenance_discharge_w:
|
maintenance_discharge_w:
|
||||||
name: "Onderhoud · Ontlaadvermogen (W)"
|
name: "Onderhoud · Ontlaadvermogen (W)"
|
||||||
description: "Hoe snel de cyclus de batterij leegtrekt; het overschot gaat naar het net. Boven het vermogen van de omvormer wordt dit BEGRENSD tot dat vermogen en niet geweigerd - de cyclus loopt gewoon door, alleen trager, en de statuspagina meldt dat. Standaard 2000, afgestemd op het standaardvermogen; verhoog eerst het omvormervermogen en dan pas dit."
|
description: "Hoe snel de cyclus de batterij leegmaakt. Boven het vermogen van de omvormer wordt het daartoe beperkt, en duurt de cyclus gewoon langer."
|
||||||
maintenance_charge_w:
|
maintenance_charge_w:
|
||||||
name: "Onderhoud · Laadvermogen (W)"
|
name: "Onderhoud · Laadvermogen (W)"
|
||||||
description: "⚠️ MOET GELIJK AAN OF LAGER ZIJN dan het vermogen van de omvormer; anders start de add-on niet op. Erboven vraagt het laden in een goedkoop venster meer dan de bewaakte grens toelaat, lost de arbiter die tegenstrijdigheid op naar 0 W, en zou het onderhoud op een standaardinstallatie stilzwijgend nooit laden in een goedkoop venster. Daarnaast wordt dit nog eens begrensd door de ruimte die de piekbewaking overlaat."
|
description: "Hoe snel de cyclus de batterij weer vollaadt. Het moet gelijk aan of lager zijn dan het vermogen van de omvormer, anders start de add-on niet."
|
||||||
maintenance_soc_floor:
|
maintenance_soc_floor:
|
||||||
name: "Onderhoud · Ontladen tot (%)"
|
name: "Onderhoud · Ontladen tot (%)"
|
||||||
description: "De diepte waar de maandelijkse herijking bewust naartoe gaat, één keer per maand, met opzet. ⚠️ DIT IS NIET DE ONDERGRENS VAN DE PIEKBEWAKING: die geeft aan hoe diep een besparende ontlading op geen enkele dag mag komen. De juiste waarden verschillen echt - 11 hier tegenover 20 daar - en daarom zijn het twee aparte instellingen die niet samengevoegd mogen worden."
|
description: "Het niveau tot waar de maandelijkse cyclus de batterij leegmaakt. Dit is niet de piekreserve, en de twee verschillen bewust."
|
||||||
maintenance_soc_target:
|
maintenance_soc_target:
|
||||||
name: "Onderhoud · Opladen tot (%)"
|
name: "Onderhoud · Opladen tot (%)"
|
||||||
description: "Het niveau waar de laadfase naartoe werkt voordat de rustperiode begint."
|
description: "Het niveau waartoe de cyclus oplaadt voor het vasthouden begint."
|
||||||
maintenance_hold_min:
|
maintenance_hold_min:
|
||||||
name: "Onderhoud · Op vol houden gedurende (minuten)"
|
name: "Onderhoud · Vol houden gedurende (minuten)"
|
||||||
description: "Hoe lang er op het streefniveau gebleven wordt zodat het batterijmanagementsysteem het balanceren van de cellen kan afmaken."
|
description: "Hoe lang de batterij vol blijft zodat de cellen in evenwicht komen."
|
||||||
sign_disagree_w:
|
sign_disagree_w:
|
||||||
name: "Veiligheid · Drempel tekenbewaking (W) — gemeten, niet wijzigen"
|
name: "Veiligheid · Drempel verkeerde richting (W)"
|
||||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Zowel het uitgestuurde setpoint als de netmeting moet boven deze waarde liggen voordat hun tegengestelde tekens überhaupt als onenigheid tellen. Op de vastgelegde gezonde dag (23-08-2026) komt het teken in 93,4 % van de beoordeelde metingen overeen; op de vastgelegde foutdag (20-08-2026) ligt dat tussen 0,0 en 10,5 %, met tegengestelde periodes tot 1752 s. Er is geen reden om hieraan te sleutelen zonder een nieuwe meetcampagne die dat onderbouwt. De waarde moet onder het omvormervermogen blijven, anders kan de bewaking nooit aanslaan, en de add-on weigert die instelling."
|
description: "Een verkeerde richting telt pas als het commando en de meterstand allebei boven deze waarde liggen. Gemeten waarde, laat ze ongewijzigd."
|
||||||
sign_disagree_s:
|
sign_disagree_s:
|
||||||
name: "Veiligheid · Wachttijd tekenbewaking (s) — gemeten, niet wijzigen"
|
name: "Veiligheid · Tijd verkeerde richting (s)"
|
||||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-02). Hoe lang het setpoint en de meter onafgebroken tegengesteld moeten blijven voordat de add-on 0 W stuurt en vergrendelt tot iemand de regeling opnieuw start. 180 s ligt midden in een zeer brede gemeten marge: de langste terechte tegengestelde periode op de gezonde dag duurt 30 s, terwijl de foutdag bij 300 s nog altijd 13 keer aanslaat. Verander dit niet zonder nieuwe metingen."
|
description: "Hoe lang een verkeerde richting mag duren voor een veiligheidsstop elke omvormer op 0 W zet. Gemeten waarde, laat ze ongewijzigd."
|
||||||
unit_sign_disagree_w:
|
unit_sign_disagree_w:
|
||||||
name: "Veiligheid · Tekendrempel per omvormer (W) — afgeleid, niet wijzigen"
|
name: "Veiligheid · Drempel verkeerde richting per omvormer (W)"
|
||||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer doet deze instelling niets. AFGELEID UIT 1890 GESLOTEN-LUSTESTS op de testopstelling met twee omvormers (SAFETY-30), waarbij niets defect was, over het volledige bereik dat deze add-on toelaat voor versterking en volgsnelheid. 250 W is precies de helft van de verzadigingsdrempel, zodat het kleinste verschil dat hier zichtbaar is, exact de afwijking is waarbij de regeling zelf al zegt dat de omvormer niet volgt. Verander dit niet zonder de afleiding opnieuw te maken. De waarde moet onder het omvormervermogen blijven, anders kan ze nooit aanslaan."
|
description: "Bij twee of meer omvormers: de drempel voor verkeerde richting per omvormer. Afgeleide waarde, laat ze ongewijzigd."
|
||||||
unit_sign_disagree_s:
|
unit_sign_disagree_s:
|
||||||
name: "Veiligheid · Wachttijd per omvormer (s) — afgeleid, niet wijzigen"
|
name: "Veiligheid · Tijd verkeerde richting per omvormer (s)"
|
||||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30): 85 s is 8,5 keer de langste terechte tegengestelde periode die die tests opleveren (10,0 s), 2,8 keer het eigen veto van 30 s in de firmware, en minder dan de helft van de 180 s die de installatiebrede controle neemt. Verander dit niet zonder de afleiding opnieuw te maken."
|
description: "Bij twee of meer omvormers: hoe lang één omvormer de verkeerde kant op mag duwen voor ze op 0 W gaat. Afgeleide waarde, laat ze ongewijzigd."
|
||||||
site_sign_disagree_w:
|
site_sign_disagree_w:
|
||||||
name: "Veiligheid · Tekendrempel voor de hele installatie (W) — gemeten, niet wijzigen"
|
name: "Veiligheid · Drempel verkeerde richting installatie (W)"
|
||||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. Dezelfde 300 W als de drempel voor één omvormer, BEWUST OVERGENOMEN (SAFETY-30): de onderbouwing hangt niet af van het aantal omvormers - rond nul is het teken ruis, de dode zone is 15 W, en er is één meter, wat er ook achter hangt. Verander dit niet zonder metingen die dat onderbouwen."
|
description: "Bij twee of meer omvormers: de drempel voor verkeerde richting van de hele installatie. Gemeten waarde, laat ze ongewijzigd."
|
||||||
site_sign_disagree_s:
|
site_sign_disagree_s:
|
||||||
name: "Veiligheid · Wachttijd voor de hele installatie (s) — afgeleid, niet wijzigen"
|
name: "Veiligheid · Tijd verkeerde richting installatie (s)"
|
||||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID UIT 1890 GESLOTEN-LUSTESTS (SAFETY-30), en bewust NIET overgenomen van de 180 s voor één omvormer. In die tests duurt de langste terechte tegengestelde periode bij 300 W 110 s met één omvormer en 215 s met twee, omdat het commando de volledige installatiegrens moet doorlopen aan een volgsnelheid die niet die van de installatie is. 180 s overnemen zou dus een vals alarm hebben opgeleverd bij de laagste versterking en volgsnelheid en een gewone belastingomkering van 2,2 kW. 325 s is 1,51 keer die waarde, blijft binnen het factuurkwartier van 900 s en binnen elke episode van de vastgelegde foutdag. Verander dit niet zonder de afleiding opnieuw te maken."
|
description: "Bij twee of meer omvormers: hoe lang de hele installatie de verkeerde kant op mag duwen voor een veiligheidsstop. Afgeleide waarde, laat ze ongewijzigd."
|
||||||
meter_freeze_s:
|
meter_freeze_s:
|
||||||
name: "Veiligheid · Alarm vastgelopen meter na (s) — gemeten, niet wijzigen"
|
name: "Veiligheid · Alarm vastgelopen meter na (s)"
|
||||||
description: "GEMETEN, NIET GEKOZEN (SAFETY-18). Hoe lang de meter mag blijven antwoorden met een ONVERANDERDE waarde, terwijl het commando op de grens staat, voordat de add-on alarm slaat. Over 2399 s van deze woning op haar ingeregelde werkpunt - 1194 uitlezingen, waarvan 735 een herhaling van de vorige waarde - duurt de langste periode zonder een nieuwe waarde 15,4 s; 300 s is 19,5 keer zoveel en blijft binnen het factuurkwartier van 900 s. ALLEEN EEN ALARM: er wordt geen 0 W gestuurd en er wordt niets vergrendeld. ⚠️ Alleen de meterbron homewizard_local kan dit überhaupt zien; bij elke andere bron meldt de statuspagina NIET GEDEKT in plaats van actief. Verander dit niet zonder de afleiding opnieuw te maken."
|
description: "Een alarm als de meter zo lang dezelfde waarde herhaalt terwijl het commando op vol vermogen staat, alleen met homewizard_local. Gemeten waarde, laat ze ongewijzigd."
|
||||||
circulation_w:
|
circulation_w:
|
||||||
name: "Veiligheid · Drempel rondpompend vermogen (W) — gemeten, niet wijzigen"
|
name: "Veiligheid · Drempel tegenwerkende omvormers (W)"
|
||||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer is deze grootheid altijd nul. Twee omvormers die tegen elkaar in duwen, heffen elkaar aan de meter op en zijn onzichtbaar voor elke andere bewaking in deze add-on. GEMETEN OVER 924 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-31): de langste terechte tegenwerking valt terug van 505 s bij een drempel van 100 W naar 5 s bij 400 W, en 400 W is de scherpe knik waaronder deze add-on u niet laat gaan - daaronder houdt gewone werking de sensor minutenlang boven de drempel en kan niets korter dan het factuurkwartier dat van een storing onderscheiden. 700 W is 1,75 keer de knik; die extra marge is er omdat het aanloopgedrag van de tweede omvormer nog gekopieerd is van de eerste en niet gemeten. Verander dit niet zonder de afleiding opnieuw te maken."
|
description: "Bij twee of meer omvormers: hoeveel vermogen de ene tegen de andere in mag duwen voor het telt. Gemeten waarde, laat ze ongewijzigd."
|
||||||
circulation_hold_s:
|
circulation_hold_s:
|
||||||
name: "Veiligheid · Wachttijd rondpompend vermogen (s) — afgeleid, niet wijzigen"
|
name: "Veiligheid · Tijd tegenwerkende omvormers (s)"
|
||||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS. AFGELEID (SAFETY-31): 75 s zijn vijftien regelcycli - 15 keer de langste terechte episode die de testopstelling kan produceren, 2,5 keer het eigen veto van 30 s in de firmware, en twaalf keer binnen het factuurkwartier van 900 s. Het is bewust geen ronde 60 of 120, want dat zou een gok zijn in het jasje van een meting. Verander dit niet zonder de afleiding opnieuw te maken."
|
description: "Bij twee of meer omvormers: hoe lang ze tegen elkaar in mogen duwen voor een veiligheidsstop. Afgeleide waarde, laat ze ongewijzigd."
|
||||||
delivery_hold_s:
|
delivery_hold_s:
|
||||||
name: "Veiligheid · Melding niet-leverende omvormer na (s) — afgeleid, niet wijzigen"
|
name: "Veiligheid · Alarm niet-leverende omvormer na (s)"
|
||||||
description: "ALLEEN BIJ TWEE OF MEER OMVORMERS; bij één omvormer kan deze controle helemaal niet aanslaan, en dat is een bewuste keuze en geen tekortkoming. Hoe lang één omvormer mag blijven staan op een ontlaadaandeel dat hij niet levert - meer dan de verzadigingsdrempel te weinig, zonder dat deze add-on er een verklaring voor kan uitlezen - terwijl minstens één andere omvormer dat NIET doet, voordat hij bij naam wordt gemeld. ALLEEN MELDEN (SAFETY-42): er wordt niets anders gestuurd, de regeling blijft ongemoeid, en de installatie blijft draaien met het aandeel van die omvormer te weinig tot u gaat kijken. AFGELEID UIT 2640 GESLOTEN-LUSTESTS waarbij niets defect was (SAFETY-42, plus een arm met één omvormer als controlegroep, die bij geen enkele drempel iets meldt): de langste terechte episode in die tests duurt 35,0 s, en de bovengrens is het factuurkwartier van 900 s. 185 s is 5,29 keer zoveel. ⚠️ Het schema laat u zo laag als 30 s instellen, en dat is ONDER die gemeten 35,0 s - op 30 s moet u meldingen bij terechte verzadiging verwachten. De tweede omvormer in die tests is een KOPIE VAN DE EERSTE, want de tweede omvormer bestaat nog niet: wat een vier keer tragere eenheid zou opleveren (75,0 s) is een gevoeligheidsstudie op dat idee en geen meting aan een echte tweede omvormer. Verander dit niet zonder de afleiding opnieuw te maken - en dat kan nu: admin/goodwe sim/delivery_window.py --campaign --blag (TEST-21). Een accu op of onder zijn eigen bms_soc_floor wordt nooit gemeld: een lege accu en een vastgelopen omvormer zijn watt voor watt identiek, en die instelling is het enige wat ze uit elkaar houdt - slaat dit toch aan bij een accu die volgens u leeg is, dan beschrijft die instelling uw batterij niet."
|
description: "Bij twee of meer omvormers: hoe lang één omvormer te weinig mag leveren voor een alarm. Het alarm verandert niets. Afgeleide waarde, laat ze ongewijzigd."
|
||||||
shadow_mode:
|
shadow_mode:
|
||||||
name: "Veiligheid · Schaduwmodus — alles beslissen, niets sturen"
|
name: "Veiligheid · Schaduwmodus"
|
||||||
description: "Doorloopt het volledige beslispad — strategieën, arbiter, begrenzers en bewakingen — en stuurt NIETS naar de omvormer. Elke omvormer wordt op 0 W gehouden, ververst op de gewone hartslag, en de statuspagina zegt onomwonden dat de batterij niet wordt aangestuurd; wat er gestuurd ZOU zijn, en waarom, staat op de statuspagina en in het actielogboek. Zo bekijkt u een installatie een dag lang voordat u hem laat handelen. 'auto' (de meegeleverde waarde) betekent schaduwmodus zolang de regeling gestopt is en gewone regeling zodra u op Start drukt — een NIEUWE installatie kijkt dus eerst toe voordat ze ooit iets stuurt, en een installatie die al regelt verandert geen watt. 'on' betekent altijd schaduwmodus, wat de Start/stop-knop ook zegt: die stand kiest u als u de lus en de strategieën wilt laten draaien op een werkende installatie zonder dat er iets bij de batterij aankomt. 'off' is gewone regeling. ⚠️ Zolang dit aanstaat wordt de batterij NIET geregeld — het net wordt niet op nul gehouden, er wordt geen prijshandel uitgevoerd en er wordt geen capaciteitspiek afgevlakt. ⚠️ Een vergrendeling die al aanstond blijft aanstaan: schaduwmodus heft die niet op, en de banner noemt hem."
|
description: "De add-on neemt elke beslissing maar houdt elke omvormer op 0 W. 'auto' doet dit zolang de regeling gestopt is, 'on' altijd, 'off' nooit."
|
||||||
auto_start:
|
auto_start:
|
||||||
name: "Geavanceerd · Automatisch beginnen te regelen bij opstarten"
|
name: "Geavanceerd · Regeling starten bij opstarten"
|
||||||
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is genomen: zolang het uit staat, moet iemand na elke herstart van de add-on op Start drukken, en dat is precies wat u wilt terwijl u tekens en entiteiten nog aan het nakijken bent."
|
description: "Staat standaard uit. Laat het uit tot de installatie in dienst is, zodat iemand na elke herstart op Start drukt."
|
||||||
gain:
|
gain:
|
||||||
name: "Geavanceerd · Versterking van de regellus"
|
name: "Geavanceerd · Versterking van de regeling"
|
||||||
description: "Welk deel van de resterende afwijking de regeling per cyclus wegregelt. GEMETEN OP ECHTE HARDWARE en al aan de bovengrens - niet verhogen. Lager is trager en veiliger."
|
description: "Welk deel van de resterende afwijking de regeling per cyclus wegwerkt. Gemeten op echte hardware, verhoog dit niet."
|
||||||
slew_w:
|
slew_w:
|
||||||
name: "Geavanceerd · Grootste verandering per cyclus (W)"
|
name: "Geavanceerd · Grootste stap per cyclus (W)"
|
||||||
description: "Hoeveel het setpoint in één regelcyclus mag verspringen. ⚠️ 0 BETEKENT VOLG HET OMVORMERVERMOGEN en is de aanbevolen instelling: de grens wordt ongeveer een negende van dat vermogen, zodat de regeling er de GEMETEN insteltijd van de installatie over doet om haar volledige bereik te doorlopen en het getal niet achterblijft wanneer het vermogen verandert. AFGELEID (SAFETY-39) uit een gemeten stapresponsie van ~18 s bij een meterinterval van 2 s. Een vast getal wordt aanvaard en wordt nooit stilzwijgend verlaagd, maar een getal sneller dan het vermogen toelaat wordt bij het starten geweigerd - bij 1000 W tegen 5000 W dreef deze regeling de meter op 2026-09-05 van 3,4 kW injectie naar 2,8 kW afname. Lager wordt altijd aanvaard."
|
description: "Hoe ver het commando in één cyclus mag verschuiven. 0 volgt het vermogen van de omvormer en is aanbevolen."
|
||||||
deadband_w:
|
deadband_w:
|
||||||
name: "Geavanceerd · Dode zone (W)"
|
name: "Geavanceerd · Negeer afwijkingen onder (W)"
|
||||||
description: "Afwijkingen kleiner dan deze waarde worden genegeerd. ⚠️ De dode zone werkt maar één kant op: elk rustpunt erbinnen blijft eeuwig staan, en het afnameregister van de meter telt elke positieve watt mee zonder injectie die dat compenseert - en precies daarom staat het rustpunt hieronder licht negatief."
|
description: "Kleinere afwijkingen worden genegeerd. Houd dit klein, want elk rustpunt daarbinnen blijft staan."
|
||||||
target_grid_w:
|
target_grid_w:
|
||||||
name: "Geavanceerd · Rustpunt aan de meter (W)"
|
name: "Geavanceerd · Rustpunt aan de meter (W)"
|
||||||
description: "Waar de meter moet staan wanneer er niets gebeurt; negatief betekent een lichte injectie. -10 W is bewust gekozen: rusten op 0 W lekt de hele dag door ongeveer de helft van de dode zone aan gefactureerde afname - grofweg 0,2 tot 0,35 kWh bij een dode zone van 15 W - en het rustpunt onder nul leggen verplaatst dat lek naar het injectieregister, dat niet gefactureerd wordt. Het kost ongeveer evenveel watt weggegeven injectie, dus houd het klein."
|
description: "Waar de meter moet staan als er niets gebeurt. Een kleine injectie voorkomt dat u de hele dag kleine afname betaalt."
|
||||||
step_w:
|
step_w:
|
||||||
name: "Geavanceerd · Afronding van het setpoint (W)"
|
name: "Geavanceerd · Afronding van het commando (W)"
|
||||||
description: "Het setpoint wordt op een veelvoud hiervan afgerond voordat het geschreven wordt, zodat de omvormer niet voor elke watt ruis een nieuw getal krijgt."
|
description: "Het commando wordt afgerond op een veelvoud hiervan, zodat ruis het niet elke cyclus verandert."
|
||||||
saturation_w:
|
saturation_w:
|
||||||
name: "Geavanceerd · Verzadigingsdrempel (W)"
|
name: "Geavanceerd · Verschil omvormer aan grens (W)"
|
||||||
description: "Het verschil tussen wat gevraagd werd en wat de omvormer levert dat geldt als 'deze omvormer zit aan een grens'. ⚠️ De tekendrempel per omvormer onder Veiligheid is afgeleid als precies de helft hiervan, dus deze waarde verschuiven, verschuift ook de onderbouwing daarvan."
|
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 · Verzadigde cycli voor bevriezen (aantal)"
|
name: "Geavanceerd · Cycli aan grens voor bevriezen"
|
||||||
description: "Hoeveel opeenvolgende verzadigde cycli er nodig zijn voordat de regeling stopt met verder oplopen. ⚠️ Zet dit niet op 1: één ruiscyclus zou de regeling dan al bevriezen."
|
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 · Begrenzing van de integrator (W, 0 betekent gelijk aan het vermogen)"
|
name: "Geavanceerd · Grens opgebouwde afwijking (W)"
|
||||||
description: "Begrenst hoeveel oude afwijking er kan klaarstaan om af te bouwen wanneer het teken omslaat. GEMETEN op de gesloten-lussimulator met een belasting van 4000 W die naar nul valt: bij een grens gelijk aan het omvormervermogen staat het commando twee cycli later op 1000 W, bij anderhalf keer dat vermogen op 1800 W. ⚠️ Zet dit niet hoger dan het omvormervermogen - de uitgangsbegrenzing bepaalt toch al wat er naar de omvormer gaat, dus extra ruimte levert alleen watts in de verkeerde richting op na elke verzadiging. Lager zetten dan het omvormervermogen is de nuttige richting, en de enige reden dat dit een aparte instelling is."
|
description: "Beperkt hoeveel oude afwijking de regeling meedraagt, en 0 betekent het vermogen van de omvormer. Zet dit niet hoger dan dat vermogen."
|
||||||
heartbeat_s:
|
heartbeat_s:
|
||||||
name: "Geavanceerd · Verversingsinterval van het setpoint (s)"
|
name: "Geavanceerd · Commando opnieuw sturen na (s)"
|
||||||
description: "Hoe vaak het setpoint opnieuw geschreven wordt, ook als het niet veranderd is. Dit moet ruim onder de eigen bewaking van de firmware blijven, die een verbinding die 30 s stil is geweest, blokkeert."
|
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 stuurprint."
|
||||||
stale_input_s:
|
stale_input_s:
|
||||||
name: "Geavanceerd · 0 W sturen nadat invoer ontbreekt gedurende (s)"
|
name: "Geavanceerd · Tijdslimiet ontbrekende invoer (s)"
|
||||||
description: "Hoe lang de invoer mag ontbreken voordat de add-on 0 W stuurt. Bij een P1-meter begint deze teller pas te lopen NADAT de verouderingsgrens van de meter al verstreken is, dus de twee stapelen: het totaal is langer dan dit getal op zich."
|
description: "Hoe lang invoer mag ontbreken voor elke omvormer op 0 W gaat. Bij een P1-meter begint dit pas na de grens voor een te oude meting."
|
||||||
cycle_floor_s:
|
cycle_floor_s:
|
||||||
name: "Geavanceerd · Langste tijd tussen twee regelcycli (s)"
|
name: "Geavanceerd · Langste tijd tussen cycli (s)"
|
||||||
description: "Een uiterste termijn voor de tijd tussen twee cycli, die door elke cyclus opnieuw begint. Bij een gezonde meter verstrijkt hij nooit en verandert hij niets. Verhoog hem alleen als uw meter trager is dan ongeveer 10 s, en houd hem onder de verouderingsgrens van de meter - daarna is er toch geen meting meer om te beoordelen."
|
description: "Een uiterste termijn tussen twee regelcycli die een gezonde meter nooit haalt. Verhoog dit alleen voor een meter trager dan ongeveer 10 s."
|
||||||
log_level:
|
log_level:
|
||||||
name: "Geavanceerd · Logniveau"
|
name: "Geavanceerd · Logniveau"
|
||||||
description: "Hoeveel detail de add-on naar zijn logboek schrijft. 'info' is de standaardinstelling; 'debug' en 'trace' dienen om een probleem te onderzoeken en leveren veel tekst op."
|
description: "Hoeveel detail de add-on in zijn logboek schrijft. 'debug' en 'trace' zijn om een probleem te zoeken."
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 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 is a user-facing string that
|
||||||
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start".
|
# does not follow STYLE.md yet: "<kind> <sha1-12 of its text> # where :: start".
|
||||||
# These are waiting for phase 2c (refusal, option-name, option-desc) and 2d
|
# These are waiting for phase 2d (log); phase 2b emptied the row and banner
|
||||||
# (log); phase 2b emptied the row and banner kinds. Delete the line when you
|
# kinds and phase 2c the refusal and option kinds. Delete the line when you
|
||||||
# rewrite the string, because a stale line fails the test. Never add a line for a new string: write the new
|
# rewrite the string, because a stale line fails the test. Never add a line for a new string: write the new
|
||||||
# string to STYLE.md instead.
|
# string to STYLE.md instead.
|
||||||
|
|
||||||
@@ -48,127 +48,3 @@ log 1688a7478ceb # validate.py over 25 words (36) :: 'price_formula is filled i
|
|||||||
log f37f9fce4433 # validate.py over 25 words (96) :: '%s has no "card" block, so its coefficients are UNGRADED: nothing reco'
|
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 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.'
|
log 37bba6ed37e3 # validate.py capitals: REFUSING TO START :: 'REFUSING TO START: the add-on configuration is not usable.'
|
||||||
option-desc b927826d01f0 # en.yaml over 30 words (112); capitals: LEAVING IT OFF DOES NOT TURN THE PRICE FEED OFF :: 'Charge in the cheapest published hour and discharge in the dearest, bu'
|
|
||||||
option-desc b98c42e34df7 # en.yaml over 30 words (34) :: 'The reserve price trading may not spend. Deliberately not the same key'
|
|
||||||
option-desc 84e054da385b # en.yaml over 30 words (39) :: 'Off by default. Leave it off until the site has been commissioned: wit'
|
|
||||||
option-desc 0f55d3b37afa # en.yaml over 30 words (38); banned: 0x0518, register, watchdog :: "The sensor that reports this inverter's own battery power, positive wh"
|
|
||||||
option-desc 307afdd74f84 # en.yaml over 30 words (98); banned: 0x0560, Register; capitals: GW5000S-BP :: "MEASURED, not chosen: the percent at which the pack's OWN battery mana"
|
|
||||||
option-desc 4da37ff6250d # en.yaml over 30 words (66); banned: SAFETY-31; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen contr'
|
|
||||||
option-desc db464169ed66 # en.yaml over 30 words (144); banned: SAFETY-31; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identica'
|
|
||||||
option-desc df9735452fa1 # en.yaml over 30 words (88); banned: OPT-08; capitals: THIS ONE IS NOT DERIVED FROM :: 'What one full round trip costs per kWh: wear on the pack plus conversi'
|
|
||||||
option-desc 8147a82a437e # en.yaml over 30 words (51) :: 'A deadline on the gap between cycles, reset by every cycle. On a healt'
|
|
||||||
option-desc cb66f230ef63 # en.yaml over 30 words (45); banned: register :: 'Errors smaller than this are ignored. ⚠️ The deadband is a one-way rat'
|
|
||||||
option-desc 4167af90cfc2 # en.yaml over 30 words (288); banned: SAFETY-42, delivery_window.py, saturation; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire a'
|
|
||||||
option-desc 3c0898cc1149 # en.yaml over 30 words (63) :: 'What you are paid for feeding a kWh back into the grid, all in. One nu'
|
|
||||||
option-desc 05560996cb2e # en.yaml capitals: MEASURED ON REAL HARDWARE :: 'How much of the remaining error the loop corrects each cycle. MEASURED'
|
|
||||||
option-desc f225d7a8f3a5 # en.yaml over 30 words (32); banned: watchdog :: 'How often the setpoint is rewritten even when it has not changed. It m'
|
|
||||||
option-desc ef70d16a93bd # en.yaml over 30 words (98); banned: clamp, saturation :: 'Caps how much stale error can be waiting to unwind when the sign flips'
|
|
||||||
option-desc bde7357af38d # en.yaml over 30 words (107); capitals: EMPTY DOES NOT MEAN :: 'Leave this empty unless the site really has more than one inverter. EM'
|
|
||||||
option-desc 217c5ae9599c # en.yaml over 30 words (66); banned: 0x0560, register :: "The percent at which THIS pack's own battery management system cuts ou"
|
|
||||||
option-desc 746a0f9ae506 # en.yaml over 30 words (143) :: "THIS pack's usable capacity in kilowatt-hours, from its datasheet or i"
|
|
||||||
option-desc cb6ad204f59d # en.yaml over 30 words (52) :: 'The supervised ceiling on what may be written to THIS inverter; the si'
|
|
||||||
option-desc 46142bcc1ab1 # en.yaml over 30 words (36); banned: heartbeat, latch :: 'A short identifier for this inverter, for example inverter-a. It gives'
|
|
||||||
option-desc d787e9f2454d # en.yaml over 30 words (63); banned: arbiter; capitals: MUST STAY AT OR BELOW :: '⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses t'
|
|
||||||
option-desc a90fced63631 # en.yaml over 30 words (52); banned: CLAMPED :: 'How fast the cycle drains the pack; the surplus is exported. Above the'
|
|
||||||
option-desc ab58c8fae4fb # en.yaml over 30 words (57); capitals: THIS IS NOT THE PEAK GUARD :: 'The depth the monthly recalibration deliberately goes to, once a month'
|
|
||||||
option-desc 73f51bdd3d6e # en.yaml over 30 words (83); capitals: THIS IS YOUR NUMBER :: 'THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may e'
|
|
||||||
option-desc dd9023e6c812 # en.yaml over 30 words (45); capitals: POSITIVE MUST MEAN DRAWING FROM THE GRID :: "The net grid power sensor, used when the meter source is 'off'. POSITI"
|
|
||||||
option-desc aa5b929f3c22 # en.yaml over 30 words (114); banned: SAFETY-18, latch; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answerin'
|
|
||||||
option-desc 1b3038c5c9e5 # en.yaml over 30 words (46) :: 'Beyond this the grid figure counts as missing. On its own it does not '
|
|
||||||
option-desc bbf26329f056 # en.yaml over 30 words (48); banned: telegram, telegrams :: 'Must match your connection. A telegram whose phase count disagrees is '
|
|
||||||
option-desc 92da72e54e77 # en.yaml over 30 words (52) :: "For meter source homewizard_local only. 5 s is the meter's own update "
|
|
||||||
option-desc 24f329136309 # en.yaml over 30 words (84) :: "'off' keeps the single Home Assistant entity below, which is what an e"
|
|
||||||
option-desc 47e5fa0dba52 # en.yaml banned: register :: 'The UNSIGNED injection register, the counterpart of the import registe'
|
|
||||||
option-desc 9b8f5ea6c656 # en.yaml banned: 192.168.2.250 :: "For meter source homewizard_local only: the meter's own address on you"
|
|
||||||
option-desc d5d3e30ac4d2 # en.yaml over 30 words (37); banned: register :: 'The UNSIGNED consumption register. Do not point this at a signed templ'
|
|
||||||
option-desc 0b05d89400ba # en.yaml over 30 words (39) :: 'For meter source ha_signed only. ONE signed sensor: positive is drawn '
|
|
||||||
option-desc e74a5db257e1 # en.yaml over 30 words (46) :: 'For meter source ha_signed only. Optional, in L1..L3 order, each signe'
|
|
||||||
option-desc 3f07a88a61fd # en.yaml over 30 words (34); capitals: THIS IS YOUR NUMBER :: 'THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tr'
|
|
||||||
option-desc 6ec195081ef9 # en.yaml over 30 words (112); banned: SAFETY-24; capitals: IT BOUNDS THE PEAK GUARD ONLY :: 'DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usa'
|
|
||||||
option-desc 228fdc168458 # en.yaml over 30 words (270); capitals: INJECTION IS EXEMPT FROM VAT :: 'Optional, and only if your supplier pays you a price that moves with t'
|
|
||||||
option-desc 98987808a7f0 # en.yaml over 30 words (98); capitals: WHAT YOU TYPE IS SENT EXACTLY AS WRITTEN :: "Leave empty to use the chosen source's own feed. Only a host that sour"
|
|
||||||
option-desc 26f17751b2f7 # en.yaml over 30 words (234); capitals: IN THE UNITS THE CARD PRINTS :: "For price source custom_formula only: your own contract's two coeffici"
|
|
||||||
option-desc 011841a82d7d # en.yaml over 30 words (34) :: "Your supplier's margin. The add-on adds it ONLY when the chosen price "
|
|
||||||
option-desc d34ef16ba99a # en.yaml over 30 words (63) :: 'The older way of getting a price, kept because retiring it would take '
|
|
||||||
option-desc c70929091e71 # en.yaml over 30 words (252); capitals: SWITCHING THIS ON IS WHAT STARTS THE PRICE FETCH :: "Where day-ahead or real-time prices come from. 'off' is what ships, an"
|
|
||||||
option-desc a62e17ccf445 # en.yaml over 30 words (230); banned: TARIFF-18; capitals: THIS NUMBER IS NOT :: 'Everything on your bill that is not the energy itself - distribution, '
|
|
||||||
option-desc 56d56f58c43e # en.yaml over 30 words (206); capitals: THE NUMBERS HERE ARE THE LEVIES ALONE :: 'Leave this empty unless your network tariff really does charge a diffe'
|
|
||||||
option-desc 0b7ea32cf8d0 # en.yaml over 30 words (193); capitals: MEASUREMENT ONLY :: 'Optional, and MEASUREMENT ONLY - it changes no control behaviour whats'
|
|
||||||
option-desc 4172d9920d65 # en.yaml over 30 words (128); capitals: MEASUREMENT ONLY :: 'Optional, and MEASUREMENT ONLY - it changes no control behaviour whats'
|
|
||||||
option-desc 09ceba2c6afb # en.yaml over 30 words (70); banned: clamp; capitals: MEASUREMENT ONLY :: 'Optional, MEASUREMENT ONLY. Some sensors - typically a kWh meter or a '
|
|
||||||
option-desc 42a7eaac6ff1 # en.yaml banned: saturated :: 'How many consecutive saturated cycles before the loop stops winding up'
|
|
||||||
option-desc 12b0dffec9dd # en.yaml over 30 words (42) :: 'The gap between what was commanded and what the inverter delivers that'
|
|
||||||
option-desc 91fd58c08a59 # en.yaml over 30 words (54) :: 'The ESPHome number entity this add-on writes the power setpoint to, no'
|
|
||||||
option-desc 01e1af709cd5 # en.yaml over 30 words (201); banned: arbiter, clamps, heartbeat, latch :: 'Runs the whole decision path — strategies, arbiter, clamps and guards '
|
|
||||||
option-desc 96b077d4e2e9 # en.yaml over 30 words (72); banned: SAFETY-02; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter '
|
|
||||||
option-desc addf5c495ae8 # en.yaml over 30 words (90); banned: SAFETY-02, watchdog; capitals: NOT CHOSEN :: 'MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and '
|
|
||||||
option-desc 0a66ac772b27 # en.yaml over 30 words (117); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (S'
|
|
||||||
option-desc c0deabb69ab6 # en.yaml over 30 words (60); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter thre'
|
|
||||||
option-desc 7d7272743277 # en.yaml over 30 words (123); banned: SAFETY-39; capitals: MEANS FOLLOW THE INVERTER RATING :: 'How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW '
|
|
||||||
option-desc 436efca21a7d # en.yaml over 30 words (31) :: "The sensor that reports the pack's state of charge in percent. Use the"
|
|
||||||
option-desc 9ac8a3b21a1e # en.yaml over 30 words (43) :: 'How long the inputs may be missing before the add-on commands 0 W. Wit'
|
|
||||||
option-desc 86cbfd6a5693 # en.yaml over 30 words (130); capitals: RECOMMENDED EVEN ON :: 'Your fixed or day/night contract, written as a single line of JSON. RE'
|
|
||||||
option-desc 996dffbf563d # en.yaml over 30 words (74); banned: register :: 'What the meter should sit at when nothing is happening; negative means'
|
|
||||||
option-desc faf346256008 # en.yaml over 30 words (52); banned: SAFETY-30; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (S'
|
|
||||||
option-desc 5323134f3131 # en.yaml over 30 words (95); banned: SAFETY-30, saturation; capitals: TWO OR MORE INVERTERS ONLY :: 'TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothin'
|
|
||||||
option-name ca3a62bb8260 # en.yaml over 6 words (7) :: 'Circulating-power hold (s) — derived, do not change'
|
|
||||||
option-name edee163fdc2b # en.yaml over 6 words (7) :: 'Circulating-power threshold (W) — measured, do not change'
|
|
||||||
option-name 99b3beeab621 # en.yaml over 6 words (7) :: 'Cost of one full charge/discharge cycle (EUR/kWh)'
|
|
||||||
option-name cccd8a42f51a # en.yaml over 6 words (8) :: 'Not-delivering report after (s) — derived, do not change'
|
|
||||||
option-name 989891e2ce23 # en.yaml over 6 words (10); banned: Integrator :: 'Integrator bound (W, 0 means the same as the rating)'
|
|
||||||
option-name 42c6b595320c # en.yaml over 6 words (9) :: 'Inverter list (leave empty if you have one inverter)'
|
|
||||||
option-name 230e6dc9d872 # en.yaml over 6 words (7) :: 'Hour of day a due cycle starts'
|
|
||||||
option-name 18e1de907c54 # en.yaml over 6 words (8) :: 'Frozen-meter alarm after (s) — measured, do not change'
|
|
||||||
option-name 9071c4cfa460 # en.yaml over 6 words (7) :: 'A reading counts as stale after (s)'
|
|
||||||
option-name e3d06ed93e2e # en.yaml banned: register :: 'Export register entity (W)'
|
|
||||||
option-name a0f59ea5de36 # en.yaml banned: register :: 'Import register entity (W)'
|
|
||||||
option-name ed757924f40f # en.yaml over 6 words (7) :: 'Per-phase signed net entities (L1, L2, L3)'
|
|
||||||
option-name 52d03808da01 # en.yaml over 6 words (8) :: 'Your own INJECTION formula (one line of JSON)'
|
|
||||||
option-name bf22f2121af4 # en.yaml over 6 words (8) :: 'Your own tariff formula (one line of JSON)'
|
|
||||||
option-name 5593992fcf5b # en.yaml over 6 words (7) :: 'How far ahead to fetch prices (hours)'
|
|
||||||
option-name d9fee5514a4b # en.yaml over 6 words (11) :: 'Levies and VAT by time of day (one line of JSON)'
|
|
||||||
option-name eac7ee084aaa # en.yaml banned: Saturated :: 'Saturated cycles before freezing (count)'
|
|
||||||
option-name 5c6bbc5cab40 # en.yaml banned: Saturation :: 'Saturation threshold (W)'
|
|
||||||
option-name 99a1ec154d36 # en.yaml over 6 words (7) :: 'Shadow mode — run every decision, command nothing'
|
|
||||||
option-name 82fcd0faa687 # en.yaml over 6 words (8); banned: watchdog :: 'Sign watchdog hold (s) — measured, do not change'
|
|
||||||
option-name 8fc34172dc01 # en.yaml over 6 words (8); banned: watchdog :: 'Sign watchdog threshold (W) — measured, do not change'
|
|
||||||
option-name 8516934704f5 # en.yaml over 6 words (8) :: 'Site-wide sign hold (s) — derived, do not change'
|
|
||||||
option-name 2b8278ab2b16 # en.yaml over 6 words (8) :: 'Site-wide sign threshold (W) — measured, do not change'
|
|
||||||
option-name bd52e795ea97 # en.yaml over 6 words (8) :: 'Command 0 W after inputs missing for (s)'
|
|
||||||
option-name 9e33febf4481 # en.yaml over 6 words (8) :: 'Fixed or day/night tariff (one line of JSON)'
|
|
||||||
option-name 40e1e3ba3841 # en.yaml over 6 words (8) :: 'Per-inverter sign hold (s) — derived, do not change'
|
|
||||||
option-name 4ba7087578dc # en.yaml over 6 words (8) :: 'Per-inverter sign threshold (W) — derived, do not change'
|
|
||||||
refusal 87620efa0704 # p1.py:1176 banned: telegram :: 'p1_phase_net_entities has {len(phase_net)} entities but meter_phases i'
|
|
||||||
refusal 5d1b8147792b # p1.py:1186 banned: 192.168.2.250 :: "meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's own addres"
|
|
||||||
refusal 701db346696d # validate.py over 30 words (85); banned: SAFETY-30, watchdog :: 'unit_sign_disagree_w is {unit_sign_w} W but max_w{unit_where} is {weak'
|
|
||||||
refusal 710b4d89a9d7 # validate.py over 30 words (88); banned: ARBITER, arbiter; capitals: THE CEILING WINS :: 'maintenance_charge_w is {charge_w} W but max_w{maint_where} is {maint_'
|
|
||||||
refusal 16d40434116b # validate.py over 30 words (193); banned: NOTES.md, §5; capitals: THE PLANT WINS :: 'slew_w is {slew_w} W against a site rail of {rail} W, so the loop cros'
|
|
||||||
refusal 613375fb6b5f # validate.py over 30 words (107); banned: SAFETY-07; capitals: THE BMS WINS :: "peak_soc_floor is {soc_floor} % but the pack's own cut-out (bms_soc_fl"
|
|
||||||
refusal ba21c5ba8c0d # validate.py over 30 words (224); banned: TARIFF-07, §1; capitals: IT HAS NEVER FETCHED :: 'price_provider is {pid}, which WAS a shipped plugin and has been WITHD'
|
|
||||||
refusal de8df32186b6 # validate.py over 30 words (46); banned: §1 :: 'price_provider is {pid}, which is not a shipped plugin. The ids that e'
|
|
||||||
refusal 0ee7c15eafef # validate.py over 30 words (103); banned: arbitrage.py, main.py, static_tariff.py; capitals: THE HOME ASSISTANT ENTITIES WIN :: "price_provider is {pid} AND {' and '.join(ha_price)} {'is' if len(ha_p"
|
|
||||||
refusal c4149a762c39 # validate.py over 30 words (62); banned: main.py; capitals: THE HOME ASSISTANT ENTITIES WIN :: "static_profile is set AND {' and '.join(ha_price)} {'is' if len(ha_pri"
|
|
||||||
refusal 6af34466bbc2 # validate.py over 30 words (83); capitals: THE TABLE WINS :: '{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is {scalar} rath'
|
|
||||||
refusal 8f69bdbbf3fc # validate.py over 30 words (55) :: '{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian levies and dis'
|
|
||||||
refusal 96a090d2ad97 # validate.py over 30 words (72); banned: DOCS.md, §1 :: "price_provider is 'custom_formula' but price_formula is empty, so the "
|
|
||||||
refusal db9d0d53c4f1 # validate.py over 30 words (45); banned: §1 :: "cycle_cost_threshold_eur is {opts.get('cycle_cost_threshold_eur')} and"
|
|
||||||
refusal 58ccb3bbd560 # validate.py over 30 words (63); banned: §1; capitals: NO PRICES AT ALL :: "arbitrage_enabled is on but price_provider is 'off' and static_profile"
|
|
||||||
refusal 780592c897fb # validate.py over 30 words (85); banned: arbitrage.py, telegram, §9 :: "arbitrage_enabled is on but meter_source is 'off', so there is no P1 t"
|
|
||||||
refusal 4f80c091ca95 # validate.py over 30 words (69) :: 'arbitrage_soc_floor is {floor} % and arbitrage_soc_ceiling is {ceiling'
|
|
||||||
refusal 0c089bb2f200 # validate.py over 30 words (57); banned: §1 :: "price_provider is 'manual_csv' but price_csv_path is empty, so the off"
|
|
||||||
refusal 152dfec79224 # validate.py over 30 words (48); banned: SAFETY-02, heartbeat, latch :: '{where} has no name. Every inverter needs one: the Store namespaces th'
|
|
||||||
refusal eb14af760f5c # validate.py over 30 words (51); banned: SAFETY-02, heartbeat, latch :: '{where} and {seen[name]} are both called {name}. The Store keys every '
|
|
||||||
refusal 36f53c989b4a # validate.py over 30 words (55) :: '{who} has no setpoint_entity, so this add-on cannot command it. It wou'
|
|
||||||
refusal c98c6ab66e9f # validate.py over 30 words (56) :: "{who} has no usable max_w (got {entry.get('max_w')}). It is the superv"
|
|
||||||
refusal 5ca16a4d0a1e # validate.py over 30 words (71) :: '{who} has capacity_kwh {raw}, which is not a usable number of kilowatt'
|
|
||||||
refusal 63a864d2e0a3 # validate.py over 30 words (73) :: "capacity_kwh is stated on {', '.join(stated)} and left empty on {', '."
|
|
||||||
refusal 8d82500ecb1d # validate.py over 30 words (64) :: 'setpoint_entity is set to {flat_sp} AND an inverters list of {len(entr'
|
|
||||||
refusal 3d778e0ab5e1 # validate.py over 30 words (36); capitals: THE DEVICE WINS :: '{name}: max_w is {cap} W but {entity} accepts at most {float(dev_max)}'
|
|
||||||
refusal ecd0883f5649 # validate.py over 30 words (36); capitals: THE DEVICE WINS :: '{name}: max_w is {cap} W, so this controller may command {-cap} W, but'
|
|
||||||
refusal eff7efac51f8 # validate.py over 30 words (56); banned: heartbeat :: 'setpoint_entity is not set, so this add-on has no actuator: every writ'
|
|
||||||
refusal b126f1a88b77 # validate.py over 30 words (76); banned: §1 :: '{len(who)} configured writers can reach the one actuator {actuator}: {'
|
|
||||||
refusal 07a88756c864 # validate.py over 30 words (48); banned: arbiter :: 'max_w is {max_w} W and must be positive - it is a magnitude, not a sig'
|
|
||||||
refusal c4a1d0371e31 # validate.py over 30 words (42); capitals: THE DEVICE WINS :: "max_w is {max_w} W but {opts.get('setpoint_entity') or 'the setpoint d"
|
|
||||||
refusal a15624d1aa10 # validate.py over 30 words (33); capitals: THE DEVICE WINS :: 'max_w is {max_w} W, so this controller may command {-max_w} W, but {op'
|
|
||||||
refusal 26d0602c32d7 # validate.py over 30 words (79); capitals: THE FIRMWARE WINS :: 'meter_max_age_s is {max_age_s} s but the ESP32 firmware vetoes any P1 '
|
|
||||||
refusal 2ade09aaa579 # validate.py over 30 words (73); banned: SAFETY-02, arbiter, watchdog :: 'sign_disagree_w is {sign_w} W but max_w{where} is {weakest} W, so the '
|
|
||||||
|
|||||||
Reference in New Issue
Block a user