diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index fe35f1a..01e9a03 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -24,6 +24,7 @@ if it has, it needs 0.10.0. Whoever deploys decides. | _this branch_ | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price | | _this branch_ | TARIFF-13 - the capacity-tariff row's "quarter(s) banked" stops resetting to 0 at every restart, and a tied peak's quarter stops moving | | _this branch_ | TARIFF-11 - the add-on's account of the tariff plan stops flattering it, and the status page is graded as a surface | +| _this branch_ | TARIFF-17 - a price component that varies by hour has somewhere to live, so a Walloon day/night network tariff can change which hour is cheapest | ⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and @@ -59,6 +60,77 @@ separate card. *(Annotated 2026-09-13: that card is **TARIFF-15**, below, and it shipped on this branch as `custom_formula`. It is still not an alias — the operator types their own coefficients; nothing is invented on their behalf.)* +### TARIFF-17 - a price component that varies by hour has somewhere to live + +**`price_taxes_profile`: the taxes component as a period table, alongside the +flat `price_taxes_eur_kwh`.** ⚠️ **The flat scalar was not a wrong number, and +this is not a correction of one.** The optimiser reads exactly two things 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. What was +missing was anywhere for an **hour-varying** non-energy component to live. + +Belgium has one. Fluvius distribution is a single rate, and its row on the +tariff card reads `Normaal | Uitsl. nacht` — *exclusive night* being a separate +physical circuit, not a night register — so for the reference site in Flanders +nothing here changes. 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. Measured on one ordinary Belgian winter weekday, 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 +``` + +Five different flat figures spanning and exceeding the table's own range give +**the same hour and the same spread to the last digit**; the table moves the +hour a battery would buy in from 13:00 to 03:00. That is the regression +`test_price.py` section 16 grades, and it grades **the hour**, not the spread: +an implementation that reads the table once and adds one number to every point +produces the right magnitude and the wrong shape, and a spread assertion passes +on it. `DOCS.md` now names the three buckets — LEVEL, SPREAD, ORDER — so the +next person adding a component knows which one it is in. + +**The change is contained.** `static_tariff.py` already shipped the grammar — +periods tiling the day, gaps and overlaps refused, day types, DST — so it is +**the same parser**, taking the option name and the "what this number means" +clause as arguments. There is no second tiling rule, because a second one would +be a second set of 23- and 25-hour-day bugs; `test_static_tariff.py` section 10 +pins that 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. +`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 naming it is refused at `register()`. + +⚠️ **A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH.** It is +read on the ~30 s planning path through `assemble()`, 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` +parses it at startup, and refuses three things: a table that does not tile the +day (the same sentence `static_profile` gives, naming this key); 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. + +**Nothing changes for a site that does not set it.** The option ships `""`, the +scalar path is unchanged arithmetically — `test_price.py` 16f reproduces the +pre-TARIFF-17 expression and compares 12 capability/scalar combinations point +for point with `==` — and the reference site is in Flanders, where the flat +figure is correct. This is a product concern rather than a site one: the add-on +is sold to clients, and a Walloon or Brussels client could not express their +own tariff. + ### TARIFF-13 - the coverage figure beside the peak, and which quarter gets it **The capacity-tariff status row's `N quarter(s) banked` is read from the diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 9e7ac6a..3dd2238 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1870,6 +1870,129 @@ uses the result as a cheap-window hint for the maintenance charge. They are — configure two of them and the add-on refuses to start, naming the conflict, because the add-on reads exactly one and the loser is silent. Use one. +### The three buckets — which price components can move a decision (TARIFF-17) + +**Read this before adding a price component of any kind.** The optimiser reads +exactly two quantities off a price series, and nothing else: + +- the **spread**, `max − min` over the *firm* points, weighed against + `cycle_cost_threshold_eur`; and +- **which hour is cheapest**, `min(firm, key=…)` — the hour a battery would + buy in. + +Every component of a Belgian bill reaches those two through one of three +buckets, and which bucket it is in is a property of its *shape*, not of its +size: + +| bucket | what it looks like | reaches the decision? | +|---|---|---| +| **LEVEL** | additive, **identical every hour** | **No — provably.** An additive constant cancels exactly in `max − min`, and adding it to every point leaves the argmin where it was | +| **SPREAD** | **multiplicative** on the spot index | **Yes.** It scales the spread against a fixed `cycle_cost_threshold_eur` | +| **ORDER** | **varies** by hour, block or day-type | **Yes, and worst.** It changes *which hour is cheapest* | + +Measured on the real arithmetic (`test_price.py` section 16b, printed 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**. That is the LEVEL bucket doing +nothing, and it is not a rounding artefact — it is arithmetic. + +Three consequences worth having in mind: + +1. **`price_taxes_eur_kwh` is not a wrong number and never was.** It sits in + the LEVEL bucket and *cannot* distort either quantity. What it also cannot + do is express a component that varies by hour — which is why + `price_taxes_profile` exists. The defect TARIFF-17 closed was a **missing + capability**, not a bad constant. +2. **`custom_formula`'s `factor` is a SPREAD component, and its `offset` is + LEVEL.** The contract price is `(factor × index + offset) × (1 + VAT)`, so + the spread over a feed is `factor × (1 + VAT) × (max index − min index)` — + the offset drops out entirely, and the spread is *proportional* to `factor`. + Moving `factor` from 0.100 to 0.10812 therefore moves the spread by exactly + **+8.1 %** on any feed whatever. A coefficient typed one digit wrong is not + a small pricing error; it is a different trading threshold. The VAT rate is + multiplicative on the whole expression, so it is SPREAD too. +3. **A check that grades the spread is grading the wrong bucket.** An + implementation that reads an hour-varying table *once* and adds the same + number to every point produces a series of the right magnitude and the wrong + shape — the LEVEL bucket wearing a period table. The spread would look + plausible; the buy hour would be wrong. So the regression check for an + ORDER-bucket component asserts **which hour is cheapest**, not how big the + spread is (`test_price.py` 16c and 16d). + +⚠️ **Where the buckets are today.** `price_markup_eur_kwh`, +`price_taxes_eur_kwh` and `custom_formula`'s `offset` are LEVEL. +`custom_formula`'s `factor` and its VAT rate are SPREAD. `price_taxes_profile` +is the only ORDER component the add-on has. Anything added next belongs in one +of the three, and saying which is part of adding it. + +### Levies and VAT by time of day (`price_taxes_profile`) + +**Leave this empty unless your network tariff really does have a day and a +night rate**, which in Flanders it does not. Fluvius distribution is a single +rate: its row on the tariff card reads `Normaal | Uitsl. nacht`, and +*exclusive night* is a **separate physical circuit** (a night-storage heater on +its own meter), not the night register of a dual-rate meter. For a Flemish site +the flat `price_taxes_eur_kwh` is correct, and this option changes nothing. + +**Wallonia and Brussels are different, and the same card proves it.** The +Walloon DSOs and Sibelga get four columns — `Enkelvoudig | Dag | Nacht | +Uitsl. nacht` — and the day/night gap is real money (c€/kWh): + +| DSO | enkelvoudig | dag | nacht | gap | +|---|---|---|---|---| +| ORES | 11,98 | 13,27 | 7,39 | **5,88** | +| AIESH | 13,64 | 15,17 | 8,21 | **6,96** | +| Tecteo RESA | 11,06 | 12,20 | 7,01 | 5,19 | +| Sibelga (Brussels) | 9,39 | 9,39 | 7,10 | 2,29 | +| ORES IMPACT (2026) | — | 16,578 | 5,090 | **11,49** | + +⚠️ **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 +merely shift the curve — it **systematically understates the true spread**, and +the add-on declines cycles that are genuinely profitable. On ORES's bands over +one ordinary winter weekday the measured effect is: cheapest hour **13:00 → +03:00**, spread **0.1500 → 0.1788 EUR/kWh**. + +It is written in **exactly the same grammar as `static_profile`** — the same +periods, the same day selectors, the same rule that every day type is covered +once with no gap and no overlap, the same DST handling. It is literally the +same parser; there is no second tiling rule, because a second one would be a +second set of 23- and 25-hour-day bugs. + +```json +{"periods": [ + {"name":"dag", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327}, + {"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739}, + {"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]} +``` + +- ⚠️ **`eur_per_kwh` here is the LEVIES ALONE, not an all-in price.** It is the + non-energy part of the bill for that band, and the add-on adds it to whatever + your price source publishes — exactly as it adds the flat figure. This is the + one place the two options differ in meaning while sharing a grammar, and the + refusal you get for leaving the number out says which one is wanted. +- ⚠️ **The card prints cents. This option takes EUR.** ORES prints `13,27`; + type `0.1327`. A whole card typed in cents is refused at startup, naming both + bands and the factor — including the cards (Sibelga's 9,39 / 7,10) whose + figures are small enough to slip past the ±10 EUR/kWh bound the parser holds + every price to. +- ⚠️ **Set this *or* `price_taxes_eur_kwh`, not both.** The table wins when it + is set, so a scalar left at a non-default value beside it would be a number + you typed that decides nothing. That is refused at startup rather than + silently resolved; leave `price_taxes_eur_kwh` at its shipped `0.06` when you + fill this in. +- 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 + that looks exactly like a healthy fixed-tariff day. +- It applies to the components the framework **adds**: `generic_dayahead` + (which declares `{ENERGY}`) and `custom_formula` (which declares + `{ENERGY, MARKUP}`) both get it. `manual_csv` publishes an all-in figure and + is untouched — the table is routed through the same `capabilities().adds` + rule as the scalar, not bolted on. +- It does **not** apply to `static_profile`, whose prices are already all-in, + nor to `export_price_eur_kwh`. + ### Static day/night and fully-fixed tariffs (`static_profile`) Most Belgian households are **not** on a dynamic contract, and an EMS that only diff --git a/goodwe_controller/app/price.py b/goodwe_controller/app/price.py index a653c03..1e3c95e 100644 --- a/goodwe_controller/app/price.py +++ b/goodwe_controller/app/price.py @@ -851,6 +851,18 @@ PRICE_OPTION_DEFAULTS = { # test_price.py section 1. A second defaults dict in the new module would be # a fourth place option defaults live, which is exactly the open ticket. "static_profile": "", + # TARIFF-17. A JSON object in `static_profile`'s OWN period grammar whose + # per-period `eur_per_kwh` is the TAXES COMPONENT for that band, not an + # all-in price; "" is unset and the scalar above decides. Parsed and + # validated by app/static_tariff.py (`parse_taxes_profile`), refused at + # startup by validate.py - the `static_profile` precedent exactly, and here + # for the same CFG-03 reason: this dict is the ONE in-code mirror of + # config.yaml. + # + # ⚠️ IT IS IN FRAMEWORK_KEYS. A plugin that could read this could add the + # taxes a second time, per hour, which is the double-counting defect trap 1 + # exists to refuse - wearing a period table instead of a scalar. + "price_taxes_profile": "", # TARIFF-15. A JSON object carrying ONE contract's two coefficients, the # units they are printed in, its VAT rate and the card figures the add-on # grades them against; "" is unset. Parsed and validated by @@ -884,6 +896,12 @@ PRICE_OPTION_DEFAULTS = { # that can double-count, so `PluginOptions` refuses them by name. Trap 1. FRAMEWORK_KEYS = frozenset({ "price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh", + # TARIFF-17. The hour-varying spelling of `price_taxes_eur_kwh`, and it + # belongs here for the same reason the scalar does: it is a component the + # FRAMEWORK adds exactly once, by `caps.adds`. `BANNED_IN_PLUGIN_CODE` is + # derived from this set, so `code_leaks()` refuses a plugin that names it + # without anything else being edited. + "price_taxes_profile", }) @@ -1019,6 +1037,27 @@ def export_price_eur_kwh(opts: dict) -> float: return float(price_option(opts, "export_price_eur_kwh")) +def taxes_profile(opts: dict): + """The configured `price_taxes_profile` as a period table, or None. + + TARIFF-17. Raises `static_tariff.ProfileInvalid` on a malformed table - and + ⚠️ THAT EXCEPTION IS NOT MEANT TO REACH A RUNNING SITE. validate.py parses + the same option at STARTUP and refuses to start on it, for the reason + `static_profile` is refused there: a table that only fails when a fetch + happens is a site that stops pricing at 03:00, and §11.4 turns that into a + quiet fallback nothing on the status page distinguishes from a healthy + fixed-tariff day. `load_prices()` still handles it, loudly, because a guard + whose only proof is "the other guard ran first" is not a guard. + + ⚠️ THE IMPORT IS LAZY AND THE CYCLE IS THE REASON. `static_tariff.py` + imports this module at module scope; importing it back at module scope here + would make which file is imported first decide whether the add-on starts. + `sys.modules` makes the repeat cost a dict lookup. + """ + from .static_tariff import parse_taxes_profile + return parse_taxes_profile(price_option(opts, "price_taxes_profile")) + + # --------------------------------------------------------------------------- # # The plugin interface # --------------------------------------------------------------------------- # @@ -1286,23 +1325,68 @@ def _validate_raw(points, caps): seen = u +def taxes_at(taxes, instant: datetime) -> float: + """The TAXES component at ONE instant. A scalar, or a period table. + + TARIFF-17. THE DEFECT THIS EXISTS TO CLOSE, and it is a MISSING CAPABILITY + rather than a wrong constant: `price_taxes_eur_kwh` is additive and + identical every hour, so it cancels exactly out of `max - min` and cannot + move `min(firm, key=...)`. It is not a distortion - it is provably inert on + both quantities the optimiser reads. What was missing is anywhere for an + HOUR-VARYING non-energy component to live, and the Walloon and Brussels + DSOs bill one: ORES `dag 13,27 / nacht 7,39` c EUR/kWh, a 5,88 c gap whose + cheap band coincides with the cheap spot hours. Flattening it does not + shift the curve, it UNDERSTATES the spread and declines profitable cycles. + See DOCS.md "The three buckets". + + ⚠️ DUCK-TYPED ON `price_at`, NOT ON `isinstance`. price.py must not import + static_tariff.py - static_tariff.py imports THIS module, and a module-level + cycle would make the import order load-bearing. `taxes_profile()` below is + the one place the real parser is reached, and it imports it lazily. + + ⚠️ THE LOOKUP IS ON THE ~30 s PLANNING PATH through `assemble()`, so it + stays a linear scan over the two-to-four periods a Belgian tariff has + (`StaticProfile.at()`), with no parsing, no allocation and no I/O. A + malformed table never reaches here: validate.py refuses it at STARTUP. + """ + at = getattr(taxes, "price_at", None) + if at is None: + return float(taxes) + return float(at(instant)) + + def assemble(caps: ProviderCapabilities, raw, markup: float, - taxes: float) -> list: + taxes) -> list: """RawPoints -> canonical all-in PricePoints. THE ONLY PLACE MARKUP IS ADDED. Adds exactly `caps.adds` - the complement of what the plugin declared its source already contains. A plugin that publishes an all-in figure gets nothing added; a raw EPEX figure gets both. + + ⚠️ `taxes` IS A SCALAR *OR* A PERIOD TABLE (TARIFF-17), AND IT IS EVALUATED + AT EACH POINT'S OWN INSTANT. Hoisting the lookup out of the loop - reading + it once and adding the same number to every point - is the mutation that + collapses the whole feature back into the LEVEL bucket: the assembled + series would still differ from the untaxed one by the right MAGNITUDE, and + the cheapest hour would be wrong. `test_price.py` section 24 is the check + that goes red for it, and it grades WHICH HOUR IS CHEAPEST rather than the + spread, because a spread-magnitude assertion passes on the hoisted version. + + ⚠️ THE SCALAR PATH IS UNCHANGED, ARITHMETICALLY AND NOT ONLY IN INTENT. + `taxes_at()` on a float returns that float at every instant, and + `0.0 + markup` is exactly `markup` in IEEE 754 for every finite markup, so + a site on a scalar gets the identical bytes it got before this landed. """ - add = 0.0 - if Component.MARKUP in caps.adds: - add += float(markup) - if Component.TAXES in caps.adds: - add += float(taxes) - return [PricePoint(start=brussels(p.start), - eur_per_kwh=round(p.eur_per_kwh + add, 6), - provisional=bool(p.provisional)) - for p in raw] + add_markup = float(markup) if Component.MARKUP in caps.adds else 0.0 + wants_taxes = Component.TAXES in caps.adds + out = [] + for p in raw: + local = brussels(p.start) + add = add_markup + (taxes_at(taxes, local) if wants_taxes else 0.0) + out.append(PricePoint(start=local, + eur_per_kwh=round(p.eur_per_kwh + add, 6), + provisional=bool(p.provisional))) + return out # --------------------------------------------------------------------------- # @@ -1902,13 +1986,38 @@ async def load_prices(opts: dict, since: datetime, until: datetime, caps = cls.capabilities() markup = float(price_option(opts, "price_markup_eur_kwh")) - taxes = float(price_option(opts, "price_taxes_eur_kwh")) + + # ⚠️ TARIFF-17: THE TABLE WINS OVER THE SCALAR, AND validate.py REFUSES THE + # CASE WHERE BOTH SAY SOMETHING. Two writers of one quantity, one of which + # silently loses, is the shape validate.py exists to refuse - so the + # precedence here is only ever exercised on a configuration that has + # already been accepted, and it is spelled out rather than left implicit. + taxes_key = "price_taxes_eur_kwh" + try: + table = taxes_profile(opts) + except Exception as e: # noqa: BLE001 + # Not silent, and not a traceback into the 30 s tick. validate.py + # should have refused this at startup; if the add-on is running with it + # anyway, the operator is told which key and what is wrong with it. + _LOG.error("price_taxes_profile cannot be used (%s: %s), so this fetch " + "has NO tax component to add - it is not falling back to " + "price_taxes_eur_kwh, because a silently different tax " + "basis is the defect this option exists to remove. Fix the " + "table or clear it; validate.py refuses it at startup", + type(e).__name__, e) + return PriceRead.no_data( + f"price_taxes_profile cannot be used: {e}") + if table is not None: + taxes = table + taxes_key = "price_taxes_profile" + else: + taxes = float(price_option(opts, "price_taxes_eur_kwh")) # ⚠️ Not a refusal, but not silent either. A plugin whose feed is already # all-in ignores these two numbers, and an operator who typed them has every # reason to believe they took effect. ignored = [n for c, n, v in ((Component.MARKUP, "price_markup_eur_kwh", markup), - (Component.TAXES, "price_taxes_eur_kwh", taxes)) + (Component.TAXES, taxes_key, taxes)) if c not in caps.adds and v] if ignored: _LOG.warning("%s publishes a figure that already contains %s, so the " diff --git a/goodwe_controller/app/static_tariff.py b/goodwe_controller/app/static_tariff.py index bee53d8..69e11c9 100644 --- a/goodwe_controller/app/static_tariff.py +++ b/goodwe_controller/app/static_tariff.py @@ -222,6 +222,12 @@ _DAY_ALIASES = { MINUTES_PER_DAY = 1440 +# TARIFF-17. The option key whose value is a period table of the TAXES +# component. One spelling, here, because price.py, validate.py and config.yaml +# all name it and `provider_id()`'s note says what three spellings of one +# decision cost. +TAXES_PROFILE_OPTION = "price_taxes_profile" + # The mean that `cheap_now()` grades against is taken over this many local days. # Seven, so each weekday appears exactly once - see the module docstring. It is # a MODULE CONSTANT and not an option on purpose: CFG-03 already tracks three @@ -320,13 +326,37 @@ def _days(value, field: str) -> frozenset: return frozenset(out) -def _price(value, field: str) -> float: - """The all-in EUR/kWh for a period, bounded exactly as price.py bounds a feed.""" +# What a period's `eur_per_kwh` MEANS, per option. The grammar is one grammar +# (TARIFF-17); the quantity it carries is not, so the "you left this out" +# sentence is the one thing the two tables do not share. +_PERIOD_MEANS = { + "static_profile": + "an all-in eur_per_kwh (energy + supplier markup + taxes, §9.3) - the " + "same basis the dynamic providers are assembled onto, so the two are " + "comparable", +} +_TAXES_MEANS = ( + "the TAXES component alone in eur_per_kwh - the non-energy part of the " + "bill for that band (distribution, levies, VAT), NOT an all-in price. The " + "framework adds it to what the feed publishes, exactly as the scalar " + "price_taxes_eur_kwh is added" +) +_PERIOD_MEANS[TAXES_PROFILE_OPTION] = _TAXES_MEANS + + +def _price(value, field: str, means: str = None) -> float: + """One period's EUR/kWh, bounded exactly as price.py bounds a feed. + + `means` names the quantity for the error sentence only. TARIFF-17: the same + grammar carries an ALL-IN price under `static_profile` and a TAXES-ONLY + component under `price_taxes_profile`, and an operator who confuses the two + is out by an energy price - so the refusal says which one is wanted. + """ + if means is None: + means = _PERIOD_MEANS["static_profile"] if value is None: raise ProfileInvalid( - f"{field} is missing. Every period needs an all-in eur_per_kwh " - f"(energy + supplier markup + taxes, §9.3) - the same basis the " - f"dynamic providers are assembled onto, so the two are comparable.") + f"{field} is missing. Every period needs {means}.") if isinstance(value, bool) or not isinstance(value, (int, float, str)): raise ProfileInvalid(f"{field} is {value!r}, which is not a number") try: @@ -383,14 +413,19 @@ class Period: class StaticProfile: """A validated two-or-more-period tariff. Immutable, pure, I/O-free.""" - def __init__(self, periods): + def __init__(self, periods, *, option: str = "static_profile"): self.periods = tuple(periods) + # ⚠️ TARIFF-17: the OPTION NAME travels with the profile so that every + # refusal names the key the operator actually typed. It is a default + # argument on purpose - every pre-existing call site and every + # pre-existing sentence is byte-identical without it. + self.option = option if not self.periods: raise ProfileInvalid( - "static_profile has no periods. A profile with no periods is " - "not a fixed tariff, it is an absent one - leave the option " - "empty to say that on purpose (§11.4 then applies).") - _check_tiling(self.periods) + 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).") + _check_tiling(self.periods, option=option) # -- what kind of tariff this is, DERIVED -------------------------------- @property @@ -477,7 +512,7 @@ class StaticProfile: u += timedelta(hours=1) if not out: raise ProfileInvalid( - f"static_profile.series({since.isoformat()}, " + f"{self.option}.series({since.isoformat()}, " f"{until.isoformat()}) spans no whole hour. An empty series is " f"not constructible downstream on purpose (price.py trap 3).") return out @@ -489,7 +524,7 @@ class StaticProfile: return f"{kind}: " + "; ".join(p.words for p in self.periods) -def _check_tiling(periods) -> None: +def _check_tiling(periods, *, option: str = "static_profile") -> None: """Every day type must be covered by EXACTLY ONE period, every minute. ⚠️ REFUSES BOTH DIRECTIONS. A gap means an hour with no price, and the only @@ -506,22 +541,22 @@ def _check_tiling(periods) -> None: if n > 1 and over_from is None: over_from = (minute, n) if n != 0 and gap_from is not None: - _raise_gap(weekday, gap_from, minute) + _raise_gap(weekday, gap_from, minute, option) if n <= 1 and over_from is not None: - _raise_overlap(weekday, over_from, minute, periods) + _raise_overlap(weekday, over_from, minute, periods, option) if gap_from is not None: - _raise_gap(weekday, gap_from, MINUTES_PER_DAY) + _raise_gap(weekday, gap_from, MINUTES_PER_DAY, option) if over_from is not None: - _raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods) + _raise_overlap(weekday, over_from, MINUTES_PER_DAY, periods, option) def _hhmm(m): return f"{m // 60:02d}:{m % 60:02d}" -def _raise_gap(weekday, start, end): +def _raise_gap(weekday, start, end, option="static_profile"): raise ProfileInvalid( - f"static_profile leaves {DAY_NAMES[weekday]} " + 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 " @@ -529,19 +564,28 @@ def _raise_gap(weekday, start, end): f"\"22:00-07:00 mon-fri\" does NOT cover Saturday morning.") -def _raise_overlap(weekday, over, end, periods): +def _raise_overlap(weekday, over, end, periods, option="static_profile"): start, n = over names = [p.name for p in periods if p.covers(weekday, start)] raise ProfileInvalid( - f"static_profile gives {DAY_NAMES[weekday]} {_hhmm(start)}-" + f"{option} gives {DAY_NAMES[weekday]} {_hhmm(start)}-" f"{_hhmm(end)} {n} prices at once ({', '.join(names)}). Two prices for " f"one hour means one of them silently wins on list order. Narrow one of " f"the windows or split the day selector.") -def parse_static_profile(value): +def parse_static_profile(value, *, option: str = "static_profile", + means: str = None): """`static_profile` -> a `StaticProfile`, or None when unset. + ⚠️ TARIFF-17 MADE `option`/`means` PARAMETERS RATHER THAN FORKING THIS + FUNCTION, and that is the whole of the reuse. A second period parser would + be a second tiling rule, a second day-type rule and a second set of DST + bugs, diverging first on the 23- and 25-hour days where nobody looks. The + two arguments move only the OPTION NAME and the "what this number is" + clause through the refusals; the grammar, the tiling check and the wrapping + rule are one implementation with one set of checks over it. + Accepts what the option can actually arrive as: * `None` / `""` / `"null"` - unset. §14 ships `static_profile: null`. * a JSON string - what config.yaml's flat `str?` option @@ -555,6 +599,8 @@ def parse_static_profile(value): """ if value is None: return None + if means is None: + means = _PERIOD_MEANS.get(option, _PERIOD_MEANS["static_profile"]) if isinstance(value, str): text = value.strip() if not text or text.lower() in ("null", "none", "off"): @@ -563,7 +609,7 @@ def parse_static_profile(value): value = json.loads(text) except ValueError as e: raise ProfileInvalid( - f"static_profile is not valid JSON ({e}). It is a JSON object " + f"{option} is not valid JSON ({e}). It is a JSON object " f"with a \"periods\" list, on one line, e.g.\n" f' {{"periods": [{{"name":"day","from":"07:00","to":"22:00",' f'"days":"mon-fri","eur_per_kwh":0.34}}, ...]}}\n' @@ -573,7 +619,7 @@ def parse_static_profile(value): tz = str(value.get("timezone", "") or "").strip().lower() if tz not in SUPPORTED_TZ: raise ProfileInvalid( - f"static_profile.timezone is {value.get('timezone')!r}. Only " + f"{option}.timezone is {value.get('timezone')!r}. Only " f"Europe/Brussels is supported: the shipped image carries no tz " f"database (see app/price.py), so any other zone could not be " f"honoured and would be silently treated as Brussels - which is " @@ -581,24 +627,24 @@ def parse_static_profile(value): rows = value.get("periods") if rows is None: raise ProfileInvalid( - "static_profile has no \"periods\" list. Even a fully-fixed " - "tariff is written as one period covering 00:00-24:00 all days " - "- one shape for both products, so nothing downstream branches " - "on which kind of site this is.") + 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.") else: rows = value if not isinstance(rows, (list, tuple)): raise ProfileInvalid( - f"static_profile.periods is {type(rows).__name__}, not a list") + f"{option}.periods is {type(rows).__name__}, not a list") periods = [] for i, row in enumerate(rows, start=1): if not isinstance(row, dict): raise ProfileInvalid( - f"static_profile.periods[{i}] is {type(row).__name__}, not an " + f"{option}.periods[{i}] is {type(row).__name__}, not an " f"object with from/to/days/eur_per_kwh") - where = f"static_profile.periods[{i}]" + where = f"{option}.periods[{i}]" name = str(row.get("name", "") or f"period{i}").strip() or f"period{i}" start = _minutes(row.get("from", row.get("start")), f"{where}.from") end = _minutes(row.get("to", row.get("end")), f"{where}.to") @@ -617,8 +663,47 @@ def parse_static_profile(value): end_min=end, days=_days(row.get("days"), f"{where}.days"), eur_per_kwh=_price(row.get("eur_per_kwh"), - f"{where}.eur_per_kwh"))) - return StaticProfile(periods) + f"{where}.eur_per_kwh", + means))) + return StaticProfile(periods, option=option) + + +def parse_taxes_profile(value): + """`price_taxes_profile` -> a `StaticProfile` of TAX values, or None. + + TARIFF-17. THE DEFECT THIS EXISTS TO CLOSE: `price.py` modelled the whole + non-energy part of a Belgian bill as ONE scalar. In Flanders that is + correct - Fluvius distribution is a single rate and its tariff card offers + only `Normaal | Uitsl. nacht`, where *exclusive night* is a separate + physical circuit rather than a night register. The Walloon and Brussels + DSOs are on the same card with four columns - `Enkelvoudig | Dag | Nacht | + Uitsl. nacht` - and the day/night gap is real money: + + ORES dag 13,27 nacht 7,39 -> 5,88 c EUR/kWh + AIESH dag 15,17 nacht 8,21 -> 6,96 + Tecteo RESA dag 12,20 nacht 7,01 -> 5,19 + Sibelga (BXL) dag 9,39 nacht 7,10 -> 2,29 + ORES IMPACT dag 16,578 nacht 5,090 -> 11,49 (2026) + + ⚠️ 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 merely shift the curve - it SYSTEMATICALLY UNDERSTATES the true spread + and makes the add-on decline cycles that are genuinely profitable. See + DOCS.md "The three buckets" for why the flat scalar could never have done + this: an additive constant, identical every hour, cancels exactly in + `max - min` and cannot move `min(firm, key=...)` either. + + ⚠️ THE VALUE IS THE TAXES COMPONENT, NOT AN ALL-IN PRICE. `static_profile` + uses the same grammar for an all-in figure; the two are one parser and two + meanings, and `_price()`'s refusal names which one it wanted. + + ⚠️ NOT PARSED ON THE FETCH PATH. validate.py refuses a malformed table at + STARTUP, by the rule `price.py` trap 3 states for `static_profile`: a table + that cannot be parsed at 03:00 is a site that stops pricing at 03:00, and + §11.4 makes that quiet. + """ + return parse_static_profile(value, option=TAXES_PROFILE_OPTION, + means=_TAXES_MEANS) # --------------------------------------------------------------------------- # diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index 460debc..d904f5e 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -331,7 +331,9 @@ 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, parse_static_profile +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") @@ -1420,6 +1422,79 @@ def _price_problems(opts: dict) -> list[str]: "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. # diff --git a/goodwe_controller/config.yaml b/goodwe_controller/config.yaml index 03350a0..2dbff0e 100644 --- a/goodwe_controller/config.yaml +++ b/goodwe_controller/config.yaml @@ -328,6 +328,40 @@ options: # validate.py warns about exactly that. static_profile: "" + # TARIFF-17. THE TAXES COMPONENT AS A DAY/NIGHT TABLE, for the sites whose + # network tariff actually has one. "" means the flat price_taxes_eur_kwh + # above decides, which is what every install has had until now and is CORRECT + # IN FLANDERS: Fluvius distribution is a single rate, and its row on the + # tariff card offers only `Normaal | Uitsl. nacht` - exclusive night being a + # separate physical circuit, not the night register of a dual-rate meter. + # + # The Walloon and Brussels DSOs are on the same card with FOUR columns - + # `Enkelvoudig | Dag | Nacht | Uitsl. nacht` - and the gap is real money + # (c EUR/kWh): ORES dag 13,27 / nacht 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. + # + # ⚠️ WHY THIS IS NOT COSMETIC. The optimiser reads the SPREAD (max - min) and + # which hour is cheapest. A flat number is additive and identical every hour, + # so it cancels out of both - it cannot distort anything, and it also cannot + # express this. These bands are ANTI-CORRELATED with what the battery wants + # to do (cheap network hours are cheap spot hours), so flattening them + # UNDERSTATES the true spread and declines cycles that would have paid. See + # DOCS.md, "The three buckets". + # + # Same JSON grammar as static_profile above - the same tiling rule, the same + # day selectors, the same DST handling, because it is literally the same + # parser. ⚠️ THE VALUE IS THE TAXES COMPONENT ALONE, NOT AN ALL-IN PRICE: + # + # {"periods": [ + # {"name":"dag", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327}, + # {"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739}, + # {"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]} + # + # ⚠️ SET THIS *OR* price_taxes_eur_kwh, NOT BOTH. Two writers of one quantity, + # one of which silently wins, is refused at startup: leave the scalar at its + # shipped default when you fill this in. + price_taxes_profile: "" + # TARIFF-15. YOUR OWN TARIFF CARD, as two fixed coefficients. Used only when # `price_provider: custom_formula`; "" is unset. # @@ -847,6 +881,10 @@ schema: # parse_static_profile() accepts an already-parsed dict too, so a future # nested schema is a config.yaml change and no code change at all. static_profile: str? + # TARIFF-17. A JSON string for `static_profile`'s reason, and parsed by + # `static_tariff.parse_taxes_profile()` - the SAME period grammar, so a + # second tiling rule and a second set of DST bugs do not exist to diverge. + price_taxes_profile: str? # TARIFF-15. A JSON string for the same reason `static_profile` is one: it is # ONE option key, it survives config.yaml's flat options block, and # parse_price_formula() accepts an already-parsed dict too, so a future diff --git a/goodwe_controller/test_price.py b/goodwe_controller/test_price.py index 0829e39..af20350 100644 --- a/goodwe_controller/test_price.py +++ b/goodwe_controller/test_price.py @@ -68,6 +68,7 @@ from app.price import ( brussels_offset, check_egress, dated_feed_url, price_option, export_price_eur_kwh, load_prices, plan_is_provisional, register, + taxes_at, taxes_profile, volatility_regime, uncertainty_of, widen_factor, Uncertainty, ) from app.custom_formula import ( @@ -80,7 +81,8 @@ from app import price as _price from app import arbitrage as _arb from app import main as _main from app.p1 import make_sample, split_signed -from app.static_tariff import TariffPlan, static_status +from app.static_tariff import ( + TariffPlan, parse_taxes_profile, static_status) from app.validate import validate_startup_config fails = [] @@ -4465,6 +4467,580 @@ check("...and this is the same wording FCST-04's *Price history* row already " else _T11_E2E["Price history"])) + +# --------------------------------------------------------------------------- # +print() +print("16. TARIFF-17 - the three buckets, and the only one that moves a " + "DECISION") +# ⚠️ WHAT THIS SECTION IS FOR, AND WHY A SPREAD ASSERTION WOULD GRADE THE WRONG +# THING. The optimiser reads exactly two quantities off a series: the SPREAD +# (`max - min` over the firm points) and which hour is cheapest +# (`min(firm, key=...)`, static_tariff.arbitrage()). Every price component +# reaches them through one of three buckets: +# +# LEVEL additive, identical every hour -> reaches NEITHER, provably. An +# additive constant cancels +# exactly in `max - min`, and +# adding it to every point leaves +# the argmin where it was. +# SPREAD multiplicative on spot -> scales the spread against a +# fixed cycle_cost_threshold_eur. +# ORDER varies by hour, block, day-type -> changes WHICH HOUR IS CHEAPEST. +# +# `price_taxes_eur_kwh` is LEVEL. It is therefore NOT a wrong number and never +# was: it cannot distort either quantity. The defect TARIFF-17 closes is that a +# component belonging in the ORDER bucket - the Walloon and Brussels DSOs' +# day/night distribution bands - had nowhere to live. +# +# ⚠️ SO THE REGRESSION BELOW GRADES THE ARGMIN, NOT THE MAGNITUDE. A +# spread-magnitude check passes on an implementation that reads the table ONCE +# and adds the same number to every point - which is the LEVEL bucket wearing a +# period table, and is the single likeliest way this feature gets broken. 16d +# is that mutation's named red. + +_T17_UTC0 = datetime(2026, 1, 14, 23, 0, tzinfo=UTC) # 00:00 Brussels, Thu +# A plain Belgian winter weekday: solar-flattened midday, evening peak. Nothing +# here is chosen to make the table win - 16b proves no SCALAR can reproduce it. +_T17_SPOT = (0.070, 0.065, 0.062, 0.060, 0.061, 0.068, + 0.082, 0.110, 0.125, 0.100, 0.070, 0.050, + 0.038, 0.030, 0.035, 0.048, 0.072, 0.120, + 0.160, 0.180, 0.150, 0.115, 0.090, 0.078) +_T17_RAW = [RawPoint(_T17_UTC0 + timedelta(hours=h), _T17_SPOT[h], False) + for h in range(24)] +_T17_CAPS = ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY}), + egress_hosts=("example.invalid",)) + +# ORES's 2026 residential distribution row, EUR/kWh. The tariff card prints +# `Enkelvoudig | Dag | Nacht | Uitsl. nacht` for every Walloon DSO and +# `Normaal | Uitsl. nacht` for Fluvius, which is why Flanders is a scalar and +# Wallonia is not. Exclusive night is a separate physical circuit and is not +# modelled here. +_T17_ENKELVOUDIG, _T17_DAG, _T17_NACHT = 0.1198, 0.1327, 0.0739 +_T17_TABLE_JSON = {"periods": [ + {"name": "dag", "from": "07:00", "to": "22:00", "days": "mon-fri", + "eur_per_kwh": _T17_DAG}, + {"name": "nacht", "from": "22:00", "to": "07:00", "days": "mon-fri", + "eur_per_kwh": _T17_NACHT}, + {"name": "nacht", "from": "00:00", "to": "24:00", "days": "sat,sun", + "eur_per_kwh": _T17_NACHT}]} +_T17_TABLE = derived(lambda: parse_taxes_profile(_T17_TABLE_JSON)) + + +def _t17_band(weekday, hour): + """ORES's band, written HERE, by different arithmetic than the module uses. + + The check that grades `taxes_at()` must not get its expected value from + `taxes_at()`, or it compares a number against itself - MUTATION.md habit 2. + """ + return _T17_DAG if (weekday < 5 and 7 <= hour < 22) else _T17_NACHT + + +def _t17_cheapest(points): + p = min(points, key=lambda q: q.eur_per_kwh) + return brussels(p.start).hour + + +def _t17_spread(points): + return round(max(p.eur_per_kwh for p in points) + - min(p.eur_per_kwh for p in points), 6) + + +print() +print("16a. the fixture is not degenerate, and the two arms differ in EXACTLY " + "ONE way - asserted before anything is concluded from them") +# ⚠️ MUTATION.md habit 1 and habit 7. Three of the checks in this chain turned +# out to be graded by something they did not own, and a fourth survived a whole +# suite because its fixture differed in two ways at once. So: the two arms are +# ONE `assemble()` call each, over the SAME caps object, the SAME RawPoint list +# and the SAME markup, and the only argument that differs is `taxes`. +_T17_FLAT_OUT = derived(lambda: assemble(_T17_CAPS, _T17_RAW, MARKUP, + _T17_ENKELVOUDIG)) +_T17_TAB_OUT = derived(lambda: assemble(_T17_CAPS, _T17_RAW, MARKUP, + _T17_TABLE)) +check("the table parses, through static_tariff's OWN grammar", + not isinstance(_T17_TABLE, _Raised)) +check(f"the two arms are the same feed: same caps, same {len(_T17_RAW)} " + f"RawPoints and the same markup {MARKUP}, by IDENTITY and not by value", + _T17_CAPS is _T17_CAPS and _T17_RAW is _T17_RAW) +_T17_BANDS = derived(lambda: sorted({p.eur_per_kwh + for p in _T17_TABLE.periods})) +check(f"...and the table is genuinely hour-varying: {_T17_BANDS} EUR/kWh, two " + f"distinct bands {round(_T17_DAG - _T17_NACHT, 4)} apart. A one-band " + f"table is a scalar and would grade nothing", + _T17_BANDS == sorted({_T17_DAG, _T17_NACHT}) and len(_T17_BANDS) == 2) +_T17_RAW_MIN = derived(lambda: min(range(24), key=lambda h: _T17_SPOT[h])) +check(f"...and the UNTAXED feed's own cheapest hour is {_T17_RAW_MIN:02d}:00, " + f"which is inside the DAY band - so the night band has something to win " + f"against. A feed already cheapest at night would pass under any " + f"implementation", + _T17_RAW_MIN == 13 and _t17_band(3, _T17_RAW_MIN) == _T17_DAG) +check(f"...and the flat scalar this is measured against is ORES's OWN " + f"enkelvoudig rate {_T17_ENKELVOUDIG}, which sits BETWEEN the two bands " + f"- so 'the table is just bigger' is not available as an explanation", + _T17_NACHT < _T17_ENKELVOUDIG < _T17_DAG) + + +print() +print("16b. the LEVEL bucket, as a FAMILY: NO scalar can do what the table " + "does") +# ⚠️ THIS IS WHAT MAKES 16c's CONCLUSION ATTRIBUTABLE. A single flat/table pair +# differs in two things at once - the arithmetic level AND the shape - and +# MUTATION.md's eleven instances are all a fixture that could not separate +# them. So the flat arm is not one scalar, it is the whole FAMILY of scalars +# swept across and beyond the table's own range: if every one of them leaves +# the cheapest hour and the spread exactly where the untaxed feed had them, +# then whatever 16c measures cannot be a level effect, because level effects +# have been shown to be inert on this feed. +_T17_SWEEP = (0.0, _T17_NACHT, _T17_ENKELVOUDIG, _T17_DAG, 1.0, + PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"]) +_T17_LEVEL = derived(lambda: [ + (s, _t17_cheapest(assemble(_T17_CAPS, _T17_RAW, MARKUP, s)), + _t17_spread(assemble(_T17_CAPS, _T17_RAW, MARKUP, s))) + for s in _T17_SWEEP]) +for _s, _h, _sp in (_T17_LEVEL if isinstance(_T17_LEVEL, list) else []): + print(f" flat taxes {_s:<7.4f} -> cheapest hour {_h:02d}, " + f"spread {_sp:.4f}") +check(f"every one of the {len(_T17_SWEEP)} scalars leaves the cheapest hour at " + f"{_T17_RAW_MIN:02d}:00 - the untaxed feed's own - so a LEVEL component " + f"provably cannot reach the argmin", + isinstance(_T17_LEVEL, list) + and {h for _s, h, _sp in _T17_LEVEL} == {_T17_RAW_MIN}) +check("...and leaves the spread IDENTICAL to the last digit, which is the " + "other half of the same statement: an additive constant cancels in " + "`max - min`", + isinstance(_T17_LEVEL, list) + and len({sp for _s, _h, sp in _T17_LEVEL}) == 1) +check("...so `price_taxes_eur_kwh` was never a wrong number. It is inert on " + "both quantities the optimiser reads, and the defect TARIFF-17 closes is " + "a MISSING CAPABILITY rather than a bad constant", + isinstance(_T17_LEVEL, list) + and _T17_LEVEL[0][1:] == _T17_LEVEL[-1][1:]) + + +print() +print("16c. THE REGRESSION: a Walloon bihoraire profile moves WHICH HOUR IS " + "CHEAPEST") +_T17_H_FLAT = derived(lambda: _t17_cheapest(_T17_FLAT_OUT)) +_T17_H_TAB = derived(lambda: _t17_cheapest(_T17_TAB_OUT)) +print(f" flat {_T17_ENKELVOUDIG} : cheapest hour {_T17_H_FLAT}, " + f"spread {_t17_spread(_T17_FLAT_OUT):.4f}") +print(f" ORES dag/nacht : cheapest hour {_T17_H_TAB}, " + f"spread {_t17_spread(_T17_TAB_OUT):.4f}") +check(f"the SAME feed is cheapest at {_T17_H_FLAT:02d}:00 under a flat scalar " + f"and at {_T17_H_TAB:02d}:00 under ORES's dag/nacht table. THIS is the " + f"property: a component in the ORDER bucket changes the hour a battery " + f"would buy in, and no scalar can", + _T17_H_FLAT == 13 and _T17_H_TAB == 3 and _T17_H_FLAT != _T17_H_TAB) +check(f"...and the hour it moves TO is a night-band hour, which is the " + f"mechanism rather than a coincidence: cheap network hours coincide with " + f"cheap spot hours, so flattening them understates the spread " + f"({_t17_spread(_T17_FLAT_OUT):.4f} against {_t17_spread(_T17_TAB_OUT):.4f} " + f"EUR/kWh) and declines cycles that would have paid", + _t17_band(3, _T17_H_TAB) == _T17_NACHT + and _t17_spread(_T17_TAB_OUT) > _t17_spread(_T17_FLAT_OUT)) +# ⚠️ AND THE SPREAD CLAUSE ABOVE IS A REPORTED CONSEQUENCE, NOT THE CHECK. The +# card is explicit that a spread-magnitude assertion grades the wrong bucket +# and would pass on a LEVEL-bucket implementation; the argmin above is what +# goes red for one. Both are printed so a reader can see which is which. + + +print() +print("16d. driven through the SHIPPED DECISION, not through assemble()'s " + "output") +# ⚠️ habit 4. `min(firm, key=...)` lives in static_tariff.arbitrage(), and that +# is the expression a strategy reads. A check that stops at the assembled list +# grades the list; this one grades the verdict an optimiser is handed. +def _t17_verdict(points): + read = PriceRead.series(points, PriceProduct.DAY_AHEAD, "t17", + datetime.now(UTC)) + plan = TariffPlan(read, None, "provider") + return plan.arbitrage(peak_guard_active=False, + min_spread_eur_kwh=PRICE_OPTION_DEFAULTS[ + "cycle_cost_threshold_eur"], + uncertainty=frozenset()) + + +_T17_V_FLAT = derived(lambda: _t17_verdict(_T17_FLAT_OUT)) +_T17_V_TAB = derived(lambda: _t17_verdict(_T17_TAB_OUT)) +_T17_BUY = derived(lambda: ( + _T17_V_FLAT.match(allowed=lambda a: brussels(a.cheapest.start).hour, + refused=lambda r: r.reason), + _T17_V_TAB.match(allowed=lambda a: brussels(a.cheapest.start).hour, + refused=lambda r: r.reason))) +print(f" arbitrage().cheapest hour: flat {_T17_BUY[0]!r}, " + f"table {_T17_BUY[1]!r}") +check("BOTH arms are ALLOWED at the shipped cycle_cost_threshold_eur, so the " + "comparison below is between two verdicts and not between a verdict and " + "a refusal", + isinstance(_T17_BUY, tuple) and _T17_BUY[0] == 13 and _T17_BUY[1] == 3) +check("...and the BUY HOUR the optimiser is handed moves 13:00 -> 03:00 on the " + "same feed. The add-on would charge the battery at midday on a flat tax " + "basis and at 03:00 on ORES's real one", + isinstance(_T17_BUY, tuple) and _T17_BUY[0] != _T17_BUY[1]) + + +print() +print("16e. assemble() evaluates the table at EACH POINT'S OWN INSTANT") +# ⚠️ THE MUTATION THIS IS AIMED AT is hoisting the lookup out of the loop - +# reading the table once and adding one number to every point. That leaves the +# series the right MAGNITUDE and the wrong SHAPE, which is exactly the LEVEL +# bucket, and 16c goes red for it. This check says the same thing per point and +# against `_t17_band()` - arithmetic written in this file, not borrowed from +# the module under test. +_T17_DELTAS = derived(lambda: [ + (brussels(p.start).hour, + round(p.eur_per_kwh - _T17_SPOT[brussels(p.start).hour] - MARKUP, 6)) + for p in _T17_TAB_OUT]) +_T17_WANT = derived(lambda: [(h, round(_t17_band(3, h), 6)) for h in range(24)]) +check(f"the tax component added to each of the 24 points is that point's own " + f"band, hour by hour, graded against this file's arithmetic: " + f"{_T17_DELTAS[:4]} ... {_T17_DELTAS[-2:]}", + _T17_DELTAS == _T17_WANT) +check(f"...and there really are TWO distinct deltas in one series " + f"{sorted({d for _h, d in _T17_DELTAS})}, so 'evaluated per point' is " + f"distinguishable from 'evaluated once'. Under a scalar there is exactly " + f"one, which is the whole difference between the buckets", + len({d for _h, d in _T17_DELTAS}) == 2 + and len({round(p.eur_per_kwh - _T17_SPOT[brussels(p.start).hour] + - MARKUP, 6) for p in _T17_FLAT_OUT}) == 1) + + +print() +print("16f. the SCALAR path is unchanged - arithmetically, not merely in " + "intent") +# ⚠️ AC1, and the reference site is on it. `taxes_at()` on a float returns that +# float, and `0.0 + markup` is exactly `markup` in IEEE 754 for every finite +# markup - so the pre-TARIFF-17 expression is reproduced here and compared +# point for point rather than reasoned about. +def _t17_old_assemble(caps, raw, markup, taxes): + """price.py's assemble() as it stood at c518007. Byte-for-byte arithmetic.""" + add = 0.0 + if Component.MARKUP in caps.adds: + add += float(markup) + if Component.TAXES in caps.adds: + add += float(taxes) + return [PricePoint(start=brussels(p.start), + eur_per_kwh=round(p.eur_per_kwh + add, 6), + provisional=bool(p.provisional)) + for p in raw] + + +_T17_SCALARS = [] +for _caps in (_T17_CAPS, + ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY, + Component.MARKUP}), + egress_hosts=("example.invalid",)), + ProviderCapabilities(product=PriceProduct.STATIC, sources=ALL_IN)): + for _t in (0.0, TAXES, _T17_ENKELVOUDIG, -0.02): + _T17_SCALARS.append( + ([p.eur_per_kwh for p in assemble(_caps, _T17_RAW, MARKUP, _t)], + [p.eur_per_kwh + for p in _t17_old_assemble(_caps, _T17_RAW, MARKUP, _t)])) +check(f"over {len(_T17_SCALARS)} (capability, scalar) combinations x " + f"{len(_T17_RAW)} points, every assembled price equals the OLD " + f"expression's to the last digit - `==`, not `near()`", + all(a == b for a, b in _T17_SCALARS)) +check("...and the combinations are not all the same answer, or the line above " + "would be one comparison repeated", + len({tuple(a) for a, _b in _T17_SCALARS}) > 1) + + +print() +print("16g. the new key is FRAMEWORK-OWNED, and a plugin that names it is " + "still refused") +check("`price_taxes_profile` is in FRAMEWORK_KEYS", + "price_taxes_profile" in _price.FRAMEWORK_KEYS) +check("...and BANNED_IN_PLUGIN_CODE is DERIVED from that set, so it arrived " + "there without a second list being edited", + "price_taxes_profile" in BANNED_IN_PLUGIN_CODE + and set(_price.FRAMEWORK_KEYS) <= set(BANNED_IN_PLUGIN_CODE)) +check("...so PluginOptions refuses to hand it over, naming the framework", + raises(lambda: PluginOptions(opts()).get("price_taxes_profile"), + FrameworkOption) + and "framework" in _reason( + lambda: PluginOptions(opts()).get("price_taxes_profile"))) + + +class _T17Rogue(PriceProvider): + """A plugin that reaches for the taxes TABLE instead of the scalar. + + The shape of the defect: it would add the site's own per-hour levies a + SECOND time, per hour, and the resulting price is wrong by one tax band + with nothing in any log reporting it. + """ + + id = "t17_rogue" + + @classmethod + def capabilities(cls): + return ProviderCapabilities(product=PriceProduct.DAY_AHEAD, + sources=frozenset({Component.ENERGY}), + egress_hosts=("example.invalid",)) + + async def fetch(self, opts, since, until, transport): + extra = opts.get("price_taxes_profile") # the leak + return [RawPoint(since, 0.1 + float(bool(extra)), False)] + + +_T17_LEAK = derived(lambda: code_leaks(_T17Rogue)) +check(f"code_leaks() finds it in a plugin's own code: {_T17_LEAK}", + _T17_LEAK == ["price_taxes_profile"]) +check("...and register() refuses to install it, so this is an inability and " + "not a convention", + raises(lambda: register(_T17Rogue), Exception) + and "price_taxes_profile" in _reason(lambda: register(_T17Rogue))) +check("...and the docstring above, which says 'taxes' and 'price' in prose, is " + "NOT what made it fire - docstrings are stripped before the scan", + "price_taxes_profile" not in + "".join(_T17Rogue.__doc__ or "") + (_T17Rogue.capabilities.__doc__ or "")) + + +print() +print("16h. it reaches BOTH shipped plugins that take taxes - and NEITHER of " + "the ones that do not") +# ⚠️ TARIFF-15's `custom_formula` declares {ENERGY, MARKUP}, so the framework +# adds TAXES for it; `generic_dayahead` declares {ENERGY} and gets both. The +# table must move the argmin under each. `manual_csv` publishes an all-in +# figure and is the NEGATIVE control - the table must be routed through +# `caps.adds` and not bolted onto every provider. +_T17_BY_PLUGIN = derived(lambda: { + pid: (_t17_cheapest(assemble(cls.capabilities(), _T17_RAW, MARKUP, + _T17_ENKELVOUDIG)), + _t17_cheapest(assemble(cls.capabilities(), _T17_RAW, MARKUP, + _T17_TABLE)), + sorted(c.value for c in cls.capabilities().adds)) + for pid, cls in sorted(PROVIDERS.items())}) +for _pid, _row in (sorted(_T17_BY_PLUGIN.items()) + if isinstance(_T17_BY_PLUGIN, dict) else []): + print(f" {_pid:<18} adds {str(_row[2]):<22} cheapest hour " + f"flat {_row[0]:02d} -> table {_row[1]:02d}") +check("custom_formula declares {ENERGY, MARKUP}, so the framework adds TAXES " + "for it - and the table moves its cheapest hour 13 -> 03", + isinstance(_T17_BY_PLUGIN, dict) + and _T17_BY_PLUGIN["custom_formula"][:2] == (13, 3) + and _T17_BY_PLUGIN["custom_formula"][2] == ["taxes"]) +check("generic_dayahead declares {ENERGY} and gets markup AND taxes - and the " + "table moves its cheapest hour 13 -> 03 too", + isinstance(_T17_BY_PLUGIN, dict) + and _T17_BY_PLUGIN["generic_dayahead"][:2] == (13, 3) + and _T17_BY_PLUGIN["generic_dayahead"][2] == ["markup", "taxes"]) +check("manual_csv publishes an all-in figure, adds NOTHING, and is left where " + "it was - the table is routed through caps.adds, not bolted on", + isinstance(_T17_BY_PLUGIN, dict) + and _T17_BY_PLUGIN["manual_csv"][2] == [] + and _T17_BY_PLUGIN["manual_csv"][0] == _T17_BY_PLUGIN["manual_csv"][1] + == 13) +check(f"...and that is EVERY registered plugin, enumerated from PROVIDERS " + f"rather than typed: {sorted(_T17_BY_PLUGIN)}", + isinstance(_T17_BY_PLUGIN, dict) + and set(_T17_BY_PLUGIN) == set(PROVIDERS) and len(PROVIDERS) >= 3) + + +print() +print("16i. Brussels WALL CLOCK, on the 25-hour day - inherited from the " + "grammar, not re-derived") +# ⚠️ THE 23- AND 25-HOUR DAYS ARE WHERE A SECOND IMPLEMENTATION WOULD DIVERGE, +# which is why there is not a second one: `StaticProfile.at()` renders the +# instant at the Brussels offset and matches on the LOCAL weekday and minute. +# 2026-10-25 is the autumn transition: local 02:00 happens twice, both passes +# are night, and the night->day boundary is still local 07:00. +_T17_DST0 = datetime(2026, 10, 24, 22, 0, tzinfo=UTC) # 00:00 CEST, Sunday +_T17_DST_RAW = [RawPoint(_T17_DST0 + timedelta(hours=h), 0.10, False) + for h in range(25)] +_T17_DST = derived(lambda: [ + (brussels(p.start).hour, brussels(p.start).utcoffset().seconds // 3600, + round(p.eur_per_kwh - 0.10 - MARKUP, 6)) + for p in assemble(_T17_CAPS, _T17_DST_RAW, MARKUP, _T17_TABLE)]) +print(f" local hours: {[h for h, _o, _d in _T17_DST]}") +check("the 25-hour day assembles 25 points and the local hour 02 appears " + "TWICE, once at +02:00 and once at +01:00", + isinstance(_T17_DST, list) and len(_T17_DST) == 25 + and [(h, o) for h, o, _d in _T17_DST].count((2, 2)) == 1 + and [(h, o) for h, o, _d in _T17_DST].count((2, 1)) == 1) +check("...and 2026-10-25 is a SUNDAY, so ORES's weekend row prices all 25 " + "hours at the night band - a table that matched on the UTC hour, or on a " + "24-slot array, would have run off the end of the day", + isinstance(_T17_DST, list) + and {d for _h, _o, d in _T17_DST} == {round(_T17_NACHT, 6)}) +# The weekday boundary, to show the band edge really is wall clock: the FIRST +# day-band point sits at local 07:00 in both halves of the year, which is +# 06:00 UTC in January and 05:00 UTC in July. +_T17_EDGE = derived(lambda: tuple( + min(brussels(p.start) for p in assemble( + _T17_CAPS, + [RawPoint(base + timedelta(hours=h), 0.10, False) for h in range(24)], + MARKUP, _T17_TABLE) + if round(p.eur_per_kwh - 0.10 - MARKUP, 6) == round(_T17_DAG, 6)) + for base in (datetime(2026, 1, 14, 23, 0, tzinfo=UTC), # Thu, CET + datetime(2026, 7, 15, 22, 0, tzinfo=UTC)))) # Thu, CEST +check(f"...and the dag band opens at local 07:00 on BOTH a CET and a CEST " + f"weekday - {[e.isoformat() for e in _T17_EDGE]} - so it is the same UTC " + f"instant only on one of them", + isinstance(_T17_EDGE, tuple) + and [e.hour for e in _T17_EDGE] == [7, 7] + and len({e.astimezone(UTC).hour for e in _T17_EDGE}) == 2) + + +print() +print("16j. a MALFORMED table: refused at startup, and loud rather than " + "silent if it ever gets past") +_T17_GAP = {"periods": [{"name": "dag", "from": "07:00", "to": "22:00", + "days": "all", "eur_per_kwh": _T17_DAG}]} +_T17_GAP_MSG = _reason(lambda: parse_taxes_profile(_T17_GAP)) +print(f" {_T17_GAP_MSG[:110]}") +check(f"a table that leaves an hour unpriced is refused by the SAME tiling " + f"rule static_profile has, and the refusal names the key the operator " + f"typed", + _T17_GAP_MSG.startswith("price_taxes_profile leaves ") + and "no period" in _T17_GAP_MSG) +check("...and the fetch path does NOT turn it into a traceback into the 30 s " + "tick, nor silently fall back to the scalar: it is a §11.4 no-data read " + "naming the key", + run(load_prices(opts(price_provider="generic_dayahead", + price_taxes_profile=_T17_GAP), + _T17_UTC0, _T17_UTC0 + timedelta(hours=24), + canned(b""))).match( + series=lambda _r: False, + no_data=lambda r: r.reason.startswith( + "price_taxes_profile cannot be used:"))) +check("...and the value that is NOT a table at all - the shipped \"\" - leaves " + "the scalar path exactly where it was", + taxes_profile(opts()) is None + and taxes_profile(opts(price_taxes_profile="")) is None + and taxes_profile(opts(price_taxes_profile="null")) is None) + + +print() +print("16k. END TO END through the SHIPPED load_prices(), on the SHIPPED " + "plugin - because everything above could be true with the option unread") +# ⚠️ WHAT THIS CATCHES THAT 16c CANNOT. 16c hands `assemble()` a parsed table +# directly. `load_prices()` is what has to READ the option, parse it and pass +# it, and a mutation that drops any of those three leaves every check above +# green - the framework would be capable of an ORDER-bucket component and no +# site could configure one. So this drives the real `generic_dayahead`, over +# the real columnar parser, from an OPTIONS DICT. +_T17_BODY = columnar_feed([v * 1000.0 for v in _T17_SPOT], start=_T17_UTC0) +_T17_E2E_TABLE = json.dumps(_T17_TABLE_JSON) + + +def _t17_load(**over): + return run(load_prices( + opts(price_provider="generic_dayahead", **over), + _T17_UTC0, _T17_UTC0 + timedelta(hours=24), + canned(_T17_BODY))) + + +_T17_E2E = derived(lambda: tuple( + r.match(series=lambda x: (_t17_cheapest(x.points), + len({round(pt.eur_per_kwh + - _T17_SPOT[brussels(pt.start).hour] + - MARKUP, 6) for pt in x.points})), + no_data=lambda x: x.reason) + for r in (_t17_load(price_taxes_eur_kwh=_T17_ENKELVOUDIG), + _t17_load(price_taxes_profile=_T17_E2E_TABLE)))) +print(f" flat scalar option : cheapest hour {_T17_E2E[0][0]}, " + f"{_T17_E2E[0][1]} distinct tax delta(s)") +print(f" table option : cheapest hour {_T17_E2E[1][0]}, " + f"{_T17_E2E[1][1]} distinct tax delta(s)") +check("the OPTION is read, parsed and applied: the same shipped feed and the " + "same shipped plugin are cheapest at 13:00 under price_taxes_eur_kwh and " + "at 03:00 under price_taxes_profile", + isinstance(_T17_E2E, tuple) and _T17_E2E[0] == (13, 1) + and _T17_E2E[1] == (3, 2)) +check("...and the scalar option is still the one that answers when the table " + "is absent, which is every install that exists today", + isinstance(_T17_E2E, tuple) and _T17_E2E[0][1] == 1) + + +# --------------------------------------------------------------------------- # +# TARIFF-17's MUTATION LEDGER. Every row was RUN, not reasoned about, and the +# method is MUTATION.md's. ⚠️ NO ROW HERE IS AN EDIT-AND-REVERT: the harness +# (`/home/homelab/.cache/t17-mut/mut.sh`) rsyncs a FRESH COPY of the tree per +# mutation, excluding `__pycache__`, mutates the copy, mounts it `:ro` into the +# checks image and deletes it. There is no revert to fail and no shared backup +# path, which is habit 18 closed structurally rather than by discipline; and a +# `:ro` tree with no cache in it cannot read a stale `.pyc`, which is TEST-15 +# closed the way CI closes it. Each row's byte delta and the mutant/clean +# sha256 pair were printed by the harness before the suites ran. +# +# # what was changed delta red on +# --- ------------------------------------------------- ----- --------------- +# M1 assemble() hoists the table lookup out of the +17 B 16c 16d 16e 16k +# loop (`taxes_at(taxes, brussels(raw[0].start))`) - THE headline +# -> the LEVEL bucket wearing a period table. mutation. FULL +# 29-FILE SWEEP: +# red on +# test_price.py, +# GREEN in the +# other 28. +# M2 taxes_at() returns `periods[0]`'s value instead +17 B 16c 16d 16e 16k +# of the point's own band. +# M3 "price_taxes_profile" removed from -27 B 16g (5 checks) +# FRAMEWORK_KEYS. +# M4 load_prices() parses the table and never applies +10 B 16k ONLY - the +# it (`if table is not None and False`). reason 16k is +# a separate +# check: every +# other TARIFF-17 +# check stays +# green while no +# site can +# configure one. +# M5 validate.py never reads the option. -32 B test_validate +# (6 checks) +# M6 the two-writers refusal (table + non-default -1 B test_validate +# scalar) is made unreachable. (2 checks) +# M7 the cents/units rung's threshold 1.0 -> 1000.0. +3 B test_validate, +# the SIBELGA +# line ONLY - the +# ORES line goes +# on passing for +# the PARSER's +# reason, which +# is what grading +# the reason +# buys. +# M8 ⚠️ TEST-16's rule: BOTH rungs that can answer +3 B test_validate +# the cents stimulus, TOGETHER - the units rung (+ 3) (3 checks), +# AND price.py's MAX_PLAUSIBLE_EUR_KWH. test_price, +# Neither masks the other. test_static +# M9 a SECOND period grammar: `_check_tiling` and +86 B test_static 10a +# `MINUTES_PER_DAY` copied into price.py. (2 checks) +# M10 parse_taxes_profile() drops `option=`, so every -61 B test_static 10b, +# refusal names `static_profile`. test_price 16j. +# ⚠️ GREEN in +# test_validate - +# that file's +# own prefix +# supplies the +# key, so it +# cannot see +# this. Named +# rather than +# rounded off. +# M11 StaticProfile.at() matches on the UTC clock +15 B 16e, 16i and +# instead of Brussels wall time. test_static +# (5 checks) +# M12 _price() ignores its `means` argument. -26 B test_static 10b +# M13 assemble() adds the markup unconditionally, -42 B 16f and +# ignoring caps.adds. section 3 +# (6 checks) +# +# ⚠️ WHAT NO ROW ABOVE COULD SEE, with its shape (habit 13): +# * passing `p.start` (UTC) to `taxes_at()` instead of `local` is an +# EQUIVALENT mutant, not a survivor: `StaticProfile.at()` calls +# `brussels()` on whatever it is given, so the two are the same instant by +# construction and no fixture can separate them; +# * 10a grades WHERE the grammar is defined, never WHAT it means - rewriting +# `_check_tiling` in place to accept a gap passes it (section 5 and 10b are +# what grade the meaning), and it covers `app/` only; +# * nothing here grades the DOCS.md bucket table, which is prose. The numbers +# it quotes are the ones 16b and 16c print on every run, so a reader can +# check them, but no assertion holds them. + + print() if fails: print(f"{len(fails)} FAILED: " + "; ".join(fails)) diff --git a/goodwe_controller/test_price_forecast.py b/goodwe_controller/test_price_forecast.py index e45771c..13b6d8f 100644 --- a/goodwe_controller/test_price_forecast.py +++ b/goodwe_controller/test_price_forecast.py @@ -1107,21 +1107,45 @@ _OPT04_KEYS = ["arbitrage_enabled", "cycle_cost_threshold_eur", # in config.yaml and nowhere else, which is the arrangement `static_profile` # already has for the same reason. _T15_KEYS = ["price_formula"] -check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's " - f"and TARIFF-15's keys - FCST-03 still added none of its own " +# TARIFF-17 added the sixth, and comes here to say so as the comment above +# instructs. `price_taxes_profile` is the hour-varying spelling of +# `price_taxes_eur_kwh` - a period table of the TAXES component, for the Walloon +# and Brussels DSOs whose distribution tariff really does have a day and a night +# band (ORES 13,27 / 7,39 c EUR/kWh). It is an ADDITION and not a replacement: +# the scalar is untouched, every site on it is unaffected, and validate.py +# refuses the configuration that sets both. It lives in this dict and in +# config.yaml and nowhere else, which is `static_profile`'s arrangement for +# `static_profile`'s reason. +_T17_KEYS = ["price_taxes_profile"] +check(f"the in-code mirror holds exactly TARIFF-02's, TARIFF-03's, OPT-04's, " + f"TARIFF-15's and TARIFF-17's keys - FCST-03 still added none of its own " f"{_defaults}", _defaults == sorted(["price_provider", "price_markup_eur_kwh", "price_taxes_eur_kwh", "export_price_eur_kwh", "price_csv_path", "price_feed_url", "price_horizon_h", "price_timeout_s", - "static_profile"] + _OPT04_KEYS + _T15_KEYS)) + "static_profile"] + + _OPT04_KEYS + _T15_KEYS + _T17_KEYS)) # ...and the later keys are genuinely NEW, not a rename of something that was # already there, or the line above would pass while the mirror silently lost a # key. -check("...and OPT-04's four and TARIFF-15's one are additions rather than " - "replacements: the nine TARIFF-02/03 keys are all still present", +check("...and OPT-04's four, TARIFF-15's one and TARIFF-17's one are additions " + "rather than replacements: the nine TARIFF-02/03 keys are all still " + "present", isinstance(_defaults, list) - and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS)) == 9) + and len(set(_defaults) - set(_OPT04_KEYS) - set(_T15_KEYS) + - set(_T17_KEYS)) == 9) +# ⚠️ AND THE ONE TARIFF-17 IS THE HOUR-VARYING TWIN OF IS STILL THERE, BY NAME. +# The line above counts nine, and a rename of `price_taxes_eur_kwh` to +# `price_taxes_profile` would also count nine. The scalar path being unchanged +# is this card's first acceptance criterion, and a count cannot grade it. +check("...and `price_taxes_eur_kwh` is still its own key beside the table, so " + "TARIFF-17 widened the framework rather than renaming the scalar", + isinstance(_defaults, list) + and "price_taxes_eur_kwh" in _defaults + and "price_taxes_profile" in _defaults + and _price.PRICE_OPTION_DEFAULTS["price_taxes_eur_kwh"] == 0.06 + and _price.PRICE_OPTION_DEFAULTS["price_taxes_profile"] == "") check("...and every one of them is still in config.yaml, which stays the " "source of truth", isinstance(_cfg, str) diff --git a/goodwe_controller/test_static_tariff.py b/goodwe_controller/test_static_tariff.py index 163a68a..d5d6d38 100644 --- a/goodwe_controller/test_static_tariff.py +++ b/goodwe_controller/test_static_tariff.py @@ -59,6 +59,7 @@ is TEST-15, and it has already turned one live mutation into a false survivor. Read MUTATION.md before you report a survivor. """ +import ast import gzip import json import sys @@ -71,8 +72,9 @@ from app.price import ( ) from app.price_history import NoHistory from app.static_tariff import ( - MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TariffPlan, - parse_static_profile, resolve_tariff, static_plan, static_status, + MEAN_WINDOW_DAYS, ProfileInvalid, StaticProfile, TAXES_PROFILE_OPTION, + TariffPlan, parse_static_profile, parse_taxes_profile, resolve_tariff, + static_plan, static_status, ) from app.validate import validate_startup_config @@ -87,6 +89,16 @@ def check(name, cond): fails.append(name) +def _reason_of(fn): + """The message fn() raised, or "" - so a check can assert WHICH rule + refused rather than only that something did (MUTATION.md habit 6).""" + try: + fn() + except Exception as e: # noqa: BLE001 + return str(e) + return "" + + def raises(fn, exc): """True only if fn() raises EXACTLY `exc`. A different exception is False - 'it was refused' and 'it broke on the way to being refused' must not grade @@ -1130,6 +1142,189 @@ check("...and the table really does exercise both rungs and the allowing path, " {want for _n, _p, want in _TABLE9} == {"flat", "firm-pair", "allowed"}) + +# --------------------------------------------------------------------------- # +print() +print("10. TARIFF-17 - ONE period grammar, not two. A second tiling rule is a " + "second set of DST bugs") +# ⚠️ THE ACCEPTANCE CRITERION THIS GRADES, and why it needs two checks rather +# than one. `price_taxes_profile` carries the TAXES component as a period table +# so that a Walloon day/night distribution band can reach the optimiser. The +# obvious way to build it is a second parser next to this one - and the two +# would agree on every ordinary day and diverge on the 23- and 25-hour days, +# where nobody looks and where §11.4 makes the divergence quiet. +# +# So the claim is an IDENTITY ("it is this grammar"), and MUTATION.md habit 10 +# says pin an identity structurally and a meaning behaviourally. Both are here: +# 10a the grammar family is DEFINED IN EXACTLY ONE MODULE, computed by +# parsing every file under app/ rather than by a list somebody typed; +# 10b the two options produce the SAME REFUSAL SENTENCES, derived from each +# other, which is what a fork would break first. + +print() +print("10a. the grammar is defined in exactly one module under app/, and the " + "scope is COMPUTED") +# ⚠️ A HAND-WRITTEN SCOPE IS THE SAME DEFECT AS A HAND-WRITTEN FIXTURE +# (habit 12): it passes while the copy sits in the file nobody added to the +# list. Every `app/*.py` is parsed; the family is taken from THIS module's own +# top-level names, so it cannot drift from what the grammar actually is. +# +# ⚠️ AND THE FAMILY LIST HAS ONE DELIBERATE OMISSION, STATED HERE RATHER THAN +# ROUNDED OFF (habit 13). `_days` is NOT in it, because `maintenance.py` has a +# `_days` of its own - a NAMESAKE, not a copy - and the check below would go +# red on it and be deleted by whoever hit it. The namesake is asserted to BE a +# namesake, by signature, immediately afterwards, so the omission is graded +# rather than assumed. +_T17_FAMILY = ("Period", "StaticProfile", "ProfileInvalid", "_check_tiling", + "_raise_gap", "_raise_overlap", "_minutes", "_price", + "parse_static_profile", "DAY_NAMES", "MINUTES_PER_DAY", + "_DAY_INDEX", "_DAY_ALIASES", "SUPPORTED_TZ") + + +def _t17_defs(path): + """Top-level names a module DEFINES - classes, functions and assignments.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + out = set() + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, + ast.ClassDef)): + out.add(node.name) + elif isinstance(node, ast.Assign): + out |= {t.id for t in node.targets if isinstance(t, ast.Name)} + return out + + +_T17_APP = sorted((HERE / "app").glob("*.py")) +_T17_WHERE = {name: sorted(p.name for p in _T17_APP if name in _t17_defs(p)) + for name in _T17_FAMILY} +for _n, _w in sorted(_T17_WHERE.items()): + print(f" {_n:<22} defined in {_w}") +check(f"every one of the {len(_T17_FAMILY)} grammar names is defined in " + f"EXACTLY ONE of the {len(_T17_APP)} modules under app/ - a copy of " + f"`_check_tiling` in price.py would take this red", + all(len(w) == 1 for w in _T17_WHERE.values())) +check("...and that module is static_tariff.py in every case, so the grammar " + "did not move house either", + {w[0] for w in _T17_WHERE.values() if w} == {"static_tariff.py"}) +check("...and the pin is not vacuous: each of those names really is a " + "top-level definition, so the scan found something rather than nothing", + all(w for w in _T17_WHERE.values()) and len(_T17_APP) > 5) + + +def _t17_sig(module, name): + tree = ast.parse((HERE / "app" / module).read_text(encoding="utf-8")) + fn = next(n for n in tree.body + if isinstance(n, ast.FunctionDef) and n.name == name) + return [a.arg for a in fn.args.args] + + +_T17_DAYS = (_t17_sig("static_tariff.py", "_days"), + _t17_sig("maintenance.py", "_days")) +print(f" _days: static_tariff{_T17_DAYS[0]} vs maintenance{_T17_DAYS[1]}") +check(f"...and the ONE name held out of the family is held out honestly: " + f"`maintenance._days{tuple(_T17_DAYS[1])}` renders an age in words and " + f"is not a second copy of the grammar's " + f"`_days{tuple(_T17_DAYS[0])}` day selector", + _T17_DAYS[0] != _T17_DAYS[1] and _T17_DAYS[0] == ["value", "field"] + and _T17_DAYS[1] == ["n"]) +# ⚠️ WHAT THIS PIN CANNOT SEE, WITH ITS SHAPE (habit 13). It grades WHERE the +# grammar is defined, never WHAT it means: `_check_tiling` could be rewritten +# in place to accept a gap and this would still pass. Section 5 and 10b are +# what grade the meaning. It also covers `app/` only, and a second grammar +# living under `tools/` or in a check file is outside it. + +_T17_BODY = ast.parse( + (HERE / "app" / "static_tariff.py").read_text(encoding="utf-8")) +_T17_WRAP = next( + (n for n in _T17_BODY.body + if isinstance(n, ast.FunctionDef) and n.name == "parse_taxes_profile"), + None) +_T17_CALLS = ([ast.unparse(n.func) for n in ast.walk(_T17_WRAP) + if isinstance(n, ast.Call)] if _T17_WRAP else []) +check(f"...and `parse_taxes_profile` is a WRAPPER and not a fork: its whole " + f"body calls {_T17_CALLS}, pinned on the AST rather than on the source " + f"text, because `\"parse_static_profile\" in src` is true of a copy that " + f"merely mentions it", + _T17_CALLS == ["parse_static_profile"]) + + +print() +print("10b. and the two options give the SAME refusals - derived from each " + "other, not typed twice") +# ⚠️ EVERY EXPECTED SENTENCE HERE IS THE OTHER OPTION'S OWN SENTENCE with the +# key substituted. Nothing is transcribed, so a fork that reworded one refusal +# - or that quietly stopped refusing something - is red on the row it broke and +# not on all of them. +_T17_BAD = [ + ("a gap", {"periods": [{"name": "d", "from": "07:00", "to": "22:00", + "days": "all", "eur_per_kwh": 0.13}]}), + ("an overlap", {"periods": [ + {"name": "a", "from": "00:00", "to": "24:00", "days": "all", + "eur_per_kwh": 0.13}, + {"name": "b", "from": "07:00", "to": "22:00", "days": "all", + "eur_per_kwh": 0.07}]}), + ("a bad HH:MM", {"periods": [{"name": "d", "from": "7.5", "to": "22:00", + "days": "all", "eur_per_kwh": 0.13}]}), + ("a bad day", {"periods": [{"name": "d", "from": "00:00", "to": "24:00", + "days": "funday", "eur_per_kwh": 0.13}]}), + ("missing days", {"periods": [{"name": "d", "from": "00:00", "to": "24:00", + "eur_per_kwh": 0.13}]}), + ("a zero-length window", {"periods": [ + {"name": "d", "from": "07:00", "to": "07:00", "days": "all", + "eur_per_kwh": 0.13}]}), + ("no periods list", {"timezone": "europe/brussels"}), + ("an empty periods list", {"periods": []}), + ("a foreign timezone", {"timezone": "utc", "periods": []}), + ("periods that are not a list", {"periods": 3}), + ("a row that is not an object", {"periods": ["dag"]}), + ("invalid JSON", "{not json"), + ("a 24:00 start", {"periods": [{"name": "d", "from": "24:00", "to": "07:00", + "days": "all", "eur_per_kwh": 0.13}]}), +] +_T17_ROWS = [] +for _what, _bad in _T17_BAD: + _a = _reason_of(lambda: parse_static_profile(_bad)) + _b = _reason_of(lambda: parse_taxes_profile(_bad)) + _T17_ROWS.append((_what, _a, _b, + _a.replace("static_profile", TAXES_PROFILE_OPTION) == _b)) +for _what, _a, _b, _same in _T17_ROWS: + print(f" {'ok ' if _same else 'XX '}{_what:<28} {_b[:74]}") +check(f"all {len(_T17_ROWS)} malformations are refused IDENTICALLY under both " + f"options, sentence for sentence, once the key name is substituted - " + f"which is what one parser means and a fork could not hold", + all(row[3] for row in _T17_ROWS)) +check("...and every one of them really did refuse, or 'the two agree' would be " + "two empty strings agreeing", + all(row[1] and row[2] for row in _T17_ROWS)) +check("...and the sentences are not all the same sentence, or the row above " + "would be one comparison repeated 13 times", + len({row[2] for row in _T17_ROWS}) == len(_T17_ROWS)) + +# ⚠️ THE ONE REFUSAL THAT IS DELIBERATELY DIFFERENT, and it is different +# because the two options carry different QUANTITIES in the same grammar. A +# period without `eur_per_kwh` gets a sentence naming what was wanted, and an +# operator who typed an ALL-IN price into the taxes table is out by an energy +# price with nothing reporting it. +_T17_NOPRICE = {"periods": [{"name": "d", "from": "00:00", "to": "24:00", + "days": "all"}]} +_T17_M_S = _reason_of(lambda: parse_static_profile(_T17_NOPRICE)) +_T17_M_T = _reason_of(lambda: parse_taxes_profile(_T17_NOPRICE)) +print(f" static_profile : {_T17_M_S[-96:]}") +print(f" taxes table : {_T17_M_T[-96:]}") +check("the 'you left the number out' refusal is the one sentence the two do " + "NOT share, and each names its own quantity: an all-in price for " + "static_profile, the taxes component alone for the table", + "an all-in eur_per_kwh" in _T17_M_S + and "an all-in eur_per_kwh" not in _T17_M_T + and "TAXES component alone" in _T17_M_T + and "NOT an all-in price" in _T17_M_T + and _T17_M_S.replace("static_profile", TAXES_PROFILE_OPTION) != _T17_M_T) +check("...and both still name the ROW the operator has to go and fix, which is " + "the half they do share", + "periods[1].eur_per_kwh is missing" in _T17_M_S + and "periods[1].eur_per_kwh is missing" in _T17_M_T) + + # --------------------------------------------------------------------------- # print() if fails: diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index 8dde80c..08fc022 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -1366,6 +1366,134 @@ check("...and the flat single-inverter config it always refused is refused " -5000, 5000) == []) + +# --------------------------------------------------------------------------- # +print() +print("TARIFF-17 - the taxes period table is refused HERE, at startup, and not " + "at 03:00") +# ⚠️ WHY IT IS REFUSED IN THIS MODULE AND NOT AT FETCH TIME. `price.assemble()` +# reads this table 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 indistinguishable on the status page from a +# healthy fixed-tariff day. That is `static_profile`'s argument, one option +# over, and this is the same rule. +_T17_DAG, _T17_NACHT = 0.1327, 0.0739 +_T17_OK = ('{"periods":[' + '{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",' + f'"eur_per_kwh":{_T17_DAG}}},' + '{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",' + f'"eur_per_kwh":{_T17_NACHT}}},' + '{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",' + f'"eur_per_kwh":{_T17_NACHT}}}]}}') + +# ⚠️ THE ACCEPTED BASELINE COMES FIRST (MUTATION.md habit 7). Every refusal +# below varies ONE thing from this line, so a refusal on the next line has +# nowhere else to have come from. +check("ORES's real dag/nacht table STARTS - it is a valid configuration, and " + "the refusals below are one edit away from this one", + validate_startup_config(base(price_taxes_profile=_T17_OK), + -5000, 5000) == []) +check("...and so does the shipped \"\", which is every install that exists: " + "the option is an addition, not a requirement", + validate_startup_config(base(price_taxes_profile=""), -5000, 5000) == [] + and validate_startup_config(base(), -5000, 5000) == []) + +_T17_GAP = ('{"periods":[{"name":"dag","from":"07:00","to":"22:00",' + f'"days":"all","eur_per_kwh":{_T17_DAG}}}]}}') +_T17_P_GAP = validate_startup_config(base(price_taxes_profile=_T17_GAP), + -5000, 5000) +print(f" {(_T17_P_GAP or [''])[0][:120]}") +check("a table that leaves 00:00-07:00 unpriced is REFUSED, by the same tiling " + "rule static_profile has - and the refusal names the KEY and the HOURS, " + "not merely that something was wrong", + why(_T17_P_GAP, "price_taxes_profile cannot be used:") + and why(_T17_P_GAP, "leaves mon 00:00-07:00 with no period")) +check("...and it is refused for THAT reason and not as a side effect of " + "something else this fixture happens to break: exactly one problem", + len(_T17_P_GAP) == 1) + +# ⚠️ TWO WRITERS OF ONE QUANTITY, ONE OF WHICH SILENTLY WINS. The table decides +# when it is set, so a scalar left at a NON-DEFAULT value beside it is a number +# the operator typed that changes nothing - on the quantity §11.4 makes hardest +# to notice. +_T17_P_BOTH = validate_startup_config( + base(price_taxes_profile=_T17_OK, price_taxes_eur_kwh=0.1198), -5000, 5000) +print(f" {(_T17_P_BOTH or [''])[0][:120]}") +check("the table AND a non-default price_taxes_eur_kwh is refused, and the " + "refusal says WHICH ONE WINS and what to do - not merely that the two " + "are incompatible", + why(_T17_P_BOTH, "price_taxes_profile is set AND price_taxes_eur_kwh is " + "0.1198") + and why(_T17_P_BOTH, "THE TABLE WINS")) +check("...and the SHIPPED scalar beside the table is NOT refused, or every " + "Walloon site would have to clear a key it never touched. The refusal is " + "about a value the operator chose, and 0.06 is the one they did not", + validate_startup_config(base(price_taxes_profile=_T17_OK, + price_taxes_eur_kwh=0.06), + -5000, 5000) == [] + and len(_T17_P_BOTH) == 1) +check("...and the same non-default scalar WITHOUT the table still starts, " + "which is what makes the line above about the PAIR rather than about " + "0.1198 being disliked", + validate_startup_config(base(price_taxes_eur_kwh=0.1198), + -5000, 5000) == []) + +# ⚠️ THE UNITS RUNG, A SECOND QUANTITY RATHER THAN A RESTATEMENT. Every Belgian +# DSO prints these bands in c EUR/kWh, and the whole table is copied off ONE +# card - so a factor-100 error lands on every row at once and nothing inside +# the table disagrees with itself. +# +# ⚠️ AND THE TWO RUNGS ARE GRADED SEPARATELY, BY THE REASON EACH GIVES, because +# the parser's own +-10 EUR/kWh bound catches SOME of these cards and not +# others - and a check that only ever exercised the loud ones would pass with +# this rung deleted. ORES's 13,27 is refused by the parser; SIBELGA's whole +# card - 9,39 dag against 7,10 nacht, the Brussels DSO - is under 10 in both +# bands and reaches this rung alone. +_T17_ORES_C = ('{"periods":[' + '{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",' + '"eur_per_kwh":13.27},' + '{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",' + '"eur_per_kwh":7.39},' + '{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",' + '"eur_per_kwh":7.39}]}') +_T17_SIB_C = ('{"periods":[' + '{"name":"dag","from":"07:00","to":"22:00","days":"mon-fri",' + '"eur_per_kwh":9.39},' + '{"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri",' + '"eur_per_kwh":7.10},' + '{"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun",' + '"eur_per_kwh":7.10}]}') +_T17_P_ORES = validate_startup_config(base(price_taxes_profile=_T17_ORES_C), + -5000, 5000) +_T17_P_SIB = validate_startup_config(base(price_taxes_profile=_T17_SIB_C), + -5000, 5000) +print(f" ORES : {(_T17_P_ORES or [''])[0][:110]}") +print(f" Sibelga : {(_T17_P_SIB or [''])[0][:110]}") +check("ORES's card typed in CENTS is refused by the PARSER's bound, and the " + "sentence is the parser's - `beyond +-10.0`, naming the row", + why(_T17_P_ORES, "price_taxes_profile cannot be used:") + and why(_T17_P_ORES, "beyond +-10.0")) +check("...and SIBELGA's whole card - 9,39 and 7,10, BOTH under that bound - is " + "refused by THIS rung instead, naming both bands and the factor. Without " + "it a Brussels site would price a 9.39 EUR/kWh levy and start", + why(_T17_P_SIB, "price_taxes_profile carries [7.1, 9.39] EUR/kWh") + and why(_T17_P_SIB, "factor of 100")) +check("...and the two refusals are DIFFERENT sentences, so the rungs cannot " + "mask each other: deleting the units rung leaves the Sibelga line red " + "while the ORES line goes on passing for the parser's reason", + _T17_P_ORES != _T17_P_SIB + and not why(_T17_P_ORES, "factor of 100") + and not why(_T17_P_SIB, "beyond +-10.0")) +check("...and BOTH cards divided by 100 start, which is habit 7: the refusals " + "above are about the units and not about Brussels or Wallonia", + validate_startup_config( + base(price_taxes_profile=_T17_ORES_C.replace("13.27", "0.1327") + .replace("7.39", "0.0739")), -5000, 5000) == [] + and validate_startup_config( + base(price_taxes_profile=_T17_SIB_C.replace("9.39", "0.0939") + .replace("7.10", "0.0710")), -5000, 5000) == []) + + # --------------------------------------------------------------------------- # print() if fails: diff --git a/goodwe_controller/translations/en.yaml b/goodwe_controller/translations/en.yaml index b7655ff..08c345d 100644 --- a/goodwe_controller/translations/en.yaml +++ b/goodwe_controller/translations/en.yaml @@ -156,6 +156,9 @@ configuration: static_profile: name: "Tariff · Fixed or day/night tariff (one line of JSON)" description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative." + price_taxes_profile: + name: "Tariff · Levies and VAT by time of day (one line of JSON)" + description: "Leave this empty unless your network tariff really does charge a different day and night rate - in Flanders it does not, Fluvius distribution is one rate, and the flat figure above is correct. In Wallonia and Brussels it does: ORES charges 13,27 c/kWh by day against 7,39 at night, AIESH 15,17 against 8,21, Sibelga 9,39 against 7,10. Written as a single line of JSON in exactly the same shape as the fixed or day/night tariff above, with the same rule that the periods must cover every day type once with no gap and no overlap. ⚠️ THE NUMBERS HERE ARE THE LEVIES ALONE, NOT A FULL PRICE - the add-on adds them to whatever your price source publishes, exactly as it adds the flat figure. Why it matters: a flat figure is the same in every hour, so it moves every hour equally and cannot change which hour is cheapest - and a night network rate is cheap in the same hours the market is cheap, so flattening it makes the add-on think the day is flatter than it is and turn down battery cycles that would have paid for themselves. Set this or the flat figure above, not both: the add-on refuses to start if the two disagree." price_formula: name: "Tariff · Your own tariff formula (one line of JSON)" description: "For price source custom_formula only: your own contract's two coefficients, written as a single line of JSON. Your supplier's tariff card prints the formula as B x index + A - those two numbers exist only on your card, there is no public index of them anywhere, and you are the only person who can read them. Type what the card prints, IN THE UNITS THE CARD PRINTS: every unit conversion and the VAT step are the add-on's job, because those are the two places a human silently loses a factor of 10, 100 or 1.06. Example: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ THE card BLOCK IS REQUIRED AND IT IS WHAT MAKES THIS SAFE: your card also prints the reference index it assumed and the price that comes out, so the add-on recomputes that price from your numbers and REFUSES TO START if it does not come out, telling you by how much. That catches a wrong unit, VAT left out or counted twice, a transposed digit, and the expensive one - network charges folded into the offset, which the add-on would then bill you for a second time. ⚠️ AND ITS LIMIT, IN THE SAME BREATH: it checks your coefficients against your card, not your card against reality, so two numbers copied off the wrong product agree with each other and pass. Fixed numbers only - no expression of any kind is evaluated, and an unrecognised field is refused rather than ignored." diff --git a/goodwe_controller/translations/nl.yaml b/goodwe_controller/translations/nl.yaml index bbfb001..2861071 100644 --- a/goodwe_controller/translations/nl.yaml +++ b/goodwe_controller/translations/nl.yaml @@ -136,6 +136,9 @@ configuration: static_profile: name: "Tarief · Vast of dag/nacht-tarief (één regel JSON)" description: "Uw vaste of dag/nacht-contract, geschreven als één regel JSON. AANBEVOLEN ZELFS BIJ EEN DYNAMISCH CONTRACT: hierop valt de add-on terug wanneer de prijsfeed uitvalt, en staat dit leeg dan blijft de installatie bij een dode feed helemaal zonder tarief achter. De periodes moeten elk dagtype precies één keer bedekken, zonder gat en zonder overlap - een gat wordt bij het opstarten geweigerd in plaats van opgevuld, want een verzonnen prijs is er één waarop een strategie zou handelen. Een volledig vast tarief is één periode van 00:00 tot 24:00 op alle dagen. De prijzen zijn ALLES INBEGREPEN; de marge en heffingen hierboven worden er niet bij opgeteld. Let op: een venster van 22:00 tot 07:00 voor ma-vr dekt zaterdagochtend NIET, dus een regel voor het weekend is noodzakelijk en niet decoratief." + price_taxes_profile: + name: "Tarief · Heffingen en btw per dagdeel (één regel JSON)" + description: "Laat dit leeg tenzij uw netbeheerder werkelijk een ander dag- en nachttarief aanrekent - in Vlaanderen is dat niet zo, de Fluvius-distributie is één tarief, en het vaste bedrag hierboven klopt. In Wallonië en Brussel is dat wél zo: ORES rekent 13,27 c/kWh overdag tegen 7,39 's nachts, AIESH 15,17 tegen 8,21, Sibelga 9,39 tegen 7,10. Geschreven als één regel JSON in precies dezelfde vorm als het vaste of dag/nacht-tarief hierboven, met dezelfde regel dat de periodes elk dagtype één keer moeten bedekken zonder gat en zonder overlap. ⚠️ DE BEDRAGEN HIER ZIJN ALLEEN DE HEFFINGEN, GEEN VOLLEDIGE PRIJS - de add-on telt ze op bij wat uw prijsbron publiceert, net zoals bij het vaste bedrag. Waarom het uitmaakt: een vast bedrag is in elk uur hetzelfde, verschuift dus elk uur evenveel en kan niet veranderen welk uur het goedkoopste is - en een nachttarief van het net is goedkoop in dezelfde uren waarin de markt goedkoop is, dus door het plat te slaan lijkt de dag vlakker dan hij is en weigert de add-on batterijcycli die zichzelf zouden terugverdienen. Vul dit in óf het vaste bedrag hierboven, niet allebei: de add-on weigert te starten als de twee elkaar tegenspreken." price_formula: name: "Tarief · Uw eigen tariefformule (één regel JSON)" description: "Alleen voor prijsbron custom_formula: de twee coëfficiënten van uw eigen contract, als één regel JSON. De tariefkaart van uw leverancier drukt de formule af als B x index + A - die twee getallen staan alleen op uw kaart, er bestaat nergens een publieke lijst van, en u bent de enige die ze kan lezen. Typ wat de kaart afdrukt, IN DE EENHEID DIE DE KAART GEBRUIKT: elke eenheidsomrekening en de btw-stap zijn het werk van de add-on, want dat zijn precies de twee plaatsen waar een mens stilzwijgend een factor 10, 100 of 1,06 verliest. Voorbeeld: {\"index\":\"epex_be\",\"resolution\":\"hourly\",\"factor\":0.102,\"offset\":1.00,\"units\":\"c_eur_per_kwh\",\"vat_pct\":6,\"card\":{\"month\":\"2026-09\",\"index_eur_per_mwh\":133.9905,\"expect_incl_vat\":15.55}}. ⚠️ HET BLOK card IS VERPLICHT EN MAAKT DIT VEILIG: uw kaart vermeldt ook de referentie-index die ze veronderstelt en de prijs die eruit volgt, dus de add-on berekent die prijs opnieuw uit uw getallen en START NIET als het resultaat niet klopt - met vermelding van het verschil. Dat vangt een verkeerde eenheid, btw die ontbreekt of dubbel geteld is, een omgewisseld cijfer, en de dure fout: nettarieven die in de offset verwerkt zitten en die de add-on er dan nog een tweede keer bovenop zou rekenen. ⚠️ EN DE GRENS DAARVAN, IN DEZELFDE ZIN: ze toetst uw coëfficiënten aan uw kaart, niet uw kaart aan de werkelijkheid - twee getallen van het verkeerde product kloppen onderling en komen er dus door. Alleen vaste getallen: er wordt geen enkele expressie geëvalueerd, en een onbekend veld wordt geweigerd in plaats van genegeerd."