From 610725bed974e80d0fed35b079229ec5f47658eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 13:51:05 +0200 Subject: [PATCH] TARIFF-11: the plan's account of itself, corrected and put on the page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects of one shape - the add-on's report of its own tariff plan was wrong, always in the direction that flattered it - and one check, which is the deliverable. 1. `TariffPlan.words` counted POINTS and called them HOURS. The live add-on logged `dynamic_dayahead from generic_dayahead, 132 h` for 132 quarter-hourly points = 33 hours. Belgium moved to a 15-minute MTU on 2025-10-01, so this was wrong every time it was printed, and NOT by a constant factor: a 40-hour soak measured 1.79x .. 5.33x, because §8.3's carried points are hourly while the firm ones are quarter-hourly. `words` now reports both quantities, plus the firm/carried split and the spacing, in FCST-04's *Price history* wording so the two rows can be compared. 2. §8.3's firm-pair refusal and the flat-tariff refusal did the same, and they reach an operator through `self.arb.explain()`. This is exactly the point<->hour conflation TARIFF-14 removed from `at()`, left standing in the prose. `price_forecast.py`'s §8.3 carry-forward log line carried the third instance - a carried-HOUR count over a POINT count - and DOCS quotes it verbatim, so it is corrected here too. 3. The plan reached no HTTP surface at all. `rows["Tariff"]` was `static_status()`'s 30 s-tick sentence and never saw the provider plan - it read 'no static profile' at all 802 soak samples on a site whose feed was answering. It now carries the plan's shape. ⚠️ The two sources are kept distinguishable: the plan half is stamped with its own age, measured from `tariff_at`, because it can be up to TARIFF_MAX_AGE_S (5400 s) old. FCST-04 pins that row to DOCS as a pair; DOCS moves in this commit. 4. `main.py`'s "if and only if" was false. `cycle()` returns at the input hold before `self.arb = None`, so an armed site read `off (arbitrage_enabled)` - naming an option that is on - and, after one completed cycle, showed a decision up to 15 s stale as a current one. The row now reads the OPTION and separates three facts; `cycle_at` and `arb_at` make "this cycle reached no decision" an observation. THE DELIVERABLE IS THE CHECK. All four survived 29 green suites because nothing graded the strings a human is shown. `test_price.py` section 12 renders through the shipped `status_payload()`, parses each number out of its OWN SLOT and compares it against a figure computed from the fixture's construction parameters - not from the property the row was built out of (habit 4), and not as a substring of a sentence the same file wrote (habit 14). The fixture set is four plan shapes and the check computes and prints the field pairs no fixture separates. The *Arbitrage* row's states are driven through real cycles with a sensor answering None and graded by INEQUALITY. No behaviour changed: no threshold, refusal rung, claim or setpoint moved. MUTATIONS: 16, no survivors, one at +0 B (the risky delta) and red. Two were found by the battery INSIDE THIS CARD'S OWN CHECK: a -190 B survivor whose fixture varied two things at once (habit 4), and a mutation whose red was a TRACEBACK in an f-string operand and therefore named nothing (FCST-06). Both closed; the table is on the PR. All 29 test files green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 90 +++ goodwe_controller/DOCS.md | 77 ++- goodwe_controller/app/main.py | 170 +++++- goodwe_controller/app/price_forecast.py | 14 +- goodwe_controller/app/static_tariff.py | 147 ++++- goodwe_controller/test_price.py | 700 +++++++++++++++++++++++- 6 files changed, 1170 insertions(+), 28 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 7358949..3e31cdf 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -22,6 +22,7 @@ if it has, it needs 0.10.0. Whoever deploys decides. | _this branch_ | TARIFF-14 - the price a trade is decided against is the price of the interval being traded | | _this branch_ | TARIFF-07 - three plugins that never fetched a price are withdrawn, and a site configured on one is told so **⚠️ BACKWARD-INCOMPATIBLE** | | _this branch_ | TARIFF-15 - `custom_formula`: your own tariff card's two coefficients, graded against the card's own printed price | +| _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 | ⚠️ **A CONFIG THAT STARTS ON THE CURRENT VERSION CAN BE REFUSED ON THE NEXT ONE.** TARIFF-07 withdraws `engie_dynamic`, `totalenergies_dynamic` and @@ -57,6 +58,95 @@ 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-11 - the plan's own account of itself, corrected and put on the page + +**Four defects, one shape: every one of them made the add-on look better than +it was.** They shipped together because nothing in this repository graded the +strings a human is shown — every tariff check inspects the `TariffPlan` +*object*, and all four were in the *sentences* built out of it. + +**1. `TariffPlan.words` counted points and called them hours.** The live +add-on logged `dynamic_dayahead from generic_dayahead, 132 h` for **132 +quarter-hourly points = 33 hours**. Belgium moved to a 15-minute MTU on +2025-10-01, so on the real feed this was wrong every time it was printed, and +⚠️ **not by a constant factor**: measured across a 40-hour soak the +overstatement ranged **1.79× to 5.33×**, because §8.3's carried points are +hourly while the firm ones are quarter-hourly and the ratio moves with the mix +through the day. It now reads + +``` +dynamic_dayahead from generic_dayahead, 132 points over 33 h, +132 firm / 0 carried, 900 s spacing +``` + +— the same plan, with each number in its own unit. On the weekday morning that +plan is partly carried and reads `60 points over 33 h, 36 firm / 24 carried, +900+3600 s spacing, provisional` instead, which is the state the soak found an +operator could learn nothing about. The `N points over H h` wording is +FCST-04's *Price history* wording on purpose, so the two rows describing the +same feed can be compared rather than being two conventions on one page. + +**2. The §8.3 and flat-tariff refusals did the same, and they reach an +operator.** `§8.3: only 1 of 96 hours carry a published price` is 96 points, +which is 24 hours; the flat rung counted the same points and called them `firm +hours`. ⚠️ **This is exactly the point≠hour conflation TARIFF-14 removed from +`at()`, left standing in the prose that describes it** — and it is not confined +to a log line, because `self.arb.explain()` puts it on the status page. Both +now print the two quantities separately: `only 1 of 96 points carry a published +price - 1 of 24 hours`. The same conflation in `price_forecast.py`'s §8.3 +carry-forward log line — `%d of %d hours` with a carried-HOUR count over a +POINT count — is corrected in the same commit. + +**3. The plan reached no HTTP surface at all.** `origin`, `product`, the +firm/carried split and the spacing appeared in **no** status row. `rows["Tariff"]` +was `static_status()`'s sentence, computed on the 30 s I/O-free tick from its +own static plan, and it **never saw the provider plan** — it read `'no static +profile'` at all **802** soak samples on a site whose feed was answering. So an +operator could not tell a working dynamic tariff from no tariff machinery at +all, and could not learn that the plan was `provisional` for 76 % of the soak +or mixed 900/3600 s for 70 %. The *Tariff* row now carries the plan. + +⚠️ **The two halves of that row are not computed at the same moment and the row +says so.** `static_status()` runs on the 30 s tick; the provider plan comes off +the 1800 s background refresh and is served for up to `TARIFF_MAX_AGE_S` +(5400 s since TARIFF-12). So the plan half is stamped with **its own age**, +measured from `tariff_at` — which TARIFF-12 deliberately does not move on a +kept plan. A provider site with no usable plan says that too, rather than +falling silently back to the static sentence. + +**4. `main.py`'s "if and only if" was false, and the row named an option that +was on.** TARIFF-10's comment asserted that `self.arb is None` **iff** +`arbitrage.enabled(self.o)` was False. `cycle()` returns at the input hold — +inputs missing, `bad_for < stale_input_s`, 15 s by default — **before** it +reaches `self.arb = None`. Driven: `arbitrage_enabled: true` plus a sensor +answering `None` gives `arbitrage.enabled(opts) = True`, `self.arb = None` and +a row reading `off (arbitrage_enabled)`. Past the first completed cycle the +same early return leaves the **previous** decision standing, which the row +rendered as a current one, up to ~15 s stale. The row now distinguishes three +facts with three remedies — trading is off; trading is on and this cycle +reached no decision; a decision, with its age — and it reads the **option** +rather than inferring it from a missing decision. The comment states what is +true. + +⚠️ **THE DELIVERABLE IS THE CHECK, NOT THE FIXES.** All four survived 29 green +suites because no check in this repository had ever read a rendered row. +`test_price.py` section 12 renders through the shipped `Controller.status_payload()`, +parses each number out of the row **by its own slot**, and compares it against +a figure computed from the fixture's own construction parameters — never from +the property the row was built out of (MUTATION.md habit 4). It is deliberately +**not** a substring test over a sentence the same file wrote: `"132 points" in +row` is satisfied by the number appearing in the *other* number's slot, which +habit 14 shows is a zero-byte edit away. The fixtures are a **set** of four +plan shapes, and the check computes and prints the table of field pairs no +fixture separates — `none` — so a pair that became degenerate would go red +rather than quietly vacuous. The *Arbitrage* row's three states are driven +through real cycles with a sensor answering `None`, and graded by +**inequality**: two facts that must not render identically. + +**Nothing about what the battery decides changes.** No threshold, no refusal +rung, no claim, no setpoint. Every edit is to a sentence, to a derived counter +behind one, or to where a sentence is rendered. + ### TARIFF-15 - your own tariff card, as two coefficients **New price provider `custom_formula`, and one new option, `price_formula`.** diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 8bfad0a..9e7ac6a 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1765,12 +1765,50 @@ hour the feed did not print at all. **What you can actually see on the status page.** *Price history* (below) says what is held. *Arbitrage* carries the strategy's own sentence, which names the -firm/provisional split whenever that split is what refused a trade. *Tariff* -does **not** report the fill: it is the static-profile or Home Assistant -price-entity sentence, regenerated on the 30 s tick. The provider plan's own -line — `dynamic_dayahead from generic_dayahead, 48 h, provisional` — goes to the -**add-on log** at every refresh, alongside `§8.3: 33 of 48 hours carried forward -from yesterday's profile and flagged provisional; 15 published`. +firm/provisional split whenever that split is what refused a trade. And +**Tariff** on the status page reads `provisional` through the morning, because +since TARIFF-11 that row carries the provider plan itself: + +``` +Tariff: no static profile (recomputed this tick); provider plan fetched 412 s + ago for a 48 h horizon: dynamic_dayahead from generic_dayahead, + 60 points over 33 h, 36 firm / 24 carried, 900+3600 s spacing, + provisional +``` + +⚠️ **The two halves of that row are not computed at the same moment, and the +row says so.** Everything before the semicolon is the static-profile or Home +Assistant price-entity sentence, regenerated on the **30 s tick** from options +alone — no network. Everything after it is the **background refresh's** +resolution, which runs every 1800 s and is served for up to 5400 s before it is +retired; `fetched N s ago` is the age of the fetch that produced it and is the +only figure in the row that tells you whether the feed is still answering. On a +site with a provider configured and no usable plan the row says that too, +rather than falling silently back to the static half. + +Read the plan half as five separate facts: which *product* the feed publishes +and which *plugin* read it, how many **points** it carries and how many **whole +hours** those points cover, how many of the points are **firm** (published for +their own interval) against **carried** (filled from yesterday under §8.3), the +**spacing** of the points in seconds — `900+3600` is the normal mixed steady +state, quarter-hourly firm points beside hourly carried ones — and `provisional` +whenever any point is carried. + +⚠️ **Points and hours are two different numbers and this row used to print one +of them under the other's name.** `words` was `f"… {len(points)} h"`, so a +33-hour plan of quarter-hourly points logged `dynamic_dayahead from +generic_dayahead, 132 h`. It is the same conflation the *Price history* row +carried before FCST-04 and the same one `at()` carried before TARIFF-14, and it +is **not a constant factor**: measured over a 40-hour soak the overstatement +ranged **1.79× to 5.33×**, because the carried points are hourly while the firm +ones are quarter-hourly, so the ratio moves with the mix as the day goes on. +The same sentence is in the **add-on log** at every refresh, alongside `§8.3: +33 of 48 hours carried forward from yesterday's profile and flagged +provisional; the feed published 60 points over the other 15 hours`. + +The §8.3 refusal an operator reads when a trade is declined counts in the same +two units — `§8.3: only 1 of 96 points carry a published price - 1 of 24 hours` +— where it used to say `only 1 of 96 hours` about a 24-hour day. The plan is also now cut to the horizon you asked for, which means **hours that have already elapsed are no longer in it**. That is a real change to arbitrage: @@ -1951,6 +1989,33 @@ only thing that decides whether a request leaves the machine is `price_provider`: with it `off`, the refresh resolves the static profile in memory and opens no connection. +**The *Arbitrage* status row says which of three things is true** — trading is +off, trading is on and this cycle reached no decision, or a decision was +reached — and it stamps every decision with its age. Until TARIFF-11 it said +one of two, because the first two rendered identically. They have three +different remedies: + +| the row opens with | what is true | what to do | +|---|---|---| +| **off** — *"arbitrage_enabled is false, so nothing trades"* | Trading is not armed. Nothing will happen, and nothing is wrong | Arm it, if you meant to | +| **ON (arbitrage_enabled), but NO DECISION has been reached yet** | Trading **is** armed, and no cycle has run as far as the arbitrage step. The row then names the control loop's own reason, usually *inputs missing* | Fix the input the loop names | +| **decided N s ago:** … | The strategy answered this cycle, and `N` is how old that answer is | Read the sentence that follows — it is the strategy's own | +| **no decision this cycle (reason); the last one was taken N s ago:** … | Trading is armed, a decision stands from an earlier cycle, and **this** cycle stopped before reaching the step | As row 2 — the standing decision is history, not a verdict on now | + +A fifth rendering — *"…no decision this cycle …, and none stands from an +earlier one"* — covers the case where trading was armed after a cycle had +already run without it. + +⚠️ **The second row used to render exactly like the first one**, naming +`arbitrage_enabled` on a site where `arbitrage_enabled` is `true`. A cycle with +a missing input returns after roughly `stale_input_s` (15 s by default) without +reaching the arbitrage step at all, so on an armed site the row reported *off* +at startup and, once one decision had been taken, went on showing that decision +for up to 15 s after the site stopped deciding anything. The age on the third +row is printed **always**, including when it is zero: a row that reports +staleness only once it is stale cannot be told from one that never reports +it. + ### Site | option | meaning | diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 9a0c00b..ce8e7ca 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -218,6 +218,106 @@ def pack_energy_of(inv): inv.capacity_kwh, inv.max_w) +def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float, + provider: bool, max_age_s: float) -> str: + """The *Tariff* status row: the 30 s tick's sentence AND the provider plan. + + THE DEFECT THIS PREVENTS (TARIFF-11). `origin`, `product`, the horizon, + the firm/carried split and the spacing appeared in NO status row. The row + was `static_status()`'s sentence alone, computed on the 30 s I/O-free tick + from its own static plan, and it NEVER SAW the provider plan - it read + `'no static profile'` at all 802 samples of a 40-hour soak on a site whose + feed was answering. The only external evidence a plan existed at all was + one `_LOG.info` line, and that line carried the point<->hour mislabel + `TariffPlan.words` is fixed for. + + ⚠️ TWO SOURCES, TWO CLOCKS, AND THE ROW SAYS SO IN WORDS. These sentences + are NOT computed at the same moment and presenting them as though they + were would be a new version of the defect being fixed: + + `tick_words` static_status() / the Home Assistant price entities, + regenerated on THIS tick, every 30 s, I/O-free; + `plan` the background refresh's resolution, up to + TARIFF_REFRESH_S old before it is retried and up to + TARIFF_MAX_AGE_S (5400 s since TARIFF-12) old before + `fresh_tariff_plan()` retires it. + + So the plan half is stamped with its OWN age in seconds, measured from + `tariff_at` - the moment of the fetch that produced it, which TARIFF-12 + deliberately does not move on a kept plan. + + ⚠️ AND `age_s` IS THE CALLER'S MEASUREMENT, NOT THIS FUNCTION'S. A clock + read in here would be a second clock disagreeing with the one that decided + the plan was fresh enough to pass at all. + + ⚠️ "NO PLAN" IS SAID OUT LOUD ON A SITE THAT CONFIGURED A PROVIDER. A + dynamic site whose refresh has never completed, or whose plan has aged + past `max_age_s`, is the exact state the soak measured; rendering it as + the bare static sentence is how it stayed invisible for 802 samples. + """ + if plan is None: + if not provider: + return tick_words + return (f"{tick_words} (recomputed this tick); NO provider plan - " + f"none has been resolved yet, or the last one is older than " + f"{max_age_s:.0f} s") + return (f"{tick_words} (recomputed this tick); provider plan fetched " + f"{age_s:.0f} s ago for a {horizon_h:.0f} h horizon: {plan.words}") + + +def arbitrage_row(*, on: bool, decision, decided_at, cycle_at, now, + why: str) -> str: + """The *Arbitrage* status row: THREE states, which used to render as two. + + THE DEFECT THIS PREVENTS (TARIFF-11). The row was + `"off (arbitrage_enabled)" if self.arb is None else self.arb.explain()`, + under a comment asserting that `self.arb is None` if and only if + `arbitrage.enabled(self.o)` was False. IT IS NOT: `cycle()` returns at the + input hold - inputs missing, `bad_for < stale_input_s`, default 15 s - + BEFORE it reaches `self.arb = None`. Driven: `arbitrage_enabled: true` + plus a sensor answering `None` gives `arbitrage.enabled(opts) = True`, + `self.arb = None` (its `__init__` value, untouched) and a row reading + `off (arbitrage_enabled)` - NAMING AN OPTION THAT IS ON. Past the first + completed cycle the same early return leaves the PREVIOUS decision + standing instead, which the row then rendered as a current one. + + THE THREE FACTS, and they are three because they have three remedies: + + trading is off the operator has not armed it. Nothing is + wrong and nothing will happen. + no decision this cycle it is armed, and the cycle did not get as far + as the arbitrage step. `why` is the control + loop's own reason - usually "inputs missing". + a decision the strategy's own sentence, with the age of + the cycle that produced it. + + ⚠️ `on` IS READ FROM THE OPTIONS, NOT INFERRED FROM `decision is None`. + Inferring it is the whole defect: the option is the only thing that + answers "is trading armed", and it is available on every render. + + ⚠️ THE AGE IS ALWAYS PRINTED, INCLUDING WHEN IT IS ZERO. A row that + reports staleness only once it is stale cannot be told, on any single + render, from one that never reports it - and the bound TARIFF-10's + comment claimed did not exist is ~15 s of it. + """ + if not on: + return "off - `arbitrage_enabled` is false, so nothing trades" + if decided_at is None: + return (f"ON (`arbitrage_enabled`), but NO DECISION has been reached " + f"yet - no cycle has run as far as the arbitrage step. The " + f"loop's own reason: {why or 'none recorded'}") + age = (now - decided_at).total_seconds() + stale = cycle_at is not None and decided_at < cycle_at + head = (f"no decision this cycle ({why or 'no reason recorded'}); the " + f"last one was taken {age:.0f} s ago: " if stale else + f"decided {age:.0f} s ago: ") + if decision is None: + return (f"ON (`arbitrage_enabled`), no decision this cycle " + f"({why or 'no reason recorded'}), and none stands from an " + f"earlier one") + return head + decision.explain() + + class Controller: def __init__(self, opts: dict, hass: HomeAssistant, store, mqtt_pub, *, stop, peak_db_path: str = "/data/tariff.db", @@ -796,6 +896,19 @@ class Controller: self.tariff_plan = None self.tariff_at = None self.arb = None + # TARIFF-11. WHEN the standing arbitrage decision was taken, or None if + # this controller has never reached the arbitrage step. ⚠️ `self.arb is + # None` DOES NOT MEAN "TRADING IS OFF" AND NEVER DID - see + # `arbitrage_row()`. It also means "cycle() returned before the step + # ran", and at that point `self.arb` is either this attribute's initial + # None (startup) or the PREVIOUS cycle's decision (an input hold), and + # the status row rendered all three identically. + self.arb_at = None + # TARIFF-11. When the most recent cycle() STARTED. `arb_at < cycle_at` + # is the exact, derived statement of "the latest cycle did not get as + # far as the arbitrage step", which is the fact the *Arbitrage* row + # used to render identically to "trading is switched off". + self.cycle_at = None self._arb_action = None self.inputs_bad_since = None # SAFETY-03. What the loop's OWN bounds did last cycle - a @@ -1565,6 +1678,24 @@ class Controller: self.cheap, self.tariff_words = static_status( o, datetime.now(timezone.utc)) + # ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND + # THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the + # 30 s I/O-free tick; `fresh_tariff_plan()` is an attribute read and an + # age comparison against TARIFF_MAX_AGE_S - still no fetch on this path, + # which is the rule the whole branch above exists to keep. What the row + # gains is the plan's SHAPE (origin, product, horizon, the firm/carried + # split, the spacing) and what it must never gain is the suggestion + # that the two halves were computed at the same instant, so the plan + # half carries its own age in seconds. See `tariff_plan_row()`. + _tick = datetime.now(timezone.utc) + _standing = self.fresh_tariff_plan(_tick) + self.tariff_words = tariff_plan_row( + _standing, self.tariff_words, + age_s=(None if _standing is None + else (_tick - self.tariff_at).total_seconds()), + horizon_h=float(price_option(o, "price_horizon_h")), + provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S) + if self.inputs_ok: if self.inputs_bad_since is not None: self.log_event("inputs recovered") @@ -1918,6 +2049,11 @@ class Controller: method's only job is to state honestly what each strategy wants. """ now = datetime.now(timezone.utc) + # ⚠️ BEFORE THE FIRST `return` OF THIS METHOD, ON PURPOSE (TARIFF-11). + # It stamps that a cycle RAN, which is a different fact from any of the + # decisions the cycle may or may not reach, and the status row needs + # the two separately. + self.cycle_at = now # ⚠️ EACH PACK ON ITS OWN READING, NOT "the SoC". §12's recalibration # drains and refills ONE pack and grades ONE coulomb counter, so each @@ -2577,7 +2713,13 @@ class Controller: # `frozenset()` would be main.py asserting a forecast is healthy when # there is not one, which is exactly what static_tariff's `uncertainty` # argument has no default in order to prevent. - self.arb = None + # ⚠️ TARIFF-11: BOTH ATTRIBUTES, AND `arb_at` IS WHAT MAKES THE ROW + # HONEST. Reaching this line is the only proof that this cycle got as + # far as the arbitrage step; the early return at the input hold above + # leaves BOTH standing at whatever the last completed cycle left, and + # `arb_at` is then the age the status row reports rather than a + # decision presented as current. + self.arb, self.arb_at = None, now if arbitrage.enabled(self.o): floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) charge_w, discharge_w = self.trade_power_w(floor_pct, ceiling_pct) @@ -4481,15 +4623,23 @@ class Controller: # every refusal it can make already carries a reason a human can # act on, and "Arbitrage: no" would throw all of them away. # - # ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE, which is worth - # saying because that card is about the two switches being - # different. `self.arb` is None if and only if - # `arbitrage.enabled(self.o)` was False, so `off (arbitrage_enabled)` - # still names the option that is actually off - and on a site with a - # provider configured it is now the row that says the prices - # arriving on the *Price history* row above are moving nothing. - ("Arbitrage", "off (arbitrage_enabled)" if self.arb is None - else self.arb.explain()), + # ⚠️ TARIFF-10 RE-READ THIS STRING AND LEFT IT ALONE UNDER A + # COMMENT THAT WAS FALSE, AND TARIFF-11 IS THAT CORRECTION. The + # claim was that `self.arb is None` **if and only if** + # `arbitrage.enabled(self.o)` was False. Only the forward direction + # holds: `cycle()` returns at the input hold (`bad_for < + # stale_input_s`, default 15 s) BEFORE it reaches `self.arb = None` + # at all, so on an armed site `self.arb` is `__init__`'s None at + # startup and the PREVIOUS cycle's decision during a later hold - + # and the row rendered `off (arbitrage_enabled)` on a site whose + # `arbitrage_enabled` is `true`. `arbitrage_row()` reads the option + # instead of inferring it, and separates "trading is off" from "no + # decision was reached this cycle"; `cycle_at` and `arb_at` are what + # make the second one an observation rather than a guess. + ("Arbitrage", arbitrage_row( + on=arbitrage.enabled(self.o), decision=self.arb, + decided_at=self.arb_at, cycle_at=self.cycle_at, + now=datetime.now(timezone.utc), why=self.reason)), ] return { "banner": banner, "level": level, "rows": rows, "checks": checks, diff --git a/goodwe_controller/app/price_forecast.py b/goodwe_controller/app/price_forecast.py index 86c1592..97fc265 100644 --- a/goodwe_controller/app/price_forecast.py +++ b/goodwe_controller/app/price_forecast.py @@ -422,9 +422,19 @@ def fill_from_yesterday(read: PriceRead, *, yesterday: PriceRead, _audit(out, published) carried = sum(1 for p in out if _utc_hour(p.start) not in published) if carried: + # ⚠️ THREE QUANTITIES, THREE NAMES (TARIFF-11). This line read + # "%d of %d hours ... ; %d published" with `carried` (a count of + # carried HOURS - the fill emits exactly one point per unpublished + # hour) over `len(out)` (a count of POINTS, four per hour wherever the + # feed printed) and `len(out) - carried` (POINTS again) under the same + # noun. On the Belgian quarter-hourly feed that reads "33 of 141 + # hours" about a 48-hour horizon. The fill's unit is the HOUR and the + # feed's is the POINT; nothing derived from one answers for the other. + total_h = len({_utc_hour(p.start) for p in out}) _LOG.info("§8.3: %d of %d hours carried forward from yesterday's " - "profile and flagged provisional; %d published", - carried, len(out), len(out) - carried) + "profile and flagged provisional; the feed published %d " + "points over the other %d hours", + carried, total_h, len(out) - carried, total_h - carried) product = read.match(series=lambda r: r.product, no_data=lambda _r: yesterday.product) diff --git a/goodwe_controller/app/static_tariff.py b/goodwe_controller/app/static_tariff.py index 974e7bf..bee53d8 100644 --- a/goodwe_controller/app/static_tariff.py +++ b/goodwe_controller/app/static_tariff.py @@ -771,6 +771,63 @@ class TariffPlan: series=lambda r: any(p.provisional for p in r.points), no_data=lambda _r: True) + # -- the plan's SHAPE, in the two units it actually has (TARIFF-11) ------ + # ⚠️ POINTS AND HOURS ARE TWO NUMBERS AND THIS PLAN HAD ONLY EVER + # REPORTED ONE OF THEM, UNDER THE OTHER'S NAME. `words` read + # `f"... {len(self.read.points)} h"`, which counts POINTS: on the + # quarter-hourly Belgian feed (15-minute MTU, 2025-10-01) a 33-hour plan + # logged `dynamic_dayahead from generic_dayahead, 132 h`. It is the same + # conflation TARIFF-14 removed from `at()` and FCST-04 removed from the + # *Price history* row, left standing in the prose. ⚠️ AND THE ERROR IS + # NOT A FLAT 4x: a 40 h soak measured 1.79x .. 5.33x, because §8.3's + # carried points are HOURLY while the firm ones are quarter-hourly, so + # the ratio moves with the firm/carried mix as the day goes on. Nothing + # derived from ONE of these numbers can answer for the other. + + @property + def n_points(self) -> int: + """How many points the series carries. A `no_data` read carries none.""" + return self.read.match(series=lambda r: len(r.points), + no_data=lambda _r: 0) + + @property + def hours(self) -> int: + """How many distinct WHOLE CLOCK HOURS those points touch. + + Same rule as `price_history._held()`, deliberately - the *Tariff* and + *Price history* rows describe the same feed and an operator reading + `132 points over 33 h` beside `96 points over 24 h` must be able to + compare them. A point is attributed to the UTC hour its own start + falls in; hours the feed never printed are simply not in the set, so + this counts what is PRICED rather than what was asked for. + """ + return self.read.match(series=lambda r: _whole_hours(r.points), + no_data=lambda _r: 0) + + @property + def n_firm(self) -> int: + """Points carrying a published price for their own interval (§8.3).""" + return len(self.firm_points()) + + @property + def n_carried(self) -> int: + """Points filled from yesterday's profile, or from §11.4's fallback.""" + return self.n_points - self.n_firm + + @property + def spacings_s(self) -> tuple: + """The distinct gaps between consecutive points, ascending, in seconds. + + ⚠️ MIXED IS THE NORMAL STEADY STATE, NOT AN EDGE CASE. FCST-04's + carry-forward emits HOURLY carried points beside QUARTER-HOURLY firm + ones and the soak measured `[900, 3600]` in 70 % of samples - so a + surface reporting one global spacing would be wrong about the usual + case. `price._cover_table()` is where that rule is enforced; this + only reports it. + """ + return self.read.match( + series=lambda r: _spacings(r.points), no_data=lambda _r: ()) + def price_at(self, instant: datetime) -> float: """EUR/kWh now. Raises `Absence` when there is no price - never 0.0.""" return self.read.match( @@ -926,20 +983,30 @@ class TariffPlan: pts = self.read.points firm = tuple(p for p in pts if not p.provisional) if len(firm) < 2: + # ⚠️ POINTS, AND THE HOUR FIGURE BESIDE IT RATHER THAN INSTEAD OF + # IT (TARIFF-11). This sentence read "only 1 of 96 hours carry a + # published price" about 96 quarter-hourly points, which are 24 + # hours - the point<->hour conflation TARIFF-14 removed from + # `at()`, still standing here, and this one reaches an OPERATOR + # through main.py's *Arbitrage* row and not only the log. return Arbitrage.refuse( - f"§8.3: only {len(firm)} of {len(pts)} hours carry a published " - f"price - the rest are provisional (origin {self.origin!r}), " - f"and no irreversible action may be taken on a provisional " - f"price. A buy hour and a sell hour must BOTH be firm before a " - f"cycle can be committed. Load shifting, self-consumption and " - f"peak shaving continue") + f"§8.3: only {len(firm)} of {len(pts)} points carry a " + f"published price - {_whole_hours(firm)} of " + f"{_whole_hours(pts)} hours - and the rest are provisional " + f"(origin {self.origin!r}), so no irreversible action may be " + f"taken on them. A buy interval and a sell interval must BOTH " + f"be firm before a cycle can be committed. Load shifting, " + f"self-consumption and peak shaving continue") spread = round(max(p.eur_per_kwh for p in firm) - min(p.eur_per_kwh for p in firm), 6) if spread == 0.0: + # ⚠️ POINTS AND HOURS, SEPARATELY, for the §8.3 rung's reason - + # this sentence counted points and called them "firm hours" too. return Arbitrage.refuse( - f"the tariff is flat - every one of the {len(firm)} firm hours " - f"costs {firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and " + f"the tariff is flat - every one of the {len(firm)} firm " + f"points, spanning {_whole_hours(firm)} hours, costs " + f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and " f"selling high is arithmetically impossible. §9.2: a fully " f"fixed product is self-consumption only") @@ -969,15 +1036,77 @@ class TariffPlan: @property def words(self) -> str: + """The plan's whole shape in one sentence - LOG *AND* STATUS PAGE. + + ⚠️ THIS STRING IS OPERATOR-FACING SINCE TARIFF-11, which is a reason + for its wording to matter MORE rather than less. It used to reach only + `_LOG.info("tariff refreshed: %s")`; main.py's *Tariff* row now carries + it, age-stamped, so every number in it is read by somebody who cannot + see the object. + + ⚠️ EVERY QUANTITY IS NAMED IN ITS OWN UNIT. `{n} points over {h} h` is + FCST-04's *Price history* wording on purpose - the two rows describe + the same feed and have to be comparable - and the firm/carried split + and the spacing are here because they were the two facts the soak + found an operator could not learn at all: the plan was `provisional` + for 76 % of a 40-hour soak and mixed 900/3600 s for 70 %, and no HTTP + surface said either. + """ if not self.has_prices: return f"no tariff - {self.read.reason}" tag = {"provider": self.read.provider, "static": "static_profile", "fallback": "static_profile (feed down)"}.get(self.origin, self.origin) - return (f"{self.product.value} from {tag}, {len(self.read.points)} h" + gaps = self.spacings_s + return (f"{self.product.value} from {tag}, " + f"{self.n_points} points over {self.hours} h, " + f"{self.n_firm} firm / {self.n_carried} carried" + + (f", {'+'.join(str(g) for g in gaps)} s spacing" + if gaps else "") + (", provisional" if self.provisional else "")) +def _whole_hours(points) -> int: + """How many distinct whole CLOCK HOURS a point list touches. Never raises. + + THE DEFECT THIS PREVENTS (TARIFF-11). Three sentences in this module + reported `len(points)` under the name "hours": `TariffPlan.words`, §8.3's + firm-pair refusal and the flat-tariff refusal. On the hourly series that + was the only resolution any fixture carried before TARIFF-08 the two + numbers are identical, which is why it shipped; on the Belgian + quarter-hourly feed (15-minute MTU, 2025-10-01) `words` read + `dynamic_dayahead from generic_dayahead, 132 h` for 33 hours and the + refusal read `only 1 of 96 hours` for 24. + + ⚠️ IT IS NOT `n / 4`, AND A CONSTANT FACTOR IS THE WRONG FIX. §8.3's + carry-forward emits ONE HOURLY point per unpublished hour beside the + feed's four-per-hour firm ones, so the measured overstatement over a + 40-hour soak ranged 1.79x .. 5.33x as the mix moved. The hour count has + to be COUNTED. + + ⚠️ IT COUNTS PRICED HOURS, NOT THE HORIZON. FCST-04 leaves an hour out + of the series when neither the feed nor yesterday can price it, so this + is at most the horizon and is smaller whenever the plan has holes - which + is the honest direction for a figure an operator reads as "how far ahead + do I have prices". + """ + return len({p.start.astimezone(timezone.utc) + .replace(minute=0, second=0, microsecond=0) for p in points}) + + +def _spacings(points) -> tuple: + """Distinct gaps between consecutive points, ascending, in whole seconds. + + Sorted by START, not by list order: `price._cover_table()` establishes + that order for `at()` rather than assuming it, and a surface that reported + the gaps of an unsorted list would print negative spacings about a series + that answers correctly. + """ + starts = sorted(p.start.astimezone(timezone.utc) for p in points) + return tuple(sorted({int((b - a).total_seconds()) + for a, b in zip(starts, starts[1:])})) + + def _absent(reason): raise Absence( f"this plan observed no prices ({reason}). A number invented here is " diff --git a/goodwe_controller/test_price.py b/goodwe_controller/test_price.py index 89d0a45..0829e39 100644 --- a/goodwe_controller/test_price.py +++ b/goodwe_controller/test_price.py @@ -51,6 +51,7 @@ import io import json import logging import os +import re import shutil import sys import tempfile @@ -79,7 +80,7 @@ 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 +from app.static_tariff import TariffPlan, static_status from app.validate import validate_startup_config fails = [] @@ -3767,6 +3768,703 @@ check("the id is offered by config.yaml's schema, so a site configured on it " "reaches these messages rather than a Supervisor schema error", "custom_formula" in _schema_ids) + +# =========================================================================== # +# 12. TARIFF-11 - THE RENDERED SURFACE, GRADED AS A SURFACE +# =========================================================================== # +# +# WHAT THIS SECTION IS FOR, and it is the only thing on the card that is not a +# one-line fix. Four defects shipped together and every one of them survived 29 +# green suites for the SAME reason: **nothing in this repository graded the +# strings a human is shown.** Every existing tariff check inspects the +# `TariffPlan` OBJECT - `plan.read.points`, `plan.firm_points()`, `verdict.kind` +# - and all four defects were in the SENTENCES built out of that object: +# +# 1. `TariffPlan.words` counted POINTS and called them HOURS, so a 33-hour +# plan logged `dynamic_dayahead from generic_dayahead, 132 h`; +# 2. §8.3's and the flat rung's refusals did the same, and those reach an +# operator through main.py's *Arbitrage* row, not only the log; +# 3. the plan reached NO status row at all - `rows["Tariff"]` was +# `static_status()`'s own sentence and read `'no static profile'` at all +# 802 samples of a 40-hour soak on a site whose feed was answering; +# 4. `rows["Arbitrage"]` rendered "trading is off" and "no decision was +# reached this cycle" as the SAME string, naming an option that was on. +# +# ⚠️ SO THIS SECTION GRADES `status_payload()["rows"]` AND THE REFUSAL +# SENTENCES, NOT THE OBJECTS BEHIND THEM. It renders through the shipped +# `Controller`, parses each number OUT OF THE ROW BY ITS OWN SLOT, and compares +# it against a figure this file computed from the FIXTURE'S CONSTRUCTION +# PARAMETERS - never from the property the row was built out of. That is +# MUTATION.md habit 4: if both sides of a comparison come from the same +# expression, the check is decoration. +# +# ⚠️ AND IT IS DELIBERATELY NOT A SUBSTRING TEST OVER A SENTENCE THIS FILE ALSO +# WROTE. Three checks in this chain turned out to be graded by something they +# did not own - a docstring, a deleted plugin's text, the HEADING above a table +# whose ROWS had been emptied (MUTATION.md, TARIFF-15 M13). `"132 points" in +# row` is that mistake again: it is satisfied by the number appearing anywhere, +# including in the other number's slot, which habit 14 shows is a ZERO-BYTE +# edit away. Every NUMBER below is asserted as a parsed slot against an integer +# derived here; every other claim is an INEQUALITY between two renderings that +# must not be equal. The ONE substring test is the rung-attribution needle +# (`"the tariff is flat"`, `"carry a published price"`) that TEST-16 already +# owns and grades on - it asks WHICH RUNG spoke, not what this file wrote. + +_T11_NOW = datetime(2026, 1, 14, 0, 0, tzinfo=timezone.utc) +_T11_BASE = dict(_T10_BASE, meter_entity="sensor.grid") + + +class _T11Hass(_T10Hass): + """`_T10Hass`, with ONE reading switchable to `None`. + + ⚠️ THE `None` IS THE WHOLE STIMULUS FOR DEFECT 4. `inputs_ok` is False the + moment any pack's SoC does not answer, and `cycle()` then returns at the + input hold (`bad_for < stale_input_s`, 15 s by default) BEFORE it reaches + `self.arb = None`. A fixture whose sensors always answer cannot reach the + row this section is about. + """ + + def __init__(self, soc=55.0): + self.soc = soc + + async def number(self, entity, invert=False): + return {"sensor.grid": 0.0, "sensor.soc": self.soc, + "sensor.batt": 0.0}.get(entity) + + +def _t11_points(firm_h: int, carried_h: int, base=None) -> list: + """`firm_h` QUARTER-HOURLY firm hours, then `carried_h` HOURLY carried ones. + + FCST-04's real mixed shape, and the reason a single spacing is the wrong + model: the feed prints four points an hour and §8.3's carry-forward emits + exactly ONE point per unpublished hour, so a normal weekday-morning plan is + 900 s at the front and 3600 s at the back. The soak measured that mix in + 70 % of samples. + + ⚠️ THE PRICES ASCEND AND THE TWO BANDS DO NOT OVERLAP, so no check below + can pass by reading the wrong point: a mutation that grades a carried point + as firm moves a price as well as a count. + """ + t0 = base or _T11_NOW + pts = [PricePoint(t0 + timedelta(hours=h, minutes=15 * q), + round(0.100 + 0.001 * (4 * h + q), 5), False) + for h in range(firm_h) for q in range(4)] + pts += [PricePoint(t0 + timedelta(hours=firm_h + h), + round(0.500 + 0.001 * h, 5), True) + for h in range(carried_h)] + return pts + + +def _t11_plan(firm_h, carried_h, *, origin="provider", + provider="generic_dayahead", product=PriceProduct.DAY_AHEAD): + return TariffPlan( + PriceRead.series(_t11_points(firm_h, carried_h), product, provider, + _T11_NOW), None, origin) + + +def _t11_want(firm_h: int, carried_h: int) -> dict: + """What a row about `_t11_points(firm_h, carried_h)` MUST say. + + ⚠️ COMPUTED FROM THE FIXTURE'S TWO CONSTRUCTION PARAMETERS AND FROM + NOTHING ELSE (habit 4). Not `len(plan.read.points)`, not `plan.hours`, not + `len(plan.firm_points())` - those are the expressions the row is built out + of, and a check that compares a rendered number against the property that + rendered it compares a thing against itself. `4 * firm_h` and + `firm_h + carried_h` are this file's own arithmetic over the shape it + asked for. + + The spacing rule, stated rather than re-counted: consecutive quarters are + 900 s apart, the last quarter of the last firm hour is 900 s before the + first carried hour, and consecutive carried hours are 3600 s apart - so + 3600 appears only when there are at least TWO carried hours. + """ + gaps = ([900] if firm_h else []) + ([3600] if carried_h >= 2 else []) + return {"points": 4 * firm_h + carried_h, "hours": firm_h + carried_h, + "firm": 4 * firm_h, "carried": carried_h, + "spacings": gaps or [3600]} + + +class _NoRows(dict): + """What a failed render hands back: a dict of `_Raised`, never a crash. + + ⚠️ FCST-06's LESSON, IN THE OPERAND RATHER THAN IN THE CONDITION. A + mutation that makes a render raise must report as ONE named red per check, + not as a traceback that takes the rest of the section with it and grades + none of it - a red COUNT is only a lower bound until the run is seen to + reach the end of its section. + """ + + def __init__(self, e): + super().__init__() + self.e = e + + def __missing__(self, key): + return _Raised(self.e) + + +class _NoCtl: + """A controller a failed render never built. Every attribute is `_Raised`.""" + + def __init__(self, e): + self._e = e + + def __getattr__(self, name): + return _Raised(self._e) + + +def _t11_render(over=None, *, plan=None, plan_age_s=0.0, soc=55.0, cycles=1, + age_arb_by_s=None, then_soc_none=False): + """Drive the SHIPPED controller and hand back what a human would read. + + Returns `(rows, ctl)` - `rows` is `dict(status_payload()["rows"])`, i.e. + the actual HTTP surface, and `ctl` is there so a check can ask the loop for + a figure to grade the row AGAINST (its own `reason`, its own `arb_at`). + + ⚠️ `read_inputs()` THEN `cycle()`, IN THAT ORDER, because that is what + `run_control()` does. A harness that called only `cycle()` would leave + every reading at `None` and grade an input-hold row it had manufactured + itself. + """ + opts = dict(_T11_BASE, **(over or {})) + tmp = tempfile.mkdtemp(prefix="tariff11-") + _tmpdirs_t10.append(tmp) + + async def _go(): + stop = asyncio.Event() + ctl = _main.Controller(opts, _T11Hass(soc), _T10Store(), _T10Pub(), + stop=stop, + peak_db_path=os.path.join(tmp, "t11.db"), + price_history_path=os.path.join(tmp, "ph.json")) + if plan is not None: + ctl.tariff_plan = plan + ctl.tariff_at = (datetime.now(timezone.utc) + - timedelta(seconds=plan_age_s)) + for _ in range(cycles): + await ctl.read_inputs() + await ctl.cycle() + if then_soc_none: + # ⚠️ THE REAL STIMULUS FOR THE SECOND HALF OF DEFECT 4, and it is + # a control-flow one: a healthy cycle takes a decision, the SoC + # then stops answering, and the NEXT cycle returns at the input + # hold before it reaches the arbitrage step at all. `self.arb` + # still holds the previous decision - the row rendered it as a + # current one, with nothing saying the site had stopped deciding. + ctl.hass.soc = None + await ctl.read_inputs() + await ctl.cycle() + if age_arb_by_s is not None and ctl.arb_at is not None: + # ⚠️ THE CLOCK IS MOVED, NOT SLEPT THROUGH. The property under test + # is "the row reports the age of the decision", and a real 15 s + # wait would put a quarter of a minute into every run for a number + # the check can set exactly. + ctl.arb_at -= timedelta(seconds=age_arb_by_s) + return dict(ctl.status_payload()["rows"]), ctl + + try: + return asyncio.run(_go()) + except Exception as e: # noqa: BLE001 + return _NoRows(e), _NoCtl(e) + + +# -- 12a. the instrument first: a parser that can tell right from wrong ------ +# ⚠️ CALIBRATED BEFORE ANYTHING IS GRADED WITH IT (habit 1, and MUTATION.md's +# eleventh instance of habit 4 was in a measuring instrument rather than in a +# check). A slot parser that returns `None` for everything makes every row +# below vacuous; one that matches a number wherever it appears reads a +# transposition as a pass. So it is driven against a row that is RIGHT and a +# row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are +# asserted. +_T11_SLOTS = { + "points": r"(\d+) points over \d+ h", + "hours": r"\d+ points over (\d+) h", + "firm": r"(\d+) firm / \d+ carried", + "carried": r"\d+ firm / (\d+) carried", + "horizon": r"for a (\d+) h horizon", + "age_s": r"fetched (\d+) s ago", + "spacings": r"([\d+]+) s spacing", + # ⚠️ THE *Arbitrage* ROW'S OWN AGE SLOT, and it is deliberately the same + # pattern for "decided N s ago" and "the last one was taken N s ago" - + # WHICH of the two sentences was rendered is graded by inequality below, + # and this only has to read the number out of whichever one it is. + "arb_age_s": r"(\d+) s ago", + # ⚠️ THE REFUSAL SENTENCES GET SLOTS TOO, for the rows' reason: `"1 of 96" + # in reason` is satisfied by that pair standing in the HOUR clause, which + # is the transposition the defect actually was. + "r83_firm_pts": r"only (\d+) of \d+ points carry a published price", + "r83_all_pts": r"only \d+ of (\d+) points carry a published price", + "r83_firm_h": r"published price - (\d+) of \d+ hours", + "r83_all_h": r"published price - \d+ of (\d+) hours", + "flat_pts": r"every one of the (\d+) firm points, spanning \d+ hours", + "flat_h": r"every one of the \d+ firm points, spanning (\d+) hours", +} + + +def _slot(row, name): + """The number in ONE NAMED SLOT of a rendered row, or `_Raised`. + + ⚠️ BY SLOT, NEVER BY PRESENCE. `"33" in row` is true of a row that prints + 33 in the POINT slot, which is a zero-byte transposition away and is + exactly MUTATION.md habit 14's finding. A slot that does not match is a + FAILED derivation and reports as one; it is not a miss that passes. + """ + def _get(): + if not isinstance(row, str): + raise TypeError(f"no row to read: {row!r}") + m = re.search(_T11_SLOTS[name], row) + if m is None: + raise ValueError(f"no {name!r} slot in {row[:90]!r}") + raw = m.group(1) + return ([int(x) for x in raw.split("+")] if name == "spacings" + else int(raw)) + return derived(_get) + + +_T11_GOOD = ("x (recomputed this tick); provider plan fetched 7 s ago for a " + "48 h horizon: dynamic_dayahead from generic_dayahead, " + "132 points over 33 h, 36 firm / 96 carried, 900+3600 s spacing") +_T11_SHIPPED_DEFECT = ("x (recomputed this tick); provider plan fetched 7 s " + "ago for a 48 h horizon: dynamic_dayahead from " + "generic_dayahead, 132 points over 132 h, " + "96 firm / 36 carried, 900+3600 s spacing") +# The slots divide into the ones a *Tariff*/​*Arbitrage* ROW carries and the +# ones a REFUSAL sentence carries; each group is calibrated against its own +# well-formed example, because a slot asked of the wrong kind of sentence is +# correctly absent and would make this check red for the wrong reason. +_T11_ROW_SLOTS = ("points", "hours", "firm", "carried", "horizon", "age_s", + "spacings", "arb_age_s") +_T11_REF_SLOTS = ("r83_firm_pts", "r83_all_pts", "r83_firm_h", "r83_all_h", + "flat_pts", "flat_h") +_T11_GOOD_REF = ("§8.3: only 1 of 96 points carry a published price - 1 of 24 " + "hours - and the rest are provisional; the tariff is flat - " + "every one of the 96 firm points, spanning 24 hours, costs") +print(f" slot parser on a correct row -> " + f"{ {k: _slot(_T11_GOOD, k) for k in _T11_ROW_SLOTS} }") +print(f" slot parser on a refusal -> " + f"{ {k: _slot(_T11_GOOD_REF, k) for k in _T11_REF_SLOTS} }") +check("the slot parser reads every field of a well-formed row and of a " + "well-formed refusal - at a single unreadable slot every check below " + "this line would be graded on a failed derivation rather than on the " + "sentence", + all(not isinstance(_slot(_T11_GOOD, k), _Raised) for k in _T11_ROW_SLOTS) + and all(not isinstance(_slot(_T11_GOOD_REF, k), _Raised) + for k in _T11_REF_SLOTS) + and sorted(_T11_ROW_SLOTS + _T11_REF_SLOTS) == sorted(_T11_SLOTS) + and _slot(_T11_GOOD, "points") == 132 + and _slot(_T11_GOOD, "hours") == 33) +check("...and it TELLS THE TWO DEFECTS APART from the correct row: the shipped " + "point<->hour conflation (132 h for 33 h) and a firm/carried " + "transposition both move a slot, so the parser is not merely finding " + "numbers somewhere in a sentence", + _slot(_T11_SHIPPED_DEFECT, "hours") == 132 + and _slot(_T11_SHIPPED_DEFECT, "firm") == 96 + and _slot(_T11_SHIPPED_DEFECT, "points") + == _slot(_T11_GOOD, "points")) +check("...and a missing slot is a FAILED derivation, not a silent pass - the " + "difference between 'the row does not say that' and 'the row says " + "nothing', which is why `derived()` exists in this file", + isinstance(_slot("nothing useful here", "points"), _Raised)) + + +# -- 12b. the fixture SET, and the degeneracy it is built to avoid ------------ +# ⚠️ ONE ARRANGEMENT CANNOT SEPARATE FIVE NUMBERS (habit 10). On the shipped +# healthy plan - an all-firm quarter-hourly day - `points` and `firm` are the +# SAME INTEGER and `carried` is 0, so a row that printed the point count in the +# firm slot would pass. On an hourly static profile `points` and `hours` are +# the same integer, which is precisely why the defect shipped: every fixture +# that existed before TARIFF-08 was hourly. So the fixtures are a SET, and the +# table below is COMPUTED and PRINTED - a pair of fields no row separates shows +# up as a named survivor and takes this check red, rather than being absent +# from a list nobody re-derived. +_T11_SHAPES = ( + ("all-firm quarter-hourly, the live healthy plan", 33, 0), + ("mixed: 9 published hours, 24 carried - the weekday morning", 9, 24), + ("§11.4 fallback: nothing published, 12 hourly carried", 0, 12), + ("late morning: 6 published hours, 3 carried", 6, 3), +) +_T11_FIELDS = ("points", "hours", "firm", "carried", "horizon") +_T11_TABLE = [(lbl, dict(_t11_want(f, c), horizon=48)) + for lbl, f, c in _T11_SHAPES] +print(" fixture set (points / hours / firm / carried / horizon):") +for _lbl, _w in _T11_TABLE: + print(f" {_w['points']:>4} {_w['hours']:>4} {_w['firm']:>4} " + f"{_w['carried']:>4} {_w['horizon']:>4} {_lbl}") +_T11_UNSEP = sorted( + f"{a}=={b}" for i, a in enumerate(_T11_FIELDS) for b in _T11_FIELDS[i + 1:] + if not any(w[a] != w[b] for _lbl, w in _T11_TABLE)) +print(f" field pairs NO fixture separates: {_T11_UNSEP or 'none'}") +check(f"every pair of the five numbers the *Tariff* row prints is told apart " + f"by at least one fixture - unseparated pairs {_T11_UNSEP}. A pair that " + f"is equal in every fixture is one the checks below cannot tell " + f"transposed, which is a ZERO-BYTE edit (habit 14)", + _T11_UNSEP == []) +check("...and the set contains BOTH the degenerate hourly arrangement (points " + "== hours, where the defect was invisible for three cards) and the " + "quarter-hourly one where they differ 4x, so the row is pinned on the " + "shape that hid it as well as on the shape that exposes it", + any(w["points"] == w["hours"] for _l, w in _T11_TABLE) + and any(w["points"] == 4 * w["hours"] for _l, w in _T11_TABLE)) + + +# -- 12c. THE *Tariff* ROW ITSELF --------------------------------------------- +_T11_ROWS = [(lbl, f, c, + derived(lambda f=f, c=c: _t11_render( + {"price_provider": "generic_dayahead"}, + plan=_t11_plan(f, c))[0]["Tariff"])) + for lbl, f, c in _T11_SHAPES] +for _lbl, _f, _c, _row in _T11_ROWS: + print(f" Tariff row | {_lbl}") + print(f" {_row}") + +_T11_BAD = [f"{lbl}: {k} slot reads {_slot(row, k)!r}, fixture says " + f"{_t11_want(f, c)[k]!r}" + for lbl, f, c, row in _T11_ROWS + for k in ("points", "hours", "firm", "carried", "spacings") + if _slot(row, k) != _t11_want(f, c)[k]] +check(f"AC: EVERY number the rendered *Tariff* row prints matches what this " + f"file computed from the fixture's own two parameters, in its OWN slot, " + f"across all four shapes - disagreements {_T11_BAD}. This is the check " + f"that did not exist: `words` said `132 h` about 33 hours for three " + f"cards and no suite could see it, because no suite read the sentence", + _T11_BAD == []) +check(f"...and the HORIZON reaches the row as hours, from the option rather " + f"than from the plan - " + f"{[_slot(r, 'horizon') for _l, _f, _c, r in _T11_ROWS]} against " + f"`price_horizon_h` = " + f"{price_option(dict(_T11_BASE), 'price_horizon_h')}. The soak measured " + f"48.0 at every sample and `words` never said 48", + all(_slot(r, "horizon") + == int(float(price_option(dict(_T11_BASE), "price_horizon_h"))) + for _l, _f, _c, r in _T11_ROWS)) + +# ⚠️ ORIGIN AND PRODUCT ARE GRADED BY DISAGREEMENT, NOT BY A SUBSTRING. Both +# are enum-ish labels, so `"provider" in row` is satisfied by the word +# "provider" this row already contains twice for other reasons. Two plans over +# the IDENTICAL point list, differing in exactly one of the two fields, must +# render differently - which is false of any row that drops the field. +# ⚠️ AND THE FIXTURE VARIES `origin` **ALONE**. The first draft of this pair +# gave the fallback plan `provider="static_profile"` as well - which is what a +# real §11.4 plan carries - so the two rows differed for TWO reasons and the +# mutation that drops `origin` from the sentence entirely (tag <- read.provider, +# -190 B) was a SURVIVOR of the whole suite. That is MUTATION.md habit 4 in the +# check written to close this card: a difference-claim whose two sides differ in +# more than the thing it names cannot grade that thing. The provider string is +# held EQUAL below, as its own precondition check. +_T11_ID_PTS = (9, 24) +_T11_PLAN_PROV = _t11_plan(*_T11_ID_PTS, origin="provider") +_T11_PLAN_FALL = _t11_plan(*_T11_ID_PTS, origin="fallback") +_T11_AS_PROVIDER = derived(lambda: _t11_render( + {"price_provider": "generic_dayahead"}, + plan=_T11_PLAN_PROV)[0]["Tariff"]) +_T11_AS_FALLBACK = derived(lambda: _t11_render( + {"price_provider": "generic_dayahead"}, + plan=_T11_PLAN_FALL)[0]["Tariff"]) +check(f"the origin pair differs in `origin` AND IN NOTHING ELSE (habit 1): " + f"provider strings {_T11_PLAN_PROV.read.provider!r} == " + f"{_T11_PLAN_FALL.read.provider!r}, products equal, point lists equal, " + f"origins {_T11_PLAN_PROV.origin!r} != {_T11_PLAN_FALL.origin!r}. Give " + f"the fallback its own provider string and the row below differs for a " + f"second reason, which is how the -190 B mutation survived the first " + f"draft", + _T11_PLAN_PROV.read.provider == _T11_PLAN_FALL.read.provider + and _T11_PLAN_PROV.product == _T11_PLAN_FALL.product + and _T11_PLAN_PROV.origin != _T11_PLAN_FALL.origin + and [p.canonical() for p in _T11_PLAN_PROV.read.points] + == [p.canonical() for p in _T11_PLAN_FALL.read.points]) +_T11_AS_REALTIME = derived(lambda: _t11_render( + {"price_provider": "generic_dayahead"}, + plan=_t11_plan(*_T11_ID_PTS, product=PriceProduct.REALTIME))[0]["Tariff"]) +print(f" origin='provider' -> {_T11_AS_PROVIDER[-88:]!r}") +print(f" origin='fallback' -> {_T11_AS_FALLBACK[-88:]!r}") +print(f" product=REALTIME -> {_T11_AS_REALTIME[-88:]!r}") +check("AC: the plan's ORIGIN reaches the row - two plans over the IDENTICAL " + "point list, one observed from the feed and one §11.4 standing in for a " + "dead one, render DIFFERENTLY. Every count in them is equal, so a row " + "that dropped `origin` could not tell an operator that the prices on " + "screen are the static profile rather than the market", + _T11_AS_PROVIDER != _T11_AS_FALLBACK + and all(_slot(_T11_AS_PROVIDER, k) == _slot(_T11_AS_FALLBACK, k) + for k in ("points", "hours", "firm", "carried"))) +check("...and so does the PRODUCT, on the same terms - §9.2's product is what " + "the optimiser widens its thresholds on, and it was on no surface", + _T11_AS_REALTIME != _T11_AS_PROVIDER + and all(_slot(_T11_AS_REALTIME, k) == _slot(_T11_AS_PROVIDER, k) + for k in ("points", "hours", "firm", "carried"))) + +# ⚠️ THE TWO TARIFF SOURCES ARE NOT COMPUTED AT THE SAME MOMENT AND THE ROW +# MAY NOT IMPLY THEY WERE. `static_status()` runs on the 30 s I/O-free tick; +# the provider plan comes off the 1800 s refresh and is served for up to +# TARIFF_MAX_AGE_S - 5400 s since TARIFF-12. So the plan half carries its own +# age, and the age is graded against a number this file SET rather than +# against one the row supplied. +_T11_FRESH = derived(lambda: _t11_render( + {"price_provider": "generic_dayahead"}, + plan=_t11_plan(9, 24), plan_age_s=0.0)[0]["Tariff"]) +_T11_OLD = derived(lambda: _t11_render( + {"price_provider": "generic_dayahead"}, + plan=_t11_plan(9, 24), plan_age_s=4000.0)[0]["Tariff"]) +print(f" plan 0 s old -> age slot {_slot(_T11_FRESH, 'age_s')!r}") +print(f" plan 4000 s old -> age slot {_slot(_T11_OLD, 'age_s')!r}") +check(f"AC/trap: the row does NOT present the 30 s tick's sentence and a " + f"4000-second-old plan as one measurement - the plan half is stamped " + f"with its own age, and the age MOVES with `tariff_at`: " + f"{_slot(_T11_FRESH, 'age_s')} s against {_slot(_T11_OLD, 'age_s')} s " + f"for the SAME plan object. A constant, or an age taken from the render " + f"clock, gives the same number twice", + isinstance(_slot(_T11_OLD, "age_s"), int) + and 4000 <= _slot(_T11_OLD, "age_s") <= 4010 + and _slot(_T11_FRESH, "age_s") <= 5) +check(f"...and the two halves are still BOTH there, in order: the tick's " + f"static sentence for this provider-only site - " + f"{static_status(dict(_T11_BASE), _T11_NOW)[1]!r} - leads, and the plan " + f"follows it. Replacing the row wholesale would lose the cheap-window " + f"evidence TARIFF-03 put there", + _T11_FRESH.startswith(static_status(dict(_T11_BASE), _T11_NOW)[1])) + +# ⚠️ AND THE STATE THE SOAK ACTUALLY MEASURED: a provider configured, and no +# plan. 802 samples of `'no static profile'`, which is the row a site with no +# provider prints too - so the surface could not tell "trading on a live feed" +# from "no tariff machinery at all". +_T11_NOPLAN = derived(lambda: _t11_render( + {"price_provider": "generic_dayahead"}, plan=None)[0]["Tariff"]) +_T11_NOPROV = derived(lambda: _t11_render({}, plan=None)[0]["Tariff"]) +print(f" provider set, no plan -> {_T11_NOPLAN!r}") +print(f" no provider at all -> {_T11_NOPROV!r}") +check(f"AC: a site with a `price_provider` and NO usable plan says so, and " + f"reads differently from a site that configured no provider at all. " + f"Both printed {_T11_NOPROV!r} before this card - all 802 soak samples, " + f"on an install whose feed was answering", + _T11_NOPLAN != _T11_NOPROV + and str(int(_main.TARIFF_MAX_AGE_S)) in _T11_NOPLAN) + + +# -- 12d. THE *Arbitrage* ROW: THREE FACTS THAT RENDERED AS TWO --------------- +# ⚠️ THE STIMULUS IS A DRIVEN CYCLE WITH A SENSOR ANSWERING `None`, not a +# hand-set attribute. The defect is a control-flow one - `cycle()` returns at +# the input hold BEFORE `self.arb = None` - and a fixture that assigned +# `ctl.arb = None` itself would have reproduced the symptom without ever +# touching the path that causes it. +_T11_OFF = derived(lambda: _t11_render({}, soc=None)) +_T11_ON_HELD = derived(lambda: _t11_render({"arbitrage_enabled": True}, + soc=None)) +_T11_ON_OK = derived(lambda: _t11_render({"arbitrage_enabled": True}, + soc=55.0)) +_T11_ON_STALE = derived(lambda: _t11_render({"arbitrage_enabled": True}, + soc=55.0, age_arb_by_s=13)) +_T11_ON_DROPPED = derived(lambda: _t11_render({"arbitrage_enabled": True}, + soc=55.0, then_soc_none=True)) +print(f" trading off, inputs missing -> {_T11_OFF[0]['Arbitrage']!r}") +print(f" trading ON, inputs missing -> {_T11_ON_HELD[0]['Arbitrage']!r}") +print(f" trading ON, inputs fine -> {_T11_ON_OK[0]['Arbitrage']!r}") +print(f" trading ON, decision 13 s old-> {_T11_ON_STALE[0]['Arbitrage']!r}") +print(f" trading ON, SoC then dropped -> {_T11_ON_DROPPED[0]['Arbitrage']!r}") + +check(f"the stimulus is the one the card names, and it is asserted rather " + f"than assumed (habit 1): `arbitrage.enabled(opts)` is " + f"{_arb.enabled(_T11_ON_HELD[1].o)} while `controller.arb` is " + f"{_T11_ON_HELD[1].arb!r}. If a later edit makes `cycle()` reach the " + f"arbitrage step under an input hold, this goes red rather than the " + f"rows below going quietly vacuous", + _arb.enabled(_T11_ON_HELD[1].o) is True + and _T11_ON_HELD[1].arb is None + and not _T11_ON_HELD[1].inputs_ok) +check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on " + f"and no decision was reached this cycle' RENDER DIFFERENTLY. They are " + f"different facts with different remedies and this row printed " + f"`off (arbitrage_enabled)` for both - naming an option that is `true` " + f"in the options blob", + _T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"]) +check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON " + f"({_T11_ON_HELD[1].reason!r}), so the operator is told which input is " + f"missing rather than being sent to look at a switch that is already on. " + f"The reason comes from main.py, not from this file", + _T11_ON_HELD[1].reason + and _T11_ON_HELD[1].reason in _T11_ON_HELD[0]["Arbitrage"]) +check(f"...and a cycle that DID reach the step renders the strategy's own " + f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a " + f"re-derivation of it, which is the guarantee OPT-04 put on this row and " + f"the one thing about it that was already right", + _T11_ON_OK[1].arb is not None + and _T11_ON_OK[1].arb.explain() in _T11_ON_OK[0]["Arbitrage"] + and _T11_ON_OK[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"]) +check(f"...and all THREE states are pairwise distinct, so no pair of them can " + f"be collapsed by a later edit without a named red here", + len({_T11_OFF[0]["Arbitrage"], _T11_ON_HELD[0]["Arbitrage"], + _T11_ON_OK[0]["Arbitrage"]}) == 3) +_T11_AGE = _slot(_T11_ON_STALE[0]["Arbitrage"], "arb_age_s") +check(f"AC: a decision the CURRENT cycle did not take is rendered as one - " + f"age slot {_T11_AGE} s against the 13 s this file moved `arb_at` back " + f"by. The card's second half: during an input hold the row went on " + f"showing the last decision, up to `stale_input_s` (15 s) stale, with " + f"nothing on the page saying so", + isinstance(_T11_AGE, int) and 13 <= _T11_AGE <= 18 + and _T11_ON_STALE[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"]) +# ⚠️ AND THE SAME CLAIM ON THE STIMULUS THAT ACTUALLY PRODUCES IT. The row +# above moves `arb_at` by hand so the AGE can be graded against an exact +# number; this one drives the control-flow path itself - one healthy cycle, +# then a sensor that stops answering - and asserts that the shipped controller +# reaches the state the card describes, rather than that this file can simulate +# it. Without this row a mutation that never sets `cycle_at` would still pass, +# because the hand-aged fixture would go on rendering `stale`. +# ⚠️ `derived()`, NOT A BARE COMPARISON, AND THE BATTERY IS WHY. The mutation +# that never stamps `cycle_at` leaves it at `__init__`'s None, and +# `datetime < None` is a TypeError - in the f-string OPERAND as much as in the +# condition. Driven, the first draft of this check produced a TRACEBACK and +# NO NAMED RED, grading none of the checks after it: FCST-06's finding, in the +# check written for this card. "It answered nothing" and "it broke on the way +# to answering" must not grade the same. +_T11_DROP_STALE = derived( + lambda: _T11_ON_DROPPED[1].arb_at < _T11_ON_DROPPED[1].cycle_at) +check(f"DRIVEN, no clock touched: a decision stands " + f"({_T11_ON_DROPPED[1].arb is not None}) while the LATEST cycle returned " + f"at the input hold without reaching the arbitrage step " + f"(arb_at < cycle_at = {_T11_DROP_STALE!r}), and the row says so instead " + f"of presenting the standing decision as this cycle's", + _T11_ON_DROPPED[1].arb is not None + and not _T11_ON_DROPPED[1].inputs_ok + and _T11_DROP_STALE is True + and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"]) +check(f"...and it names the reason the cycle stopped " + f"({_T11_ON_DROPPED[1].reason!r}) beside the age of the decision it is " + f"still showing, so 'the strategy decided this' and 'the strategy last " + f"decided this' are not one sentence", + _T11_ON_DROPPED[1].reason in _T11_ON_DROPPED[0]["Arbitrage"] + and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int)) + +check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than " + "as prose: a comment is not a check, and three findings in this chain " + "were graded by a sentence their author also wrote. What makes the " + "comment true is that the row reads the OPTION - so with the option on " + "and no decision taken, the row does not equal the option-off row, which " + "is the row above", + _arb.enabled(_T11_ON_HELD[1].o) + and _T11_ON_HELD[1].arb is None + and _T11_ON_HELD[0]["Arbitrage"] != _T11_OFF[0]["Arbitrage"]) + + +# -- 12e. the two REFUSAL sentences, which reach this same row ---------------- +# ⚠️ THESE ARE OPERATOR-FACING THROUGH `self.arb.explain()`, WHICH IS WHY THEY +# ARE HERE AND NOT ONLY IN test_static_tariff.py. `§8.3: only 1 of 96 hours +# carry a published price` is 96 POINTS, which is 24 hours - the same +# conflation TARIFF-14 removed from `at()`, left standing in the prose that +# describes it. +_T11_A9 = {"peak_guard_active": False, "min_spread_eur_kwh": 0.0, + "uncertainty": frozenset()} +_T11_ONEFIRM = TariffPlan( + PriceRead.series( + [PricePoint(_T11_NOW + timedelta(minutes=15 * i), + round(0.10 + 0.001 * i, 5), i != 0) for i in range(96)], + PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider") +_T11_FLAT = TariffPlan( + PriceRead.series( + [PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False) + for i in range(96)], + PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider") +_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason) +_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason) +print(f" §8.3 refusal : {_r83[:118]}") +print(f" flat refusal : {_rflat[:118]}") +# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them. +# Both figures are this file's arithmetic over the fixture it built. +# The fixture is 96 points at 15-minute spacing, built four lines above, so it +# spans 96 // 4 = 24 hours and its single firm point occupies ONE of them. +# Both expectations are this file's arithmetic over the shape it asked for. +_T11_R83 = {k: _slot(_r83, k) for k in + ("r83_firm_pts", "r83_all_pts", "r83_firm_h", "r83_all_h")} +print(f" §8.3 slots : {_T11_R83}") +print(f" flat slots : " + f"{ {k: _slot(_rflat, k) for k in ('flat_pts', 'flat_h')} }") +check(f"AC: §8.3's refusal reports POINTS and HOURS as two quantities, each in " + f"its OWN slot - {_T11_R83} over a fixture of 96 quarter-hourly points " + f"spanning 24 hours, of which one is firm. It read `only 1 of 96 hours` " + f"about a 24-hour day, and this sentence reaches the status page", + _T11_R83["r83_firm_pts"] == 1 and _T11_R83["r83_all_pts"] == 96 + and _T11_R83["r83_firm_h"] == 1 and _T11_R83["r83_all_h"] == 96 // 4) +check(f"...and the two clauses are not the same pair twice: the POINT clause " + f"and the HOUR clause disagree, which is exactly what the shipped " + f"sentence could not express", + _T11_R83["r83_all_pts"] != _T11_R83["r83_all_h"]) +check(f"...and the flat-tariff refusal does the same - it counted the same 96 " + f"points and called them `firm hours`: slots " + f"{_slot(_rflat, 'flat_pts')} points, {_slot(_rflat, 'flat_h')} hours", + _slot(_rflat, "flat_pts") == 96 + and _slot(_rflat, "flat_h") == 96 // 4) +check("...and neither sentence lost what TEST-16 put in it: §8.3's refusal is " + "still attributable to the FIRM-PAIR rung by its own words, and does not " + "degrade into the flat rung's sentence about a series whose prices " + "differ by 0.095 EUR/kWh", + "§8.3" in _r83 and "carry a published price" in _r83 + and "the tariff is flat" not in _r83 + and "the tariff is flat" in _rflat) + + +# -- 12f. END TO END, against the COMMITTED capture -------------------------- +# ⚠️ EVERY FIXTURE ABOVE IS SYNTHETIC, AND habit 19 IS WHY THIS ROW EXISTS. +# The shapes above are this file's own arithmetic; they cannot tell whether the +# SHIPPED refresh puts a real plan on the row. This drives `run_tariff_refresh` +# against `scenarios/energy-charts-be-price-*.json` - bytes captured from +# api.energy-charts.info - and grades the row against the CAPTURE'S OWN point +# count, which neither main.py nor static_tariff.py shaped. +_T11_N_CAP = len(_CAPTURE["price"]) + + +def _t11_e2e(): + tmp = tempfile.mkdtemp(prefix="tariff11-e2e-") + _tmpdirs_t10.append(tmp) + opts = dict(_T11_BASE, price_provider="generic_dayahead") + + async def _get(url, timeout_s): + return _restamped_body(datetime.now(timezone.utc)) + + real = _price.https_get + _price.https_get = _get + try: + async def _go(): + stop = asyncio.Event() + ctl = _main.Controller(opts, _T11Hass(), _T10Store(), _T10Pub(), + stop=stop, + peak_db_path=os.path.join(tmp, "e.db"), + price_history_path=os.path.join(tmp, "p.json")) + task = asyncio.create_task(ctl.run_tariff_refresh()) + for _ in range(250): # ~5 s ceiling, habit 5 + if ctl.tariff_at is not None: + break + await asyncio.sleep(0.02) + ctl.stopping = True + stop.set() + await asyncio.wait_for(task, timeout=5) + await ctl.read_inputs() + await ctl.cycle() + return dict(ctl.status_payload()["rows"]) + return asyncio.run(_go()) + finally: + _price.https_get = real + + +_T11_E2E = derived(_t11_e2e) +_T11_E2E_ROW = "" if isinstance(_T11_E2E, _Raised) else _T11_E2E["Tariff"] +print(f" end to end, {_T11_N_CAP} captured prices at 900 s:") +print(f" {_T11_E2E_ROW}") +check(f"AC, DRIVEN THROUGH THE SHIPPED REFRESH: the *Tariff* row reports the " + f"{_T11_N_CAP} points the COMMITTED capture publishes, over " + f"{_T11_N_CAP // 4} hours, on a quarter-hourly feed. Both numbers come " + f"from the captured document's own length - not from the plan the code " + f"under test built out of it (habit 19)", + _slot(_T11_E2E_ROW, "points") == _T11_N_CAP + and _slot(_T11_E2E_ROW, "hours") == _T11_N_CAP // 4 + and _slot(_T11_E2E_ROW, "points") != _slot(_T11_E2E_ROW, "hours")) +check(f"...and the row an operator reads names the SPACING that makes those " + f"two numbers differ - {_slot(_T11_E2E_ROW, 'spacings')!r} s. The plan " + f"carried mixed 900/3600 s for 70 % of a 40-hour soak and no surface " + f"said so", + _slot(_T11_E2E_ROW, "spacings") == [900]) +check("...and this is the same wording FCST-04's *Price history* row already " + "used, so the two rows describing the same feed can be compared rather " + "than being two conventions on one page", + "points over" in _T11_E2E_ROW + and "points over" in ("" if isinstance(_T11_E2E, _Raised) + else _T11_E2E["Price history"])) + + print() if fails: print(f"{len(fails)} FAILED: " + "; ".join(fails)) -- 2.54.0