checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat scalar. This adds `price_taxes_profile`, which carries the TAXES component as a period table, and makes `assemble()` evaluate it at each point's own instant. ⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE. The optimiser reads exactly two quantities off a series - the spread `max - min` over the firm points, and which hour is cheapest - and an additive constant identical in every hour cancels out of the first and leaves the second where it was. It is provably inert. Measured, and printed by test_price.py 16b on every run: with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00 EUR/kWh the same 24-hour feed is cheapest at 13:00 in all five cases and its spread is 0.1500 EUR/kWh in all five cases. What was missing is anywhere for an HOUR-VARYING non-energy component to live. This is a missing capability, not a bad constant. Belgium has one. Fluvius distribution is a single rate - its tariff-card row reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical circuit and not a night register - so nothing changes for the reference site in Flanders. The Walloon DSOs and Sibelga are on the same card with four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real money (c/kWh): ORES 13,27 / 7,39; AIESH 15,17 / 8,21; Tecteo RESA 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090. ⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The cheap network hours are the cheap spot hours, so flattening them does not shift the curve - it UNDERSTATES the spread and declines cycles that would have paid. Same feed, same markup, the only difference being the taxes argument: flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500 ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788 The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an implementation that reads the table once and adds one number to every point is the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a spread assertion passes on it. Measured: hoisting the lookup out of `assemble()`'s loop (+17 B) is red on six named checks in test_price.py and GREEN in the other 28 files. ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is the same parser with the option name and the "what this number means" clause as arguments. A second tiling rule would be a second set of 23- and 25-hour-day bugs. test_static_tariff.py section 10 pins that two ways: by parsing every module under `app/` and asserting the grammar is defined in exactly one of them, and by deriving each of thirteen refusal sentences for the new option from the old option's own sentence rather than transcribing them. `Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee are untouched. The new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and a plugin that names it is refused at `register()`. It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP}) and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the table is routed through `capabilities().adds`, exactly as the scalar is. ⚠️ A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH. It is read on the ~30 s planning path, and a table that first fails at 03:00 is a site that stops pricing at 03:00, which §11.4 turns into a quiet fallback indistinguishable from a healthy fixed-tariff day. validate.py refuses three things: a table that does not tile the day; a table set beside a NON-DEFAULT `price_taxes_eur_kwh`, because the table wins and the scalar would then be a number the operator typed that decides nothing; and a whole card typed in CENTS, which is how these figures are printed. That last rung is not a restatement of the parser's +-10 EUR/kWh bound - ORES's 13,27 trips the parser, but Sibelga's entire card, 9,39 and 7,10, is under it and would otherwise have started as a 9.39 EUR/kWh levy. Both rungs are graded by the REASON each gives, and mutating them together (TEST-16) moves three rows. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the scalar path is unchanged arithmetically rather than merely in intent: test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with `==`. 13 mutations, every one red on a named check, ledger in test_price.py below section 16k with byte deltas and the one equivalent mutant named. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
1693 lines
100 KiB
Python
1693 lines
100 KiB
Python
"""Startup configuration validation - the gate before the control loop runs.
|
|
|
|
WHAT THIS IS FOR, in one sentence: some configurations are not "a bit off", they
|
|
are configurations under which the add-on's own safety machinery does not work,
|
|
and for those the correct behaviour is to refuse to start rather than to log a
|
|
line and carry on.
|
|
|
|
⚠️ THE FAILURE THIS REPLACES. `main.py` used to read the setpoint device's own
|
|
±5000 W rating, notice that a configured `max_w` of, say, 8000 W exceeded it,
|
|
write one WARNING to a log nobody reads, and then run anyway with
|
|
`write_setpoint()` quietly clamping. The operator's stated ceiling was simply not
|
|
the ceiling, and the only evidence was a line that had scrolled off. specs §1.5
|
|
is explicit that a configuration in which the system cannot honour what it was
|
|
told is refused at startup, not survived at runtime.
|
|
|
|
WHERE THE LINE IS DRAWN, and why it is not further out. A validator that refuses
|
|
on trivia gets switched off by the first person it inconveniences, so this one
|
|
refuses only where **continuing produces one of two outcomes:**
|
|
|
|
(a) **a stated limit is untrue** - the operator configured a bound, a lower
|
|
layer silently enforces a different one, and nothing says which won; or
|
|
(b) **the add-on is inert and indistinguishable from healthy** - it starts,
|
|
serves its UI, reports green, and moves no energy, with no symptom that
|
|
separates it from a correctly-idle system.
|
|
|
|
⚠️ (b) IS NOT A SPECIAL CASE OF (a), and an earlier draft of this file wrongly
|
|
implied it was by claiming every refusal here "makes a safety mechanism
|
|
inoperative". It does not: when `setpoint_entity` is blank, layer 1 pinning the
|
|
inverter at 0 W is the failsafe WORKING. The problem is not that the system
|
|
becomes unsafe, it is that it becomes a very convincing impression of a working
|
|
controller - and an operator has no way to tell. That is its own reason to
|
|
refuse, and stating it as one is what stops the rule being stretched later to
|
|
cover mere untidiness.
|
|
|
|
Both halves have to stay narrow. (a) is not "any two numbers that could
|
|
disagree"; it is a bound the operator SET being overridden by a layer they
|
|
cannot see. (b) is not "anything degraded"; it is inert AND silent - if a red
|
|
NOT READY banner already names the problem, (b) does not apply.
|
|
|
|
1. `max_w` outside the setpoint device's own rating. [(a)]
|
|
The stated limit is untrue. arbiter.py's `supervised` SAFETY claim says the
|
|
output is bounded by ±max_w; it is not, it is bounded by the device, and the
|
|
two numbers disagree. Which one wins is a coin toss to anyone reading the
|
|
config.
|
|
|
|
2. No `setpoint_entity`. [(b)]
|
|
A controller with no actuator, and nothing about it looks broken.
|
|
`write_setpoint()` returns immediately on a blank entity, so: no device
|
|
rating is ever read (the `device` SAFETY claim is never made at all), and
|
|
the ESP32 heartbeat - the thing that feeds LAYER 1 - never happens. The
|
|
add-on serves a UI, publishes MQTT, resolves claims, and moves nothing.
|
|
Layer 1 then holds the inverter at 0 W, which is the failsafe working
|
|
exactly as designed - this is (b), not (a). Nothing is unsafe; it is simply
|
|
impossible to tell from the outside that the thing is doing nothing.
|
|
|
|
3. A `meter_source` that can never produce a reading. [(b)]
|
|
§5.1 makes P1 the only authoritative measurement and deliberately provides
|
|
no fallback, so a transport that cannot be built means `grid` is None
|
|
forever: permanent "inputs missing", 0 W, and a status page that reports a
|
|
meter with a climbing age exactly as it would for a healthy meter nobody has
|
|
sent anything to yet. The predicates live in `p1.validate_meter_options()`,
|
|
which is also what `build_source()` uses - one definition, two callers.
|
|
|
|
4. `meter_max_age_s` above the firmware's own ingest veto. [(a)]
|
|
The purest example of (a) in the system, and structurally the same fault as
|
|
1. The add-on's schema accepts `int(5,300)`. The firmware's threshold is a
|
|
COMPILE-TIME SUBSTITUTION - `max_age_s: "30"` in
|
|
goodwe-master-stage4-failsafe.yaml - used by both its monotonic receipt
|
|
clock and its `ingest_stale` veto, which trips layer 1 on
|
|
`p1_age_s >= 30`. Set 120 and the add-on believes a 119 s reading is fresh
|
|
and keeps commanding into it, while the firmware vetoed at 30 s and is
|
|
holding the inverter at 0 W. Status page: healthy meter, running
|
|
controller, nothing moving, and no line anywhere saying the number you set
|
|
was overruled. That firmware file's own comment says a lower bound on this
|
|
"belongs in CFG-01, not here" - so here it is.
|
|
|
|
5. `sign_disagree_w` - or SAFETY-30's `unit_sign_disagree_w` - at or above
|
|
`max_w`. [(a)]
|
|
The arbiter's `supervised` limit bounds every command to +-max_w, and
|
|
SAFETY-02's predicate needs `abs(command) > sign_disagree_w`. Set the
|
|
threshold at or above the ceiling and the command side of the comparison
|
|
is unsatisfiable BY CONSTRUCTION: the watchdog can never fire, on any
|
|
input, ever. Same shape as 1 and 4 - the operator set a number, a layer
|
|
they cannot see makes it mean something else, and nothing says so. Worse
|
|
here, because what the status page reports in that state is "sign
|
|
watchdog: armed". A disarmed guard that advertises itself as armed is the
|
|
one thing this project has decided repeatedly it will not ship.
|
|
|
|
6. `maintenance_charge_w` above `max_w`. [(a)]
|
|
The same shape as 5, and found the same way - by checking a worked example
|
|
against what actually ships. maintenance's cheap-window shaping files
|
|
`Claim.limit(hi=-maintenance_charge_w)` at P_MAINT_SHAPE, against the
|
|
`supervised` SAFETY limit's `lo=-max_w`. Both bind (P_SAFETY and
|
|
P_MAINT_SHAPE both outrank the loop that wins the SET), so a charge rate
|
|
above the ceiling gives `lo > hi` - the arbiter's clause 3, which it
|
|
correctly resolves to 0 W and logs as a CONTRADICTION. Measured against
|
|
the arbiter, loop asking +500 W:
|
|
|
|
max_w=2000 hi=-2500 -> +0 W contradiction=True
|
|
max_w=2500 hi=-2500 -> -2500 W limited by supervised(max_w)
|
|
max_w=3000 hi=-2500 -> -2500 W limited by maintenance(...)
|
|
|
|
So MAINTENANCE SILENTLY NEVER CHARGES IN A CHEAP WINDOW until max_w is at
|
|
or above the charge rate, and the only evidence is a contradiction line
|
|
that reads like a bug in the arbiter rather than like the two numbers the
|
|
operator set. Equality is fine and deliberately not refused - at
|
|
max_w == charge_w the window is a single reachable point, -max_w.
|
|
|
|
⚠️ `maintenance_discharge_w` IS NOT THE SAME CASE, and this was checked
|
|
rather than assumed. THE SHAPING IS NOT SYMMETRIC: the drain phase does
|
|
not shape the loop, it OWNS the actuator - `Claim.set(P_MAINTENANCE,
|
|
+discharge_w)`. A SET is clamped by the limits above it, not contradicted
|
|
by them, so 2500 against a 2000 W ceiling emits +2000 W and the arbiter's
|
|
own explain() says `maintenance -> 2000 W, limited by supervised(max_w)`
|
|
on the status page every cycle. That is neither (a) - nothing is silent -
|
|
nor (b) - it moves energy and the cycle still completes, just slower
|
|
(13.8 kWh usable at 2000 W is ~6.9 h against the 720 min DRAIN_TIMEOUT).
|
|
It is warned about below, not refused.
|
|
|
|
7. A price configuration that cannot mean what it says. [(a)]
|
|
Four cases, all in `_price_problems()`: an unknown `price_provider` id
|
|
(refused rather than defaulted to 'off', per the §14 warning box); a
|
|
RETIRED id - one this add-on really did ship and has been accepting ever
|
|
since, whose plugin never once fetched a price because its endpoint does
|
|
not exist (TARIFF-07), which is case (a) in its purest form: §11.4's
|
|
silent fall back to the static profile IS the layer the operator cannot
|
|
see, and it has been overriding their chosen source since the day they
|
|
set it;
|
|
`price_provider` set alongside `price_now_entity`/`price_avg_entity`,
|
|
which is TWO PRICE SOURCES ON ONE SITE where the Home Assistant pair
|
|
silently wins; and `manual_csv` with no `price_csv_path`, which turns the
|
|
documented offline fallback into a permanent no-price-data condition
|
|
reached by accident. Same shape as 1, 4 and 5 every time - a source the
|
|
operator chose, overridden by a layer they cannot see.
|
|
|
|
⚠️ `price_provider: "off"` IS NOT REFUSED AND MUST NOT BECOME SO. §11.4
|
|
makes no-price-data a supported state, and §9.2 says a large share of the
|
|
installed base is on a fixed or static tariff. Refusing to start without a
|
|
price feed would lock those sites out of the add-on entirely.
|
|
|
|
8. `peak_soc_floor` at or below `bms_soc_floor`, ON ANY UNIT. [(a)]
|
|
SAFETY-24, and the same shape as 1, 4, 5 and 6. The peak guard can COMMAND
|
|
a discharge to hold a billed quarter down (SAFETY-07), and `peak_soc_floor`
|
|
is the EMS-side bound on how far down it may take the pack. The BMS has its
|
|
own cut-out and enforces it regardless. Set the EMS floor at or below that
|
|
cut-out and THE BMS WINS: the EMS bound can never be the thing that stops
|
|
the discharge, so the add-on carries an energy backstop that is incapable
|
|
of binding while its status page reports one - rule 5's "a disarmed guard
|
|
that advertises itself as armed", on the guard whose failure mode is a pack
|
|
that is quietly never available when it is wanted.
|
|
⚠️ SAFETY-32: `bms_soc_floor` DESCRIBES A BATTERY, so at a roster it is a
|
|
per-entry key and this rule grades EVERY unit and NAMES the one it refuses.
|
|
One number for two packs of different models refuses the wrong config and
|
|
accepts the wrong one - cut-outs of 10 % and 30 % under `peak_soc_floor: 20`
|
|
used to start. A missing per-entry cut-out is refused here rather than
|
|
defaulted: a cut-out invented for a pack IS this defect.
|
|
|
|
⚠️ `<=`, NOT `<`, and the equality case is the interesting one. At exactly
|
|
equal, the EMS floor and the pack's cut-out act at the same percent, so the
|
|
EMS floor buys ZERO margin - it can never prevent the pack reaching the BMS
|
|
limit, which is the entire thing this option exists to prevent. That is not
|
|
"close to the rail", it is the disarmed case with an extra step. Rule 6
|
|
permits equality because equality there leaves one reachable point that
|
|
works; here it leaves none.
|
|
|
|
9. An `inverters:` roster that cannot mean what it says. [(a) and (b)]
|
|
CFG-04's five cases are enumerated on `_roster_problems()`. Three of them
|
|
are (b) - an entry with no `setpoint_entity`, or no rating, is a unit that
|
|
takes a share of the site target every cycle and delivers none of it, on a
|
|
status page that reports a site running at full strength. Two are (a): two
|
|
entries sharing a name share one Store namespace, so one unit's SAFETY-02
|
|
latch IS the other's and an operator clearing one clears both; and a flat
|
|
`setpoint_entity` left set beside a declared roster is an actuator the
|
|
configuration names and nothing writes.
|
|
|
|
⚠️ RULES 5 AND 6 CHANGED SHAPE FOR THIS, and that is the part worth
|
|
reading. Both compared a threshold against the flat `max_w`, which was
|
|
right while there was exactly one inverter and is a PERMISSIVE READER the
|
|
moment there are two: `max_w: 5000` left over from a single-inverter
|
|
install, beside a roster of two 2000 W units, passed rule 5 at
|
|
`sign_disagree_w: 3000` while the watchdog could fire on NEITHER unit -
|
|
rule 5's own failure mode, reached through rule 5. They now read
|
|
`unit_caps()`, which is the roster's caps and, at one inverter, is
|
|
`[max_w]` exactly.
|
|
|
|
What rule 9 deliberately does NOT do is grade a rating against a device.
|
|
`dev_min`/`dev_max` here are one device's range and there are now N of
|
|
them, none of which has answered at startup. `validate_unit_ranges()` is
|
|
the per-unit form and main.py runs it from `run_control()`, once every
|
|
unit's own ESPHome has replied.
|
|
|
|
10. `slew_w` above the fraction of the SITE RAIL the plant can follow. [(a)]
|
|
SAFETY-39, and the first rule here whose subject is a RELATIONSHIP rather
|
|
than a threshold. `slew_w` bounds how far the site command may move in one
|
|
cycle; `control.SLEW_RAIL_FRAC` is `LOOP_TICK_S / PLANT_SETTLE_S`, so
|
|
`SLEW_RAIL_FRAC * rail` is the largest step at which the loop still takes
|
|
the plant's own measured settling time to traverse its whole output range.
|
|
Above it the controller commands its entire range before it can observe
|
|
its answer to the first step, and the stated limit - "the command moves at
|
|
most `slew_w` per cycle, so it cannot jump ahead of the inverter" - is
|
|
untrue: it jumps ahead by construction.
|
|
|
|
⚠️ THIS IS (a) AND NOT (b), and the distinction is what makes it a refusal
|
|
rather than a warning. Nothing goes inert. What happens is that a bound
|
|
the operator set means something other than what it says, and the layer
|
|
that overrules it is the PLANT - which has no status row, publishes
|
|
nothing, and cannot be read off any page in this add-on. Measured on the
|
|
owner's system 2026-09-05 11:43-11:46 at `max_w: 5000`, `slew_w: 1000`:
|
|
setpoint 380 -> 5000 in five 2 s ticks, battery -5154 W against a 5000 VA
|
|
inverter, and the grid driven from 3.4 kW of EXPORT to 2.8 kW of IMPORT.
|
|
Large (>= 900 W) setpoint steps per 30 min climbed 2 -> 1 -> 13 -> 43 that
|
|
morning. Nothing in the add-on reported anything wrong, because nothing
|
|
was: the loop followed its command exactly, and the command was wrong.
|
|
|
|
⚠️ IT IS GRADED AGAINST THE ROSTER SUM, NOT THE FLAT `max_w` AND NOT THE
|
|
WEAKEST UNIT, and this is the one rule in this file for which that is the
|
|
right rail. Rules 5, 6 and 8 are all about ONE unit - one watchdog, one
|
|
shaping claim, one pack - so they take `min()` over `unit_caps()`.
|
|
`slew_w` is not per unit: `main.py` builds `Tuning(max_w=self.site_max_w)`
|
|
and `compute()` produces the SITE target, which the allocator then splits.
|
|
So the range this bound is about is the sum, `Controller.site_max_w`, and
|
|
`site_rail_w()` below is that same number computed from options.
|
|
|
|
⚠️ AND THE `site_sign_disagree_w` NOTE IN config.yaml IS NOT A PRECEDENT
|
|
AGAINST THIS. That note says there is deliberately no rule pairing
|
|
`site_sign_disagree_w` with `max_w`, "because the bound on the SITE command
|
|
is `Controller.site_max_w`, the ROSTER SUM, and `validate_startup_config()`
|
|
is handed options rather than a roster. A rule written against max_w would
|
|
refuse a perfectly reachable two-inverter configuration." That objection is
|
|
to grading a site quantity against the FLAT key. It is answered rather than
|
|
ignored: `unit_caps()` already reads the roster the add-on will build, so
|
|
the roster sum IS available here, and this rule takes it.
|
|
|
|
⚠️ THE DIRECTION OF THIS RULE, SAID PLAINLY, BECAUSE IT IS NOT THE
|
|
DIRECTION THE TICKET ASSUMED. A rail that GROWS loosens this bound: at a
|
|
9600 W two-inverter rail, `slew_w: 1000` traverses the rail in 19.2 s and
|
|
passes, while at 5000 W the same 1000 W is the configuration measured
|
|
above. That is what the constraint says and it is correct - the bound is on
|
|
traverse TIME, and a bigger rail at the same step size takes longer to
|
|
cross. What this rule therefore protects against is the constant being left
|
|
behind when the rail moves EITHER way. It is NOT a bound on how much power
|
|
the site may command, and it does NOT bound the over-absorption: see the
|
|
note below on `gain`, which is the term that owns that and which SAFETY-39
|
|
measured and did not change.
|
|
|
|
WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
|
|
|
- `integrator_max_w` above `max_w`. DOCS.md is right that the only thing the
|
|
extra headroom buys is more wrong-direction power to unwind after
|
|
saturation - but the arbiter's `supervised` limit still binds the wire to
|
|
±max_w, so nothing out of range ever reaches the inverter. The cost is
|
|
latency, not amperage. Warned about, not refused.
|
|
- A missing `soc_entity` / `batt_entity`. `inputs_ok` goes False, the loop
|
|
commands 0 W, and `checks()` already renders a red NOT READY banner naming
|
|
the missing entity. Fail-safe AND already loud; a refusal adds nothing.
|
|
- A `setpoint_entity` that exists in the config but is not found on the Core
|
|
API at startup. This is the transient case - ESPHome is frequently not up
|
|
yet when the add-on boots - and refusing here would turn a device that
|
|
arrives thirty seconds late into a restart loop needing a human. It stays a
|
|
NOT READY banner.
|
|
- `meter_poll_s` above half `meter_max_age_s`. One missed poll costs a stale
|
|
reading and a 0 W cycle. Warned about in p1.py, not refused.
|
|
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
|
|
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
|
|
not refused.
|
|
- A device rating that makes SAFETY-18's freeze rail unreachable. SAFETY-20's
|
|
AC 4, decided here rather than left implicit: **NO NEW RULE, and rule 2 is
|
|
the reason.** The configuration the ticket is about is `max_w` above the
|
|
setpoint device's rating, which is rule 2 EXACTLY - already refused, on the
|
|
identical predicate, since CFG-01, and re-run by `run_control()` once
|
|
ESPHome is up and the real rating is known. A second rule phrased as
|
|
"`RAIL_FRAC * max_w` exceeds the device rating" would fire on a strict
|
|
subset of the configurations rule 2 already refuses (`0.95 * max_w > dev_max`
|
|
implies `max_w > dev_max`), so it could never be the rule that stopped a
|
|
start, and its whole visible effect would be a second entry in a refusal
|
|
list that already names the problem.
|
|
|
|
⚠️ AND IT WOULD NOT COVER THE CASE THAT REMAINS, WHICH IS WHY THE STATUS ROW
|
|
IS NOT A CONSOLATION PRIZE. The residue after rule 2 is the state where
|
|
`hass.limits()` answered `(None, None)` - ESPHome not up when the add-on
|
|
booted - so the range checks were SKIPPED rather than guessed at, and the
|
|
rating is never re-read afterwards. There is nothing to refuse at startup in
|
|
that state, by construction: the number a refusal would need does not exist
|
|
yet. That is a runtime coverage question, and the runtime surface -
|
|
`freeze.rail_unreachable()` and the NOT COVERED row it feeds - is where it
|
|
is answered. Compare rule 5, which CAN be settled at startup because both
|
|
`sign_disagree_w` and `max_w` are config and are both known at boot.
|
|
|
|
- `gain` at any value the schema accepts, INCLUDING against a large rail.
|
|
SAFETY-39 measured this and deliberately left it alone, which is worth more
|
|
than the rule would have been. On admin/goodwe's rig, over a family of 51
|
|
export steps from 1.0 to 4.2 kW at `max_w: 5000` and a 2 s tick, the
|
|
over-absorption - peak battery power beyond what the site actually needed -
|
|
is FLAT in `slew_w`: 958 W at slew 1000 and 1081 W at slew 250. It falls
|
|
in `gain`: 987 W at 0.6, 876 W at 0.3, 435 W at 0.2 - a real reduction, but
|
|
NOT to zero, and the 0.2 figure is the one worth not rounding away. The
|
|
reason is structural and is visible in control.py: `slew_w` bounds the
|
|
OUTPUT, while the integrator accumulates `gain * error` per cycle and is
|
|
bounded only by `integrator_max_w`. At gain 0.6 into a 3.4 kW error the
|
|
integrator moves ~2040 W per tick and reaches a 5000 W rail in under three
|
|
ticks whatever `slew_w` is.
|
|
So the 2026-08-22 derivation's own sentence - "the gain, not the slew, is
|
|
the limit by then" - is now literally true, and `gain` is the constant that
|
|
needs re-deriving next. It is NOT refused and NOT changed here: it is a
|
|
live control-law constant on a running house, changing it in the same pull
|
|
request as `slew_w` would make the next measurement uninterpretable, and a
|
|
validator rule pairing `gain` with a rail would be inventing a relationship
|
|
nobody has measured. Named here so it is a known open item rather than an
|
|
omission. See DECISIONS/SAFETY-39-slew-against-the-rail.md.
|
|
|
|
- `slew_w` BELOW the rail fraction, at any value the schema accepts. Slower
|
|
than the plant is always safe and is sometimes necessary - the live install
|
|
has run an explicit 400 W against a 5000 W rail since 2026-09-05 12:00,
|
|
which is 12.5 ticks to traverse the rail against the 9 this rule requires.
|
|
Refusing "too slow" would refuse the mitigation that is holding.
|
|
|
|
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
|
|
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
|
|
every ordering of the two is a legitimate site configuration. Refusing on
|
|
an ordering would be inventing a relationship the system does not have.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from .arbitrage import enabled as arbitrage_on
|
|
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
|
from .custom_formula import (
|
|
FormulaInvalid, implausible_all_in, parse_price_formula, stale_card)
|
|
from .inverter import roster_entries
|
|
from .maintenance import MAINT_OPTION_DEFAULTS
|
|
from .p1 import is_enabled, validate_meter_options
|
|
from .peakguard import PEAK_OPTION_DEFAULTS
|
|
from .price import PRICE_OPTION_DEFAULTS, PROVIDERS, RETIRED_PROVIDERS
|
|
from .static_tariff import (
|
|
ProfileInvalid, TAXES_PROFILE_OPTION, parse_static_profile,
|
|
parse_taxes_profile)
|
|
from .watchdog import SIGN_OPTION_DEFAULTS
|
|
|
|
_LOG = logging.getLogger("goodwe.config")
|
|
|
|
# ⚠️ A MIRROR OF A FIRMWARE CONSTANT, AND IT CANNOT BE IMPORTED. The firmware
|
|
# threshold is a compile-time YAML substitution baked into the ESP32 image
|
|
# (`max_age_s: "30"`, master/goodwe-master-stage4-failsafe.yaml), so nothing at
|
|
# runtime can ask the board what value it was built with. This number is a copy,
|
|
# and a copy of a constant in another repo is a thing that goes stale.
|
|
#
|
|
# What makes that acceptable here: the copy can only ever be WRONG IN THE SAFE
|
|
# DIRECTION for the check below. If the firmware's veto is raised above 30, this
|
|
# refuses some configs that would in fact have worked - annoying, visible
|
|
# immediately, and fixed by editing one line. It cannot start accepting a config
|
|
# the firmware vetoes unless the firmware's number goes DOWN, which would be a
|
|
# tightening nobody does silently.
|
|
#
|
|
# ⚠️ TEL-06 IS EDITING `ingest_stale` DIRECTLY. If that ticket changes what the
|
|
# veto MEANS - e.g. thresholding arrival rather than the sensor's value - this
|
|
# constant's justification has to be re-read, not just its number. Do not assume
|
|
# a matching integer means a matching rule.
|
|
FIRMWARE_INGEST_VETO_S = 30.0
|
|
|
|
# ⚠️ THE OPTION KEYS UNDER WHICH A WRITER CAN BE DECLARED - AN ENUMERATION, AND
|
|
# SAFETY-28 DEMOTED IT TO ONE. This tuple used to BE the guarantee. The comment
|
|
# that stood here said specs §1.5 makes "any configuration in which two writers
|
|
# can reach one actuator" a non-goal, and that the non-goal was ENFORCED by
|
|
# construction: exactly one such key, so a second writer is not expressible in
|
|
# the options schema at all.
|
|
#
|
|
# That was true, and it graded THE SHAPE OF THE OPTIONS SCHEMA rather than the
|
|
# writes. Add CFG-04's per-inverter config - a list of inverters, each with its
|
|
# own `setpoint_entity` - and there are two real writers with `len()` of this
|
|
# tuple still equal to 1. The check stays green while the property it protects
|
|
# is gone, which is this repository's named failure mode: a check that observes
|
|
# something other than what it claims to observe.
|
|
#
|
|
# So the guarantee moved to `actuator_writers()` below, which counts WRITERS PER
|
|
# ACTUATOR rather than keys, and finds them wherever in the options tree they
|
|
# are declared. This tuple survives only because a census has to know which key
|
|
# NAMES an actuator; it is not a bound on how many writers there can be, and
|
|
# nothing should ever assert on its length again.
|
|
ACTUATOR_WRITE_KEYS = ("setpoint_entity",)
|
|
|
|
|
|
def _collect_writers(node, path: str, out: list) -> None:
|
|
"""Walk the options tree, collecting `(where, entity)` for every writer.
|
|
|
|
⚠️ STRUCTURAL, NOT NAME-BASED ON THE CONTAINER, and that is the point. A
|
|
census that looked for a key called `inverters` would be guessing the name
|
|
CFG-04 has not chosen yet, and a guess that is wrong goes GREEN while two
|
|
writers exist - the same defect one level along. This walks whatever shape
|
|
the options happen to have and reports every `setpoint_entity` in it,
|
|
however deeply nested and under whatever container.
|
|
"""
|
|
if isinstance(node, dict):
|
|
for k, v in node.items():
|
|
here = f"{path}.{k}" if path else str(k)
|
|
if k in ACTUATOR_WRITE_KEYS:
|
|
entity = str(v or "").strip()
|
|
if entity:
|
|
out.append((here, entity))
|
|
continue
|
|
_collect_writers(v, here, out)
|
|
elif isinstance(node, (list, tuple)):
|
|
for i, v in enumerate(node):
|
|
_collect_writers(v, f"{path}[{i}]", out)
|
|
|
|
|
|
def actuator_writers(opts: dict) -> dict:
|
|
"""actuator -> the configured writers that can reach it. INSTANCE-AWARE.
|
|
|
|
The replacement for `len(ACTUATOR_WRITE_KEYS) == 1`. It answers HOW MANY
|
|
THINGS CAN WRITE TO EACH ACTUATOR, which is the property §1.5 actually
|
|
names, and it keeps answering it when the schema grows a per-inverter list:
|
|
two inverters with two entities are two writers on two actuators and fine,
|
|
two declarations naming ONE entity are the non-goal and are refused.
|
|
|
|
The key is the entity id, lowercased - Home Assistant entity ids are
|
|
lowercase by construction, and folding case here means a config cannot
|
|
smuggle a second writer past the census on `Number.X` versus `number.x`.
|
|
|
|
⚠️ SAY WHAT THIS STILL DOES NOT OBSERVE, because the argument that demoted
|
|
the tuple applies to its replacement too. This is a census of CONFIGURED
|
|
writers. A second write path added in CODE - a `hass.set_number()` call next
|
|
to some new strategy - declares nothing and is invisible here, exactly as it
|
|
was invisible to the tuple. That hole is not closed at startup and cannot
|
|
be: it is closed at runtime, by `Controller.write_refusal()`, which refuses
|
|
any value the single allocator did not place on this tick and does not care
|
|
what the options look like. The two checks are deliberately different
|
|
kinds - one grades the config, one grades the writes - and only the second
|
|
one survives a schema this file has not seen yet.
|
|
"""
|
|
found: list = []
|
|
_collect_writers(opts, "", found)
|
|
census: dict = {}
|
|
for where, entity in found:
|
|
census.setdefault(entity.lower(), []).append(where)
|
|
return census
|
|
|
|
|
|
def unit_caps(opts: dict) -> list[float]:
|
|
"""Each configured inverter's supervised cap, in roster order.
|
|
|
|
⚠️ WHY THIS EXISTS AT ALL, AND IT IS THE PERMISSIVE-READER PROBLEM IN THIS
|
|
FILE. Rules 5 and 6 both compare a threshold against "max_w", and both of
|
|
them are really comparing it against WHAT ONE UNIT MAY BE COMMANDED - the
|
|
arbiter files `supervised` per inverter, from `inv.max_w`, and there is no
|
|
such thing as a site rating on the wire. At one inverter those are the same
|
|
number and reading the flat option was right. At two they are not: a config
|
|
with `max_w: 5000` left over from a single-inverter install and a roster of
|
|
two 2000 W units would pass rule 5 at `sign_disagree_w: 3000` while the
|
|
watchdog could not fire on EITHER unit - the disarmed-guard-reporting-armed
|
|
state those rules exist to refuse, reached through the rule itself.
|
|
|
|
So the caps come from the roster the add-on will actually build
|
|
(`inverter.roster_entries()`, not a second reading of the option), and fall
|
|
back to the flat `max_w` only where that roster is the flat one too.
|
|
"""
|
|
return [cap for _, cap in unit_caps_indexed(opts)]
|
|
|
|
|
|
def unit_caps_indexed(opts: dict) -> list[tuple[int, float]]:
|
|
"""`unit_caps()`, but each cap carries the ROSTER INDEX it came from.
|
|
|
|
⚠️ WHY THE INDEX HAS TO TRAVEL WITH THE NUMBER. This function SKIPS entries
|
|
with no usable `max_w`, so position i in the caps list is not position i in
|
|
the roster the moment one entry is malformed. Rules 5 and 6 used to feed a
|
|
caps-list index straight into `_unit_label()`, which indexes `entries` - so
|
|
on a roster whose first entry has a junk `max_w`, the refusal named the
|
|
WRONG INVERTER. An operator told to lower a number on `inverter-a` when the
|
|
number is on `inverter-b` is being sent to the wrong device by the very
|
|
message that exists to stop that. Rule 9(d) already refuses such a config so
|
|
the two lists cannot desynchronise in shipped use - but "unreachable" is a
|
|
claim about today's rule ordering, not a property of this function, and
|
|
`validate_startup_config()` is also `sim/inject.py`'s entry point.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
cap = _number(opts, "max_w", 2000.0)
|
|
return [] if cap is None else [(0, cap)]
|
|
caps: list[tuple[int, float]] = []
|
|
for index, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
cap = _number(entry, "max_w", None)
|
|
if cap is not None:
|
|
caps.append((index, cap))
|
|
return caps
|
|
|
|
|
|
def site_rail_w(opts: dict) -> float | None:
|
|
"""The SITE rail: what the whole site may be commanded, in W. Rule 10's rail.
|
|
|
|
⚠️ THE SUM, NOT THE MINIMUM, AND NOT THE FLAT KEY - and it is the only place
|
|
in this file that wants a sum. Rules 5, 6 and 8 grade a threshold against
|
|
ONE unit (one watchdog, one shaping claim, one pack) and take `min()` over
|
|
`unit_caps()`. `slew_w` is not a per-unit quantity at all: `main.py` builds
|
|
`Tuning(max_w=self.site_max_w)` from the roster sum and `compute()` emits
|
|
the SITE target, which the allocator then divides. So the output range
|
|
`slew_w` is a rate across is `Controller.site_max_w`, and this is that same
|
|
number read out of options rather than off a built roster.
|
|
|
|
At the shipped roster of one this is `unit_caps()`'s single entry, which is
|
|
`max_w` exactly - byte for byte the number the single-inverter shape has
|
|
always meant. None when no entry carries a usable cap, which rule 9 has
|
|
already refused by the time rule 10 runs.
|
|
"""
|
|
caps = unit_caps(opts)
|
|
return float(sum(caps)) if caps else None
|
|
|
|
|
|
def unit_bms_floors_indexed(opts: dict) -> list[tuple[int, object, object]]:
|
|
"""SAFETY-32. Each pack's OWN `bms_soc_floor`, carrying its ROSTER INDEX.
|
|
|
|
`(roster index, the raw value as configured, the parsed float or None)`, one
|
|
triple per unit. The raw value travels too because the refusal quotes what
|
|
the operator actually wrote, and `!r` of a re-read `opts` key is the wrong
|
|
string the moment the value came off a roster entry.
|
|
|
|
⚠️ THE INDEX IS THE ROSTER'S, NOT THIS LIST'S, AND THAT IS CFG-04'S BUG
|
|
WRITTEN DOWN. Rules 5 and 6 fed a CAPS-list index into `_unit_label()`,
|
|
which indexes the ROSTER, so on a roster whose first entry was malformed the
|
|
refusal named the WRONG INVERTER - an operator sent to the wrong device by
|
|
the very message that exists to stop that. `unit_caps_indexed()` exists
|
|
because of it. This function goes one step further and SKIPS NOTHING: every
|
|
entry produces a triple, including the ones with no usable floor, because a
|
|
missing cut-out is itself what rule 8 has to refuse BY NAME. There is
|
|
therefore no second list for an index to desynchronise from.
|
|
|
|
⚠️ AND NOTHING IS INHERITED. At a declared roster the FLAT `bms_soc_floor` is
|
|
not read at all - CFG-04's rule, and this card's subject: a pack that borrows
|
|
a sibling's cut-out is graded against a battery it is not. At the flat shape
|
|
the flat key IS this one pack's cut-out, defaulted from CFG-03's single
|
|
mirror exactly as `inverter._flat_bms_floor()` defaults it.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
return [(0, opts.get("bms_soc_floor"),
|
|
_number(opts, "bms_soc_floor",
|
|
PEAK_OPTION_DEFAULTS["bms_soc_floor"]))]
|
|
out: list[tuple[int, object, object]] = []
|
|
for index, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
# Rule 9(a)/(b) already refuses a non-dict entry by index, and it
|
|
# runs FIRST. Naming it twice would hand the operator two sentences
|
|
# about one line of YAML.
|
|
continue
|
|
raw = entry.get("bms_soc_floor")
|
|
out.append((index, raw,
|
|
None if raw is None or raw == ""
|
|
else _number(entry, "bms_soc_floor", None)))
|
|
return out
|
|
|
|
|
|
def _unit_label(opts: dict, index: int) -> str:
|
|
""""" at a single-inverter config, " (inverter-b)" at a roster.
|
|
|
|
The messages below have to read identically on the install that upgrades
|
|
into this version - it is the same configuration, refused for the same
|
|
reason - and have to NAME THE UNIT the moment there is more than one, or an
|
|
operator with three inverters is told a number is wrong and not which one.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if len(entries) <= 1:
|
|
return ""
|
|
entry = entries[index] if index < len(entries) else {}
|
|
name = ""
|
|
if isinstance(entry, dict):
|
|
name = str(entry.get("name") or "").strip()
|
|
return f" ({name or f'inverters[{index}]'})"
|
|
|
|
|
|
def _roster_problems(opts: dict) -> list[str]:
|
|
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
|
|
what it says.
|
|
|
|
⚠️ THE SUPERVISOR ALREADY ENFORCES MOST OF THIS AND THAT IS NOT A REASON TO
|
|
DROP IT. `config.yaml`'s schema makes `name`, `setpoint_entity` and `max_w`
|
|
required per entry, and Supervisor 2026.08.0 refuses a blob that omits one
|
|
by name - measured, not assumed. But `validate_startup_config()` is also
|
|
`sim/inject.py`'s entry point and every check's, and those reach it with a
|
|
dict the Supervisor never saw; the same argument `price_provider` makes two
|
|
rules up. A rule the Supervisor duplicates costs nothing and covers the
|
|
callers it does not.
|
|
|
|
Seven things are refused, and each is a config that would otherwise RUN:
|
|
|
|
a. `inverters` is not a list. `roster_entries()` wraps a scalar so this
|
|
can name it; without the rule the roster would be one inverter built
|
|
out of a string's `.get`, i.e. an immediate AttributeError.
|
|
b. An entry with no usable `name`. Store keys are namespaced BY NAME
|
|
(`store.unit_key`), so two nameless entries would share one namespace
|
|
and one unit's SAFETY-02 latch would silently be the other's.
|
|
c. Two entries with the SAME name - (b) reached the long way round.
|
|
d. An entry with no `setpoint_entity`, or no usable `max_w`. The first is
|
|
a unit this controller cannot command; the second is a unit whose
|
|
supervised claim would be `+-0 W`, which is a unit pinned at zero by a
|
|
missing key rather than by a decision.
|
|
e. A flat `setpoint_entity` alongside a declared roster. The flat keys are
|
|
the SINGLE-inverter shape; leaving one set while a roster is declared
|
|
means one of two things and neither is safe. If it names an entity an
|
|
entry also names, SAFETY-28's census below already refuses it as two
|
|
writers on one actuator. If it names a DIFFERENT one, nothing writes
|
|
it, the site runs quietly smaller than the configuration reads, and the
|
|
one place it would show up is a status page nobody compares against the
|
|
config. Refused rather than ignored, for §1.5's reason and for this
|
|
card's: a config that silently becomes a different config is worse than
|
|
one that refuses to load.
|
|
f. OPT-09. An entry whose `capacity_kwh` is stated but is not a usable
|
|
positive number of kWh. It is read to weight the SPLIT by energy
|
|
headroom; a zero, a negative or a word is a pack the split would
|
|
quietly stop preferring while the operator can see a value in the box.
|
|
g. OPT-09. `capacity_kwh` stated on SOME entries and not others. The
|
|
weighting is a COMPARISON between packs, so a pack measured in kWh
|
|
against a sibling weighing a dimensionless 1.0 is not one -
|
|
`packshare.weights()` answers "no opinion" for the WHOLE roster in that
|
|
case, which means every key the operator filled in does nothing at all
|
|
and nothing says so. All or none, and this is clause (e)'s argument
|
|
restated: a config that silently becomes a different config is worse
|
|
than one that refuses to load.
|
|
"""
|
|
problems: list[str] = []
|
|
raw = opts.get("inverters")
|
|
if raw is None or raw == "" or raw == []:
|
|
return problems
|
|
if not isinstance(raw, (list, tuple)):
|
|
return [f"inverters is {type(raw).__name__} {raw!r}, and it must be a "
|
|
"LIST of inverter objects - one per inverter, each naming its "
|
|
"own name, setpoint_entity and max_w. A single inverter needs "
|
|
"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)
|
|
seen: dict = {}
|
|
for i, entry in enumerate(entries):
|
|
where = f"inverters[{i}]"
|
|
if not isinstance(entry, dict):
|
|
problems.append(
|
|
f"{where} is {type(entry).__name__} {entry!r}, and every entry "
|
|
"must be an object with at least name, setpoint_entity and "
|
|
"max_w.")
|
|
continue
|
|
name = str(entry.get("name") or "").strip()
|
|
if not name:
|
|
problems.append(
|
|
f"{where} has no name. Every inverter needs one: the Store "
|
|
"namespaces this unit's SAFETY-02 latch, its heartbeat and its "
|
|
"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:
|
|
problems.append(
|
|
f"{where} and {seen[name]} are both called {name!r}. The Store "
|
|
"keys every per-unit latch under the unit's name, so two units "
|
|
"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:
|
|
seen[name] = where
|
|
who = f"{where} ({name})" if name else where
|
|
if not str(entry.get("setpoint_entity") or "").strip():
|
|
problems.append(
|
|
f"{who} has no setpoint_entity, so this add-on cannot command "
|
|
"it. It would sit in the roster, take a share of the site "
|
|
"target every cycle and deliver none of it, while the status "
|
|
"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:
|
|
problems.append(
|
|
f"{who} has no usable max_w (got {entry.get('max_w')!r}). It is "
|
|
"the supervised cap on what may be written to THIS inverter, it "
|
|
"is not shared with any other unit and it is deliberately not "
|
|
"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 ---
|
|
stated, blank = [], []
|
|
for i, entry in enumerate(entries):
|
|
if not isinstance(entry, dict):
|
|
continue # already refused above, by name
|
|
raw = entry.get("capacity_kwh")
|
|
nm = str(entry.get("name") or "").strip()
|
|
who = f"inverters[{i}] ({nm})" if nm else f"inverters[{i}]"
|
|
if raw is None or raw == "":
|
|
blank.append(who)
|
|
continue
|
|
try:
|
|
val = float(raw)
|
|
except (TypeError, ValueError):
|
|
val = None
|
|
if val is None or not (val > 0.0):
|
|
problems.append(
|
|
f"{who} has capacity_kwh {raw!r}, which is not a usable number "
|
|
"of kilowatt-hours. It is THIS pack's usable capacity and it is "
|
|
"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:
|
|
stated.append(who)
|
|
if stated and blank:
|
|
problems.append(
|
|
f"capacity_kwh is stated on {', '.join(stated)} and left empty on "
|
|
f"{', '.join(blank)}. It is OPTIONAL but it is not PARTIAL: the "
|
|
"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()
|
|
if entries and flat_sp:
|
|
problems.append(
|
|
f"setpoint_entity is set to {flat_sp} AND an inverters list of "
|
|
f"{len(entries)} is declared. The flat setpoint_entity/soc_entity/"
|
|
"batt_entity/max_w keys are the SINGLE-inverter shape and are not "
|
|
"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
|
|
|
|
|
|
def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
|
"""Rule 2, per inverter, against each unit's OWN reported device range.
|
|
|
|
⚠️ THE READER THIS REPLACES FAILED PERMISSIVE, WHICH IS THE DIRECTION THAT
|
|
HIDES A DEFECT. `run_control()` re-ran rule 2 behind `if
|
|
self.o.get("setpoint_entity")` - the FLAT key - because the flat options
|
|
were the only per-device options there were. Declare a roster and that key
|
|
is blank by rule 9(e), so the whole re-check was skipped and NO inverter's
|
|
`max_w` was ever graded against the device it is written to. That is rule
|
|
2's own failure mode restored by a config shape: the operator's stated
|
|
ceiling is not the ceiling and nothing says which won.
|
|
|
|
`units` is the roster (anything with `.name`, `.setpoint_entity`, `.max_w`,
|
|
`.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not
|
|
guessed at, for the reason in this module's docstring: ESPHome is often not
|
|
up when the add-on boots and (None, None) means "not read", not "zero".
|
|
|
|
`skip_entity` is the flat `setpoint_entity` when the caller has already
|
|
graded it through `validate_startup_config(opts, dev_min, dev_max)`. At a
|
|
single-inverter config that is the one unit, and grading it twice would
|
|
report the same conflict twice under two wordings.
|
|
"""
|
|
problems: list[str] = []
|
|
skip = str(skip_entity or "").strip().lower()
|
|
for unit in units:
|
|
entity = str(getattr(unit, "setpoint_entity", "") or "").strip()
|
|
if not entity or entity.lower() == skip:
|
|
continue
|
|
cap = float(getattr(unit, "max_w", 0.0) or 0.0)
|
|
dev_min = getattr(unit, "dev_min", None)
|
|
dev_max = getattr(unit, "dev_max", None)
|
|
name = getattr(unit, "name", entity)
|
|
if dev_max is not None and cap > float(dev_max):
|
|
problems.append(
|
|
f"{name}: max_w is {cap:.0f} W but {entity} accepts at most "
|
|
f"{float(dev_max):.0f} W - THE DEVICE WINS, so the {cap:.0f} W "
|
|
f"configured for this inverter would silently never happen. "
|
|
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):
|
|
problems.append(
|
|
f"{name}: max_w is {cap:.0f} W, so this controller may command "
|
|
f"{-cap:.0f} W, but {entity} accepts no less than "
|
|
f"{float(dev_min):.0f} W - THE DEVICE WINS on the charge side. "
|
|
f"Lower this entry's max_w to {abs(float(dev_min)):.0f} W or "
|
|
"less.")
|
|
return problems
|
|
|
|
|
|
class ConfigRefused(Exception):
|
|
"""The configuration is wrong. Not "something broke" - see main.py's handler.
|
|
|
|
Carries the list of problems so the caller can render them; str() is the
|
|
first one, so an unhandled traceback still names something useful.
|
|
"""
|
|
|
|
def __init__(self, problems: list[str]):
|
|
self.problems = problems
|
|
super().__init__(problems[0] if problems else "configuration refused")
|
|
|
|
|
|
def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]:
|
|
"""Return the list of reasons this configuration must not be started.
|
|
|
|
Empty list means go. `dev_min`/`dev_max` are the setpoint device's own
|
|
reported range (`hass.limits()`), or None when the device could not be
|
|
reached - in which case the range checks are skipped rather than guessed at,
|
|
for the transient reason in this module's docstring.
|
|
|
|
Pure and side-effect free apart from two warnings, so it can be called from
|
|
a test or from sim/inject.py without a Home Assistant anywhere near it.
|
|
"""
|
|
problems: list[str] = []
|
|
|
|
# --- 1. an actuator to write to, and AT MOST ONE WRITER PER ACTUATOR -----
|
|
writers = actuator_writers(opts)
|
|
if not writers:
|
|
problems.append(
|
|
"setpoint_entity is not set, so this add-on has no actuator: every "
|
|
"write is a no-op, the device's own rating is never read, and the "
|
|
"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
|
|
# count of option keys. Read `actuator_writers()` for why the old check went
|
|
# green on the very config this one is here to refuse.
|
|
#
|
|
# ⚠️ AND IT REFUSES A DUPLICATE DECLARATION EVEN WHEN BOTH NAME THE SAME
|
|
# ENTITY, which is the case an author of the per-inverter config will meet
|
|
# first: keep `setpoint_entity` as inverter A's and add A to the list again
|
|
# and there are two config sites aimed at one actuator, each with its own
|
|
# neighbouring options. "They happen to be the same entity, so it is fine"
|
|
# is precisely the reasoning that makes two writers invisible. Declare each
|
|
# actuator exactly once and the refusal does not fire.
|
|
for actuator, who in sorted(writers.items()):
|
|
if len(who) > 1:
|
|
problems.append(
|
|
f"{len(who)} configured writers can reach the one actuator "
|
|
f"{actuator}: {', '.join(who)}. specs §1.5 makes any "
|
|
"configuration in which two writers can reach one actuator a "
|
|
"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 --
|
|
# ⚠️ FIRST, THOUGH IT IS THE NEWEST RULE. Rules 5 and 6 below now ask the
|
|
# roster what one unit may be commanded, so a roster that is nonsense has to
|
|
# have been named as nonsense before they read it - otherwise an entry with
|
|
# no `max_w` silently contributes no cap, `min()` sees a shorter list, and
|
|
# the operator is told about a sign watchdog when the real problem is a
|
|
# missing rating one line up.
|
|
problems.extend(_roster_problems(opts))
|
|
|
|
# --- 2. the configured ceiling against the device's own rating -----------
|
|
# ⚠️ THE FLAT `max_w`, AND ONLY THE FLAT ONE. `dev_min`/`dev_max` are ONE
|
|
# device's range - the flat `setpoint_entity`'s - so this rule can only ever
|
|
# speak for the single-inverter shape. The per-unit form of it is
|
|
# `validate_unit_ranges()`, which main.py runs once every unit's own device
|
|
# has answered; it is not called from here because at startup no device has.
|
|
max_w = _number(opts, "max_w", 2000.0)
|
|
if max_w is None:
|
|
problems.append(f"max_w {opts.get('max_w')!r} is not a number")
|
|
# ⚠️ 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
|
|
# validate_startup_config() is now also sim/inject.py's entry point and can
|
|
# be handed any dict at all. A max_w of 0 or below inverts the arbiter's
|
|
# `supervised` bounds (lo=-max_w becomes the HIGHER number), and a limit
|
|
# claim whose lo exceeds its hi is exactly the contradiction the arbiter
|
|
# resolves to 0 W - i.e. it would look like a safety trip rather than the
|
|
# nonsense config it is.
|
|
elif max_w <= 0:
|
|
problems.append(
|
|
f"max_w is {max_w:.0f} W and must be positive - it is a magnitude, "
|
|
"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:
|
|
problems.append(
|
|
f"max_w is {max_w:.0f} W but {opts.get('setpoint_entity') or 'the setpoint device'} accepts at "
|
|
f"most {dev_max:.0f} W - THE DEVICE WINS, so the {max_w:.0f} W you "
|
|
f"configured would silently never happen. Lower max_w to "
|
|
f"{dev_max:.0f} W or less, or point setpoint_entity at a device that "
|
|
f"can take {max_w:.0f} W.")
|
|
elif dev_min is not None and -max_w < dev_min:
|
|
problems.append(
|
|
f"max_w is {max_w:.0f} W, so this controller may command "
|
|
f"{-max_w:.0f} W, but {opts.get('setpoint_entity') or 'the setpoint device'} accepts no less "
|
|
f"than {dev_min:.0f} W - THE DEVICE WINS on the charge side. Lower "
|
|
f"max_w to {abs(dev_min):.0f} W or less.")
|
|
|
|
# --- 3. a meter that can actually read -----------------------------------
|
|
# ⚠️ Not a second copy of these rules - p1.py owns them, because
|
|
# build_source() has to apply the identical predicates and a drifted copy
|
|
# would be worse than no check.
|
|
phases = _number(opts, "meter_phases", 1.0)
|
|
max_age_s = _number(opts, "meter_max_age_s", 30.0)
|
|
if phases is None or max_age_s is None:
|
|
problems.append("meter_phases and meter_max_age_s must both be numbers "
|
|
f"(got {opts.get('meter_phases')!r} / "
|
|
f"{opts.get('meter_max_age_s')!r})")
|
|
else:
|
|
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
|
|
|
# --- 4. the staleness window the FIRMWARE will actually enforce -----------
|
|
# ⚠️ Only when P1 ingestion is on, and that is a real scope limit rather than
|
|
# caution: meter_max_age_s is read by nothing but P1Ingest, and the veto it
|
|
# collides with keys off `p1_age_s.has_state()`, which is only ever published
|
|
# when ingestion is running (see Controller.publish()). With meter_source off
|
|
# there is no sensor, the veto is inert, and the number governs nothing.
|
|
#
|
|
# Strictly greater, not >=: the firmware trips at `>= 30` and P1Ingest goes
|
|
# stale at `> max_age_s`, so 30 against 30 has the firmware fractionally
|
|
# stricter by one sample. That is the shipped default and the tested pair.
|
|
# BELOW 30 is fine and deliberately not refused - the add-on then gives up
|
|
# first, which is fail-safe and visible on its own status page. Only ABOVE is
|
|
# the silent case, where the add-on keeps commanding into a reading the board
|
|
# has already rejected.
|
|
if (is_enabled(opts) and max_age_s is not None
|
|
and max_age_s > FIRMWARE_INGEST_VETO_S):
|
|
problems.append(
|
|
f"meter_max_age_s is {max_age_s:.0f} s but the ESP32 firmware vetoes "
|
|
f"any P1 sample at {FIRMWARE_INGEST_VETO_S:.0f} s and forces the "
|
|
"layer-1 failsafe - THE FIRMWARE WINS, and it wins silently. This "
|
|
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 ---------------------------
|
|
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
|
|
# `abs(commanded_w) > min_w`, and the arbiter holds `abs(commanded_w)` at or
|
|
# below max_w, so threshold == max_w is already unsatisfiable. Anything
|
|
# BELOW max_w is reachable and therefore fine - this refuses the disarmed
|
|
# case only, not "close to the rail".
|
|
#
|
|
# ⚠️ AND IT IS GRADED AGAINST THE SMALLEST UNIT'S CAP, NOT AGAINST THE FLAT
|
|
# `max_w`. There is one SignWatchdog PER INVERTER (DEV-02) and each grades
|
|
# its own unit's standing command, which the arbiter bounds by THAT UNIT's
|
|
# `supervised` claim - so the watchdog that can never fire is the one on the
|
|
# smallest inverter, and a threshold under the site's largest cap says
|
|
# nothing about it. At one inverter `unit_caps()` is `[max_w]` and this is
|
|
# the identical comparison it always was.
|
|
sign_w = _number(opts, "sign_disagree_w", 300.0)
|
|
# ⚠️ (index, cap) PAIRS, NOT BARE CAPS. `unit_caps()` skips entries with no
|
|
# usable `max_w`, so `caps.index(weakest)` was an index into the CAPS list
|
|
# being handed to `_unit_label()`, which indexes the ROSTER - and the message
|
|
# named the wrong inverter as soon as the two lengths differed. The index
|
|
# travels with the number now, so the name is the name of the unit the
|
|
# number came off.
|
|
caps_ix = unit_caps_indexed(opts)
|
|
caps = [cap for _, cap in caps_ix]
|
|
weakest = min(caps) if caps else None
|
|
if sign_w is None:
|
|
problems.append(f"sign_disagree_w {opts.get('sign_disagree_w')!r} is not a number")
|
|
elif weakest is not None and weakest > 0 and sign_w >= weakest:
|
|
where = _unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
|
problems.append(
|
|
f"sign_disagree_w is {sign_w:.0f} W but max_w{where} is "
|
|
f"{weakest:.0f} W, so "
|
|
"the SAFETY-02 sign-disagreement watchdog can never fire: the "
|
|
"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
|
|
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
|
|
# unit's own SHARE against that unit's own measured power, and a share is
|
|
# bounded by the same `supervised` claim at +-max_w - so a threshold at or
|
|
# above the ceiling makes the command side unsatisfiable by construction,
|
|
# exactly as above. Written as its own branch rather than folded into the
|
|
# one above because the two produce different sentences and an operator
|
|
# needs to be told WHICH watchdog they have disarmed.
|
|
#
|
|
# ⚠️ REFUSED EVEN AT A ROSTER OF ONE, WHERE THIS CHECK IS NOT ARMED. A
|
|
# config that cannot work is refused when it is WRITTEN, not on the day a
|
|
# second inverter is added - which is the day nobody is watching the startup
|
|
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
|
|
# prevents is silent.
|
|
#
|
|
# ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32
|
|
# ROUND 5. This branch used to compare against `max_w = _number(opts,
|
|
# "max_w", 2000.0)`, the FLAT option, which on a roster config is either
|
|
# ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the
|
|
# site) or a leftover from a single-inverter install (so it compared against
|
|
# a number no unit has). Both directions were live and both were measured:
|
|
#
|
|
# a roster of two 2000 W units beside a leftover `max_w: 5000` and
|
|
# `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED,
|
|
# with a per-inverter watchdog reporting `armed` on the status page and
|
|
# incapable of tripping on EITHER unit. That is the identical sentence the
|
|
# branch above exists to refuse, reached through the branch beside it;
|
|
#
|
|
# and a roster of two 5000 W units with no flat key was REFUSED at
|
|
# `unit_sign_disagree_w: 3000` against a 2000 W default the operator never
|
|
# wrote, for a threshold both units can reach.
|
|
#
|
|
# `unit_caps()`'s own docstring already said why: there is no such thing as
|
|
# a site rating on the wire, and this watchdog grades ONE UNIT's standing
|
|
# command against THAT unit's `supervised` bound. So it takes the same
|
|
# `weakest` the branch above takes, from the same list, and names the same
|
|
# unit. At a roster of one `unit_caps()` is `[max_w]` and this is the
|
|
# identical comparison it always was - which is what keeps the shipped
|
|
# single-inverter refusal byte-for-byte what it was.
|
|
unit_sign_w = _number(opts, "unit_sign_disagree_w",
|
|
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
|
|
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
|
if caps_ix else "")
|
|
if unit_sign_w is None:
|
|
problems.append(f"unit_sign_disagree_w "
|
|
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
|
|
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
|
problems.append(
|
|
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
|
|
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
|
|
"watchdog can never fire: no inverter is ever commanded past max_w, "
|
|
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
|
|
"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 --------
|
|
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
|
# `Claim.limit(hi=-charge_w)` against `supervised`'s `lo=-max_w`, so
|
|
# charge_w == max_w leaves the single reachable point -max_w and works;
|
|
# only ABOVE is `lo > hi`, arbiter clause 3, 0 W and a contradiction.
|
|
#
|
|
# Not scoped to maintenance_enabled, deliberately. The pair is
|
|
# contradictory whether or not the schedule is on, and scoping it would
|
|
# leave the trap armed for the one operator action - ticking
|
|
# maintenance_enabled - that cannot possibly be expected to change what a
|
|
# charge rate means.
|
|
# ⚠️ THE FALLBACK IS config.yaml's SHIPPED VALUE, READ RATHER THAN TYPED
|
|
# (CFG-03). It is reachable only when a caller hands over a dict with the
|
|
# key missing (sim/inject.py, a test); the Supervisor always supplies every
|
|
# key from config.yaml, so on a real install the argument is always present
|
|
# and this default is dead code. That is precisely why it went stale
|
|
# unnoticed for a whole card: MAINT-04 moved config.yaml to 2000 while
|
|
# main.py and MaintConfig still said 2500, so a dict with the key ABSENT was
|
|
# accepted here at 2000 and then built by main.py at 2500 - the
|
|
# contradiction this rule exists to catch, in the one case it could not see.
|
|
# The KNOWN MISMATCH block that used to stand here, and the tripwire in
|
|
# test_validate.py that pinned it, are both DELETED: the divergence is
|
|
# closed rather than recorded. `maintenance.MAINT_OPTION_DEFAULTS` is now
|
|
# the one in-code mirror of config.yaml for this family - the same move
|
|
# rule 8 below already makes with `PEAK_OPTION_DEFAULTS` - and
|
|
# test_maintenance.py pins it by reading the shipped file.
|
|
#
|
|
# ⚠️ MAINT-05: IT IS GRADED AGAINST THE WEAKEST UNIT'S CAP NOW, AND THE
|
|
# COMMENT THIS REPLACES SAID SO WOULD HAPPEN. It read: "there is exactly ONE
|
|
# `Maintenance` in this add-on and `Controller.maint_unit` names the pack it
|
|
# recalibrates - the first in the roster, because the cycle's history lives
|
|
# under un-namespaced Store keys. The shaping claim is filed on THAT
|
|
# actuator and on no other, so the cap that can contradict it is THAT
|
|
# unit's. Taking `min()` here would refuse a perfectly workable site - a
|
|
# 5000 W pack being recalibrated beside a 2000 W sibling the cycle never
|
|
# touches ... When `maint_unit` stops being `inverters[0]`, this line moves
|
|
# with it."
|
|
#
|
|
# `maint_unit` has stopped being `inverters[0]`. There is one `Maintenance`
|
|
# per roster entry, each with its own namespaced history, and EVERY pack is
|
|
# recalibrated on its own schedule - so the sibling "the cycle never
|
|
# touches" does not exist any more and the over-refusal argument has no
|
|
# subject. The shaping claim is filed on whichever pack is charging, so the
|
|
# cap that can contradict it is any cap on the roster, and the one that
|
|
# contradicts it FIRST is the smallest. This is rule 5's conjunction for
|
|
# rule 5's reason: a threshold that is fine against the biggest unit says
|
|
# nothing about the smallest, and the smallest is the one that goes to 0 W.
|
|
#
|
|
# ⚠️ THIS REFUSES CONFIGS THAT USED TO START, AND THAT IS THE POINT RATHER
|
|
# THAN A SIDE EFFECT. `max_w` 5000 on pack A beside 2000 on pack B with
|
|
# `maintenance_charge_w` 3000 was accepted before this card and was correct
|
|
# while only pack A was ever recalibrated. Pack B now recalibrates too, and
|
|
# its cheap-window charge asks the arbiter for `hi = -3000` against a
|
|
# supervised `lo = -2000`: `lo > hi`, arbiter clause 3, 0 W, contradiction -
|
|
# a maintenance charge that silently never happens on that pack, which is
|
|
# exactly what rule 6 exists to refuse at the moment it is WRITTEN. At a
|
|
# roster of one `min()` over one cap is that cap and the comparison, the
|
|
# message and the boundary are identical.
|
|
charge_w = _number(opts, "maintenance_charge_w",
|
|
float(MAINT_OPTION_DEFAULTS["maintenance_charge_w"]))
|
|
# ⚠️ THE SAME PAIRING AS RULE 5, FOR THE SAME REASON: `unit_caps_indexed()`
|
|
# SKIPS entries with no usable `max_w`, so a caps-list position is not a
|
|
# roster position and `_unit_label()` indexes the roster. The index travels
|
|
# with the number, so the unit the message names is the unit the number came
|
|
# off - which on a roster whose first entry has a junk `max_w` is not the
|
|
# same thing at all.
|
|
maint_index, maint_cap = (min(caps_ix, key=lambda p: p[1]) if caps_ix
|
|
else (0, None))
|
|
maint_where = _unit_label(opts, maint_index)
|
|
if charge_w is None:
|
|
problems.append(
|
|
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
|
|
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
|
problems.append(
|
|
f"maintenance_charge_w is {charge_w:.0f} W but max_w{maint_where} is "
|
|
f"{maint_cap:.0f} W, so a maintenance charge in a cheap window can "
|
|
"never happen: the shaping asks the arbiter for at most "
|
|
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 -----------------------
|
|
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
|
|
# threshold, and the whole point of writing it as one is that the number it
|
|
# bounds must not be able to outlive the rail again. `control.py`'s header
|
|
# carries the derivation; the short version is that `slew_w: 1000` was
|
|
# derived against a 1500 W clamp and the live install raised the clamp to
|
|
# 5000 without revisiting it, so the loop traversed its whole output range
|
|
# in 10 s against a plant measured at ~18 s.
|
|
#
|
|
# ⚠️ `slew_w: 0` IS NOT GRADED AND MUST NOT BE. Zero means "follow the
|
|
# rail" - `control.slew_limit_w()` resolves it to `SLEW_RAIL_FRAC * max_w`,
|
|
# which satisfies this rule by construction at every rail there is. It is
|
|
# the shipped default, and a rule that refused it would refuse the fix.
|
|
#
|
|
# ⚠️ STRICTLY `>`, MATCHING RULE 6 RATHER THAN RULE 5. Equality is the
|
|
# derived value itself: at `slew_w == SLEW_RAIL_FRAC * rail` the loop takes
|
|
# exactly `PLANT_SETTLE_S` to cross its range, which is the constraint
|
|
# satisfied, not violated. Refusing it would refuse the value this rule
|
|
# exists to recommend - and `slew_limit_w()` produces exactly that value
|
|
# for every default install, so `>=` would refuse the shipped default
|
|
# through a float comparison. A tolerance is applied for the same reason:
|
|
# `SLEW_RAIL_FRAC * 5000` is 555.55..., and an operator who writes the 556
|
|
# a status page rounded to is one watt over a boundary that is not
|
|
# meaningful to one watt.
|
|
slew_w = _number(opts, "slew_w", 0.0)
|
|
rail = site_rail_w(opts)
|
|
if slew_w is None:
|
|
problems.append(f"slew_w {opts.get('slew_w')!r} is not a number")
|
|
elif slew_w < 0:
|
|
problems.append(
|
|
f"slew_w is {slew_w:.0f} W and must not be negative - it is a "
|
|
"magnitude, and 0 means 'follow max_w' (the recommended setting).")
|
|
elif slew_w and rail is not None and rail > 0:
|
|
bound = SLEW_RAIL_FRAC * rail
|
|
if slew_w > bound + 1.0:
|
|
ticks = rail / slew_w
|
|
problems.append(
|
|
f"slew_w is {slew_w:.0f} W against a site rail of {rail:.0f} W, "
|
|
f"so the loop crosses its whole output range in {ticks:.1f} "
|
|
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 ------------------------------------
|
|
problems.extend(_price_problems(opts))
|
|
|
|
# --- 8. an EMS energy floor the BMS does not already enforce -------------
|
|
# ⚠️ THE FALLBACKS ARE peakguard.PEAK_OPTION_DEFAULTS, NOT LITERALS, and
|
|
# that is CFG-03 read as a rule rather than as a complaint. Rule 6's own
|
|
# comment used to document a LIVE divergence in this repo caused by typing
|
|
# 2000 here and 2500 in main.py; SAFETY-24 refused to add a second instance
|
|
# of it, and CFG-03 has since closed the first. The dict is the single
|
|
# in-code mirror, pinned to config.yaml by test_peakguard.py, and main.py
|
|
# reads the same one - exactly as maintenance.MAINT_OPTION_DEFAULTS now is
|
|
# for the `maintenance_*` family.
|
|
#
|
|
# ⚠️ SAFETY-32 - GRADED ONCE PER UNIT, AND THE REFUSAL NAMES THE UNIT. One
|
|
# `bms_soc_floor` for two packs compares the EMS floor against whichever
|
|
# battery happened to be written into the flat key: a site with cut-outs of
|
|
# 10 % and 30 % and `peak_soc_floor: 20` STARTED, and the guard would then
|
|
# command a forced discharge that the second pack's BMS - not the operator's
|
|
# floor - is the thing that stops. That is the disarmed-backstop state this
|
|
# rule exists to refuse, reached through the rule itself. So the loop is over
|
|
# units, and `_unit_label()` is fed the ROSTER index the value came off (see
|
|
# `unit_bms_floors_indexed()`), because CFG-04 has just fixed the version of
|
|
# this that named the wrong inverter.
|
|
soc_floor = _number(opts, "peak_soc_floor",
|
|
PEAK_OPTION_DEFAULTS["peak_soc_floor"])
|
|
for _ix, _raw, bms_floor in unit_bms_floors_indexed(opts):
|
|
where = _unit_label(opts, _ix)
|
|
if soc_floor is None or bms_floor is None:
|
|
problems.append(
|
|
f"peak_soc_floor and bms_soc_floor{where} must both be numbers "
|
|
f"(got {opts.get('peak_soc_floor')!r} / {_raw!r})")
|
|
elif soc_floor <= bms_floor:
|
|
problems.append(
|
|
f"peak_soc_floor is {soc_floor:.0f} % but the pack's own cut-out "
|
|
f"(bms_soc_floor{where}) is {bms_floor:.0f} %, so SAFETY-07's forced "
|
|
"discharge has no EMS-side bound at all - THE BMS WINS, and it is "
|
|
"the pack's protection rather than yours. The guard would report a "
|
|
"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 -------------------------
|
|
integ = opts.get("integrator_max_w")
|
|
integ_w = None if not integ else _number(opts, "integrator_max_w", None)
|
|
if integ_w is not None and max_w is not None and integ_w > max_w:
|
|
_LOG.warning(
|
|
"integrator_max_w %.0f W is above max_w %.0f W. Not refused - the "
|
|
"arbiter still bounds the wire to +-max_w - but the only thing the "
|
|
"extra headroom buys is more wrong-direction power to unwind after "
|
|
"saturation. 0 (follow max_w) is the recommended value.",
|
|
integ_w, max_w)
|
|
|
|
# ⚠️ THE SIBLING OF RULE 6, AND NOT THE SAME CASE - see the docstring. The
|
|
# drain phase OWNS the actuator (`Claim.set` at P_MAINTENANCE), and a SET is
|
|
# clamped by the limits above it rather than contradicted by them. So this
|
|
# drains at max_w instead of the configured rate, the arbiter says so in
|
|
# words on the status page every cycle, and the cycle still completes -
|
|
# slower, and eating into maintenance.DRAIN_TIMEOUT_MIN. Loud and it moves
|
|
# energy, so it is neither (a) nor (b): warned, not refused.
|
|
# ⚠️ AND THE FALLBACK COMES FROM THE SAME MIRROR AS RULE 6's. CFG-03's PM
|
|
# proofread caught this one: `maintenance_discharge_w` had the IDENTICAL
|
|
# three-way divergence (config.yaml 2000, MaintConfig 2500) and the card
|
|
# named only the charge side. Closing one key and leaving its twin is how a
|
|
# single-source fix gets reproduced as a single-source bug one line down.
|
|
# ⚠️ AND AGAINST THE MAINTENANCE UNIT'S CAP, for rule 6's reason: it is the
|
|
# same one actuator being clamped, so grading the warning against the flat
|
|
# `max_w` would warn about a site number while the clamp is a unit number.
|
|
disch_w = _number(opts, "maintenance_discharge_w",
|
|
float(MAINT_OPTION_DEFAULTS["maintenance_discharge_w"]))
|
|
if (disch_w is not None and maint_cap is not None and maint_cap > 0
|
|
and disch_w > maint_cap):
|
|
_LOG.warning(
|
|
"maintenance_discharge_w %.0f W is above max_w%s %.0f W. Not "
|
|
"refused - the drain phase SETS the setpoint and the arbiter clamps "
|
|
"it to %.0f W and names the clamp ('limited by supervised(max_w)'), "
|
|
"so nothing is silent and the cycle still completes. But it drains "
|
|
"at %.0f W, not the %.0f W you configured, and the drain phase has "
|
|
"a 720 min timeout. Lower it to %.0f W or raise max_w to remove the "
|
|
"surprise.",
|
|
disch_w, maint_where, maint_cap, maint_cap, maint_cap, disch_w,
|
|
maint_cap)
|
|
|
|
return problems
|
|
|
|
|
|
def _price_problems(opts: dict) -> list[str]:
|
|
"""Rule 7 (TARIFF-02): the price configuration, refused where it is untrue.
|
|
|
|
⚠️ NOTE WHAT IS *NOT* HERE. `price_provider: "off"` is not refused and never
|
|
will be: §11.4 makes no-price-data a first-class supported state (static
|
|
profile, provisional plan, no arbitrage), and an add-on that will not start
|
|
without a price feed is an add-on that a Belgian fixed-tariff site - a large
|
|
share of the installed base, per §9.2 - cannot run at all.
|
|
|
|
Five refusals and two log lines (TARIFF-03 added one refusal, one parse and
|
|
two notices; TARIFF-07 added the retired-id one; TARIFF-15 added three -
|
|
a `custom_formula` with no formula, a formula that does not parse or does
|
|
not reproduce its own card, and one whose card lands outside the plausible
|
|
band - plus two notices, a formula nothing reads and a stale card), and
|
|
every refusal is still case (a) from this module's docstring: a source the
|
|
operator SET being overridden by a layer they cannot see. The additions are `static_profile`
|
|
alongside the Home Assistant price pair - the same silent-loser shape as
|
|
provider-plus-pair, and a LIVE one - and a `static_profile` that does not
|
|
tile the day, which would leave hours with no price at all.
|
|
|
|
⚠️ THE RETIRED-ID BRANCH IS NOT A FRIENDLIER SPELLING OF THE UNKNOWN-ID
|
|
ONE, AND MUST NOT COLLAPSE INTO IT. They answer different questions. An
|
|
unknown id is a TYPO: the operator meant something else and the fix is to
|
|
spell it right. A retired id is a CHOICE THE ADD-ON ACCEPTED - it was in
|
|
the schema, it was in the dropdown, it resolved to a class, and every
|
|
startup since said nothing. What that operator needs is not "this id is
|
|
wrong" but "this id has never once returned a price, and here is what your
|
|
site has actually been running instead" - because §11.4 made the failure
|
|
silent and NOTHING on their status page has ever distinguished it from a
|
|
working provider that never finds a cheap hour. The retired branch is
|
|
therefore FIRST, and it names the id, the endpoint, the observation, the
|
|
tariff that has really been in force, and the move to make.
|
|
"""
|
|
problems: list[str] = []
|
|
pid = str(opts.get("price_provider", "") or "off").strip() or "off"
|
|
|
|
if pid in RETIRED_PROVIDERS:
|
|
# ⚠️ BEFORE the unknown-id branch, and the order is load-bearing: a
|
|
# retired id is ALSO not in PROVIDERS, so the generic message would
|
|
# win on a reordering and the operator would be told they mistyped
|
|
# something they chose on purpose.
|
|
# ⚠️ AND THE SECOND SENTENCE CHANGES WITH `static_profile`, because
|
|
# "you have been on the static profile" is FALSE on a site that never
|
|
# set one. §11.4 falls back to the static profile; with no profile to
|
|
# fall back to, the site has had no tariff at all - a worse state, and
|
|
# one this message must not paper over.
|
|
been_running = (
|
|
f"the static day/night tariff in static_profile"
|
|
if str(opts.get("static_profile", "") or "").strip()
|
|
else "NOTHING AT ALL - static_profile is empty too, so there was "
|
|
"no tariff to fall back to either")
|
|
problems.append(
|
|
f"price_provider is {pid!r}, which WAS a shipped plugin and has "
|
|
f"been WITHDRAWN (TARIFF-07). This is not a typo and you did not "
|
|
f"mistype it - the add-on offered this id, accepted it, and has "
|
|
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
|
|
|
|
if pid != "off" and pid not in PROVIDERS:
|
|
problems.append(
|
|
f"price_provider is {pid!r}, which is not a shipped plugin. The "
|
|
f"ids that exist are: 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
|
|
|
|
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
|
|
# price_now_entity/price_avg_entity are the original delegation of pricing
|
|
# to Home Assistant: main.py reduces them to ONE BOOLEAN, `cheap`, read by
|
|
# the maintenance cheap-window charge floor. They are KEPT - retiring them
|
|
# would take a live install's only price signal away - but they may not run
|
|
# alongside another price source, because main.py reads exactly one and the
|
|
# loser is silent. That is (a), and it is the "silently double up" the PM
|
|
# named on 2026-08-30.
|
|
#
|
|
# ⚠️ THE REASON THIS REFUSAL GIVES WAS REWRITTEN BY TARIFF-03, AND THE OLD
|
|
# WORDING IS WHY. Until TARIFF-03 this message said the configured provider
|
|
# "would decide nothing", which was true only because nothing consumed the
|
|
# series; test_price.py section 12 carried a tripwire that went red the
|
|
# moment a consumer landed, with instructions to fix the wording here in the
|
|
# same commit. It landed, it went red, and this is that fix. What is true
|
|
# NOW, stated exactly and no wider:
|
|
#
|
|
# * app/static_tariff.py consumes load_prices(). resolve_tariff() turns a
|
|
# provider series into a TariffPlan, a cheap-window flag and an
|
|
# arbitrage verdict, so the seam is no longer inert.
|
|
# * main.py reads the STATIC side of that module every cycle
|
|
# (static_status()), which makes static_profile a real writer of `cheap`.
|
|
# * main.py does NOT call resolve_tariff() on the control tick, and that
|
|
# is still deliberate: a provider fetch is network I/O and the tick is
|
|
# what feeds the ESP32 heartbeat. OPT-04 put it on a BACKGROUND REFRESH
|
|
# instead (Controller.run_tariff_refresh), and the tick reads the plan
|
|
# that task last stored - an attribute read, with nothing to block on.
|
|
# * so a configured PROVIDER now DOES shape an energy decision, but only
|
|
# through app/arbitrage.py and only while `arbitrage_enabled` is on.
|
|
# With it off - the shipped configuration - the series is still fetched,
|
|
# validated and published, and still moves nothing.
|
|
#
|
|
# Hence three refusals below rather than two, and none of them claims more
|
|
# than that list. ⚠️ THE MIDDLE CLAUSE OF THE FIRST MESSAGE IS WHAT OPT-04
|
|
# CHANGED, exactly as the note it replaced predicted - the RULE is
|
|
# untouched, because the reason it exists (main.py reads the Home Assistant
|
|
# pair FIRST and the other source is silent) is unchanged.
|
|
ha_price = [k for k in ("price_now_entity", "price_avg_entity")
|
|
if str(opts.get(k, "") or "").strip()]
|
|
if pid != "off" and ha_price:
|
|
problems.append(
|
|
f"price_provider is {pid!r} AND {' and '.join(ha_price)} "
|
|
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two price "
|
|
"sources on one site. THE HOME ASSISTANT ENTITIES WIN: they are "
|
|
"what main.py reduces to the `cheap` flag the maintenance cycle "
|
|
"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
|
|
# CONFLICT RATHER THAN A LATENT ONE. main.py's read_inputs() takes `cheap`
|
|
# from the Home Assistant pair when it is set and from the static profile
|
|
# otherwise - one flag, first writer wins, and the operator who filled in a
|
|
# day/night tariff has no way to see that it lost.
|
|
if ha_price and str(opts.get("static_profile", "") or "").strip():
|
|
problems.append(
|
|
f"static_profile is set AND {' and '.join(ha_price)} "
|
|
f"{'is' if len(ha_price) == 1 else 'are'} set, which is two "
|
|
"tariff sources feeding one cheap-window flag. THE HOME ASSISTANT "
|
|
"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
|
|
# that does not tile the day has hours with no price, and the only ways to
|
|
# serve those are to invent a number or to fail at the moment the clock
|
|
# reaches them. §14's warning box - refuse to start on a mismatch rather
|
|
# than clamp quietly - is the same instruction one level down.
|
|
raw_profile = opts.get("static_profile", "")
|
|
if str(raw_profile or "").strip() or isinstance(raw_profile, (list, dict)):
|
|
try:
|
|
profile = parse_static_profile(raw_profile)
|
|
except ProfileInvalid as e:
|
|
profile = None
|
|
problems.append(f"static_profile cannot be used: {e}")
|
|
if profile is not None and profile.product is not None \
|
|
and len(set(profile.prices)) == 1:
|
|
_LOG.info(
|
|
"static_profile carries one distinct price (%.4f EUR/kWh "
|
|
"all-in), so this site is priced as FULLY FIXED. Arbitrage is "
|
|
"off by arithmetic - a flat series has no spread - and the "
|
|
"add-on runs self-consumption only, which is §9.2's handling "
|
|
"for this product. Peak shaving, reserve management and the "
|
|
"maintenance cycle are unaffected.", profile.prices[0])
|
|
|
|
# ⚠️ TARIFF-17: THE TAXES PERIOD TABLE, PARSED AT STARTUP RATHER THAN AT
|
|
# 03:00, FOR `static_profile`'s REASON ONE OPTION OVER. This table is read
|
|
# by `price.assemble()` on the ~30 s planning path; a table that only fails
|
|
# when a fetch happens is a site that stops pricing mid-afternoon, and
|
|
# §11.4 turns that into a quiet fallback that looks exactly like a healthy
|
|
# fixed-tariff day. So the parse happens here, once, where a refusal is a
|
|
# banner naming the key.
|
|
raw_taxes_profile = opts.get(TAXES_PROFILE_OPTION, "")
|
|
taxes_table = None
|
|
if str(raw_taxes_profile or "").strip() \
|
|
or isinstance(raw_taxes_profile, (list, dict)):
|
|
try:
|
|
taxes_table = parse_taxes_profile(raw_taxes_profile)
|
|
except ProfileInvalid as e:
|
|
problems.append(f"{TAXES_PROFILE_OPTION} cannot be used: {e}")
|
|
|
|
if taxes_table is not None:
|
|
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
|
|
# exact case (a) this module exists for, and the same refusal shape as
|
|
# the price-entity/static_profile pair above. The table decides when it
|
|
# is set, so a non-default scalar left beside it is a number the
|
|
# operator typed that changes nothing, on the quantity §11.4 makes
|
|
# hardest to notice. It is compared against the SHIPPED DEFAULT rather
|
|
# than against presence, because the Supervisor writes every schema key
|
|
# into the options file whether the operator touched it or not.
|
|
scalar = _number(opts, "price_taxes_eur_kwh",
|
|
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
|
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
|
if scalar is not None and abs(scalar - shipped) > 1e-12:
|
|
problems.append(
|
|
f"{TAXES_PROFILE_OPTION} is set AND price_taxes_eur_kwh is "
|
|
f"{scalar} rather than its shipped {shipped}. THE TABLE WINS: "
|
|
f"the add-on reads the per-period figures and never looks at "
|
|
f"the scalar, so the number you typed there would decide "
|
|
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.")
|
|
|
|
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
|
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
|
|
# (ORES `13,27`), and the whole table is copied off one card - so the
|
|
# factor-100 error arrives on EVERY row at once and nothing internal to
|
|
# the table disagrees with itself. The parser's own +-10 EUR/kWh bound
|
|
# catches a 13,27 typed raw, but NOT ORES IMPACT's night band of 5,090,
|
|
# which would be a 5.09 EUR/kWh levy sailing through. Belgian
|
|
# non-energy components run roughly 0.05-0.25 EUR/kWh, so anything at
|
|
# or above 1.0 is the wrong unit and not a tariff.
|
|
steep = sorted({p.eur_per_kwh for p in taxes_table.periods
|
|
if abs(p.eur_per_kwh) >= 1.0})
|
|
if steep:
|
|
problems.append(
|
|
f"{TAXES_PROFILE_OPTION} carries {steep} EUR/kWh. Belgian "
|
|
f"levies and distribution run about 0.05-0.25 EUR/kWh, so "
|
|
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
|
|
if p.eur_per_kwh < 0})
|
|
if negative:
|
|
# ⚠️ A WARNING, NOT A REFUSAL. A negative non-energy component is
|
|
# implausible rather than impossible - a prosumer rebate is
|
|
# expressible this way - and refusing a site over it would be this
|
|
# module asserting a fact about Belgian tariff design it cannot
|
|
# observe. §14's warning box governs CONTRADICTIONS, and this is
|
|
# not one.
|
|
_LOG.warning(
|
|
"%s carries negative levies %s EUR/kWh. That is accepted, but "
|
|
"a tax component below zero is usually a sign error: the "
|
|
"add-on will SUBTRACT it from the published price in those "
|
|
"hours.", TAXES_PROFILE_OPTION, negative)
|
|
|
|
# ⚠️ TARIFF-15: THE FORMULA, GRADED AGAINST THE OPERATOR'S OWN CARD, AT
|
|
# STARTUP RATHER THAN AT 03:00.
|
|
#
|
|
# THE DEFECT THIS EXISTS TO PREVENT, and it is not one `code_leaks()` can
|
|
# see because it is not in code: an operator folds their NETWORK CHARGES
|
|
# into `offset` while `price_taxes_eur_kwh` carries them as well, and the
|
|
# site is billed against a price ~12 c EUR/kWh high on every hour for as
|
|
# long as nobody re-reads the configuration. §11.4 makes every price
|
|
# defect quiet, so nothing on the status page would ever differ.
|
|
#
|
|
# The card is what grades it. A Belgian tariff card prints the formula,
|
|
# the reference index it assumed AND the resulting price, so the add-on
|
|
# recomputes that printed price from the numbers the operator typed and
|
|
# refuses to start unless the two agree to within CARD_TOLERANCE. An
|
|
# inflated `offset` cannot pass - the printed estimate is the energy
|
|
# component alone, so ~12 c EUR/kWh moves 15.55 to ~27. The same check
|
|
# catches VAT omitted, VAT applied twice, a transposed digit, and a unit
|
|
# MISMATCH between the coefficients and the printed estimate. The one unit
|
|
# error it cannot see is the one applied to BOTH sides at once, because
|
|
# that cancels out of a comparison between them - which is why
|
|
# `implausible_all_in()` below is a second refusal on a second quantity
|
|
# rather than a restatement of this one.
|
|
#
|
|
# ⚠️ AND ITS LIMIT, IN THE SAME SENTENCE AS THE CLAIM, exactly as price.py
|
|
# states trap 1's: IT GRADES THE COEFFICIENTS AGAINST THE CARD, NOT THE
|
|
# CARD AGAINST REALITY. A consistent-but-wrong pair - both numbers off the
|
|
# wrong product - reproduces its own printed price and passes. Nothing can
|
|
# catch that.
|
|
raw_formula = opts.get("price_formula", "")
|
|
has_formula = bool(str(raw_formula or "").strip()) \
|
|
or isinstance(raw_formula, dict)
|
|
if pid == "custom_formula" and not has_formula:
|
|
problems.append(
|
|
"price_provider is 'custom_formula' but price_formula is empty, "
|
|
"so the add-on has no coefficients to price anything with. Every "
|
|
"fetch would return no-price-data and §11.4 would fall back "
|
|
"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:
|
|
try:
|
|
spec = parse_price_formula(raw_formula)
|
|
except FormulaInvalid as e:
|
|
spec = None
|
|
problems.append(f"price_formula cannot be used: {e}")
|
|
if spec is not None and pid != "custom_formula":
|
|
# ⚠️ 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
|
|
# anything - so the §14 warning box does not apply. But an option
|
|
# an operator filled in and that decides nothing is exactly the
|
|
# silent loser this module's case (a) is about, so it is said out
|
|
# loud rather than left to be discovered from a bill.
|
|
_LOG.warning(
|
|
"price_formula is filled in but price_provider is %r, so the "
|
|
"coefficients decide NOTHING - only 'custom_formula' reads "
|
|
"them. Set price_provider to 'custom_formula' to use them, or "
|
|
"clear price_formula so the configuration says what the "
|
|
"add-on is doing.", pid)
|
|
if spec is not None and pid == "custom_formula":
|
|
taxes = _number(opts, "price_taxes_eur_kwh",
|
|
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
|
band = implausible_all_in(spec, taxes if taxes is not None else 0.0)
|
|
if band:
|
|
problems.append(band)
|
|
aged = stale_card(spec)
|
|
if aged:
|
|
# ⚠️ WARN, NEVER REFUSE. ENGIE freezes a contract's
|
|
# coefficients for its duration, so a two-year-old card can be
|
|
# exactly right and a site refused over one is a site that
|
|
# cannot start.
|
|
_LOG.warning("%s", aged)
|
|
|
|
# ⚠️ A WARNING, NOT A REFUSAL, AND THE ASYMMETRY IS DELIBERATE. §11.4 says a
|
|
# site with no price data falls back to the static profile; a dynamic site
|
|
# with none configured has nothing to fall back to when its feed dies. That
|
|
# is a real gap worth naming - but refusing to start over it would break
|
|
# every install TARIFF-02 shipped, none of which could have had one.
|
|
if pid != "off" and not str(opts.get("static_profile", "") or "").strip():
|
|
_LOG.warning(
|
|
"price_provider is %r but static_profile is empty. §11.4's "
|
|
"degradation row is 'no price data -> fall back to the static "
|
|
"profile'; with none configured, a feed outage leaves this site "
|
|
"with no tariff at all rather than with a coarse one. Not refused, "
|
|
"but filling it in is what makes that row work.", pid)
|
|
|
|
# ⚠️ OPT-04. THE THREE WAYS `arbitrage_enabled: true` CAN BE A LIE, refused
|
|
# rather than logged, because a strategy that is armed in the UI and cannot
|
|
# fire is indistinguishable from one that fires and finds no opportunity -
|
|
# which is the §14 warning box's case exactly. None of these can break an
|
|
# existing install: the option is new and ships `false`.
|
|
# ⚠️ CHECKED WHETHER OR NOT ARBITRAGE IS ARMED. A threshold that is not a
|
|
# non-negative number is a configuration error the moment it is typed, and
|
|
# deferring the complaint until somebody turns the strategy on would report
|
|
# it at the worst possible moment - the one where energy starts moving.
|
|
thresh = _number(opts, "cycle_cost_threshold_eur",
|
|
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
|
|
if thresh is None or thresh < 0:
|
|
problems.append(
|
|
f"cycle_cost_threshold_eur is "
|
|
f"{opts.get('cycle_cost_threshold_eur')!r} and must be a "
|
|
f"non-negative number of EUR/kWh - it is what one round trip COSTS "
|
|
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 pid == "off" and not str(opts.get("static_profile", "") or "").strip():
|
|
problems.append(
|
|
"arbitrage_enabled is on but price_provider is 'off' and "
|
|
"static_profile is empty, so this site has NO PRICES AT ALL. "
|
|
"§10.1's priority 3 is 'charge cheap, discharge expensive' and "
|
|
"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):
|
|
problems.append(
|
|
"arbitrage_enabled is on but meter_source is 'off', so there "
|
|
"is no P1 telegram stream, the capacity-tariff peak guard "
|
|
"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",
|
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_floor"])
|
|
ceiling = _number(opts, "arbitrage_soc_ceiling",
|
|
PRICE_OPTION_DEFAULTS["arbitrage_soc_ceiling"])
|
|
if floor is None or ceiling is None:
|
|
problems.append(
|
|
f"arbitrage_soc_floor {opts.get('arbitrage_soc_floor')!r} and "
|
|
f"arbitrage_soc_ceiling {opts.get('arbitrage_soc_ceiling')!r} "
|
|
f"must both be numbers - they are percentages of pack charge.")
|
|
elif floor >= ceiling:
|
|
problems.append(
|
|
f"arbitrage_soc_floor is {floor:g} % and arbitrage_soc_ceiling "
|
|
f"is {ceiling:g} %, so there is no band left between them: "
|
|
f"every state of charge is either at or under the floor (no "
|
|
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():
|
|
problems.append(
|
|
"price_provider is 'manual_csv' but price_csv_path is empty, so "
|
|
"the offline fallback has no file to read. Every fetch would "
|
|
"return no-price-data with a filesystem error as its reason - "
|
|
"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
|
|
|
|
|
|
def _number(opts: dict, key, default):
|
|
raw = opts.get(key, default)
|
|
if raw is None or raw == "":
|
|
return default
|
|
try:
|
|
return float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def refuse(problems: list[str]) -> None:
|
|
"""Log a refusal an operator can tell apart from a crash. Does not raise.
|
|
|
|
⚠️ THE POINT OF THE BANNER. "You configured this wrong" must not read like
|
|
"it broke". A Python traceback in an add-on log means the same thing to most
|
|
operators as a segfault - file a bug - whereas this block names the keys,
|
|
names the fix, and says in words that nothing is broken. main.py catches
|
|
ConfigRefused and exits 78 (sysexits EX_CONFIG), so no traceback is printed
|
|
at all and even the exit status distinguishes the two.
|
|
"""
|
|
_LOG.error("=" * 72)
|
|
_LOG.error("REFUSING TO START: the add-on configuration is not usable.")
|
|
_LOG.error("This is a CONFIGURATION problem, not a crash. Nothing is broken;")
|
|
_LOG.error("the add-on will start as soon as the settings below are fixed.")
|
|
_LOG.error("Fix them in Settings > Add-ons > GoodWe RS485 Controller >")
|
|
_LOG.error("Configuration, then start the add-on again.")
|
|
_LOG.error("-" * 72)
|
|
for i, problem in enumerate(problems, 1):
|
|
_LOG.error(" %d. %s", i, problem)
|
|
_LOG.error("=" * 72)
|