Compare commits

..
5 Commits
Author SHA1 Message Date
admin a11597e0b3 Merge remote-tracking branch 'origin/release/1.0' into TARIFF-11
checks / unit (pull_request) Successful in 7m20s
# Conflicts:
#	goodwe_controller/CHANGELOG.md
2026-09-13 13:51:35 +02:00
adminandClaude Opus 5 610725bed9 TARIFF-11: the plan's account of itself, corrected and put on the page
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 13:51:05 +02:00
admin b4a3801f68 Merge pull request 'TARIFF-13: the count of what was measured is as durable as the measurement' (#83) from TARIFF-13 into release/1.0
A 40-hour soak drove a restart and caught the capacity-tariff row asserting '0 quarter(s) banked' for a month in which 120 quarters had been measured. The peak and its attributed quarter persisted; the count did not. It is the count, not the peak, that tells an operator how much of the billing month the measurement actually covers - a peak of 365 W over 120 quarters and the same peak over 0 quarters are very different claims about the same number, and after a restart the page made the weaker claim while displaying the stronger one's value.

Reproduced through a real Controller and P1Ingest with a real restart, never by reading code: 12 -> 0 with the store still holding 12 rows. Fixed durable rather than removed - the count is the useful half of the row. PeakStore.banked_count(month) queries quarter_blocks and `recorded` became a property over it. After the fix the printed count matches the store on both sides, 12 -> 13, and 13 is CORRECT: the restart's restore() banks the stale open block, which the old process-wide counter never saw.

TWO DEFECTS THE TICKET DID NOT NAME, both closed by the same change: the count was never month-scoped, so a fresh October reported a quarter banked from September; and it missed blocks banked by restore(). An unreadable store now returns None and renders 'an unreadable number of quarter(s) banked' - rendering it as 0 was rejected because it reads as a real zero, which is TARIFF-01's own disarming-value argument applied to the coverage figure beside the peak.

THE TICKET'S SECOND FINDING WAS FALSE AND THE AGENT DISPROVED IT. I wrote that ties at the same wattage are re-attributed to the most recent matching quarter. The upsert is strictly '>', so an exact tie never writes at all - six exactly-equal 365.0 W quarters re-attributed zero times. The soak's drift was a real new maximum below the row's whole-watt display resolution: 365.0000001 W is a new peak to the database and the same '365 W' to the reader. A plausible diagnosis, checked rather than implemented.

Tie attribution is nevertheless decided deliberately - EARLIEST wins - on what the row is for rather than on money: wattage is what is billed, and an operator investigating one event needs a stable answer. It is also what recompute_month() already did, and the two readers genuinely diverged: fed newest-first, monthly_peak answered 09:45 and the recompute 09:00, breaking the invariant part 8 exists to prove. peak_w is untouched and SAFETY-07 reads identical watts.

Evidence: 29/29 green. Eleven mutations, all red, purged before each with the fingerprint verified after every revert. One was a REAL SURVIVOR at -3 B: banked_count() has two None paths and the first battery reached only one, because a never-opened handle short-circuits before any SQL. Closed by dropping the table out from under an OPEN store, behind a precondition asserting the same call answered 4 first.

main.py is untouched - the row picks the fix up unedited because `recorded` is now a property. The residual is named rather than hidden: `recorded` answers for now() while the peak follows checks(when=...). Production has exactly one call site, self.checks() with no argument, where both are the same month, so the live page is correct and durable; they part only under the suite's when= affordance, and that mismatch is strictly smaller than the process-wide counter it replaces. recorded_in(when) is shipped and the row's move onto it is one token.
2026-09-13 13:03:30 +02:00
glenn schrooyenandClaude Opus 5 a991438e40 TARIFF-13: the coverage figure beside the peak stops resetting at a restart
checks / unit (pull_request) Successful in 7m27s
The capacity-tariff status row printed a peak read from SQLite next to a count
of banked quarters read from PROCESS MEMORY - `PeakTracker.recorded`, an `int`
set to 0 in `__init__` and counted up inside `sync()`. A 40-hour soak caught
what that means at a driven restart, on both instances independently:

  before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
          - 10 % of the 3500 W soft limit; 120 quarter(s) banked
  after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
          - 10 % of the 3500 W soft limit;   0 quarter(s) banked

Reproduced here before anything changed, through the real Controller and a real
restart - a second Controller object against the same file on disk - at 12 -> 0
with 12 rows standing in `quarter_blocks`.

The peak is durable and was never wrong. The COUNT is what says how much of the
billing month that peak covers, and 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same number. Mid-month, after every
restart, the page made the weaker claim while displaying the stronger one's
value. That is TARIFF-01's own argument - an unreadable store must not read as a
zero peak, because a zero peak is disarming - one column over.

`PeakStore.banked_count(month)` asks the database, and `recorded` is a property
over it. Two more defects in the same variable fell out once it was driven, and
neither was in the ticket: it was never scoped to a MONTH (on 1 October the row
still showed September's tally beside October's empty month - measured), and it
missed the block `restore()` banks from the checkpoint, which never passes
through `sync()`.

⚠️ NOT 0, AND NOT None, WHEN THE STORE WILL NOT ANSWER. `banked_count()` returns
None and the row renders `an unreadable number of quarter(s) banked`.
`f"{None} quarter(s) banked"` was rejected because it reads as the same zero.
`0 quarter(s) banked` stays true and reachable - it is what a month nothing has
been written to says, and the two are asserted to be different text.

⚠️ THE DEDUPE IS NOT REMOVED WITH THE COUNTER. `_seen` still stops `cycle()`'s
once-a-second `sync()` re-banking a block `P1Ingest.blocks` keeps for a day. The
check that graded it asserted `recorded == 1` - the dedupe and the figure
reporting it were the same variable - and now grades `sync()`'s RETURN, because
counting rows cannot see a re-bank at all (PRIMARY KEY no-op).

THE TIE RULE, DECIDED RATHER THAN EMERGENT: at equal wattage the EARLIEST
quarter keeps the attribution. No bill and no guard moves - `peak_w` is still
only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts.
The two readers DID disagree: the upsert's strict `>` kept whichever tied block
was WRITTEN first while `recompute_month()` has always kept the earliest, the
same only while blocks arrive in time order, and measured to diverge (09:45
against 09:00) fed newest-first.

⚠️ AND THE SOAK'S DRIFTING QUARTER WAS NOT A TIE. Six exactly equal 365.0 W
quarters re-attributed zero times on the shipped tree - `>` is strict. What
moved it was a real new maximum BELOW THE ROW'S OWN RESOLUTION: the row renders
whole watts, so 365.0000001 W is a new peak to the database and the same 365 W
to the reader. Left alone deliberately, and pinned so it is not re-diagnosed.

test_tariff.py parts 11 and 12. 12 mutations, all red, listed on the PR; M2 -
`banked_count()`'s sqlite3.Error arm returning 0 - was a SURVIVOR at -3 B on the
first battery, because a handle that never opened short-circuits before any SQL
runs and nothing reached the except arm. Closed with an open store whose table
is dropped under it, with a precondition that the same call answered 4 first.

main.py is NOT touched - TARIFF-11 holds its status rows. The row picks this up
without editing because `recorded` is an attribute access and is now a property.
The residual is stated in DECISIONS §3: `recorded` answers for now() while the
peak follows `checks(when=...)`, and the shipped install has exactly one call
site - `self.checks()` with no argument, main.py:4359 - where they are the same
month, so the live page is correct and durable. `recorded_in(when)` is shipped
and the row's move onto it is one token, for whoever holds main.py next; part 10
pins the present behaviour against a row count taken from `quarter_blocks` so
the gap cannot be quietly forgotten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-13 12:54:15 +02:00
admin 068b83f57c Merge pull request 'TARIFF-15: custom_formula, graded against the card it was copied from' (#82) from TARIFF-15 into release/1.0
The owner asked for a custom formula for exotic suppliers. TARIFF-07's research established why it is the only shape that can work: there is no machine-readable source of Belgian tariff coefficients anywhere - CREG's tariff-card pages 404, VREG was unreachable, and every coefficient found came from a supplier's own PDF, one image-only. Coefficients are per-contract and live behind a klantenzone login. So no scraping and no per-supplier plugin can work; the customer is the only party who can see their own numbers, and the card they read them from is the only artefact that can grade them.

The shape is the market's published arithmetic rather than our approximation: both readable cards print the formula, the reference index assumed, AND the resulting price. Through shipped code, Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.54705 against a card printing 15,55; ENGIE (0.1 x 61.96 + 0.9) x 1.06 = 7.52176 against 7,522.

That is also the safety mechanism. validate.py recomputes the card's own worked example at startup and REFUSES TO START unless it matches to 0.01. An offset inflated by network charges cannot pass, because the card's printed estimate is by construction the energy component alone. The refusal names how far out, in which direction, by what ratio, and then names the expensive cause: network charges folded into `offset` bills you for it twice, which is why the check cannot be switched off.

NO EXPRESSION EVALUATION, guaranteed three structural ways - coefficients must be numbers, an unknown field is refused rather than ignored, and there is no evaluator in the module - and checked by an AST walk rather than a grep, because the module's own docstring contains the text eval(s, {"__builtins__": {}}) explaining why that is not a sandbox, and a text scan would fire on the documentation it mandates. That is code_leaks()'s lesson applied to a second file. A mutation plants a real eval in the shipped module and turns it red.

TWO CORRECTIONS TO THE TICKET, both the agent's and both right. The plausibility band's floor was specified on the all-in figure; the shipped 0.06 of taxes is 3x the 0.02 floor, so a hundredfold error (0.0016 -> 0.0616) sails over it. The floor now sits on the ENERGY figure and the ceiling on the all-in. And the ticket claimed the card check catches 10x/100x/1000x errors: it catches a MISMATCH between the two sides, and a unit error applied to both cancels out of a comparison between them. The wording was narrowed everywhere rather than restated, and the band added as a separate refusal on a separate quantity.

Stated limits rather than glossed: the check grades the coefficients against the card, not the card against reality - a consistent-but-wrong pair, both numbers from the wrong product, passes, and DOCS says so in the same sentence as the claim. Only TWO real tariffs could be reproduced end to end, not three: the repo's third real formula (Eneco injection) has no printed price beside it, so there is nothing to grade it against and none was fabricated.

Evidence: 29/29 green, 347 checks in test_price.py. 22 mutations, no survivors. One was a -13 B SURVIVOR on the first attempt - the check graded the preview's heading rather than the row, the third instance this week of a check anchored to something it does not own - and is now graded on a rendered row built from the capture. One mutation passes the all-in check by declaring ALL_IN, exactly as trap 1 says no code can catch; what goes red is the declaration-agreement check plus the card reproductions.

The riskiest edit is flagged rather than buried: test_price.py's summary block had to move, because a section appended after `if fails: sys.exit(1)` would have left every new check's failure unreported with exit 0. Verified on the base that sections 13 and 14 were always before it, so no earlier evidence is affected.
2026-09-13 03:58:46 +02:00
9 changed files with 1929 additions and 45 deletions
+141
View File
@@ -22,6 +22,8 @@ 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-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 |
⚠️ **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 +59,145 @@ 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-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
database instead of from process memory.** It was an `int` on `PeakTracker`,
starting at 0 and counted up inside `sync()`, printed next to a peak read from
SQLite. A 40-hour soak caught what that means at a restart, on both instances:
```
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
```
The peak is durable and was never wrong. The **count** is what says how much of
the billing month that peak covers, and 365 W over 120 quarters and 365 W over
0 quarters are different claims about the same number — so mid-month, after
every restart, the page made the weaker claim while showing the stronger one's
value. TARIFF-01's rule that an unreadable store must not read as a zero peak,
one column over. Two more defects in the same variable, neither in the ticket:
it was **never scoped to a month** (on 1 October the row still showed
September's tally beside October's empty month), and it **missed the block
`restore()` banks** from the checkpoint at startup.
⚠️ **AN UNREADABLE STORE NOW SAYS SO IN THAT CLAUSE.** Where the database will
not answer a count, the row reads `an unreadable number of quarter(s) banked`.
`0` was never available; `None` was rejected too, because `None quarter(s)
banked` reads as the same zero. `0 quarter(s) banked` stays true and reachable —
it is what a month nothing has been written to says.
⚠️ **AND A TIED PEAK'S ATTRIBUTED QUARTER IS NOW DECIDED: the EARLIEST wins.**
The wattage is what the utility bills, so this changes no bill and no guard —
`peak_w` is still only ever raised and SAFETY-07 reads the same watts. What it
changes is the answer to "when did my monthly peak happen", which an operator
goes and investigates; it now stops moving. The two readers of that record
**did** disagree: the `monthly_peak` upsert's strict `>` kept whichever tied
block was *written* first, while `recompute_month()` has always kept the
earliest — the same by accident only while blocks arrive in time order, and
measured to diverge (09:45 against 09:00) when they do not.
⚠️ **THE SOAK'S DRIFTING QUARTER WAS NOT A TIE, AND IS LEFT ALONE.** Driven,
exactly equal quarters never re-attributed at all. What moved the timestamp was
a genuine new maximum **below the row's whole-watt resolution** — 365.0000001 W
is a new peak to the database and the same `365 W` to the reader. That is the
instrument being right, and printing more decimals to explain it would put
noise in the one row an operator reads when the guard engages.
Reasoning in `DECISIONS/TARIFF-13-the-coverage-of-a-peak.md`; parts 11 and 12
of `test_tariff.py` drive a real restart — a second `Controller` against the
same file — and pin both decisions.
### 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`.**
@@ -0,0 +1,225 @@
# TARIFF-13 — the coverage of a peak, and which quarter gets the credit
Two decisions. §1 is the defect the ticket was raised on; §2 is the smaller
question it asked to be settled deliberately rather than left as an accident.
§3 is what this branch deliberately did **not** do, and why.
---
## 0. What was actually wrong, stated once
`main.py`'s capacity-tariff status row prints a peak read from SQLite and a
coverage figure read from **process memory**:
```python
_pk = self.peak.peak(when) # SQLite, durable
...
+ f"; {self.peak.recorded} quarter(s) banked" # an int on the tracker
```
`PeakTracker.recorded` was `self.recorded = 0` in `__init__`, incremented once
per block inside `sync()`. A 40-hour soak caught what that means at a driven
restart, on both instances independently:
```
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
```
Reproduced here before anything was changed, through the real `Controller`, the
real `P1Ingest`, and a **real restart** — a second `Controller` object against
the same file on disk, never a re-read of the first:
```
before: 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
- 10 % of the 3500 W soft limit; 12 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-10T09:00:00+00:00
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
peak survives the restart: True
count survives the restart: False (12 -> 0, store held 12 rows)
```
**TARIFF-01's argument, one column over.** That module's whole design is that
an unreadable store must not read as a zero peak, *because a zero peak is
disarming*. A coverage figure of zero is disarming in exactly the same way and
about the same number: the count is what says how much of the billing month the
watt figure actually covers, and 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same 365 W. After a restart the page
made the weaker claim while displaying the stronger one's value. It did not
under-report the peak — it under-reported **its own evidence**.
Two further defects fell out of the same variable once it was driven, neither
of them in the ticket:
* **it was never scoped to a month.** It counted every block the *process* had
banked, so on 1 October the row read September's tally beside October's "no
completed quarter recorded yet". Measured, on the shipped tree:
`2026-10: no completed quarter recorded yet - 0 % of the 3500 W soft limit;
1 quarter(s) banked`.
* **it missed the block `restore()` banks.** A stale block closed from the
checkpoint goes into `quarter_blocks` without passing through `sync()`, so
the counter never saw it.
---
## 1. Decision: the count is READ FROM THE TIER THE PEAK IS READ FROM
The acceptance criterion allowed either route — make the count durable, or stop
printing a number that silently resets. **Durable**, because the count is the
useful half of the row and removing it would have cost the operator the one
thing that says whether the peak means anything yet.
`PeakStore.banked_count(month)` asks the database `SELECT COUNT(*) FROM
quarter_blocks WHERE month=?`, and `PeakTracker.recorded` is a property over it
rather than an attribute. Three consequences, and all three are the defects
above:
* a restart re-reads the same rows, so the figure survives it;
* the count is per **month**, so it agrees with the peak it is printed beside;
* a block banked by `restore()` is a row like any other and is counted.
Three things this route deliberately does **not** do:
* **it does not count `len(blocks_in(month))`.** `blocks_in()` returns `[]` on a
read error — safe there only because no caller reads its emptiness as a peak,
which its own docstring says — so taking its length would rebuild the
disarming zero one call further out. `banked_count()` returns **`None`** when
the database will not answer.
* **it does not count `monthly_peak`.** That tier holds one row per *month* and
would answer `1` for every month ever measured — wrong by exactly the amount
that makes it look plausible. The two tables are proven equal on the **peak**
by `recompute_month()`; they were never equal on the **count**.
* **it does not remove the dedupe.** `_seen` is still what stops `cycle()`'s
once-a-second `sync()` re-banking a block that `P1Ingest.blocks` keeps for a
day, and `sync()`'s return value is still the number newly banked. What moved
is only where the *status page* reads its figure. ⚠️ The check that graded the
dedupe used to assert `recorded == 1` — the dedupe and the figure reporting it
were the same variable. Counting rows in `quarter_blocks` cannot see a
re-bank at all (it is a PRIMARY KEY no-op), so that check is now graded on
`sync()`'s **return**, with the store's tally as the second half.
### ⚠️ The unreadable case renders a WORD, not a number
`banked_count()` returns `int | None`; `recorded_in()` renders the `None` as
the module constant `COUNT_UNREADABLE = "an unreadable number of"`, so the
sentence reads
```
... ; an unreadable number of quarter(s) banked
```
`0` was never available. `None` was rejected too: `f"{None} quarter(s) banked"`
renders **"None quarter(s) banked"**, which an operator reads as the same zero.
This is the `PeakReading` trade — no `peak_w_or(default)`, ever — applied to the
coverage figure: a caller that formats it gets a true sentence in both states,
and a caller that does arithmetic on it gets a `TypeError`, which is loud.
"0 quarter(s) banked" remains a **true and reachable** sentence: it is what a
month nothing has been written to says, and part 11 asserts that the two states
are spelled differently.
---
## 2. Decision: at EQUAL wattage, the EARLIEST quarter keeps the attribution
**First, not most recent.** The reasoning, in the order it matters:
1. **It is not a money question.** The utility bills the wattage; the timestamp
is not part of the bill. So the decision is settled entirely on what the
status row is *for*.
2. **The row answers "when did my monthly peak happen".** An operator reading it
goes and looks at one event — what was running, what the weather was. A
stable answer is the one that can be investigated; "most recent wins"
guarantees the answer moves every time the wattage recurs, and an operator
who looked yesterday is sent somewhere else today for no reason.
3. **It is what one reader already did unconditionally.** `recompute_month()`
is `ORDER BY offtake_avg_w DESC, quarter_start ASC LIMIT 1` — earliest wins.
Choosing "most recent" would have meant changing both readers; choosing
"earliest" meant making the other one say out loud what it was already doing
by accident.
### ⚠️ And the two readers DID diverge, which is why this is a code change
The `monthly_peak` upsert was `WHERE excluded.peak_w > monthly_peak.peak_w` —
strictly greater, so an exact tie never wrote and the row kept whichever tied
block was **written first**. That coincides with *earliest* only while blocks
arrive in time order. Fed four equal 365 W blocks newest-first, measured on the
shipped tree:
```
ascending arrival monthly_peak -> 09:00:00 recompute_month -> 09:00:00 agree=True
DESCENDING arrival monthly_peak -> 09:45:00 recompute_month -> 09:00:00 agree=False
```
That is a divergence in the derived record that part 8 of `test_tariff.py`
exists to prove cannot happen — latent, because nothing in the shipped path
banks out of order today. The upsert now carries the tie term explicitly:
```sql
WHERE excluded.peak_w > monthly_peak.peak_w
OR (excluded.peak_w = monthly_peak.peak_w
AND excluded.quarter_start < monthly_peak.quarter_start)
```
**`peak_w` is untouched.** The `>` term is exactly as it was, no tie can move
the billed number, and SAFETY-07's guard reads the same watt figure it read
before. The second term moves only the timestamp.
### ⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE AT ALL
The ticket's reading — "ties at the same wattage are re-attributed to the most
recent matching quarter" — is **false**, and driving it is what showed that.
Six exactly-equal 365.0 W quarters on the shipped tree moved the attribution
**zero** times, because `>` is strict.
What the soak saw was a genuine new maximum **smaller than the row's own
resolution**. The row renders `{peak_w:.0f}` — whole watts — so 365.0000001 W is
a new peak to the database and the same `365 W` to the reader:
```
q0 fed 365.0 -> renders 365 W, attributed 09:00:00
q1 fed 365.0000001 -> renders 365 W, attributed 09:15:00
q2 fed 365.0000002 -> renders 365 W, attributed 09:30:00
```
**Decision: left alone, deliberately.** It is the instrument behaving correctly
— the peak really did rise — and the alternative, printing more decimal places,
puts noise in the one row an operator reads at the moment the guard engages, to
explain a movement that is real. Both cases are pinned in part 12 so that
neither can be changed by accident, and the distinction is recorded here so the
next reader does not re-diagnose it as a tie.
---
## 3. What this branch did NOT do, and who owns it
**`main.py`'s status row was not touched.** TARIFF-11 holds `main.py`'s status
rows on a concurrent branch, and the standing instruction is to stop rather than
race there. Everything above is in `app/tariff.py`, and the row picks the fix up
without editing, because `self.peak.recorded` is an attribute access and is now
a property.
**The residual, stated with its scope.** `recorded` answers for
`datetime.now()`. The peak beside it follows `checks(when=...)`. The shipped
install has **exactly one** call site — `self.checks()` with no argument, at
`main.py:4359` — where the two are the same month, so **the live page is correct
and durable**. They part company only under the `when=` affordance TEST-17 added
for the suite, and the mismatch there is strictly smaller than the one it
replaces (this month's tally, rather than a process-wide total across all
months).
`PeakTracker.recorded_in(when)` is the scoped form and is shipped. Moving the
row onto it is:
```python
- + f"; {self.peak.recorded} quarter(s) banked"
+ + f"; {self.peak.recorded_in(when)} quarter(s) banked"
```
one token, on one line, inside the block TARIFF-11 owns. `test_tariff.py`
part 10 **pins the present behaviour** — graded against a row count taken
straight from `quarter_blocks`, not against the property that produces it — so
the gap goes red if it changes and cannot be quietly forgotten.
+71 -6
View File
@@ -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 |
+160 -10
View File
@@ -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,
+12 -2
View File
@@ -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)
+138 -9
View File
@@ -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 "
+124 -3
View File
@@ -132,6 +132,18 @@ OBSERVED = "observed" # the store answered and there is a peak
NO_RECORD = "no_record" # the store answered and this month has none yet
NOT_OBSERVED = "not_observed" # the store did not answer. NOT a zero peak.
# TARIFF-13. What the COVERAGE figure reads when the store cannot count.
#
# ⚠️ NOT 0, AND NOT `None`. This is the reading-state argument of TARIFF-01
# applied to the number printed BESIDE the peak rather than to the peak. A
# coverage figure of 0 says "this month has been measured over no quarters at
# all", which is the strongest possible claim that the peak next to it means
# nothing - and `f"{None} quarter(s) banked"` renders "None quarter(s) banked",
# which an operator reads as that same zero. The one honest answer to "how many
# quarters have I banked" when the table will not answer is that it is not
# known, and it has to SAY so in the sentence the operator reads.
COUNT_UNREADABLE = "an unreadable number of"
# --------------------------------------------------------------------------- #
# local time, without a hard dependency on the tz database
@@ -388,6 +400,23 @@ class PeakStore:
" per_phase_json=excluded.per_phase_json"
" WHERE excluded.offtake_avg_w > quarter_blocks.offtake_avg_w",
(start, month, w, pp_json))
# TARIFF-13. THE TIE RULE, DECIDED RATHER THAN EMERGENT:
# at EQUAL wattage the EARLIEST quarter keeps the attribution.
# See DECISIONS/TARIFF-13-the-coverage-of-a-peak.md §2.
#
# `peak_w` itself is still only ever RAISED - the `>` term is
# untouched and no tie can move the billed number - so SAFETY-07
# reads exactly the watt figure it read before. The second term
# moves only the TIMESTAMP the status page attributes it to.
#
# ⚠️ WITHOUT THE SECOND TERM THE TWO READERS DISAGREE, measured:
# `>` alone keeps whichever tied block was WRITTEN FIRST, while
# `recompute_month()`'s `ORDER BY offtake_avg_w DESC,
# quarter_start ASC` keeps the EARLIEST. Those coincide only
# while blocks arrive in time order. Fed four equal 365 W blocks
# newest-first, `monthly_peak` answered 09:45 and the recompute
# answered 09:00 - a divergence in the derived record part 8 of
# test_tariff.py exists to prove cannot happen.
self._db.execute(
"INSERT INTO monthly_peak(month, peak_w, quarter_start, per_phase_json)"
" VALUES(?,?,?,?)"
@@ -395,7 +424,9 @@ class PeakStore:
" peak_w=excluded.peak_w,"
" quarter_start=excluded.quarter_start,"
" per_phase_json=excluded.per_phase_json"
" WHERE excluded.peak_w > monthly_peak.peak_w",
" WHERE excluded.peak_w > monthly_peak.peak_w"
" OR (excluded.peak_w = monthly_peak.peak_w"
" AND excluded.quarter_start < monthly_peak.quarter_start)",
(month, w, start, pp_json))
return True
except sqlite3.Error as err:
@@ -449,6 +480,49 @@ class PeakStore:
quarter_start=datetime.fromisoformat(row[1]),
per_phase_w=pp)
def banked_count(self, month: str) -> int | None:
"""How many quarters this month has banked. `None` when unknowable.
TARIFF-13. THE DEFECT THIS PREVENTS: the status page printed a coverage
figure held in PROCESS MEMORY - `PeakTracker.recorded`, incremented once
per block banked by THIS process - beside a peak read from SQLite. The
peak outlives a restart and the counter does not, so a driven restart
mid-month took a real reading of
2026-09: peak 365 W ... ; 120 quarter(s) banked
to the same peak, the same attributed quarter and `0 quarter(s) banked`,
on both instances independently. The count is what tells an operator how
much of the billing month the number beside it actually covers: 365 W
over 120 quarters and 365 W over 0 quarters are different claims about
the same watt figure, and after a restart the page made the weaker claim
while displaying the stronger one's value. Reading the count from the
same durable tier the peak comes from is what makes the two agree.
⚠️ `None`, NOT 0, and NOT `len(blocks_in(month))`. `blocks_in()` returns
`[]` on a read error - safe there only because no caller reads its
emptiness as a peak (§ its own docstring) - so counting its length would
rebuild the disarming zero one call further out. This asks the database
for a COUNT and refuses to answer when the database refuses.
⚠️ Counts `quarter_blocks`, the tier with one row per closed block, and
NOT `monthly_peak`, which has one row per MONTH and would answer 1 for
every month that has ever been measured. The two tables are proven equal
on the PEAK by `recompute_month()`; they were never equal on the COUNT
and are not meant to be.
"""
if self._db is None:
return None
try:
row = self._db.execute(
"SELECT COUNT(*) FROM quarter_blocks WHERE month=?",
(month,)).fetchone()
except sqlite3.Error as err:
self.last_error = str(err)
_LOG.error("could not count quarters for %s (%s)", month, err)
return None
return int(row[0])
def blocks_in(self, month: str) -> list:
"""(quarter_start, offtake_avg_w) for a month, oldest first. [] on error
is safe here ONLY because no caller reads emptiness as a peak - AC 3's
@@ -539,11 +613,59 @@ class PeakTracker:
self.cap_w = float(cap_w)
self.policy = policy
self.checkpoint_s = float(checkpoint_s)
self.recorded = 0
self.restore_state = "not attempted"
self._seen: set = set()
self._last_ckpt = 0.0
# -- coverage -----------------------------------------------------------
#
# TARIFF-13. `recorded` USED TO BE AN `int` ATTRIBUTE ON THIS OBJECT,
# starting at 0 and incremented in `sync()`. It is a property over the store
# now, and the three things that changes are all defects it was carrying:
#
# * it RESET TO 0 ON EVERY RESTART, beside a peak that did not - see
# `PeakStore.banked_count()` for the driven reading;
# * it was NOT SCOPED TO A MONTH. It counted every block this process had
# banked, so on 1 October the row still read September's tally beside
# October's "no completed quarter recorded yet";
# * it MISSED the block `restore()` banks. A stale block closed from the
# checkpoint goes into `quarter_blocks` and never went through `sync()`,
# so the counter never saw it. Counting the table cannot miss it.
#
# ⚠️ REMOVING THE COUNTER DOES NOT REMOVE THE DEDUPE. `_seen` is still what
# stops `cycle()`'s once-a-second `sync()` re-banking a closed quarter that
# `P1Ingest.blocks` keeps for a DAY, and `sync()`'s RETURN value is still
# the count of blocks newly banked on that call. What changed is only where
# the STATUS PAGE's figure is read from. The check that grades the dedupe
# must therefore grade `sync()`'s return, not this property, which now
# answers 1 from the database however many times the block was offered.
def recorded_in(self, when: datetime | None = None):
"""Quarters banked in `when`'s billing month - `int`, or the sentinel.
⚠️ The return type is `int | str` on purpose, and the `str` is
`COUNT_UNREADABLE`. A caller that formats this into a sentence gets a
true sentence in both cases; a caller that wants to do arithmetic on it
gets a TypeError, which is loud. That is the same trade `PeakReading`
makes by having no `peak_w_or(default)`.
"""
n = self.store.banked_count(month_key(when or datetime.now(timezone.utc)))
return COUNT_UNREADABLE if n is None else n
@property
def recorded(self):
"""Quarters banked in the month being billed TODAY.
⚠️ This is the live status page's reading, where the peak beside it is
also read for `datetime.now()`. `checks(when=X)` for an X in another
month is a test affordance (TEST-17); the row scoped that way gets this
month's count beside that month's peak, which is strictly better than
the process-wide counter it replaces but is still a mismatch.
`recorded_in(when)` is the scoped form the row should read once
`main.py`'s status rows are free to change - TARIFF-11 holds them.
"""
return self.recorded_in()
# -- startup ------------------------------------------------------------
def restore(self, averager, now: datetime | None = None) -> str:
"""Put the open block back. Returns which of the three cases happened."""
@@ -600,7 +722,6 @@ class PeakTracker:
continue
if self.store.record_block(block, self.policy):
self._seen.add(key)
self.recorded += 1
n += 1
if n:
# A block just closed, so the checkpoint's block is gone: clearing it
+699 -1
View File
@@ -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))
+359 -14
View File
@@ -2,8 +2,8 @@
python3 test_tariff.py
Ten parts. What each is for, and - where it matters - what it is deliberately
NOT allowed to be tested at:
Twelve parts. What each is for, and - where it matters - what it is
deliberately NOT allowed to be tested at:
1. THE BLOCK ARITHMETIC, against a worked example written out segment by
segment. The fixture is chosen to be non-degenerate on three axes at once:
@@ -33,6 +33,17 @@ NOT allowed to be tested at:
proven equal to the tier it derives from, and proven never to be lowered.
9. AC 3, ON THE REPLAY DATA THAT ACTUALLY EXISTS. See the honesty note below.
10. THE WIRING, through the real `Controller` and its real status page.
11. TARIFF-13: THE COVERAGE FIGURE PRINTED BESIDE THE PEAK, across a REAL
restart - a second `Controller` against the same file. The peak was
always durable; the count of banked quarters was an `int` on the
tracker and reset to 0, so the page asserted "0 quarter(s) banked"
for a month it had measured 120 of. An unreadable store must not
produce a coverage figure of 0 either - part 5's argument, one
column over.
12. TARIFF-13: WHICH QUARTER A TIED PEAK IS ATTRIBUTED TO, decided
(earliest wins) rather than left emergent, and pinned on BOTH
readers with a newest-first fixture - ascending arrival cannot tell
"the earliest quarter" from "the first row written".
------------------------------------------------------------------------------
⚠️ PART 9 IS A TWO-DAY MEASUREMENT AND SAYS SO. THE 30-DAY ANSWER IS ELSEWHERE
@@ -106,9 +117,9 @@ from pathlib import Path
from app.main import Controller
from app.p1 import P1Ingest, QuarterAverager, QUARTER_S, make_sample, split_signed
from app.tariff import (
BILLING_TZ, CHECKPOINT_S, NO_RECORD, NOT_OBSERVED, POLICY_MAX,
POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _EUTimezone, billed_w,
month_key,
BILLING_TZ, CHECKPOINT_S, COUNT_UNREADABLE, NO_RECORD, NOT_OBSERVED,
POLICY_MAX, POLICY_NET, POLICY_SUM, PeakStore, PeakTracker, _ClosedBlock,
_EUTimezone, billed_w, month_key,
)
fails = []
@@ -932,18 +943,28 @@ check("the closed quarter reached SQLite through the tracker the cycle calls",
# ⚠️ `cycle()` calls sync() ONCE A SECOND against a `blocks` list that keeps a
# closed quarter for a DAY. Without a dedupe that is ~86 000 re-banks of the
# same row per block, and `recorded` - the number the status page prints as
# "quarters banked" - would be a count of loop iterations rather than of
# quarters. The database stays correct either way, which is exactly why this
# needs its own check instead of being caught by the peak.
# same row per block, and a coverage figure derived from the number of times a
# block was OFFERED would be a count of loop iterations rather than of quarters.
# The database stays correct either way, which is exactly why this needs its own
# check instead of being caught by the peak.
#
# ⚠️ GRADED ON `sync()`'s RETURN, and on the STORE's tally for the month the
# quarter is in - TARIFF-13. It used to be graded on `PeakTracker.recorded`,
# which was then an `int` incremented inside this very loop, so the dedupe and
# the figure that reported it were the same variable. They are not any more:
# `recorded_in()` counts rows in `quarter_blocks`, where re-banking the same
# `quarter_start` is a PRIMARY KEY no-op and could never have shown up as a
# second quarter. A check written against it alone would now pass however many
# times sync() re-banked, which is why the return value is the load-bearing
# half of this assertion and is asserted first.
again = c.peak.sync(c.p1)
third = c.peak.sync(c.p1)
print(f" sync() called three times over the same closed quarter: "
f"{n}, {again}, {third} newly banked; tracker reports "
f"{c.peak.recorded} quarter(s)")
f"{n}, {again}, {third} newly banked; the store holds "
f"{c.peak.recorded_in(B)} quarter(s) for {month_key(B)}")
check("...and syncing the same quarter again banks nothing new, so the count "
"the status page shows is quarters and not cycles",
again == 0 and third == 0 and c.peak.recorded == 1)
again == 0 and third == 0 and c.peak.recorded_in(B) == 1)
check("...and the peak is unchanged by the repeat",
near(c.peak.peak(B).peak_w, 4998.0, 1e-6))
@@ -957,11 +978,33 @@ peak_rows = [r for r in rows if "peak" in r["text"] and "quarter(s) banked" in r
print(" status row: " + (peak_rows[0]["text"] if peak_rows else "(none)"))
check("the status page carries exactly one capacity-tariff peak row",
len(peak_rows) == 1)
check("...naming the peak, the soft limit and the quarters banked, all derived "
check("...naming the peak, the soft limit and a coverage figure, all derived "
"from the controller that was actually built",
bool(peak_rows) and "4998 W" in peak_rows[0]["text"]
and f"{SHIPPED_CAP_W:.0f} W soft limit" in peak_rows[0]["text"]
and "1 quarter(s) banked" in peak_rows[0]["text"])
and "quarter(s) banked" in peak_rows[0]["text"])
# ⚠️ AND THE COVERAGE FIGURE IN THAT ROW IS SCOPED TO THE LIVE BILLING MONTH,
# NOT TO `when` - TARIFF-13, and it is a KNOWN GAP stated rather than rounded
# off. `main.py`'s row reads `self.peak.recorded`, which answers for
# `datetime.now()`; the peak beside it follows `when`. The two agree on the
# ONLY call site a shipped install has - `checks()` with no argument, at
# main.py:4359 - which is why the live page is correct and durable; they part
# company only under this suite's `when=` affordance. `PeakTracker` already
# carries the scoped form, `recorded_in(when)`, and moving the row onto it is a
# one-token edit inside main.py's status-row block, which TARIFF-11 holds. This
# check PINS the present behaviour so the gap cannot be forgotten, and part 11
# is the authority on the figure itself.
_live_month = month_key(datetime.now(timezone.utc))
_live_rows = len(c.peak.store.blocks_in(_live_month))
print(f" the row read at {month_key(B)} carries the tally for "
f"{_live_month} ({_live_rows} quarter(s)), which is this fixture's "
f"known scoping gap")
check("the row's coverage figure is the LIVE month's tally - graded against a "
"row count taken straight from quarter_blocks, so this pins the gap "
"rather than restating the property that produces it",
bool(peak_rows)
and f"; {_live_rows} quarter(s) banked" in peak_rows[0]["text"])
check("...and it is FLAGGED, because 4998 W is over §9.1's 80 % warning point "
f"on a {SHIPPED_CAP_W:.0f} W limit",
bool(peak_rows) and not peak_rows[0]["ok"] and peak_rows[0]["warn"])
@@ -1149,6 +1192,308 @@ check(f"...and the same IMPORT grader over main.py finds {_main_imports}, so "
bool(_main_imports))
# ==============================================================================
print()
print("11. TARIFF-13: what the instrument says about its OWN COVERAGE, "
"across a restart")
# THE DEFECT THIS PART PREVENTS. The status row prints a peak read from SQLite
# beside a count of banked quarters, and the count used to be an `int` attribute
# on `PeakTracker` that started at 0 in `__init__`. A 40-hour soak caught what
# that means at a driven restart, on both instances independently:
#
# before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
# - 10 % of the 3500 W soft limit; 120 quarter(s) banked
# after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
# - 10 % of the 3500 W soft limit; 0 quarter(s) banked
#
# The peak and its attributed quarter persist; the coverage beside them does
# not. §9.1's peak is what the utility bills, but the COUNT is what says how
# much of the billing month the figure covers - 365 W over 120 quarters and
# 365 W over 0 quarters are different claims about the same watt figure, and
# after a restart the page made the weaker claim while showing the stronger
# one's value. TARIFF-01's argument is that an unreadable store must not read as
# a zero peak because a zero peak is DISARMING; a zero COVERAGE beside a live
# peak is the same error one column over.
#
# ⚠️ THE RESTART HERE IS A REAL ONE - a second `Controller` against the same
# file on disk, never a re-read of the first object. 11b asserts that the second
# object has banked nothing itself, so the number it prints cannot have come
# from process memory. Without that precondition this whole part is satisfied by
# a counter that was simply never reset.
_T13 = TMP / "t13-restart.db"
# The fixture lives in the month being billed TODAY, and the row is read with NO
# `when` argument - the LIVE status page, the surface that misinformed. That
# also makes the check stable on every day of the year, which a fixed calendar
# date is not (TEST-17, one part up, is the same lesson).
_now13 = datetime.now(timezone.utc)
_mon13 = month_key(_now13)
# Anchor on the first 900 s boundary of the billing month, so all N quarters are
# inside `_mon13` however close to a month edge the suite happens to run.
_ms = datetime(int(_mon13[:4]), int(_mon13[5:]), 1, tzinfo=timezone.utc)
_q0 = datetime.fromtimestamp(
(int(_ms.timestamp()) // QUARTER_S + 1) * QUARTER_S, timezone.utc)
_N13 = 6 # > 1, so "count == rows" cannot be satisfied by 0 == 0
_W13 = 1234.0
c13 = built(_T13)
for _q in range(_N13):
_base = _q0 + timedelta(seconds=QUARTER_S * _q)
for _i in range(0, QUARTER_S + 1, 5):
c13.p1.submit(sample(_base + timedelta(seconds=_i), _W13))
c13.peak.sync(c13.p1)
# Ground truth from the OTHER tier, which the tracker did not supply - the count
# under test must be graded against something that is not itself (habit 2).
_truth_before = len(c13.peak.store.blocks_in(_mon13))
_row_before = _peak_rows(c13.checks())[0]["text"]
print(" before restart: " + _row_before)
check(f"precondition: the fixture banked {_N13} quarters in {_mon13}, so a "
f"count of 0 and a count of the truth are different numbers",
_truth_before == _N13 and _N13 > 1)
check(f"...and the row says so before the restart",
f"{_N13} quarter(s) banked" in _row_before)
c13.peak.store.close()
# THE RESTART.
c13b = built(_T13)
_row_after = _peak_rows(c13b.checks())[0]["text"]
_truth_after = len(c13b.peak.store.blocks_in(_mon13))
print(" after restart: " + _row_after)
print(f" rows in quarter_blocks: {_truth_before} before, "
f"{_truth_after} after; the restarted tracker has banked "
f"{len(c13b.peak._seen)} block(s) of its own through sync()")
check("precondition: the second Controller is a REAL restart - it shares no "
"object with the first and has banked nothing through sync(), so any "
"count it prints came off the disk and not out of memory",
c13b.peak is not c13.peak and len(c13b.peak._seen) == 0)
check("the peak and its attributed quarter survive the restart - the durable "
"half, asserted so that the coverage claim below is not graded against a "
"reading that had already collapsed",
_row_before.split(" - ")[0] == _row_after.split(" - ")[0]
and f"{_W13:.0f} W" in _row_after)
check("...and the COUNT BESIDE IT DOES NOT COLLAPSE TO ZERO, which is the "
"defect: a coverage figure that resets while the value beside it "
"persists actively misinforms",
"; 0 quarter(s) banked" not in _row_after)
check(f"...and it is the store's own tally, {_truth_after}, not a "
f"process-lifetime counter - graded against a COUNT OF ROWS in the "
f"other tier rather than against the property under test",
f"{_truth_after} quarter(s) banked" in _row_after and _truth_after >= _N13)
# ⚠️ The restarted run banks one MORE than it drove, and that is correct: the
# checkpointed open block was stale, so `restore()` closed and banked it. The
# in-memory counter never saw that block at all - it only ever counted what went
# through `sync()` - so this is a third way the old figure under-read, and it is
# asserted rather than tolerated. Driven at a chosen `now` so it cannot depend
# on when the suite runs.
_t13s = fresh_store("t13-stale")
_tr13 = PeakTracker(_t13s, cap_w=SHIPPED_CAP_W)
_av13 = QuarterAverager(phases=1)
_sb = _q0 + timedelta(seconds=QUARTER_S * 40)
for _i in range(0, 600, 5):
list(_av13.add(sample(_sb + timedelta(seconds=_i), 2000.0)))
_t13s.save_partial(_av13.snapshot())
_before_stale = _tr13.recorded_in(_sb)
_state = _tr13.restore(QuarterAverager(phases=1),
now=_sb + timedelta(seconds=QUARTER_S * 3))
print(f" stale-block restore: {_state}")
print(f" quarters banked in {month_key(_sb)}: {_before_stale} -> "
f"{_tr13.recorded_in(_sb)}")
check("a block closed by restore() rather than by sync() is counted too - the "
"old counter incremented only in sync() and missed it entirely",
_before_stale == 0 and _tr13.recorded_in(_sb) == 1
and "banked" in _state)
# --- the count is scoped to the MONTH it is printed beside --------------------
# ⚠️ TWO MONTHS AT DIFFERENT COUNTS. Equal counts would be satisfied by a
# cross-month total, by a constant, and by reading either month for the other.
_t13m = fresh_store("t13-months")
_MA = datetime(2026, 4, 6, 9, 0, tzinfo=timezone.utc) # 2026-04
_MB = datetime(2026, 5, 6, 9, 0, tzinfo=timezone.utc) # 2026-05
_trm = PeakTracker(_t13m, cap_w=SHIPPED_CAP_W)
for _m, _k in ((_MA, 3), (_MB, 5)):
for _q in range(_k):
_t13m.record_block(_ClosedBlock(
start=_m + timedelta(seconds=QUARTER_S * _q),
offtake_avg_w=900.0 + _q, per_phase_offtake_avg_w=None))
print(f" banked by month: {month_key(_MA)}={_trm.recorded_in(_MA)}, "
f"{month_key(_MB)}={_trm.recorded_in(_MB)}, "
f"a month never written={_trm.recorded_in(datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc))}")
check("precondition: the two months were given DIFFERENT numbers of quarters, "
"so a count that answered either one for the other would be visible here",
month_key(_MA) != month_key(_MB))
check("each month reports ITS OWN quarters and not the pair's total - the old "
"counter was per PROCESS, so on 1 October the row still showed "
"September's tally beside October's empty month",
_trm.recorded_in(_MA) == 3 and _trm.recorded_in(_MB) == 5)
check("...and a month with no quarters reports 0, which is TRUE of a month "
"nothing was written to - the sentinel below is for a store that will "
"not answer, and the two states must not be spelled the same way",
_trm.recorded_in(datetime(2026, 7, 6, 9, 0, tzinfo=timezone.utc)) == 0)
# --- and a store that cannot answer does NOT report zero ----------------------
# The other half of part 5's argument, applied to the coverage figure. `0
# quarter(s) banked` and `an unreadable number of quarter(s) banked` are
# opposite states: the first says the month has been measured over nothing, the
# second says the instrument cannot say. Spelling them the same way is exactly
# what this card was raised about.
_t13u = PeakStore(str(TMP / "no-such-dir" / "nope13.db"))
_tru = PeakTracker(_t13u, cap_w=SHIPPED_CAP_W)
print(f" unreadable store: banked_count -> {_t13u.banked_count(_mon13)!r}, "
f"rendered -> {_tru.recorded_in(_now13)!r}")
check("an unreadable store answers None to a COUNT, never 0 - the store-level "
"half",
_t13u.banked_count(_mon13) is None)
check("...and the rendered figure says it is unreadable instead of naming a "
"number, so the sentence cannot read as a measured zero",
_tru.recorded_in(_now13) == COUNT_UNREADABLE
and f"{_tru.recorded_in(_now13)} quarter(s) banked"
== "an unreadable number of quarter(s) banked")
check("...and that sentence is not merely NON-zero by accident: the string a "
"count of 0 would produce is different text",
f"{_tru.recorded_in(_now13)} quarter(s) banked"
!= f"{0} quarter(s) banked")
# ⚠️ A STORE THAT IS OPEN AND WHOSE COUNT QUERY FAILS IS A SECOND CODE PATH, and
# the checks above cannot reach it - a handle that never opened short-circuits
# on `self._db is None` before any SQL runs, so `banked_count()`'s
# `except sqlite3.Error` arm was executed by nothing. Found by this card's own
# mutation battery, not by review: returning 0 instead of None from that arm
# survived the whole suite at -3 B. It is the same third-path argument part 5
# makes for `peak()`, and it is the arm a half-corrupt database on a real
# install actually takes.
_t13b = fresh_store("t13-broken")
for _q in range(4):
_t13b.record_block(_ClosedBlock(start=_q0 + timedelta(seconds=QUARTER_S * _q),
offtake_avg_w=800.0 + _q,
per_phase_offtake_avg_w=None))
_trb = PeakTracker(_t13b, cap_w=SHIPPED_CAP_W)
_count_ok = _t13b.banked_count(_mon13)
_t13b._db.execute("DROP TABLE quarter_blocks")
_t13b._db.commit()
_count_broken = _t13b.banked_count(_mon13)
print(f" open store, count query FAILS: {_count_ok} -> {_count_broken!r}, "
f"rendered -> {_trb.recorded_in(_now13)!r}")
check(f"precondition: before the table was dropped the same call answered "
f"{_count_ok}, so 'it returns nothing now' is a change and not the "
f"store's resting state",
_count_ok == 4)
check("a store whose COUNT QUERY fails answers None too - a different code "
"path from a handle that never opened, and the one a half-corrupt "
"database takes",
_count_broken is None)
check("...and it renders as unreadable rather than as 0, so a half-corrupt "
"database cannot print a coverage figure that reads like a measured "
"month with nothing in it",
_trb.recorded_in(_now13) == COUNT_UNREADABLE)
# --- the count reads the BLOCK tier, not the month tier -----------------------
# `monthly_peak` holds one row per MONTH, so counting it answers 1 for every
# month that has ever been measured - a figure that is wrong by exactly the
# amount that makes it look plausible.
check("the coverage figure counts quarter_blocks and not monthly_peak, which "
"would answer 1 for every measured month however many quarters it holds",
_trm.recorded_in(_MB) == 5
and _t13m.peak(_MB).is_observed and _t13m.recompute_month(month_key(_MB)).is_observed)
# ==============================================================================
print()
print("12. TARIFF-13: which quarter a TIED peak is attributed to")
# THE DECISION, recorded in DECISIONS/TARIFF-13-the-coverage-of-a-peak.md §2:
# at equal wattage the EARLIEST quarter keeps the attribution. The wattage is
# what is billed, so this is not a money question - it is the answer to "when
# did my monthly peak happen", and an operator investigating that goes and looks
# at one event. A stable answer is the useful one; "most recent wins" guarantees
# the answer moves every time the wattage recurs.
#
# ⚠️ AND THE SOAK'S DRIFT WAS NOT A TIE. The 40-hour run showed 365 W attributed
# to three different quarters with no restart and no new peak, and the obvious
# reading - "ties re-attribute to the most recent" - is false, measured: the
# upsert's term is strictly `>`, so an exact tie never wrote. What moved was a
# genuine new maximum SMALLER THAN THE ROW'S OWN RESOLUTION. The row renders
# `{peak_w:.0f}`, whole watts; 365.0000001 W is a new peak to the database and
# the same "365 W" to the reader. Both cases are pinned below, because the fix
# for one is not the fix for the other.
_t13t = fresh_store("t13-ties")
_TB = datetime(2026, 3, 9, 9, 0, tzinfo=timezone.utc)
def _bank(store, q, w):
store.record_block(_ClosedBlock(start=_TB + timedelta(seconds=QUARTER_S * q),
offtake_avg_w=w, per_phase_offtake_avg_w=None))
def _qt(n):
return _TB + timedelta(seconds=QUARTER_S * n)
for _q in (0, 1, 2, 3):
_bank(_t13t, _q, 365.0)
_asc = _t13t.peak(_TB)
print(f" four EXACTLY equal 365 W quarters, oldest first -> attributed "
f"{_asc.quarter_start.isoformat()}")
check("an exact tie keeps the EARLIEST quarter: the attribution does not move "
"when the same wattage recurs",
_asc.quarter_start == _qt(0) and near(_asc.peak_w, 365.0))
# ⚠️ FED NEWEST-FIRST, which is what separates "the earliest quarter" from
# "whichever row was written first". Ascending arrival cannot tell those apart -
# they are the same row - so an ascending-only fixture grades neither.
_t13r = fresh_store("t13-ties-rev")
for _q in (3, 2, 1, 0):
_bank(_t13r, _q, 365.0)
_desc = _t13r.peak(_TB)
_desc_rc = _t13r.recompute_month(month_key(_TB))
print(f" the same four fed NEWEST first -> monthly_peak attributed "
f"{_desc.quarter_start.isoformat()}, recompute_month attributed "
f"{_desc_rc.quarter_start.isoformat()}")
check("...and it is the earliest QUARTER, not the first row WRITTEN - fed "
"newest-first the answer is still the oldest quarter",
_desc.quarter_start == _qt(0))
check("...and BOTH readers give it, so the derived record has not diverged "
"from the tier it derives from on the tie rule either (part 8's "
"invariant, at equal wattage)",
_desc_rc.quarter_start == _desc.quarter_start
and near(_desc_rc.peak_w, _desc.peak_w))
# The other direction, and the reason the tie rule may never touch `peak_w`: a
# STRICTLY HIGHER quarter must still take the attribution, whenever it arrives.
_t13h = fresh_store("t13-higher")
for _q, _w in ((0, 365.0), (1, 366.0), (2, 365.0)):
_bank(_t13h, _q, _w)
_hi = _t13h.peak(_TB)
print(f" 365, then 366, then 365 -> peak {_hi.peak_w:.0f} W at "
f"{_hi.quarter_start.isoformat()}")
check("a strictly higher quarter still takes both the peak and the "
"attribution - the tie rule moves the TIMESTAMP and never the watts",
near(_hi.peak_w, 366.0) and _hi.quarter_start == _qt(1))
# And the mechanism the soak actually saw: an increase below the row's own
# resolution. Not a tie, and no tie rule can address it.
_t13d = fresh_store("t13-subdisplay")
_seen13 = []
for _q, _w in enumerate((365.0, 365.0000001, 365.0000002)):
_bank(_t13d, _q, _w)
_seen13.append(_t13d.peak(_TB).quarter_start)
print(f" 365.0, 365.0000001, 365.0000002 W all render "
f"{_t13d.peak(_TB).peak_w:.0f} W and attribute to "
f"{[s.time().isoformat() for s in _seen13]}")
check("an increase BELOW the row's whole-watt resolution is a real new maximum "
"and does move the attribution - so the soak's drifting quarter was a "
"rounded display, not a tie, and is left alone deliberately",
len(set(_seen13)) == 3
and len({f"{w:.0f}" for w in (365.0, 365.0000001, 365.0000002)}) == 1)
# ==============================================================================
print()
shutil.rmtree(TMP, ignore_errors=True)