UX-08 2c: short refusals, error texts and option texts
Validator refusals name the setting by its label and key, say what is wrong and what to change, and keep the rejected value and limit. Option names and descriptions are short in en and nl; citations moved to DOCS "For installers" and the test_translations citation check now pins the instruction on the tab and the record in DOCS. No rule, key, schema, default or exit code changed. Allowlist 167 -> 43 (log lines only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e1d0970b23
commit
2c516a032c
@@ -262,10 +262,8 @@ def threshold_eur_kwh(opts: dict) -> float:
|
||||
v = float(price_option(opts, "cycle_cost_threshold_eur"))
|
||||
if not math.isfinite(v) or v < 0:
|
||||
raise ValueError(
|
||||
f"cycle_cost_threshold_eur is {v!r}. It is what a round trip COSTS, "
|
||||
f"in EUR/kWh, so it is non-negative and finite - a negative "
|
||||
f"threshold is one every spread clears, which is arbitrage with the "
|
||||
f"brake removed.")
|
||||
f"“Battery cycle cost” (cycle_cost_threshold_eur) is {v!r}. Set "
|
||||
f"it to 0 or more, in EUR/kWh.")
|
||||
return v
|
||||
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ RESOLUTIONS: tuple = ("hourly", "quarter_hourly")
|
||||
# index id -> the human name of the series it refers to. One entry today; a
|
||||
# second index is a new row here plus a fetch path, not a new grammar.
|
||||
INDEXES: dict = {
|
||||
"epex_be": "EPEX BE day-ahead, EUR/MWh",
|
||||
"epex_be": "Belgian day-ahead price, EUR/MWh",
|
||||
}
|
||||
|
||||
# ⚠️ 0.01 IN THE CARD'S OWN UNITS, WHICH IS THE LAST DIGIT BOTH REAL CARDS
|
||||
@@ -285,87 +285,29 @@ _LEGS = {
|
||||
'"offset":1.00,"units":"c_eur_per_kwh","vat_pct":6,'
|
||||
'"card":{"month":"2026-09","index_eur_per_mwh":133.9905,'
|
||||
'"expect_incl_vat":15.55}}'),
|
||||
when="Leave it empty unless price_provider is 'custom_formula'.",
|
||||
zero_factor=("every spread is exactly 0, and arbitrage can never "
|
||||
"clear any threshold - a strategy that silently never "
|
||||
"fires rather than one that is off"),
|
||||
flat=("that is `static_profile` with a single period covering "
|
||||
"00:00-24:00 on all days and price_provider 'off'; the add-on "
|
||||
"then says so at startup instead of leaving you to find out "
|
||||
"from a year of unchanged bills"),
|
||||
neg_factor=("No consumption tariff is indexed negatively; if your "
|
||||
"card shows a minus sign it is on the OFFSET (`A`), "
|
||||
"which is allowed here and common on injection"),
|
||||
when=("Leave it empty unless “Dynamic price source” (price_provider) "
|
||||
"is 'custom_formula'."),
|
||||
zero_factor=" and trading never finds a price difference",
|
||||
flat="use “Fixed or day/night tariff” (static_profile) instead",
|
||||
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||
card_required=True,
|
||||
card_note=("It is the only thing that can tell a correct pair of "
|
||||
"coefficients from a plausible-looking wrong one"),
|
||||
card_causes=(
|
||||
"or - the expensive one - network charges, levies or excise "
|
||||
"folded into `offset`. ⚠️ THEY DO NOT BELONG THERE: a Belgian "
|
||||
"card's Tariefformule line is the ENERGY component alone (Eneco "
|
||||
"tabulates network tariffs and levies separately; ENGIE says in "
|
||||
"so many words that the formula excludes network, distribution, "
|
||||
"transport, levies and excise), the add-on adds "
|
||||
"price_taxes_eur_kwh on top of it, and folding ~12 c EUR/kWh of "
|
||||
"network charge into `offset` bills you for it twice. That is "
|
||||
"the error this check exists to catch, and it is why it cannot "
|
||||
"be switched off."),
|
||||
card_note="The add-on needs it to check your two numbers",
|
||||
card_causes="that network charges are not in the offset",
|
||||
),
|
||||
EXPORT_FORMULA_OPTION: _Leg(
|
||||
option=EXPORT_FORMULA_OPTION,
|
||||
what="injection",
|
||||
# ⚠️ NO `card` BLOCK IN THE EXAMPLE, because Eneco's injection line has
|
||||
# none to copy - the formula is printed with no price beside it. An
|
||||
# example carrying one would invite an operator to compute the figure
|
||||
# themselves, which is the one thing this leg must not encourage.
|
||||
example=('{"index":"epex_be","resolution":"hourly","factor":0.1,'
|
||||
'"offset":-1.188,"units":"c_eur_per_kwh","vat_pct":0}'),
|
||||
when=("Leave it empty unless your supplier pays you an injection "
|
||||
"price that moves with the market; a flat one is "
|
||||
"export_price_eur_kwh. ⚠️ This option is read whatever "
|
||||
"price_provider is set to - it is the leg you are PAID on, not "
|
||||
"the one you buy on."),
|
||||
zero_factor=("every hour pays the same, which is a FLAT injection "
|
||||
"price written as a formula. ⚠️ Note this is NOT the "
|
||||
"arbitrage argument the consumption leg gives: no "
|
||||
"strategy in this add-on reads an injection price at "
|
||||
"all, so a flat one here fires nothing and declines "
|
||||
"nothing - it is simply the wrong option"),
|
||||
flat=("that is `export_price_eur_kwh`, the flat injection scalar, "
|
||||
"which is what every site used before this option existed and "
|
||||
"which still answers when this one is empty"),
|
||||
neg_factor=("No Belgian injection tariff is indexed negatively - you "
|
||||
"are paid MORE when the market is dearer, not less. The "
|
||||
"minus sign on an injection card is on the OFFSET (`A`): "
|
||||
"Eneco prints `0,1 x BELPEX-H - 1,188` and Aspiravi "
|
||||
"`0,07 x Belpex - 2`, and a negative offset is accepted "
|
||||
"here"),
|
||||
when=("Fill it in only if your injection price follows the market. It "
|
||||
"is used whatever the price source is."),
|
||||
zero_factor="",
|
||||
flat="use “Injection price” (export_price_eur_kwh) instead",
|
||||
neg_factor="Put the minus sign on the offset, not on the factor",
|
||||
card_required=False,
|
||||
card_note=(
|
||||
"⚠️ IT IS OPTIONAL ON THIS LEG, AND THAT IS A STATED GAP RATHER "
|
||||
"THAN A RELAXED RULE. A Belgian card prints the CONSUMPTION "
|
||||
"formula with the reference index and the resulting price beside "
|
||||
"it, which is what makes `price_formula`'s self-check possible. "
|
||||
"Eneco's injection line has NO printed price beside it, so on "
|
||||
"that card there is nothing to grade against and requiring a "
|
||||
"`card` block would make the market's own published formula "
|
||||
"inexpressible - or, worse, invite an operator to compute the "
|
||||
"estimate themselves, at which point the check grades the "
|
||||
"coefficients against arithmetic done with the same coefficients. "
|
||||
"Supply it if YOUR card prints one; when it is absent the add-on "
|
||||
"says at startup that this leg is UNGRADED and the two "
|
||||
"coefficient bounds below are all there is"),
|
||||
card_causes=(
|
||||
"or VAT added where there is none. ⚠️ INJECTION IS VAT-EXEMPT IN "
|
||||
"BELGIUM - \"Injectietarieven zijn vrijgesteld van btw\" - while "
|
||||
"consumption is at 6%, so `vat_pct` on THIS option is 0 on every "
|
||||
"Belgian card the research found, and a 6 typed here because it "
|
||||
"is 6 on the consumption leg is a ratio of exactly 1.06. Nothing "
|
||||
"is added to this leg by the framework: price_taxes_eur_kwh is a "
|
||||
"CONSUMPTION component and is not applied to what you are paid "
|
||||
"for export, so an offset carrying network charges is a "
|
||||
"different error here than it is on price_formula and this "
|
||||
"check cannot tell you which one you made."),
|
||||
card_note=("It is optional here. Without it the add-on only checks "
|
||||
"the size of your two numbers"),
|
||||
card_causes="that vat_pct is 0 for injection",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -488,16 +430,12 @@ def _finite(value, field: str, option: str = "price_formula") -> float:
|
||||
"""
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise FormulaInvalid(
|
||||
f"{option}.{field} is {value!r}, which is not a number. "
|
||||
f"Write the coefficient as a bare JSON number - 0.102, not "
|
||||
f"\"0.102\" and not an expression: this provider takes FIXED "
|
||||
f"coefficients and evaluates nothing.")
|
||||
f"{option}.{field} is {value!r}, which is not a number. Write a "
|
||||
f"plain number, for example 0.102.")
|
||||
if not math.isfinite(float(value)):
|
||||
raise FormulaInvalid(
|
||||
f"{option}.{field} is {value!r}, which is not finite. A NaN "
|
||||
f"or an infinity here makes every price NaN, every price "
|
||||
f"comparison False, and an arbitrage strategy that never fires "
|
||||
f"while reporting that it is armed.")
|
||||
f"{option}.{field} is {value!r}, which is not finite. Write a "
|
||||
f"plain number, for example 0.102.")
|
||||
return float(value)
|
||||
|
||||
|
||||
@@ -517,11 +455,8 @@ def _card(raw, leg: _Leg = None) -> tuple:
|
||||
if not leg.card_required:
|
||||
return ("", None, None)
|
||||
raise FormulaInvalid(
|
||||
f"{leg.option} has no \"card\" block, and it is REQUIRED. "
|
||||
f"{leg.card_note}: the add-on recomputes your "
|
||||
"supplier's own printed example from the numbers you typed and "
|
||||
"refuses to start if it does not come out. Copy the three figures "
|
||||
"off the same tariff card as the coefficients: "
|
||||
f"{leg.option} has no \"card\" block. {leg.card_note}. Copy "
|
||||
"the three figures from your tariff card, for example "
|
||||
"{\"month\": \"2026-09\", \"index_eur_per_mwh\": 133.9905, "
|
||||
"\"expect_incl_vat\": 15.55}.")
|
||||
if not isinstance(raw, dict):
|
||||
@@ -531,16 +466,13 @@ def _card(raw, leg: _Leg = None) -> tuple:
|
||||
unknown = sorted(set(raw) - set(_CARD_KEYS))
|
||||
if unknown:
|
||||
raise FormulaInvalid(
|
||||
f"{leg.option}.card has no field(s) {unknown}. The fields it "
|
||||
f"takes are {list(_CARD_KEYS)}; a key that is merely ignored is a "
|
||||
f"number the operator believes took effect.")
|
||||
f"{leg.option}.card has unknown field(s) {unknown}. It takes "
|
||||
f"only {list(_CARD_KEYS)}.")
|
||||
month = str(raw.get("month", "") or "").strip()
|
||||
if not _month_date(month):
|
||||
raise FormulaInvalid(
|
||||
f"{leg.option}.card.month is {raw.get('month')!r}; write the "
|
||||
f"month the card is dated as \"YYYY-MM\", e.g. \"2026-09\". It is "
|
||||
f"not used in any price - it is what lets the add-on tell you "
|
||||
f"your card has gone stale.")
|
||||
f"{leg.option}.card.month is {raw.get('month')!r}. Write the "
|
||||
f"month of the card as \"YYYY-MM\", for example \"2026-09\".")
|
||||
return (month,
|
||||
_finite(raw.get("index_eur_per_mwh"), "card.index_eur_per_mwh",
|
||||
leg.option),
|
||||
@@ -594,8 +526,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise FormulaInvalid(
|
||||
f"{option} is not valid JSON ({e}). It is a JSON object "
|
||||
f"on one line, e.g.\n"
|
||||
f"{option} is not valid JSON ({e}). Write it on one line, for "
|
||||
f"example\n"
|
||||
f" {leg.example}\n"
|
||||
f"{leg.when}")
|
||||
if not isinstance(value, dict):
|
||||
@@ -612,34 +544,21 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
unknown = sorted(set(value) - set(_TOP_KEYS))
|
||||
if unknown:
|
||||
raise FormulaInvalid(
|
||||
f"{option} has no field(s) {unknown}. The fields it takes "
|
||||
f"are {list(_TOP_KEYS)}. ⚠️ There is deliberately NO field that "
|
||||
f"takes an expression: this provider multiplies and adds two "
|
||||
f"FIXED coefficients and evaluates nothing, because two "
|
||||
f"coefficients can be graded against your card's printed price "
|
||||
f"and an arbitrary expression cannot be graded against anything. "
|
||||
f"If your contract needs a shape this cannot express, it needs a "
|
||||
f"new named field here - say which shape.")
|
||||
f"{option} has unknown field(s) {unknown}. It takes only "
|
||||
f"{list(_TOP_KEYS)}.")
|
||||
|
||||
index = str(value.get("index", "") or "").strip()
|
||||
if index not in INDEXES:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.index is {value.get('index')!r}. The reference "
|
||||
f"series this add-on can fetch are "
|
||||
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}. "
|
||||
f"If your card names a different index, the add-on cannot get it "
|
||||
f"and guessing which series your supplier meant would price your "
|
||||
f"battery against the wrong curve.")
|
||||
f"{option}.index is {value.get('index')!r}. The add-on can only "
|
||||
f"fetch "
|
||||
f"{', '.join(f'{k!r} ({v})' for k, v in sorted(INDEXES.items()))}.")
|
||||
|
||||
resolution = str(value.get("resolution", "") or "").strip()
|
||||
if resolution not in RESOLUTIONS:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.resolution is {value.get('resolution')!r}; it "
|
||||
f"must be one of {list(RESOLUTIONS)}. Belgium has been on a "
|
||||
f"15-minute MTU since 2025-10-01 and the feed publishes 96 points "
|
||||
f"a day; a contract written against the HOURLY series is priced "
|
||||
f"on the mean of each hour's quarters. Which one your contract "
|
||||
f"uses is on your card and cannot be inferred from the feed.")
|
||||
f"{option}.resolution is {value.get('resolution')!r}. Choose one "
|
||||
f"of {list(RESOLUTIONS)}, as your tariff card says.")
|
||||
|
||||
# ⚠️ `units` IS REQUIRED AND HAS NO DEFAULT. A default here would be the
|
||||
# factor-1000 class with a friendly face: the add-on would silently assume
|
||||
@@ -648,12 +567,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
units = str(value.get("units", "") or "").strip()
|
||||
if units not in UNITS:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.units is {value.get('units')!r} and is REQUIRED - "
|
||||
f"there is no default, on purpose. It must be one of "
|
||||
f"{sorted(UNITS)}. Your card prints the formula in ONE of these "
|
||||
f"and two suppliers use two different conventions for the same "
|
||||
f"shape, so assuming one would be a factor of 100 or 1000 on "
|
||||
f"every hour with nothing able to notice.")
|
||||
f"{option}.units is {value.get('units')!r}. Choose one of "
|
||||
f"{sorted(UNITS)}, as printed beside the formula on your card.")
|
||||
|
||||
factor = _finite(value.get("factor"), "factor", option)
|
||||
offset = _finite(value.get("offset"), "offset", option)
|
||||
@@ -667,10 +582,8 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
# the operator at it rather than let them build it by accident.
|
||||
if factor == 0.0:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.factor is 0, so the index would not enter the "
|
||||
"price at all: every hour of every day comes out at "
|
||||
f"{offset:g} x (1 + {vat_pct:g}/100), {leg.zero_factor}. If "
|
||||
f"your {leg.what} tariff really is one flat price, {leg.flat}.")
|
||||
f"{option}.factor is 0, so every hour gets the same {leg.what} "
|
||||
f"price{leg.zero_factor}. For one flat price, {leg.flat}.")
|
||||
|
||||
# ⚠️ A NEGATIVE FACTOR INVERTS THE MARKET, and the direction is the danger:
|
||||
# the dearest hour becomes the cheapest one, so the battery is told to
|
||||
@@ -678,22 +591,16 @@ def parse_price_formula(value, *, option: str = "price_formula"):
|
||||
# mispriced site, it is a site actively trading against itself.
|
||||
if factor < 0.0:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.factor is {factor:g}, which is NEGATIVE. That "
|
||||
f"inverts the market: the dearest hour of the day comes out as "
|
||||
f"the cheapest one, so the battery would charge at the evening "
|
||||
f"peak and discharge at 03:00 - trading against itself on every "
|
||||
f"cycle. {leg.neg_factor}.")
|
||||
f"{option}.factor is {factor:g}, which turns the market upside "
|
||||
f"down. {leg.neg_factor}.")
|
||||
|
||||
# ⚠️ 0 IS LEGITIMATE AND MUST STAY SO: injection is VAT-exempt in Belgium,
|
||||
# and some cards are already quoted VAT-inclusive. The bound exists for the
|
||||
# 106 / 1.06 / 6 confusion, not to make VAT compulsory.
|
||||
if not 0.0 <= vat_pct <= 30.0:
|
||||
raise FormulaInvalid(
|
||||
f"{option}.vat_pct is {vat_pct:g} and must be between 0 and "
|
||||
f"30. It is a PERCENTAGE, so Belgian residential electricity is "
|
||||
f"6, not 0.06 and not 106. Zero is legitimate and is not refused: "
|
||||
f"injection is VAT-exempt, and a card that already quotes "
|
||||
f"VAT-inclusive coefficients needs no second multiplication.")
|
||||
f"{option}.vat_pct is {vat_pct:g}. Write a percentage between 0 "
|
||||
f"and 30, for example 6.")
|
||||
|
||||
month, card_index, card_expect = _card(value.get("card"), leg)
|
||||
spec = FormulaSpec(index=index, resolution=resolution, factor=factor,
|
||||
@@ -777,27 +684,11 @@ def card_refusal(spec: FormulaSpec, delta: float) -> str:
|
||||
exists at all.
|
||||
"""
|
||||
got = in_card_units(spec, spec.card_index_eur_per_mwh)
|
||||
ratio = (got / spec.card_expect_incl_vat
|
||||
if spec.card_expect_incl_vat else float("inf"))
|
||||
leg = _LEGS[spec.option]
|
||||
return (
|
||||
f"{spec.option} does not reproduce its own card, so the add-on will "
|
||||
f"NOT start on it. Your contract's arithmetic is "
|
||||
f"price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100); "
|
||||
f"on the card's own index that is "
|
||||
f"({spec.factor:g} x {spec.card_index_eur_per_mwh:g} + "
|
||||
f"{spec.offset:g}) x (1 + {spec.vat_pct:g}/100) = {got:.4f} "
|
||||
f"{spec.units}, but card.expect_incl_vat says "
|
||||
f"{spec.card_expect_incl_vat:g} - OUT BY {delta:+.4f} {spec.units} "
|
||||
f"(ratio {ratio:.4g}; the tolerance is {CARD_TOLERANCE}, which is the "
|
||||
f"last digit a card prints). One of the two sides is wrong and the "
|
||||
f"add-on cannot tell which, so it refuses rather than price a battery "
|
||||
f"on it. The usual causes, in the order they happen: the coefficients "
|
||||
f"and the expected price are in DIFFERENT units (ratio 10, 100 or "
|
||||
f"1000 - `units` must be the one the formula line is printed in); VAT "
|
||||
f"left out or applied twice (ratio 1.06 or 1/1.06); a transposed "
|
||||
f"digit; the two sides copied off DIFFERENT cards or different "
|
||||
f"months; {leg.card_causes}")
|
||||
f"{spec.option} does not match its own card: it gives {got:.4f} "
|
||||
f"{spec.units} and the card says {spec.card_expect_incl_vat:g}. Check "
|
||||
f"the units, the VAT and {leg.card_causes}.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -852,20 +743,10 @@ def implausible_all_in(spec: FormulaSpec, taxes_eur_kwh: float):
|
||||
if energy >= PLAUSIBLE_LO_EUR_KWH and all_in <= PLAUSIBLE_HI_EUR_KWH:
|
||||
return None
|
||||
return (
|
||||
f"{spec.option}'s own card works out at {energy:.5f} EUR/kWh of "
|
||||
f"energy ({spec.card_expect_incl_vat:g} {spec.units} as the card "
|
||||
f"prints it) and {all_in:.5f} EUR/kWh all-in once "
|
||||
f"price_taxes_eur_kwh is added, which is outside the plausible band "
|
||||
f"{PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} EUR/kWh for a Belgian "
|
||||
f"retail tariff. The card self-check passed, so the coefficients and "
|
||||
f"the printed price AGREE - they simply agree on a number no Belgian "
|
||||
f"bill carries, which is what a whole card read in the wrong units "
|
||||
f"looks like (a factor of 10, 100 or 1000 applied to BOTH sides at "
|
||||
f"once cancels out of that check and cannot cancel out of this one). "
|
||||
f"Check `units` against the unit printed beside your formula. "
|
||||
f"⚠️ This bounds the CARD'S OWN POINT, not every hour: a live hour may "
|
||||
f"legitimately fall outside the band, and a negative day-ahead hour - "
|
||||
f"the best charging opportunity of the year - is never refused.")
|
||||
f"{spec.option} gives {energy:.5f} EUR/kWh for energy and "
|
||||
f"{all_in:.5f} EUR/kWh in total on its own card, outside the "
|
||||
f"plausible band of {PLAUSIBLE_LO_EUR_KWH}-{PLAUSIBLE_HI_EUR_KWH} "
|
||||
f"EUR/kWh. Check \"units\" against the unit beside your formula.")
|
||||
|
||||
|
||||
def implausible_injection(spec: FormulaSpec):
|
||||
@@ -914,36 +795,16 @@ def implausible_injection(spec: FormulaSpec):
|
||||
"""
|
||||
if not PASSTHROUGH_LO <= spec.passthrough <= PASSTHROUGH_HI:
|
||||
return (
|
||||
f"{spec.option}.factor is {spec.factor:g} {spec.units} per "
|
||||
f"EUR/MWh, which passes through {spec.passthrough:.4g}x the "
|
||||
f"wholesale price - outside the plausible "
|
||||
f"{PASSTHROUGH_LO}-{PASSTHROUGH_HI}x for an injection tariff. A "
|
||||
f"Belgian injection formula pays a share of the market price a "
|
||||
f"little under or about equal to it: Eneco's "
|
||||
f"`0,1 x BELPEX-H - 1,188` c EUR/kWh is 1.00x and Aspiravi's "
|
||||
f"`0,07 x Belpex - 2` is 0.70x. {spec.passthrough:.4g}x is what a "
|
||||
f"factor typed in the wrong units looks like - check `units` "
|
||||
f"({spec.units!r}) against the unit printed beside your formula, "
|
||||
f"because the card self-check cannot see a factor of 10, 100 or "
|
||||
f"1000 applied to BOTH sides at once. ⚠️ This bounds the "
|
||||
f"COEFFICIENT and not the price: an injection price below zero is "
|
||||
f"accepted and is the real market - Eneco's goes negative under "
|
||||
f"11.88 EUR/MWh of BELPEX-H, which is the middle of a sunny day.")
|
||||
f"{spec.option}.factor passes through {spec.passthrough:.4g}x the "
|
||||
f"market price, outside {PASSTHROUGH_LO}-{PASSTHROUGH_HI}x. Check "
|
||||
f"\"units\" ({spec.units!r}) against the unit beside your "
|
||||
f"formula.")
|
||||
if abs(spec.offset_eur_kwh) > INJECTION_OFFSET_MAX_EUR_KWH:
|
||||
return (
|
||||
f"{spec.option}.offset is {spec.offset:g} {spec.units} = "
|
||||
f"{spec.offset_eur_kwh:+.5f} EUR/kWh, and |offset| above "
|
||||
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh is not a Belgian "
|
||||
f"injection tariff in either direction. The published ones are "
|
||||
f"small and NEGATIVE - Eneco -1,188 c EUR/kWh (-0.01188), "
|
||||
f"Aspiravi -2 c (-0.02) - so a minus sign here is expected and is "
|
||||
f"not what this refuses; a MAGNITUDE two orders out is. The usual "
|
||||
f"cause is the offset typed in a different unit from `units` "
|
||||
f"({spec.units!r}), or a consumption offset copied onto the "
|
||||
f"injection leg: they are different numbers on the same card. "
|
||||
f"⚠️ Network charges and levies do not belong here either - "
|
||||
f"nothing is added to what you are PAID for export, so an offset "
|
||||
f"carrying them is simply wrong rather than double-counted.")
|
||||
f"{spec.option}.offset is {spec.offset:g} {spec.units}, which is "
|
||||
f"{spec.offset_eur_kwh:+.5f} EUR/kWh and larger than "
|
||||
f"{INJECTION_OFFSET_MAX_EUR_KWH} EUR/kWh either way. Check its "
|
||||
f"unit.")
|
||||
return None
|
||||
|
||||
|
||||
@@ -957,15 +818,9 @@ def stale_card(spec: FormulaSpec, today=None):
|
||||
if dated is None or (now - dated).days <= CARD_STALE_DAYS:
|
||||
return None
|
||||
return (
|
||||
f"{spec.option}'s card is dated {spec.card_month}, which is "
|
||||
f"{(now - dated).days} days ago. Suppliers reprice: if your contract "
|
||||
f"has been renewed or rolled over since then, the coefficients here "
|
||||
f"are last year's and every price this add-on computes is wrong by "
|
||||
f"however far they moved - and the card self-check cannot see it, "
|
||||
f"because it grades your coefficients against the card you typed, not "
|
||||
f"the card against your current contract. Not refused: ENGIE freezes "
|
||||
f"coefficients for the contract's duration, so an old card is often "
|
||||
f"exactly right. Worth re-reading your klantenzone once.")
|
||||
f"{spec.option}'s card is dated {spec.card_month}, "
|
||||
f"{(now - dated).days} days ago. The add-on still starts, but if your "
|
||||
f"contract changed since, copy the new figures from your supplier.")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -798,3 +798,51 @@ def samples() -> list:
|
||||
meter_frozen(600),
|
||||
meter_frozen(90),
|
||||
]
|
||||
|
||||
|
||||
# --- refusals (UX-08 phase 2c) --------------------------------------------------
|
||||
# A refusal names a setting by its label on the Configuration tab and then by its
|
||||
# key, because an installer editing in YAML needs the key. The label is the
|
||||
# en.yaml name without its group prefix and unit; test_messages.py checks that.
|
||||
LABELS = {
|
||||
"setpoint_entity": "Inverter setpoint entity",
|
||||
"max_w": "Inverter rating",
|
||||
"bms_soc_floor": "Battery cut-out level",
|
||||
"capacity_kwh": "Usable battery capacity",
|
||||
"inverters": "Inverter list",
|
||||
"name": "Name",
|
||||
"meter_source": "Where the meter reading comes from",
|
||||
"meter_phases": "Number of phases",
|
||||
"p1_host": "HomeWizard P1 address",
|
||||
"meter_poll_s": "Time between meter reads",
|
||||
"meter_max_age_s": "Reading too old after",
|
||||
"meter_mqtt_topic": "MQTT topic",
|
||||
"p1_import_entity": "Grid import entity",
|
||||
"p1_export_entity": "Grid export entity",
|
||||
"p1_net_entity": "Signed net power entity",
|
||||
"p1_phase_net_entities": "Net entities per phase",
|
||||
"peak_soc_floor": "Peak reserve",
|
||||
"price_provider": "Dynamic price source",
|
||||
"price_taxes_eur_kwh": "Distribution, levies and VAT",
|
||||
"static_profile": "Fixed or day/night tariff",
|
||||
"price_taxes_profile": "Day/night levies and VAT",
|
||||
"price_formula": "Your own tariff formula",
|
||||
"price_export_formula": "Your own injection formula",
|
||||
"price_csv_path": "Price CSV file",
|
||||
"price_now_entity": "Current price entity",
|
||||
"price_avg_entity": "Average price entity",
|
||||
"arbitrage_enabled": "Trade on the price spread",
|
||||
"cycle_cost_threshold_eur": "Battery cycle cost",
|
||||
"arbitrage_soc_floor": "Trading keeps at least",
|
||||
"arbitrage_soc_ceiling": "Trading charges at most",
|
||||
"maintenance_charge_w": "Recharge rate",
|
||||
"sign_disagree_w": "Wrong-direction threshold",
|
||||
"unit_sign_disagree_w": "Per-inverter wrong-direction threshold",
|
||||
"slew_w": "Largest change per cycle",
|
||||
"shadow_mode": "Shadow mode",
|
||||
}
|
||||
|
||||
|
||||
def setting(key: str, unit: str = "") -> str:
|
||||
"""`max_w` -> `“Inverter rating” (max_w)`, plus ` for inverter-b` in a list."""
|
||||
return f"“{LABELS[key]}” ({key})" + (f" for {unit}" if unit else "")
|
||||
|
||||
+31
-23
@@ -47,6 +47,10 @@ from datetime import datetime, timezone
|
||||
|
||||
import aiohttp
|
||||
|
||||
from . import messages
|
||||
|
||||
_S = messages.setting # a refusal names the setting by its label (UX-08)
|
||||
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
except ImportError: # pragma: no cover - container always has it
|
||||
@@ -180,7 +184,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
|
||||
# already applied a sign we are about to apply again - reject it rather
|
||||
# than silently double-signing the control loop.
|
||||
if imp < 0 or exp < 0:
|
||||
raise P1Error(f"unsigned registers cannot be negative (import={imp:g} export={exp:g})")
|
||||
raise P1Error(f"import and export readings cannot be negative "
|
||||
f"(import={imp:g} export={exp:g})")
|
||||
|
||||
per_phase = per_phase_import = None
|
||||
if phase_import_w is not None or phase_export_w is not None:
|
||||
@@ -188,8 +193,8 @@ def make_sample(source: str, import_w, export_w, *, phases: int,
|
||||
pe = list(phase_export_w or [0.0] * len(pi))
|
||||
if len(pi) != phases or len(pe) != phases:
|
||||
raise P1Error(
|
||||
f"phase count mismatch: telegram has {len(pi)} import / {len(pe)} export "
|
||||
f"phases, meter_phases is {phases}")
|
||||
f"the meter sent {len(pi)} import and {len(pe)} export phases, "
|
||||
f"but {_S('meter_phases')} is {phases}")
|
||||
vals = [_watts(a, f"L{i + 1} import") - _watts(b, f"L{i + 1} export")
|
||||
for i, (a, b) in enumerate(zip(pi, pe))]
|
||||
per_phase = tuple(vals)
|
||||
@@ -1160,37 +1165,37 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
||||
exp = str(opts.get("p1_export_entity", "") or "").strip()
|
||||
if not imp or not exp:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HA} needs both p1_import_entity and "
|
||||
f"p1_export_entity (got {imp or '<blank>'!r} / {exp or '<blank>'!r})")
|
||||
f"{_S('meter_source')} is {SOURCE_HA!r}, so it needs "
|
||||
f"{_S('p1_import_entity')} and {_S('p1_export_entity')}. They "
|
||||
f"are {imp or '<blank>'!r} and {exp or '<blank>'!r}.")
|
||||
elif source == SOURCE_HA_SIGNED:
|
||||
net = str(opts.get("p1_net_entity", "") or "").strip()
|
||||
phase_net = [str(e).strip() for e in
|
||||
(opts.get("p1_phase_net_entities") or []) if str(e).strip()]
|
||||
if not net:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HA_SIGNED} needs p1_net_entity "
|
||||
"(sensor.p1_sample_age_s would otherwise be announced with "
|
||||
"nothing feeding it)")
|
||||
f"{_S('meter_source')} is {SOURCE_HA_SIGNED!r}, so it needs "
|
||||
f"{_S('p1_net_entity')}.")
|
||||
if phase_net and len(phase_net) != phases:
|
||||
problems.append(
|
||||
f"p1_phase_net_entities has {len(phase_net)} entities but "
|
||||
f"meter_phases is {phases} - every telegram would be rejected "
|
||||
"on the phase-count check")
|
||||
f"{_S('p1_phase_net_entities')} has {len(phase_net)} entities, "
|
||||
f"but {_S('meter_phases')} is {phases}. Make them match.")
|
||||
elif source == SOURCE_MQTT:
|
||||
if not str(opts.get("meter_mqtt_topic", "") or "").strip():
|
||||
problems.append(f"meter_source {SOURCE_MQTT} needs meter_mqtt_topic")
|
||||
problems.append(f"{_S('meter_source')} is {SOURCE_MQTT!r}, so it "
|
||||
f"needs {_S('meter_mqtt_topic')}.")
|
||||
elif source == SOURCE_HOMEWIZARD:
|
||||
host, port_s = homewizard_host_port(opts)
|
||||
if not host:
|
||||
problems.append(
|
||||
f"meter_source {SOURCE_HOMEWIZARD} needs p1_host (the meter's "
|
||||
"own address, e.g. 192.168.2.250)")
|
||||
f"{_S('meter_source')} is {SOURCE_HOMEWIZARD!r}, so it needs "
|
||||
f"{_S('p1_host')}, the meter's address on your network.")
|
||||
if port_s:
|
||||
try:
|
||||
int(port_s)
|
||||
except ValueError:
|
||||
problems.append(f"p1_host {opts.get('p1_host')!r} has an "
|
||||
"unparseable port")
|
||||
problems.append(f"{_S('p1_host')} is {opts.get('p1_host')!r}, and "
|
||||
"its port is not a number.")
|
||||
try:
|
||||
# ⚠️ Not `or 5`: that turns an explicit 0 into the default, and a
|
||||
# cadence of 0 is a misconfiguration that must be reported, not
|
||||
@@ -1198,23 +1203,26 @@ def validate_meter_options(opts: dict, phases: int, max_age_s: float) -> list[st
|
||||
raw = opts.get("meter_poll_s", 5)
|
||||
poll_s = float(5 if raw is None or raw == "" else raw)
|
||||
except (TypeError, ValueError):
|
||||
problems.append(f"meter_poll_s {opts.get('meter_poll_s')!r} is not a number")
|
||||
problems.append(f"{_S('meter_poll_s')} is "
|
||||
f"{opts.get('meter_poll_s')!r}, which is not a "
|
||||
"number.")
|
||||
else:
|
||||
if poll_s <= 0:
|
||||
problems.append("meter_poll_s must be positive")
|
||||
problems.append(f"{_S('meter_poll_s')} must be above 0.")
|
||||
# ⚠️ The age can never be fresher than the poll interval. At or past
|
||||
# max_age_s every reading is stale before its successor arrives, so
|
||||
# the controller would sit permanently on missing inputs while the
|
||||
# meter is perfectly healthy - refuse it rather than ship that.
|
||||
elif poll_s >= max_age_s:
|
||||
problems.append(
|
||||
f"meter_poll_s {poll_s:.1f} is not under meter_max_age_s "
|
||||
f"{max_age_s:.1f} - every reading would go stale before the "
|
||||
"next poll")
|
||||
f"{_S('meter_poll_s')} is {poll_s:.1f}, not below "
|
||||
f"{_S('meter_max_age_s')} at {max_age_s:.1f}. Poll more "
|
||||
"often, or every reading goes stale.")
|
||||
else:
|
||||
problems.append(
|
||||
f"meter_source {source!r} is not one of "
|
||||
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD)))
|
||||
f"{_S('meter_source')} is {source!r}. Choose one of: off, "
|
||||
+ ", ".join((SOURCE_HA, SOURCE_MQTT, SOURCE_HA_SIGNED, SOURCE_HOMEWIZARD))
|
||||
+ ".")
|
||||
return problems
|
||||
|
||||
|
||||
|
||||
@@ -1453,9 +1453,8 @@ def _validate_raw(points, caps):
|
||||
if abs(p.eur_per_kwh) > MAX_PLAUSIBLE_EUR_KWH:
|
||||
raise SourceUnavailable(
|
||||
f"point at {p.start.isoformat()} is {p.eur_per_kwh} EUR/kWh, "
|
||||
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH} - the usual cause is a feed "
|
||||
f"in EUR/MWh being read as EUR/kWh, which is a factor of 1000 "
|
||||
f"and would make every hour look like an emergency")
|
||||
f"beyond +-{MAX_PLAUSIBLE_EUR_KWH}, so the feed is probably in "
|
||||
f"EUR/MWh")
|
||||
u = p.start.astimezone(timezone.utc)
|
||||
if seen is not None and u <= seen:
|
||||
raise SourceUnavailable(
|
||||
@@ -1708,18 +1707,13 @@ def _points_from_columnar_json(body, expect_unit, scale,
|
||||
if len(times) != len(prices):
|
||||
raise SourceUnavailable(
|
||||
f"{COLUMNAR_TIME_KEY} has {len(times)} entries and "
|
||||
f"{COLUMNAR_PRICE_KEY} has {len(prices)}; refusing to zip them. "
|
||||
f"The short array would truncate the series silently, and a price "
|
||||
f"series missing its last hours is one missing the evening peak - "
|
||||
f"traded on as if complete, where no data at all would have "
|
||||
f"switched arbitrage off (§11.4)")
|
||||
f"{COLUMNAR_PRICE_KEY} has {len(prices)}, so the feed is cut short "
|
||||
f"and none of it is used")
|
||||
unit = _columnar_unit(doc.get(COLUMNAR_UNIT_KEY, ""))
|
||||
if unit != _columnar_unit(expect_unit):
|
||||
raise SourceUnavailable(
|
||||
f"the feed reports {doc.get(COLUMNAR_UNIT_KEY)!r}, not "
|
||||
f"{expect_unit!r}. This provider converts with a fixed factor of "
|
||||
f"{scale}, so a changed unit is a factor of 1000 on every price "
|
||||
f"with nothing downstream able to notice it")
|
||||
f"{expect_unit!r}, so its prices cannot be converted")
|
||||
if doc.get("deprecated"):
|
||||
# Not a refusal: today's prices are still prices. But §11.4 makes a
|
||||
# dead feed look exactly like a quiet one, so the warning that the
|
||||
@@ -2011,11 +2005,9 @@ class CustomFormula(PriceProvider):
|
||||
raise SourceUnavailable(f"price_formula: {e}")
|
||||
if spec is None:
|
||||
raise SourceUnavailable(
|
||||
"price_provider is 'custom_formula' but price_formula is "
|
||||
"empty, so there are no coefficients to price anything with. "
|
||||
"validate.py refuses this configuration at startup; reaching "
|
||||
"it here means the options blob changed under a running "
|
||||
"add-on")
|
||||
"“Dynamic price source” (price_provider) is 'custom_formula', "
|
||||
"but “Your own tariff formula” (price_formula) is empty. "
|
||||
"Restart the add-on to check the settings again")
|
||||
url = dayahead_feed_url(self.id, opts, since, until,
|
||||
self.FEED_BASE, self.BZN)
|
||||
check_egress(url, self.capabilities())
|
||||
|
||||
@@ -504,9 +504,6 @@ def problems(opts: dict) -> list:
|
||||
text = str("" if raw is None else raw).strip().lower()
|
||||
if text in MODES:
|
||||
return []
|
||||
return [f"shadow_mode is {raw!r}, which is not one of "
|
||||
+ ", ".join(f"'{m}'" for m in MODES)
|
||||
+ ". 'auto' leaves the battery alone while control is stopped and "
|
||||
"commands it once control is started; 'on' records every "
|
||||
"decision and commands nothing at all; 'off' is ordinary "
|
||||
"control."]
|
||||
return [f"{messages.setting('shadow_mode')} is {raw!r}. Choose "
|
||||
+ ", ".join(f"'{m}'" for m in MODES[:-1])
|
||||
+ f" or '{MODES[-1]}'."]
|
||||
|
||||
@@ -262,9 +262,8 @@ def _minutes(value, field: str) -> int:
|
||||
parts = text.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is {value!r}; it must be \"HH:MM\" local Belgian time, "
|
||||
f"e.g. \"22:00\". A bare hour or a decimal is refused rather than "
|
||||
f"guessed at - 7.5 could be 07:30 or 07:50.")
|
||||
f"{field} is {value!r}. Write it as \"HH:MM\" in Belgian time, "
|
||||
f"for example \"22:00\".")
|
||||
try:
|
||||
h, m = int(parts[0]), int(parts[1])
|
||||
except ValueError:
|
||||
@@ -272,9 +271,8 @@ def _minutes(value, field: str) -> int:
|
||||
f"integers, e.g. \"07:00\"")
|
||||
if not (0 <= m < 60) or not (0 <= h <= 24) or (h == 24 and m != 0):
|
||||
raise ProfileInvalid(
|
||||
f"{field} is {value!r}, which is not a time of day. Hours run 0-23, "
|
||||
f"minutes 0-59; \"24:00\" is accepted only as an END meaning "
|
||||
f"midnight at the end of the day.")
|
||||
f"{field} is {value!r}, which is not a time of day. Use 00:00 to "
|
||||
f"23:59, or \"24:00\" as an end time.")
|
||||
return h * 60 + m
|
||||
|
||||
|
||||
@@ -286,11 +284,8 @@ def _days(value, field: str) -> frozenset:
|
||||
"""
|
||||
if value is None:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is missing. Name the days this period applies to - "
|
||||
f"\"mon-fri\", \"sat,sun\" or \"all\". It is required rather than "
|
||||
f"defaulted to every day, because a Belgian night tariff that "
|
||||
f"silently covered the weekend too would misprice 48 h a week with "
|
||||
f"nothing reporting it.")
|
||||
f"{field} is missing. Name the days, for example \"mon-fri\", "
|
||||
f"\"sat,sun\" or \"all\".")
|
||||
tokens = []
|
||||
if isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
@@ -367,10 +362,8 @@ def _price(value, field: str, means: str = None) -> float:
|
||||
raise ProfileInvalid(f"{field} is {value!r}, which is not finite")
|
||||
if abs(out) > MAX_PLAUSIBLE_EUR_KWH:
|
||||
raise ProfileInvalid(
|
||||
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. The "
|
||||
f"usual cause is a figure in cents or in EUR/MWh - 34 instead of "
|
||||
f"0.34 - which would make every hour look like an emergency. Same "
|
||||
f"bound the fetched feeds are held to (price.py `_validate_raw`).")
|
||||
f"{field} is {out} EUR/kWh, beyond +-{MAX_PLAUSIBLE_EUR_KWH}. "
|
||||
f"Write the price in EUR/kWh, for example 0.34 and not 34.")
|
||||
return out
|
||||
|
||||
|
||||
@@ -422,9 +415,8 @@ class StaticProfile:
|
||||
self.option = option
|
||||
if not self.periods:
|
||||
raise ProfileInvalid(
|
||||
f"{option} has no periods. A profile with no periods is "
|
||||
f"not a fixed tariff, it is an absent one - leave the option "
|
||||
f"empty to say that on purpose (§11.4 then applies).")
|
||||
f"{option} has no periods. Add at least one, or leave the "
|
||||
f"setting empty.")
|
||||
_check_tiling(self.periods, option=option)
|
||||
|
||||
# -- what kind of tariff this is, DERIVED --------------------------------
|
||||
@@ -558,10 +550,7 @@ def _raise_gap(weekday, start, end, option="static_profile"):
|
||||
raise ProfileInvalid(
|
||||
f"{option} leaves {DAY_NAMES[weekday]} "
|
||||
f"{_hhmm(start)}-{_hhmm(end)} with no period, so those hours have no "
|
||||
f"price. Refused rather than defaulted: an invented price is one an "
|
||||
f"optimiser would trade on, and §11.4 already says what a missing price "
|
||||
f"means. Add a period covering it - remember a window written "
|
||||
f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.")
|
||||
f"price. Add a period that covers them.")
|
||||
|
||||
|
||||
def _raise_overlap(weekday, over, end, periods, option="static_profile"):
|
||||
@@ -569,9 +558,8 @@ def _raise_overlap(weekday, over, end, periods, option="static_profile"):
|
||||
names = [p.name for p in periods if p.covers(weekday, start)]
|
||||
raise ProfileInvalid(
|
||||
f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-"
|
||||
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for "
|
||||
f"one hour means one of them silently wins on list order. Narrow one of "
|
||||
f"the windows or split the day selector.")
|
||||
f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Make the "
|
||||
f"periods stop overlapping.")
|
||||
|
||||
|
||||
def parse_static_profile(value, *, option: str = "static_profile",
|
||||
@@ -609,28 +597,23 @@ def parse_static_profile(value, *, option: str = "static_profile",
|
||||
value = json.loads(text)
|
||||
except ValueError as e:
|
||||
raise ProfileInvalid(
|
||||
f"{option} is not valid JSON ({e}). It is a JSON object "
|
||||
f"with a \"periods\" list, on one line, e.g.\n"
|
||||
f"{option} is not valid JSON ({e}). Write it on one line with "
|
||||
f"a \"periods\" list, for example\n"
|
||||
f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",'
|
||||
f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n'
|
||||
f"Leave it empty for a dynamic-only or price-free site.")
|
||||
f"Leave it empty if you have no fixed tariff.")
|
||||
|
||||
if isinstance(value, dict):
|
||||
tz = str(value.get("timezone", "") or "").strip().lower()
|
||||
if tz not in SUPPORTED_TZ:
|
||||
raise ProfileInvalid(
|
||||
f"{option}.timezone is {value.get('timezone')!r}. Only "
|
||||
f"Europe/Brussels is supported: the shipped image carries no tz "
|
||||
f"database (see app/price.py), so any other zone could not be "
|
||||
f"honoured and would be silently treated as Brussels - which is "
|
||||
f"wrong by an hour for part of the year.")
|
||||
f"Europe/Brussels is supported.")
|
||||
rows = value.get("periods")
|
||||
if rows is None:
|
||||
raise ProfileInvalid(
|
||||
f"{option} has no \"periods\" list. Even a fully-fixed "
|
||||
f"tariff is written as one period covering 00:00-24:00 all days "
|
||||
f"- one shape for both products, so nothing downstream branches "
|
||||
f"on which kind of site this is.")
|
||||
f"{option} has no \"periods\" list. A fixed price is one "
|
||||
f"period from 00:00 to 24:00 on all days.")
|
||||
else:
|
||||
rows = value
|
||||
|
||||
|
||||
+140
-297
@@ -322,6 +322,7 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
|
||||
|
||||
import logging
|
||||
|
||||
from . import messages
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from .custom_formula import (
|
||||
@@ -571,6 +572,17 @@ def _unit_label(opts: dict, index: int) -> str:
|
||||
return f" ({name or f'inverters[{index}]'})"
|
||||
|
||||
|
||||
def S(key: str, where: str = "") -> str:
|
||||
"""A setting as a refusal names it; `where` is `_unit_label()`'s " (name)"."""
|
||||
return messages.setting(key, where.strip().strip("()"))
|
||||
|
||||
|
||||
def _labels(keys, joiner: str = "and") -> str:
|
||||
"""Several settings, each with its key: the config panel files a refusal
|
||||
under every key it names (`configui.problems_by_field()`)."""
|
||||
return f" {joiner} ".join(S(k) for k in keys)
|
||||
|
||||
|
||||
def _roster_problems(opts: dict) -> list[str]:
|
||||
"""Rule 9 (CFG-04): the `inverters:` roster, refused where it cannot mean
|
||||
what it says.
|
||||
@@ -625,11 +637,9 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
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."]
|
||||
return [f"{S('inverters')} is {type(raw).__name__} {raw!r}, not a list. "
|
||||
"Add one entry per inverter, or leave it empty for a single "
|
||||
"inverter."]
|
||||
|
||||
entries = roster_entries(opts)
|
||||
seen: dict = {}
|
||||
@@ -637,45 +647,31 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
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.")
|
||||
f"{where} in the “Inverter list” is {entry!r}. Each entry needs "
|
||||
"a name, a setpoint entity and an inverter rating.")
|
||||
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.")
|
||||
f"{where} in the “Inverter list” has no name. Give every "
|
||||
"inverter a short name, for example inverter-a.")
|
||||
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.")
|
||||
f"{where} and {seen[name]} are both called {name!r}. Give every "
|
||||
"inverter its own name.")
|
||||
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.")
|
||||
f"{who} has no {S('setpoint_entity')}, so the add-on cannot "
|
||||
"control it. Set its ESPHome number entity, 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"{who} has no usable {S('max_w')}: it is "
|
||||
f"{entry.get('max_w')!r}. Set this inverter's own rating in "
|
||||
"watts.")
|
||||
|
||||
# --- (f) and (g): OPT-09's capacity, which is optional but not partial ---
|
||||
stated, blank = [], []
|
||||
@@ -694,35 +690,21 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
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.")
|
||||
f"{who} has {S('capacity_kwh')} {raw!r}, which is not a usable "
|
||||
"number. Set it in kWh, or leave it empty on every inverter.")
|
||||
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.")
|
||||
f"{S('capacity_kwh')} is set on {', '.join(stated)} but empty on "
|
||||
f"{', '.join(blank)}. Set it on every inverter, or on none.")
|
||||
|
||||
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.")
|
||||
f"{S('setpoint_entity')} is set to {flat_sp}, but the "
|
||||
f"{S('inverters')} has {len(entries)} entries. Move that inverter "
|
||||
"into the list and clear this setting.")
|
||||
return problems
|
||||
|
||||
|
||||
@@ -760,17 +742,14 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
||||
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.")
|
||||
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts at "
|
||||
f"most {float(dev_max):.0f} W. Lower this inverter's rating to "
|
||||
f"{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.")
|
||||
f"{name}: {S('max_w')} is {cap:.0f} W, but {entity} accepts no "
|
||||
f"less than {float(dev_min):.0f} W when charging. Lower this "
|
||||
f"inverter's rating to {abs(float(dev_min)):.0f} W or less.")
|
||||
return problems
|
||||
|
||||
|
||||
@@ -803,11 +782,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
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.")
|
||||
f"{S('setpoint_entity')} is empty, so the add-on cannot control the "
|
||||
"inverter. Set it to the ESPHome number entity for 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.
|
||||
@@ -822,15 +798,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
for actuator, who in sorted(writers.items()):
|
||||
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.")
|
||||
f"{len(who)} settings point at the same inverter entity "
|
||||
f"{actuator}: {', '.join(who)}. Give each inverter its own "
|
||||
"setpoint 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
|
||||
@@ -861,7 +831,8 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# has answered; it is not called from here because at startup no device has.
|
||||
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")
|
||||
problems.append(f"{S('max_w')} is {opts.get('max_w')!r}, which is not "
|
||||
"a number. Set it in watts.")
|
||||
# ⚠️ config.yaml's int(100,5000) already blocks this on a real install, so
|
||||
# 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
|
||||
@@ -872,24 +843,19 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
# 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.")
|
||||
f"{S('max_w')} is {max_w:.0f} W. Set it above 0 W.")
|
||||
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.")
|
||||
f"{S('max_w')} is {max_w:.0f} W, but "
|
||||
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts at "
|
||||
f"most {dev_max:.0f} W. Lower the rating to {dev_max:.0f} W or "
|
||||
"less.")
|
||||
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.")
|
||||
f"{S('max_w')} is {max_w:.0f} W, but "
|
||||
f"{opts.get('setpoint_entity') or 'the setpoint entity'} accepts no "
|
||||
f"less than {dev_min:.0f} W when charging. Lower the rating to "
|
||||
f"{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
|
||||
@@ -898,9 +864,10 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
phases = _number(opts, "meter_phases", 1.0)
|
||||
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})")
|
||||
problems.append(f"{S('meter_phases')} and {S('meter_max_age_s')} must "
|
||||
f"both be numbers. They are "
|
||||
f"{opts.get('meter_phases')!r} and "
|
||||
f"{opts.get('meter_max_age_s')!r}.")
|
||||
else:
|
||||
problems.extend(validate_meter_options(opts, int(phases), max_age_s))
|
||||
|
||||
@@ -921,15 +888,9 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
if (is_enabled(opts) and max_age_s is not None
|
||||
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.")
|
||||
f"{S('meter_max_age_s')} is {max_age_s:.0f} s, but the control "
|
||||
f"board stops the inverter after {FIRMWARE_INGEST_VETO_S:.0f} s. "
|
||||
f"Set it to {FIRMWARE_INGEST_VETO_S:.0f} s or less.")
|
||||
|
||||
# --- 5. a sign watchdog that can actually fire ---------------------------
|
||||
# ⚠️ Strictly `>=`, matching the predicate exactly: watchdog.py tests
|
||||
@@ -956,19 +917,15 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
caps = [cap for _, cap in caps_ix]
|
||||
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")
|
||||
problems.append(f"{S('sign_disagree_w')} is "
|
||||
f"{opts.get('sign_disagree_w')!r}, which is not a "
|
||||
"number. Set it back to 300 W.")
|
||||
elif weakest is not None and weakest > 0 and sign_w >= weakest:
|
||||
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.")
|
||||
f"{S('sign_disagree_w')} is {sign_w:.0f} W, but the "
|
||||
f"{S('max_w', where)} is {weakest:.0f} W, so this check can never "
|
||||
f"trip. Lower it below {weakest:.0f} W, or raise the rating.")
|
||||
|
||||
# ⚠️ AND THE SAME RULE FOR SAFETY-30's PER-UNIT THRESHOLD, WHICH IS A
|
||||
# SEPARATE COMPARISON AND THEREFORE A SEPARATE WAY TO DISARM. It grades a
|
||||
@@ -1014,20 +971,16 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
|
||||
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")
|
||||
problems.append(f"{S('unit_sign_disagree_w')} is "
|
||||
f"{opts.get('unit_sign_disagree_w')!r}, which is not a "
|
||||
"number. Set it back to "
|
||||
f"{SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W.")
|
||||
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
|
||||
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.")
|
||||
f"{S('unit_sign_disagree_w')} is {unit_sign_w:.0f} W, but the "
|
||||
f"{S('max_w', unit_where)} is {weakest:.0f} W, so this check can "
|
||||
f"never trip. Lower it below {weakest:.0f} W, or raise the "
|
||||
"rating.")
|
||||
|
||||
# --- 6. a maintenance charge the ceiling can actually accommodate --------
|
||||
# ⚠️ Strictly `>`, matching the arbiter exactly. The shaping is
|
||||
@@ -1100,18 +1053,14 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
maint_where = _unit_label(opts, maint_index)
|
||||
if charge_w is None:
|
||||
problems.append(
|
||||
f"maintenance_charge_w {opts.get('maintenance_charge_w')!r} is not a number")
|
||||
f"{S('maintenance_charge_w')} is "
|
||||
f"{opts.get('maintenance_charge_w')!r}, which is not a number. Set "
|
||||
"it in watts.")
|
||||
elif maint_cap is not None and maint_cap > 0 and charge_w > maint_cap:
|
||||
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.")
|
||||
f"{S('maintenance_charge_w')} is {charge_w:.0f} W, but the "
|
||||
f"{S('max_w', maint_where)} is {maint_cap:.0f} W, so the monthly "
|
||||
f"recharge cannot run. Lower it to {maint_cap:.0f} W or less.")
|
||||
|
||||
# --- 10. a slew rate the plant can actually follow -----------------------
|
||||
# ⚠️ SAFETY-39. The one rule here that grades a RATIO rather than a
|
||||
@@ -1140,34 +1089,19 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
slew_w = _number(opts, "slew_w", 0.0)
|
||||
rail = site_rail_w(opts)
|
||||
if slew_w is None:
|
||||
problems.append(f"slew_w {opts.get('slew_w')!r} is not a number")
|
||||
problems.append(f"{S('slew_w')} is {opts.get('slew_w')!r}, which is "
|
||||
"not a number. Set it to 0 to follow the inverter "
|
||||
"rating.")
|
||||
elif slew_w < 0:
|
||||
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).")
|
||||
f"{S('slew_w')} is {slew_w:.0f} W and cannot be negative. Set it to "
|
||||
"0 to follow the inverter rating.")
|
||||
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.")
|
||||
f"{S('slew_w')} is {slew_w:.0f} W, too fast for a {rail:.0f} W "
|
||||
f"site. Set it to 0 or to at most {bound:.0f} W.")
|
||||
|
||||
# --- 7. two price sources on one site ------------------------------------
|
||||
problems.extend(_price_problems(opts))
|
||||
@@ -1198,22 +1132,15 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
|
||||
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})")
|
||||
f"{S('peak_soc_floor')} and {S('bms_soc_floor', where)} must "
|
||||
f"both be numbers. They are {opts.get('peak_soc_floor')!r} and "
|
||||
f"{_raw!r}.")
|
||||
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.")
|
||||
f"{S('peak_soc_floor')} is {soc_floor:.0f} %, not above the "
|
||||
f"{S('bms_soc_floor', where)} of {bms_floor:.0f} %. Raise the "
|
||||
f"reserve above {bms_floor:.0f} %, or correct the cut-out "
|
||||
"level.")
|
||||
|
||||
# --- warnings: real, but not worth refusing over -------------------------
|
||||
integ = opts.get("integrator_max_w")
|
||||
@@ -1306,43 +1233,19 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# fall back to, the site has had no tariff at all - a worse state, and
|
||||
# one this message must not paper over.
|
||||
been_running = (
|
||||
f"the static day/night tariff in static_profile"
|
||||
f"the {S('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")
|
||||
else "no tariff at all")
|
||||
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.")
|
||||
f"{S('price_provider')} is {pid!r}, which was withdrawn and never "
|
||||
f"fetched a price. This site ran on {been_running}. Choose "
|
||||
"'generic_dayahead', 'manual_csv' or 'off'.")
|
||||
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.")
|
||||
f"{S('price_provider')} is {pid!r}, which does not exist. Choose "
|
||||
f"one of: off, {', '.join(sorted(PROVIDERS))}.")
|
||||
return problems # nothing below can be judged against an unknown id
|
||||
|
||||
# ⚠️ THE HA-SIDE OVERLAP THE PM TOLD US TO DECIDE ABOUT, DECIDED HERE.
|
||||
@@ -1386,19 +1289,10 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if str(opts.get(k, "") or "").strip()]
|
||||
if 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.")
|
||||
f"{S('price_provider')} is {pid!r}, but {_labels(ha_price)} "
|
||||
f"{'is' if len(ha_price) == 1 else 'are'} also set and would win. "
|
||||
f"Clear {'it' if len(ha_price) == 1 else 'them'}, or set the price "
|
||||
"source to 'off'.")
|
||||
|
||||
# ⚠️ 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`
|
||||
@@ -1407,14 +1301,9 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
# 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.")
|
||||
f"{S('static_profile')} and {_labels(ha_price)} are both set, and "
|
||||
"only the Home Assistant price entities would count. Clear one of "
|
||||
"the two.")
|
||||
|
||||
# ⚠️ 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
|
||||
@@ -1427,7 +1316,7 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
profile = parse_static_profile(raw_profile)
|
||||
except ProfileInvalid as e:
|
||||
profile = None
|
||||
problems.append(f"static_profile cannot be used: {e}")
|
||||
problems.append(f"“{messages.LABELS['static_profile']}” cannot be used. {e}")
|
||||
if profile is not None and profile.product is not None \
|
||||
and len(set(profile.prices)) == 1:
|
||||
_LOG.info(
|
||||
@@ -1452,7 +1341,7 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
try:
|
||||
taxes_table = parse_taxes_profile(raw_taxes_profile)
|
||||
except ProfileInvalid as e:
|
||||
problems.append(f"{TAXES_PROFILE_OPTION} cannot be used: {e}")
|
||||
problems.append(f"“{messages.LABELS[TAXES_PROFILE_OPTION]}” cannot be used. {e}")
|
||||
|
||||
if taxes_table is not None:
|
||||
# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY LOSES - the
|
||||
@@ -1482,21 +1371,13 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"])
|
||||
shipped = PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]
|
||||
untouched = (shipped,) + tuple(SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
superseded = ", ".join(
|
||||
str(u) for u in SUPERSEDED_TAXES_DEFAULTS_EUR_KWH)
|
||||
if scalar is not None \
|
||||
and not any(abs(scalar - u) <= 1e-12 for u in untouched):
|
||||
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. (A scalar still sitting on a SUPERSEDED "
|
||||
f"default - {superseded} - is accepted and NOT refused: "
|
||||
f"that is a number this add-on wrote into your options "
|
||||
f"blob, not one you typed.)")
|
||||
f"{S(TAXES_PROFILE_OPTION)} is set, so "
|
||||
f"{S('price_taxes_eur_kwh')} is not used, but it reads {scalar} "
|
||||
f"instead of {shipped}. Set it back to {shipped}, or clear the "
|
||||
"table.")
|
||||
|
||||
# ⚠️ THE UNITS RUNG, AND IT IS A SECOND QUANTITY RATHER THAN A
|
||||
# RESTATEMENT. Every Belgian DSO prints these bands in c EUR/kWh
|
||||
@@ -1511,13 +1392,8 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
if abs(p.eur_per_kwh) >= 1.0})
|
||||
if 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.")
|
||||
f"{S(TAXES_PROFILE_OPTION)} has {steep} EUR/kWh, which looks "
|
||||
"like cents. Divide the figures by 100, or clear this setting.")
|
||||
negative = sorted({p.eur_per_kwh for p in taxes_table.periods
|
||||
if p.eur_per_kwh < 0})
|
||||
if negative:
|
||||
@@ -1567,25 +1443,15 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
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.")
|
||||
f"{S('price_provider')} is 'custom_formula', but "
|
||||
f"{S('price_formula')} is empty. Copy the formula from your "
|
||||
"supplier's tariff card, or choose 'generic_dayahead'.")
|
||||
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}")
|
||||
problems.append(f"“{messages.LABELS['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
|
||||
@@ -1639,7 +1505,7 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
xspec = parse_export_formula(raw_export)
|
||||
except FormulaInvalid as e:
|
||||
xspec = None
|
||||
problems.append(f"{EXPORT_FORMULA_OPTION} cannot be used: {e}")
|
||||
problems.append(f"“{messages.LABELS[EXPORT_FORMULA_OPTION]}” cannot be used. {e}")
|
||||
if xspec is not None:
|
||||
# ⚠️ THE 6% ASYMMETRY, PRINTED RATHER THAN ASSUMED. The two legs
|
||||
# carry INDEPENDENT `vat_pct` values and a Belgian site's are 6 and
|
||||
@@ -1732,62 +1598,39 @@ def _price_problems(opts: dict) -> list[str]:
|
||||
PRICE_OPTION_DEFAULTS["cycle_cost_threshold_eur"])
|
||||
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.")
|
||||
f"{S('cycle_cost_threshold_eur')} is "
|
||||
f"{opts.get('cycle_cost_threshold_eur')!r}. Set it to 0 or more, "
|
||||
"in EUR/kWh.")
|
||||
|
||||
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.")
|
||||
f"{S('arbitrage_enabled')} is on, but the site has no prices. "
|
||||
f"Set {_labels(['price_provider', 'static_profile'], 'or')}, "
|
||||
"or turn trading 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.")
|
||||
f"{S('arbitrage_enabled')} is on, but no meter is set in "
|
||||
f"{S('meter_source')}. Choose a meter, or turn trading 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.")
|
||||
f"{S('arbitrage_soc_floor')} and {S('arbitrage_soc_ceiling')} "
|
||||
"must both be numbers, in %.")
|
||||
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.")
|
||||
f"{S('arbitrage_soc_floor')} is {floor:g} %, not below "
|
||||
f"{S('arbitrage_soc_ceiling')} at {ceiling:g} %. Make the lower "
|
||||
"limit 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.")
|
||||
f"{S('price_provider')} is 'manual_csv', but "
|
||||
f"{S('price_csv_path')} is empty. Set the path to the CSV file, or "
|
||||
"choose 'off'.")
|
||||
|
||||
return problems
|
||||
|
||||
|
||||
Reference in New Issue
Block a user