Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11597e0b3 | ||
|
|
610725bed9 | ||
|
|
b4a3801f68 | ||
|
|
a991438e40 | ||
|
|
068b83f57c |
@@ -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.
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,6 +51,7 @@ import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -79,7 +80,7 @@ from app import price as _price
|
||||
from app import arbitrage as _arb
|
||||
from app import main as _main
|
||||
from app.p1 import make_sample, split_signed
|
||||
from app.static_tariff import TariffPlan
|
||||
from app.static_tariff import TariffPlan, static_status
|
||||
from app.validate import validate_startup_config
|
||||
|
||||
fails = []
|
||||
@@ -3767,6 +3768,703 @@ check("the id is offered by config.yaml's schema, so a site configured on it "
|
||||
"reaches these messages rather than a Supervisor schema error",
|
||||
"custom_formula" in _schema_ids)
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# 12. TARIFF-11 - THE RENDERED SURFACE, GRADED AS A SURFACE
|
||||
# =========================================================================== #
|
||||
#
|
||||
# WHAT THIS SECTION IS FOR, and it is the only thing on the card that is not a
|
||||
# one-line fix. Four defects shipped together and every one of them survived 29
|
||||
# green suites for the SAME reason: **nothing in this repository graded the
|
||||
# strings a human is shown.** Every existing tariff check inspects the
|
||||
# `TariffPlan` OBJECT - `plan.read.points`, `plan.firm_points()`, `verdict.kind`
|
||||
# - and all four defects were in the SENTENCES built out of that object:
|
||||
#
|
||||
# 1. `TariffPlan.words` counted POINTS and called them HOURS, so a 33-hour
|
||||
# plan logged `dynamic_dayahead from generic_dayahead, 132 h`;
|
||||
# 2. §8.3's and the flat rung's refusals did the same, and those reach an
|
||||
# operator through main.py's *Arbitrage* row, not only the log;
|
||||
# 3. the plan reached NO status row at all - `rows["Tariff"]` was
|
||||
# `static_status()`'s own sentence and read `'no static profile'` at all
|
||||
# 802 samples of a 40-hour soak on a site whose feed was answering;
|
||||
# 4. `rows["Arbitrage"]` rendered "trading is off" and "no decision was
|
||||
# reached this cycle" as the SAME string, naming an option that was on.
|
||||
#
|
||||
# ⚠️ SO THIS SECTION GRADES `status_payload()["rows"]` AND THE REFUSAL
|
||||
# SENTENCES, NOT THE OBJECTS BEHIND THEM. It renders through the shipped
|
||||
# `Controller`, parses each number OUT OF THE ROW BY ITS OWN SLOT, and compares
|
||||
# it against a figure this file computed from the FIXTURE'S CONSTRUCTION
|
||||
# PARAMETERS - never from the property the row was built out of. That is
|
||||
# MUTATION.md habit 4: if both sides of a comparison come from the same
|
||||
# expression, the check is decoration.
|
||||
#
|
||||
# ⚠️ AND IT IS DELIBERATELY NOT A SUBSTRING TEST OVER A SENTENCE THIS FILE ALSO
|
||||
# WROTE. Three checks in this chain turned out to be graded by something they
|
||||
# did not own - a docstring, a deleted plugin's text, the HEADING above a table
|
||||
# whose ROWS had been emptied (MUTATION.md, TARIFF-15 M13). `"132 points" in
|
||||
# row` is that mistake again: it is satisfied by the number appearing anywhere,
|
||||
# including in the other number's slot, which habit 14 shows is a ZERO-BYTE
|
||||
# edit away. Every NUMBER below is asserted as a parsed slot against an integer
|
||||
# derived here; every other claim is an INEQUALITY between two renderings that
|
||||
# must not be equal. The ONE substring test is the rung-attribution needle
|
||||
# (`"the tariff is flat"`, `"carry a published price"`) that TEST-16 already
|
||||
# owns and grades on - it asks WHICH RUNG spoke, not what this file wrote.
|
||||
|
||||
_T11_NOW = datetime(2026, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
_T11_BASE = dict(_T10_BASE, meter_entity="sensor.grid")
|
||||
|
||||
|
||||
class _T11Hass(_T10Hass):
|
||||
"""`_T10Hass`, with ONE reading switchable to `None`.
|
||||
|
||||
⚠️ THE `None` IS THE WHOLE STIMULUS FOR DEFECT 4. `inputs_ok` is False the
|
||||
moment any pack's SoC does not answer, and `cycle()` then returns at the
|
||||
input hold (`bad_for < stale_input_s`, 15 s by default) BEFORE it reaches
|
||||
`self.arb = None`. A fixture whose sensors always answer cannot reach the
|
||||
row this section is about.
|
||||
"""
|
||||
|
||||
def __init__(self, soc=55.0):
|
||||
self.soc = soc
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
return {"sensor.grid": 0.0, "sensor.soc": self.soc,
|
||||
"sensor.batt": 0.0}.get(entity)
|
||||
|
||||
|
||||
def _t11_points(firm_h: int, carried_h: int, base=None) -> list:
|
||||
"""`firm_h` QUARTER-HOURLY firm hours, then `carried_h` HOURLY carried ones.
|
||||
|
||||
FCST-04's real mixed shape, and the reason a single spacing is the wrong
|
||||
model: the feed prints four points an hour and §8.3's carry-forward emits
|
||||
exactly ONE point per unpublished hour, so a normal weekday-morning plan is
|
||||
900 s at the front and 3600 s at the back. The soak measured that mix in
|
||||
70 % of samples.
|
||||
|
||||
⚠️ THE PRICES ASCEND AND THE TWO BANDS DO NOT OVERLAP, so no check below
|
||||
can pass by reading the wrong point: a mutation that grades a carried point
|
||||
as firm moves a price as well as a count.
|
||||
"""
|
||||
t0 = base or _T11_NOW
|
||||
pts = [PricePoint(t0 + timedelta(hours=h, minutes=15 * q),
|
||||
round(0.100 + 0.001 * (4 * h + q), 5), False)
|
||||
for h in range(firm_h) for q in range(4)]
|
||||
pts += [PricePoint(t0 + timedelta(hours=firm_h + h),
|
||||
round(0.500 + 0.001 * h, 5), True)
|
||||
for h in range(carried_h)]
|
||||
return pts
|
||||
|
||||
|
||||
def _t11_plan(firm_h, carried_h, *, origin="provider",
|
||||
provider="generic_dayahead", product=PriceProduct.DAY_AHEAD):
|
||||
return TariffPlan(
|
||||
PriceRead.series(_t11_points(firm_h, carried_h), product, provider,
|
||||
_T11_NOW), None, origin)
|
||||
|
||||
|
||||
def _t11_want(firm_h: int, carried_h: int) -> dict:
|
||||
"""What a row about `_t11_points(firm_h, carried_h)` MUST say.
|
||||
|
||||
⚠️ COMPUTED FROM THE FIXTURE'S TWO CONSTRUCTION PARAMETERS AND FROM
|
||||
NOTHING ELSE (habit 4). Not `len(plan.read.points)`, not `plan.hours`, not
|
||||
`len(plan.firm_points())` - those are the expressions the row is built out
|
||||
of, and a check that compares a rendered number against the property that
|
||||
rendered it compares a thing against itself. `4 * firm_h` and
|
||||
`firm_h + carried_h` are this file's own arithmetic over the shape it
|
||||
asked for.
|
||||
|
||||
The spacing rule, stated rather than re-counted: consecutive quarters are
|
||||
900 s apart, the last quarter of the last firm hour is 900 s before the
|
||||
first carried hour, and consecutive carried hours are 3600 s apart - so
|
||||
3600 appears only when there are at least TWO carried hours.
|
||||
"""
|
||||
gaps = ([900] if firm_h else []) + ([3600] if carried_h >= 2 else [])
|
||||
return {"points": 4 * firm_h + carried_h, "hours": firm_h + carried_h,
|
||||
"firm": 4 * firm_h, "carried": carried_h,
|
||||
"spacings": gaps or [3600]}
|
||||
|
||||
|
||||
class _NoRows(dict):
|
||||
"""What a failed render hands back: a dict of `_Raised`, never a crash.
|
||||
|
||||
⚠️ FCST-06's LESSON, IN THE OPERAND RATHER THAN IN THE CONDITION. A
|
||||
mutation that makes a render raise must report as ONE named red per check,
|
||||
not as a traceback that takes the rest of the section with it and grades
|
||||
none of it - a red COUNT is only a lower bound until the run is seen to
|
||||
reach the end of its section.
|
||||
"""
|
||||
|
||||
def __init__(self, e):
|
||||
super().__init__()
|
||||
self.e = e
|
||||
|
||||
def __missing__(self, key):
|
||||
return _Raised(self.e)
|
||||
|
||||
|
||||
class _NoCtl:
|
||||
"""A controller a failed render never built. Every attribute is `_Raised`."""
|
||||
|
||||
def __init__(self, e):
|
||||
self._e = e
|
||||
|
||||
def __getattr__(self, name):
|
||||
return _Raised(self._e)
|
||||
|
||||
|
||||
def _t11_render(over=None, *, plan=None, plan_age_s=0.0, soc=55.0, cycles=1,
|
||||
age_arb_by_s=None, then_soc_none=False):
|
||||
"""Drive the SHIPPED controller and hand back what a human would read.
|
||||
|
||||
Returns `(rows, ctl)` - `rows` is `dict(status_payload()["rows"])`, i.e.
|
||||
the actual HTTP surface, and `ctl` is there so a check can ask the loop for
|
||||
a figure to grade the row AGAINST (its own `reason`, its own `arb_at`).
|
||||
|
||||
⚠️ `read_inputs()` THEN `cycle()`, IN THAT ORDER, because that is what
|
||||
`run_control()` does. A harness that called only `cycle()` would leave
|
||||
every reading at `None` and grade an input-hold row it had manufactured
|
||||
itself.
|
||||
"""
|
||||
opts = dict(_T11_BASE, **(over or {}))
|
||||
tmp = tempfile.mkdtemp(prefix="tariff11-")
|
||||
_tmpdirs_t10.append(tmp)
|
||||
|
||||
async def _go():
|
||||
stop = asyncio.Event()
|
||||
ctl = _main.Controller(opts, _T11Hass(soc), _T10Store(), _T10Pub(),
|
||||
stop=stop,
|
||||
peak_db_path=os.path.join(tmp, "t11.db"),
|
||||
price_history_path=os.path.join(tmp, "ph.json"))
|
||||
if plan is not None:
|
||||
ctl.tariff_plan = plan
|
||||
ctl.tariff_at = (datetime.now(timezone.utc)
|
||||
- timedelta(seconds=plan_age_s))
|
||||
for _ in range(cycles):
|
||||
await ctl.read_inputs()
|
||||
await ctl.cycle()
|
||||
if then_soc_none:
|
||||
# ⚠️ THE REAL STIMULUS FOR THE SECOND HALF OF DEFECT 4, and it is
|
||||
# a control-flow one: a healthy cycle takes a decision, the SoC
|
||||
# then stops answering, and the NEXT cycle returns at the input
|
||||
# hold before it reaches the arbitrage step at all. `self.arb`
|
||||
# still holds the previous decision - the row rendered it as a
|
||||
# current one, with nothing saying the site had stopped deciding.
|
||||
ctl.hass.soc = None
|
||||
await ctl.read_inputs()
|
||||
await ctl.cycle()
|
||||
if age_arb_by_s is not None and ctl.arb_at is not None:
|
||||
# ⚠️ THE CLOCK IS MOVED, NOT SLEPT THROUGH. The property under test
|
||||
# is "the row reports the age of the decision", and a real 15 s
|
||||
# wait would put a quarter of a minute into every run for a number
|
||||
# the check can set exactly.
|
||||
ctl.arb_at -= timedelta(seconds=age_arb_by_s)
|
||||
return dict(ctl.status_payload()["rows"]), ctl
|
||||
|
||||
try:
|
||||
return asyncio.run(_go())
|
||||
except Exception as e: # noqa: BLE001
|
||||
return _NoRows(e), _NoCtl(e)
|
||||
|
||||
|
||||
# -- 12a. the instrument first: a parser that can tell right from wrong ------
|
||||
# ⚠️ CALIBRATED BEFORE ANYTHING IS GRADED WITH IT (habit 1, and MUTATION.md's
|
||||
# eleventh instance of habit 4 was in a measuring instrument rather than in a
|
||||
# check). A slot parser that returns `None` for everything makes every row
|
||||
# below vacuous; one that matches a number wherever it appears reads a
|
||||
# transposition as a pass. So it is driven against a row that is RIGHT and a
|
||||
# row that is WRONG IN EXACTLY THE WAY THE DEFECT WAS, and both answers are
|
||||
# asserted.
|
||||
_T11_SLOTS = {
|
||||
"points": r"(\d+) points over \d+ h",
|
||||
"hours": r"\d+ points over (\d+) h",
|
||||
"firm": r"(\d+) firm / \d+ carried",
|
||||
"carried": r"\d+ firm / (\d+) carried",
|
||||
"horizon": r"for a (\d+) h horizon",
|
||||
"age_s": r"fetched (\d+) s ago",
|
||||
"spacings": r"([\d+]+) s spacing",
|
||||
# ⚠️ THE *Arbitrage* ROW'S OWN AGE SLOT, and it is deliberately the same
|
||||
# pattern for "decided N s ago" and "the last one was taken N s ago" -
|
||||
# WHICH of the two sentences was rendered is graded by inequality below,
|
||||
# and this only has to read the number out of whichever one it is.
|
||||
"arb_age_s": r"(\d+) s ago",
|
||||
# ⚠️ THE REFUSAL SENTENCES GET SLOTS TOO, for the rows' reason: `"1 of 96"
|
||||
# in reason` is satisfied by that pair standing in the HOUR clause, which
|
||||
# is the transposition the defect actually was.
|
||||
"r83_firm_pts": r"only (\d+) of \d+ points carry a published price",
|
||||
"r83_all_pts": r"only \d+ of (\d+) points carry a published price",
|
||||
"r83_firm_h": r"published price - (\d+) of \d+ hours",
|
||||
"r83_all_h": r"published price - \d+ of (\d+) hours",
|
||||
"flat_pts": r"every one of the (\d+) firm points, spanning \d+ hours",
|
||||
"flat_h": r"every one of the \d+ firm points, spanning (\d+) hours",
|
||||
}
|
||||
|
||||
|
||||
def _slot(row, name):
|
||||
"""The number in ONE NAMED SLOT of a rendered row, or `_Raised`.
|
||||
|
||||
⚠️ BY SLOT, NEVER BY PRESENCE. `"33" in row` is true of a row that prints
|
||||
33 in the POINT slot, which is a zero-byte transposition away and is
|
||||
exactly MUTATION.md habit 14's finding. A slot that does not match is a
|
||||
FAILED derivation and reports as one; it is not a miss that passes.
|
||||
"""
|
||||
def _get():
|
||||
if not isinstance(row, str):
|
||||
raise TypeError(f"no row to read: {row!r}")
|
||||
m = re.search(_T11_SLOTS[name], row)
|
||||
if m is None:
|
||||
raise ValueError(f"no {name!r} slot in {row[:90]!r}")
|
||||
raw = m.group(1)
|
||||
return ([int(x) for x in raw.split("+")] if name == "spacings"
|
||||
else int(raw))
|
||||
return derived(_get)
|
||||
|
||||
|
||||
_T11_GOOD = ("x (recomputed this tick); provider plan fetched 7 s ago for a "
|
||||
"48 h horizon: dynamic_dayahead from generic_dayahead, "
|
||||
"132 points over 33 h, 36 firm / 96 carried, 900+3600 s spacing")
|
||||
_T11_SHIPPED_DEFECT = ("x (recomputed this tick); provider plan fetched 7 s "
|
||||
"ago for a 48 h horizon: dynamic_dayahead from "
|
||||
"generic_dayahead, 132 points over 132 h, "
|
||||
"96 firm / 36 carried, 900+3600 s spacing")
|
||||
# The slots divide into the ones a *Tariff*/*Arbitrage* ROW carries and the
|
||||
# ones a REFUSAL sentence carries; each group is calibrated against its own
|
||||
# well-formed example, because a slot asked of the wrong kind of sentence is
|
||||
# correctly absent and would make this check red for the wrong reason.
|
||||
_T11_ROW_SLOTS = ("points", "hours", "firm", "carried", "horizon", "age_s",
|
||||
"spacings", "arb_age_s")
|
||||
_T11_REF_SLOTS = ("r83_firm_pts", "r83_all_pts", "r83_firm_h", "r83_all_h",
|
||||
"flat_pts", "flat_h")
|
||||
_T11_GOOD_REF = ("§8.3: only 1 of 96 points carry a published price - 1 of 24 "
|
||||
"hours - and the rest are provisional; the tariff is flat - "
|
||||
"every one of the 96 firm points, spanning 24 hours, costs")
|
||||
print(f" slot parser on a correct row -> "
|
||||
f"{ {k: _slot(_T11_GOOD, k) for k in _T11_ROW_SLOTS} }")
|
||||
print(f" slot parser on a refusal -> "
|
||||
f"{ {k: _slot(_T11_GOOD_REF, k) for k in _T11_REF_SLOTS} }")
|
||||
check("the slot parser reads every field of a well-formed row and of a "
|
||||
"well-formed refusal - at a single unreadable slot every check below "
|
||||
"this line would be graded on a failed derivation rather than on the "
|
||||
"sentence",
|
||||
all(not isinstance(_slot(_T11_GOOD, k), _Raised) for k in _T11_ROW_SLOTS)
|
||||
and all(not isinstance(_slot(_T11_GOOD_REF, k), _Raised)
|
||||
for k in _T11_REF_SLOTS)
|
||||
and sorted(_T11_ROW_SLOTS + _T11_REF_SLOTS) == sorted(_T11_SLOTS)
|
||||
and _slot(_T11_GOOD, "points") == 132
|
||||
and _slot(_T11_GOOD, "hours") == 33)
|
||||
check("...and it TELLS THE TWO DEFECTS APART from the correct row: the shipped "
|
||||
"point<->hour conflation (132 h for 33 h) and a firm/carried "
|
||||
"transposition both move a slot, so the parser is not merely finding "
|
||||
"numbers somewhere in a sentence",
|
||||
_slot(_T11_SHIPPED_DEFECT, "hours") == 132
|
||||
and _slot(_T11_SHIPPED_DEFECT, "firm") == 96
|
||||
and _slot(_T11_SHIPPED_DEFECT, "points")
|
||||
== _slot(_T11_GOOD, "points"))
|
||||
check("...and a missing slot is a FAILED derivation, not a silent pass - the "
|
||||
"difference between 'the row does not say that' and 'the row says "
|
||||
"nothing', which is why `derived()` exists in this file",
|
||||
isinstance(_slot("nothing useful here", "points"), _Raised))
|
||||
|
||||
|
||||
# -- 12b. the fixture SET, and the degeneracy it is built to avoid ------------
|
||||
# ⚠️ ONE ARRANGEMENT CANNOT SEPARATE FIVE NUMBERS (habit 10). On the shipped
|
||||
# healthy plan - an all-firm quarter-hourly day - `points` and `firm` are the
|
||||
# SAME INTEGER and `carried` is 0, so a row that printed the point count in the
|
||||
# firm slot would pass. On an hourly static profile `points` and `hours` are
|
||||
# the same integer, which is precisely why the defect shipped: every fixture
|
||||
# that existed before TARIFF-08 was hourly. So the fixtures are a SET, and the
|
||||
# table below is COMPUTED and PRINTED - a pair of fields no row separates shows
|
||||
# up as a named survivor and takes this check red, rather than being absent
|
||||
# from a list nobody re-derived.
|
||||
_T11_SHAPES = (
|
||||
("all-firm quarter-hourly, the live healthy plan", 33, 0),
|
||||
("mixed: 9 published hours, 24 carried - the weekday morning", 9, 24),
|
||||
("§11.4 fallback: nothing published, 12 hourly carried", 0, 12),
|
||||
("late morning: 6 published hours, 3 carried", 6, 3),
|
||||
)
|
||||
_T11_FIELDS = ("points", "hours", "firm", "carried", "horizon")
|
||||
_T11_TABLE = [(lbl, dict(_t11_want(f, c), horizon=48))
|
||||
for lbl, f, c in _T11_SHAPES]
|
||||
print(" fixture set (points / hours / firm / carried / horizon):")
|
||||
for _lbl, _w in _T11_TABLE:
|
||||
print(f" {_w['points']:>4} {_w['hours']:>4} {_w['firm']:>4} "
|
||||
f"{_w['carried']:>4} {_w['horizon']:>4} {_lbl}")
|
||||
_T11_UNSEP = sorted(
|
||||
f"{a}=={b}" for i, a in enumerate(_T11_FIELDS) for b in _T11_FIELDS[i + 1:]
|
||||
if not any(w[a] != w[b] for _lbl, w in _T11_TABLE))
|
||||
print(f" field pairs NO fixture separates: {_T11_UNSEP or 'none'}")
|
||||
check(f"every pair of the five numbers the *Tariff* row prints is told apart "
|
||||
f"by at least one fixture - unseparated pairs {_T11_UNSEP}. A pair that "
|
||||
f"is equal in every fixture is one the checks below cannot tell "
|
||||
f"transposed, which is a ZERO-BYTE edit (habit 14)",
|
||||
_T11_UNSEP == [])
|
||||
check("...and the set contains BOTH the degenerate hourly arrangement (points "
|
||||
"== hours, where the defect was invisible for three cards) and the "
|
||||
"quarter-hourly one where they differ 4x, so the row is pinned on the "
|
||||
"shape that hid it as well as on the shape that exposes it",
|
||||
any(w["points"] == w["hours"] for _l, w in _T11_TABLE)
|
||||
and any(w["points"] == 4 * w["hours"] for _l, w in _T11_TABLE))
|
||||
|
||||
|
||||
# -- 12c. THE *Tariff* ROW ITSELF ---------------------------------------------
|
||||
_T11_ROWS = [(lbl, f, c,
|
||||
derived(lambda f=f, c=c: _t11_render(
|
||||
{"price_provider": "generic_dayahead"},
|
||||
plan=_t11_plan(f, c))[0]["Tariff"]))
|
||||
for lbl, f, c in _T11_SHAPES]
|
||||
for _lbl, _f, _c, _row in _T11_ROWS:
|
||||
print(f" Tariff row | {_lbl}")
|
||||
print(f" {_row}")
|
||||
|
||||
_T11_BAD = [f"{lbl}: {k} slot reads {_slot(row, k)!r}, fixture says "
|
||||
f"{_t11_want(f, c)[k]!r}"
|
||||
for lbl, f, c, row in _T11_ROWS
|
||||
for k in ("points", "hours", "firm", "carried", "spacings")
|
||||
if _slot(row, k) != _t11_want(f, c)[k]]
|
||||
check(f"AC: EVERY number the rendered *Tariff* row prints matches what this "
|
||||
f"file computed from the fixture's own two parameters, in its OWN slot, "
|
||||
f"across all four shapes - disagreements {_T11_BAD}. This is the check "
|
||||
f"that did not exist: `words` said `132 h` about 33 hours for three "
|
||||
f"cards and no suite could see it, because no suite read the sentence",
|
||||
_T11_BAD == [])
|
||||
check(f"...and the HORIZON reaches the row as hours, from the option rather "
|
||||
f"than from the plan - "
|
||||
f"{[_slot(r, 'horizon') for _l, _f, _c, r in _T11_ROWS]} against "
|
||||
f"`price_horizon_h` = "
|
||||
f"{price_option(dict(_T11_BASE), 'price_horizon_h')}. The soak measured "
|
||||
f"48.0 at every sample and `words` never said 48",
|
||||
all(_slot(r, "horizon")
|
||||
== int(float(price_option(dict(_T11_BASE), "price_horizon_h")))
|
||||
for _l, _f, _c, r in _T11_ROWS))
|
||||
|
||||
# ⚠️ ORIGIN AND PRODUCT ARE GRADED BY DISAGREEMENT, NOT BY A SUBSTRING. Both
|
||||
# are enum-ish labels, so `"provider" in row` is satisfied by the word
|
||||
# "provider" this row already contains twice for other reasons. Two plans over
|
||||
# the IDENTICAL point list, differing in exactly one of the two fields, must
|
||||
# render differently - which is false of any row that drops the field.
|
||||
# ⚠️ AND THE FIXTURE VARIES `origin` **ALONE**. The first draft of this pair
|
||||
# gave the fallback plan `provider="static_profile"` as well - which is what a
|
||||
# real §11.4 plan carries - so the two rows differed for TWO reasons and the
|
||||
# mutation that drops `origin` from the sentence entirely (tag <- read.provider,
|
||||
# -190 B) was a SURVIVOR of the whole suite. That is MUTATION.md habit 4 in the
|
||||
# check written to close this card: a difference-claim whose two sides differ in
|
||||
# more than the thing it names cannot grade that thing. The provider string is
|
||||
# held EQUAL below, as its own precondition check.
|
||||
_T11_ID_PTS = (9, 24)
|
||||
_T11_PLAN_PROV = _t11_plan(*_T11_ID_PTS, origin="provider")
|
||||
_T11_PLAN_FALL = _t11_plan(*_T11_ID_PTS, origin="fallback")
|
||||
_T11_AS_PROVIDER = derived(lambda: _t11_render(
|
||||
{"price_provider": "generic_dayahead"},
|
||||
plan=_T11_PLAN_PROV)[0]["Tariff"])
|
||||
_T11_AS_FALLBACK = derived(lambda: _t11_render(
|
||||
{"price_provider": "generic_dayahead"},
|
||||
plan=_T11_PLAN_FALL)[0]["Tariff"])
|
||||
check(f"the origin pair differs in `origin` AND IN NOTHING ELSE (habit 1): "
|
||||
f"provider strings {_T11_PLAN_PROV.read.provider!r} == "
|
||||
f"{_T11_PLAN_FALL.read.provider!r}, products equal, point lists equal, "
|
||||
f"origins {_T11_PLAN_PROV.origin!r} != {_T11_PLAN_FALL.origin!r}. Give "
|
||||
f"the fallback its own provider string and the row below differs for a "
|
||||
f"second reason, which is how the -190 B mutation survived the first "
|
||||
f"draft",
|
||||
_T11_PLAN_PROV.read.provider == _T11_PLAN_FALL.read.provider
|
||||
and _T11_PLAN_PROV.product == _T11_PLAN_FALL.product
|
||||
and _T11_PLAN_PROV.origin != _T11_PLAN_FALL.origin
|
||||
and [p.canonical() for p in _T11_PLAN_PROV.read.points]
|
||||
== [p.canonical() for p in _T11_PLAN_FALL.read.points])
|
||||
_T11_AS_REALTIME = derived(lambda: _t11_render(
|
||||
{"price_provider": "generic_dayahead"},
|
||||
plan=_t11_plan(*_T11_ID_PTS, product=PriceProduct.REALTIME))[0]["Tariff"])
|
||||
print(f" origin='provider' -> {_T11_AS_PROVIDER[-88:]!r}")
|
||||
print(f" origin='fallback' -> {_T11_AS_FALLBACK[-88:]!r}")
|
||||
print(f" product=REALTIME -> {_T11_AS_REALTIME[-88:]!r}")
|
||||
check("AC: the plan's ORIGIN reaches the row - two plans over the IDENTICAL "
|
||||
"point list, one observed from the feed and one §11.4 standing in for a "
|
||||
"dead one, render DIFFERENTLY. Every count in them is equal, so a row "
|
||||
"that dropped `origin` could not tell an operator that the prices on "
|
||||
"screen are the static profile rather than the market",
|
||||
_T11_AS_PROVIDER != _T11_AS_FALLBACK
|
||||
and all(_slot(_T11_AS_PROVIDER, k) == _slot(_T11_AS_FALLBACK, k)
|
||||
for k in ("points", "hours", "firm", "carried")))
|
||||
check("...and so does the PRODUCT, on the same terms - §9.2's product is what "
|
||||
"the optimiser widens its thresholds on, and it was on no surface",
|
||||
_T11_AS_REALTIME != _T11_AS_PROVIDER
|
||||
and all(_slot(_T11_AS_REALTIME, k) == _slot(_T11_AS_PROVIDER, k)
|
||||
for k in ("points", "hours", "firm", "carried")))
|
||||
|
||||
# ⚠️ THE TWO TARIFF SOURCES ARE NOT COMPUTED AT THE SAME MOMENT AND THE ROW
|
||||
# MAY NOT IMPLY THEY WERE. `static_status()` runs on the 30 s I/O-free tick;
|
||||
# the provider plan comes off the 1800 s refresh and is served for up to
|
||||
# TARIFF_MAX_AGE_S - 5400 s since TARIFF-12. So the plan half carries its own
|
||||
# age, and the age is graded against a number this file SET rather than
|
||||
# against one the row supplied.
|
||||
_T11_FRESH = derived(lambda: _t11_render(
|
||||
{"price_provider": "generic_dayahead"},
|
||||
plan=_t11_plan(9, 24), plan_age_s=0.0)[0]["Tariff"])
|
||||
_T11_OLD = derived(lambda: _t11_render(
|
||||
{"price_provider": "generic_dayahead"},
|
||||
plan=_t11_plan(9, 24), plan_age_s=4000.0)[0]["Tariff"])
|
||||
print(f" plan 0 s old -> age slot {_slot(_T11_FRESH, 'age_s')!r}")
|
||||
print(f" plan 4000 s old -> age slot {_slot(_T11_OLD, 'age_s')!r}")
|
||||
check(f"AC/trap: the row does NOT present the 30 s tick's sentence and a "
|
||||
f"4000-second-old plan as one measurement - the plan half is stamped "
|
||||
f"with its own age, and the age MOVES with `tariff_at`: "
|
||||
f"{_slot(_T11_FRESH, 'age_s')} s against {_slot(_T11_OLD, 'age_s')} s "
|
||||
f"for the SAME plan object. A constant, or an age taken from the render "
|
||||
f"clock, gives the same number twice",
|
||||
isinstance(_slot(_T11_OLD, "age_s"), int)
|
||||
and 4000 <= _slot(_T11_OLD, "age_s") <= 4010
|
||||
and _slot(_T11_FRESH, "age_s") <= 5)
|
||||
check(f"...and the two halves are still BOTH there, in order: the tick's "
|
||||
f"static sentence for this provider-only site - "
|
||||
f"{static_status(dict(_T11_BASE), _T11_NOW)[1]!r} - leads, and the plan "
|
||||
f"follows it. Replacing the row wholesale would lose the cheap-window "
|
||||
f"evidence TARIFF-03 put there",
|
||||
_T11_FRESH.startswith(static_status(dict(_T11_BASE), _T11_NOW)[1]))
|
||||
|
||||
# ⚠️ AND THE STATE THE SOAK ACTUALLY MEASURED: a provider configured, and no
|
||||
# plan. 802 samples of `'no static profile'`, which is the row a site with no
|
||||
# provider prints too - so the surface could not tell "trading on a live feed"
|
||||
# from "no tariff machinery at all".
|
||||
_T11_NOPLAN = derived(lambda: _t11_render(
|
||||
{"price_provider": "generic_dayahead"}, plan=None)[0]["Tariff"])
|
||||
_T11_NOPROV = derived(lambda: _t11_render({}, plan=None)[0]["Tariff"])
|
||||
print(f" provider set, no plan -> {_T11_NOPLAN!r}")
|
||||
print(f" no provider at all -> {_T11_NOPROV!r}")
|
||||
check(f"AC: a site with a `price_provider` and NO usable plan says so, and "
|
||||
f"reads differently from a site that configured no provider at all. "
|
||||
f"Both printed {_T11_NOPROV!r} before this card - all 802 soak samples, "
|
||||
f"on an install whose feed was answering",
|
||||
_T11_NOPLAN != _T11_NOPROV
|
||||
and str(int(_main.TARIFF_MAX_AGE_S)) in _T11_NOPLAN)
|
||||
|
||||
|
||||
# -- 12d. THE *Arbitrage* ROW: THREE FACTS THAT RENDERED AS TWO ---------------
|
||||
# ⚠️ THE STIMULUS IS A DRIVEN CYCLE WITH A SENSOR ANSWERING `None`, not a
|
||||
# hand-set attribute. The defect is a control-flow one - `cycle()` returns at
|
||||
# the input hold BEFORE `self.arb = None` - and a fixture that assigned
|
||||
# `ctl.arb = None` itself would have reproduced the symptom without ever
|
||||
# touching the path that causes it.
|
||||
_T11_OFF = derived(lambda: _t11_render({}, soc=None))
|
||||
_T11_ON_HELD = derived(lambda: _t11_render({"arbitrage_enabled": True},
|
||||
soc=None))
|
||||
_T11_ON_OK = derived(lambda: _t11_render({"arbitrage_enabled": True},
|
||||
soc=55.0))
|
||||
_T11_ON_STALE = derived(lambda: _t11_render({"arbitrage_enabled": True},
|
||||
soc=55.0, age_arb_by_s=13))
|
||||
_T11_ON_DROPPED = derived(lambda: _t11_render({"arbitrage_enabled": True},
|
||||
soc=55.0, then_soc_none=True))
|
||||
print(f" trading off, inputs missing -> {_T11_OFF[0]['Arbitrage']!r}")
|
||||
print(f" trading ON, inputs missing -> {_T11_ON_HELD[0]['Arbitrage']!r}")
|
||||
print(f" trading ON, inputs fine -> {_T11_ON_OK[0]['Arbitrage']!r}")
|
||||
print(f" trading ON, decision 13 s old-> {_T11_ON_STALE[0]['Arbitrage']!r}")
|
||||
print(f" trading ON, SoC then dropped -> {_T11_ON_DROPPED[0]['Arbitrage']!r}")
|
||||
|
||||
check(f"the stimulus is the one the card names, and it is asserted rather "
|
||||
f"than assumed (habit 1): `arbitrage.enabled(opts)` is "
|
||||
f"{_arb.enabled(_T11_ON_HELD[1].o)} while `controller.arb` is "
|
||||
f"{_T11_ON_HELD[1].arb!r}. If a later edit makes `cycle()` reach the "
|
||||
f"arbitrage step under an input hold, this goes red rather than the "
|
||||
f"rows below going quietly vacuous",
|
||||
_arb.enabled(_T11_ON_HELD[1].o) is True
|
||||
and _T11_ON_HELD[1].arb is None
|
||||
and not _T11_ON_HELD[1].inputs_ok)
|
||||
check(f"AC, AND THE WHOLE OF DEFECT 4: 'trading is off' and 'trading is on "
|
||||
f"and no decision was reached this cycle' RENDER DIFFERENTLY. They are "
|
||||
f"different facts with different remedies and this row printed "
|
||||
f"`off (arbitrage_enabled)` for both - naming an option that is `true` "
|
||||
f"in the options blob",
|
||||
_T11_OFF[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
||||
check(f"...and the armed-but-held row carries the CONTROL LOOP'S OWN REASON "
|
||||
f"({_T11_ON_HELD[1].reason!r}), so the operator is told which input is "
|
||||
f"missing rather than being sent to look at a switch that is already on. "
|
||||
f"The reason comes from main.py, not from this file",
|
||||
_T11_ON_HELD[1].reason
|
||||
and _T11_ON_HELD[1].reason in _T11_ON_HELD[0]["Arbitrage"])
|
||||
check(f"...and a cycle that DID reach the step renders the strategy's own "
|
||||
f"sentence - {_T11_ON_OK[1].arb.explain()[:50]!r} - rather than a "
|
||||
f"re-derivation of it, which is the guarantee OPT-04 put on this row and "
|
||||
f"the one thing about it that was already right",
|
||||
_T11_ON_OK[1].arb is not None
|
||||
and _T11_ON_OK[1].arb.explain() in _T11_ON_OK[0]["Arbitrage"]
|
||||
and _T11_ON_OK[0]["Arbitrage"] != _T11_ON_HELD[0]["Arbitrage"])
|
||||
check(f"...and all THREE states are pairwise distinct, so no pair of them can "
|
||||
f"be collapsed by a later edit without a named red here",
|
||||
len({_T11_OFF[0]["Arbitrage"], _T11_ON_HELD[0]["Arbitrage"],
|
||||
_T11_ON_OK[0]["Arbitrage"]}) == 3)
|
||||
_T11_AGE = _slot(_T11_ON_STALE[0]["Arbitrage"], "arb_age_s")
|
||||
check(f"AC: a decision the CURRENT cycle did not take is rendered as one - "
|
||||
f"age slot {_T11_AGE} s against the 13 s this file moved `arb_at` back "
|
||||
f"by. The card's second half: during an input hold the row went on "
|
||||
f"showing the last decision, up to `stale_input_s` (15 s) stale, with "
|
||||
f"nothing on the page saying so",
|
||||
isinstance(_T11_AGE, int) and 13 <= _T11_AGE <= 18
|
||||
and _T11_ON_STALE[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
|
||||
# ⚠️ AND THE SAME CLAIM ON THE STIMULUS THAT ACTUALLY PRODUCES IT. The row
|
||||
# above moves `arb_at` by hand so the AGE can be graded against an exact
|
||||
# number; this one drives the control-flow path itself - one healthy cycle,
|
||||
# then a sensor that stops answering - and asserts that the shipped controller
|
||||
# reaches the state the card describes, rather than that this file can simulate
|
||||
# it. Without this row a mutation that never sets `cycle_at` would still pass,
|
||||
# because the hand-aged fixture would go on rendering `stale`.
|
||||
# ⚠️ `derived()`, NOT A BARE COMPARISON, AND THE BATTERY IS WHY. The mutation
|
||||
# that never stamps `cycle_at` leaves it at `__init__`'s None, and
|
||||
# `datetime < None` is a TypeError - in the f-string OPERAND as much as in the
|
||||
# condition. Driven, the first draft of this check produced a TRACEBACK and
|
||||
# NO NAMED RED, grading none of the checks after it: FCST-06's finding, in the
|
||||
# check written for this card. "It answered nothing" and "it broke on the way
|
||||
# to answering" must not grade the same.
|
||||
_T11_DROP_STALE = derived(
|
||||
lambda: _T11_ON_DROPPED[1].arb_at < _T11_ON_DROPPED[1].cycle_at)
|
||||
check(f"DRIVEN, no clock touched: a decision stands "
|
||||
f"({_T11_ON_DROPPED[1].arb is not None}) while the LATEST cycle returned "
|
||||
f"at the input hold without reaching the arbitrage step "
|
||||
f"(arb_at < cycle_at = {_T11_DROP_STALE!r}), and the row says so instead "
|
||||
f"of presenting the standing decision as this cycle's",
|
||||
_T11_ON_DROPPED[1].arb is not None
|
||||
and not _T11_ON_DROPPED[1].inputs_ok
|
||||
and _T11_DROP_STALE is True
|
||||
and _T11_ON_DROPPED[0]["Arbitrage"] != _T11_ON_OK[0]["Arbitrage"])
|
||||
check(f"...and it names the reason the cycle stopped "
|
||||
f"({_T11_ON_DROPPED[1].reason!r}) beside the age of the decision it is "
|
||||
f"still showing, so 'the strategy decided this' and 'the strategy last "
|
||||
f"decided this' are not one sentence",
|
||||
_T11_ON_DROPPED[1].reason in _T11_ON_DROPPED[0]["Arbitrage"]
|
||||
and isinstance(_slot(_T11_ON_DROPPED[0]["Arbitrage"], "arb_age_s"), int))
|
||||
|
||||
check("...and `main.py`'s 'if and only if' is graded as BEHAVIOUR rather than "
|
||||
"as prose: a comment is not a check, and three findings in this chain "
|
||||
"were graded by a sentence their author also wrote. What makes the "
|
||||
"comment true is that the row reads the OPTION - so with the option on "
|
||||
"and no decision taken, the row does not equal the option-off row, which "
|
||||
"is the row above",
|
||||
_arb.enabled(_T11_ON_HELD[1].o)
|
||||
and _T11_ON_HELD[1].arb is None
|
||||
and _T11_ON_HELD[0]["Arbitrage"] != _T11_OFF[0]["Arbitrage"])
|
||||
|
||||
|
||||
# -- 12e. the two REFUSAL sentences, which reach this same row ----------------
|
||||
# ⚠️ THESE ARE OPERATOR-FACING THROUGH `self.arb.explain()`, WHICH IS WHY THEY
|
||||
# ARE HERE AND NOT ONLY IN test_static_tariff.py. `§8.3: only 1 of 96 hours
|
||||
# carry a published price` is 96 POINTS, which is 24 hours - the same
|
||||
# conflation TARIFF-14 removed from `at()`, left standing in the prose that
|
||||
# describes it.
|
||||
_T11_A9 = {"peak_guard_active": False, "min_spread_eur_kwh": 0.0,
|
||||
"uncertainty": frozenset()}
|
||||
_T11_ONEFIRM = TariffPlan(
|
||||
PriceRead.series(
|
||||
[PricePoint(_T11_NOW + timedelta(minutes=15 * i),
|
||||
round(0.10 + 0.001 * i, 5), i != 0) for i in range(96)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
|
||||
_T11_FLAT = TariffPlan(
|
||||
PriceRead.series(
|
||||
[PricePoint(_T11_NOW + timedelta(minutes=15 * i), 0.10, False)
|
||||
for i in range(96)],
|
||||
PriceProduct.DAY_AHEAD, "generic_dayahead", _T11_NOW), None, "provider")
|
||||
_r83 = derived(lambda: _T11_ONEFIRM.arbitrage(**_T11_A9).reason)
|
||||
_rflat = derived(lambda: _T11_FLAT.arbitrage(**_T11_A9).reason)
|
||||
print(f" §8.3 refusal : {_r83[:118]}")
|
||||
print(f" flat refusal : {_rflat[:118]}")
|
||||
# 96 quarter-hourly points span 24 hours; ONE firm point spans one of them.
|
||||
# Both figures are this file's arithmetic over the fixture it built.
|
||||
# The fixture is 96 points at 15-minute spacing, built four lines above, so it
|
||||
# spans 96 // 4 = 24 hours and its single firm point occupies ONE of them.
|
||||
# Both expectations are this file's arithmetic over the shape it asked for.
|
||||
_T11_R83 = {k: _slot(_r83, k) for k in
|
||||
("r83_firm_pts", "r83_all_pts", "r83_firm_h", "r83_all_h")}
|
||||
print(f" §8.3 slots : {_T11_R83}")
|
||||
print(f" flat slots : "
|
||||
f"{ {k: _slot(_rflat, k) for k in ('flat_pts', 'flat_h')} }")
|
||||
check(f"AC: §8.3's refusal reports POINTS and HOURS as two quantities, each in "
|
||||
f"its OWN slot - {_T11_R83} over a fixture of 96 quarter-hourly points "
|
||||
f"spanning 24 hours, of which one is firm. It read `only 1 of 96 hours` "
|
||||
f"about a 24-hour day, and this sentence reaches the status page",
|
||||
_T11_R83["r83_firm_pts"] == 1 and _T11_R83["r83_all_pts"] == 96
|
||||
and _T11_R83["r83_firm_h"] == 1 and _T11_R83["r83_all_h"] == 96 // 4)
|
||||
check(f"...and the two clauses are not the same pair twice: the POINT clause "
|
||||
f"and the HOUR clause disagree, which is exactly what the shipped "
|
||||
f"sentence could not express",
|
||||
_T11_R83["r83_all_pts"] != _T11_R83["r83_all_h"])
|
||||
check(f"...and the flat-tariff refusal does the same - it counted the same 96 "
|
||||
f"points and called them `firm hours`: slots "
|
||||
f"{_slot(_rflat, 'flat_pts')} points, {_slot(_rflat, 'flat_h')} hours",
|
||||
_slot(_rflat, "flat_pts") == 96
|
||||
and _slot(_rflat, "flat_h") == 96 // 4)
|
||||
check("...and neither sentence lost what TEST-16 put in it: §8.3's refusal is "
|
||||
"still attributable to the FIRM-PAIR rung by its own words, and does not "
|
||||
"degrade into the flat rung's sentence about a series whose prices "
|
||||
"differ by 0.095 EUR/kWh",
|
||||
"§8.3" in _r83 and "carry a published price" in _r83
|
||||
and "the tariff is flat" not in _r83
|
||||
and "the tariff is flat" in _rflat)
|
||||
|
||||
|
||||
# -- 12f. END TO END, against the COMMITTED capture --------------------------
|
||||
# ⚠️ EVERY FIXTURE ABOVE IS SYNTHETIC, AND habit 19 IS WHY THIS ROW EXISTS.
|
||||
# The shapes above are this file's own arithmetic; they cannot tell whether the
|
||||
# SHIPPED refresh puts a real plan on the row. This drives `run_tariff_refresh`
|
||||
# against `scenarios/energy-charts-be-price-*.json` - bytes captured from
|
||||
# api.energy-charts.info - and grades the row against the CAPTURE'S OWN point
|
||||
# count, which neither main.py nor static_tariff.py shaped.
|
||||
_T11_N_CAP = len(_CAPTURE["price"])
|
||||
|
||||
|
||||
def _t11_e2e():
|
||||
tmp = tempfile.mkdtemp(prefix="tariff11-e2e-")
|
||||
_tmpdirs_t10.append(tmp)
|
||||
opts = dict(_T11_BASE, price_provider="generic_dayahead")
|
||||
|
||||
async def _get(url, timeout_s):
|
||||
return _restamped_body(datetime.now(timezone.utc))
|
||||
|
||||
real = _price.https_get
|
||||
_price.https_get = _get
|
||||
try:
|
||||
async def _go():
|
||||
stop = asyncio.Event()
|
||||
ctl = _main.Controller(opts, _T11Hass(), _T10Store(), _T10Pub(),
|
||||
stop=stop,
|
||||
peak_db_path=os.path.join(tmp, "e.db"),
|
||||
price_history_path=os.path.join(tmp, "p.json"))
|
||||
task = asyncio.create_task(ctl.run_tariff_refresh())
|
||||
for _ in range(250): # ~5 s ceiling, habit 5
|
||||
if ctl.tariff_at is not None:
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
ctl.stopping = True
|
||||
stop.set()
|
||||
await asyncio.wait_for(task, timeout=5)
|
||||
await ctl.read_inputs()
|
||||
await ctl.cycle()
|
||||
return dict(ctl.status_payload()["rows"])
|
||||
return asyncio.run(_go())
|
||||
finally:
|
||||
_price.https_get = real
|
||||
|
||||
|
||||
_T11_E2E = derived(_t11_e2e)
|
||||
_T11_E2E_ROW = "" if isinstance(_T11_E2E, _Raised) else _T11_E2E["Tariff"]
|
||||
print(f" end to end, {_T11_N_CAP} captured prices at 900 s:")
|
||||
print(f" {_T11_E2E_ROW}")
|
||||
check(f"AC, DRIVEN THROUGH THE SHIPPED REFRESH: the *Tariff* row reports the "
|
||||
f"{_T11_N_CAP} points the COMMITTED capture publishes, over "
|
||||
f"{_T11_N_CAP // 4} hours, on a quarter-hourly feed. Both numbers come "
|
||||
f"from the captured document's own length - not from the plan the code "
|
||||
f"under test built out of it (habit 19)",
|
||||
_slot(_T11_E2E_ROW, "points") == _T11_N_CAP
|
||||
and _slot(_T11_E2E_ROW, "hours") == _T11_N_CAP // 4
|
||||
and _slot(_T11_E2E_ROW, "points") != _slot(_T11_E2E_ROW, "hours"))
|
||||
check(f"...and the row an operator reads names the SPACING that makes those "
|
||||
f"two numbers differ - {_slot(_T11_E2E_ROW, 'spacings')!r} s. The plan "
|
||||
f"carried mixed 900/3600 s for 70 % of a 40-hour soak and no surface "
|
||||
f"said so",
|
||||
_slot(_T11_E2E_ROW, "spacings") == [900])
|
||||
check("...and this is the same wording FCST-04's *Price history* row already "
|
||||
"used, so the two rows describing the same feed can be compared rather "
|
||||
"than being two conventions on one page",
|
||||
"points over" in _T11_E2E_ROW
|
||||
and "points over" in ("" if isinstance(_T11_E2E, _Raised)
|
||||
else _T11_E2E["Price history"]))
|
||||
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: " + "; ".join(fails))
|
||||
|
||||
@@ -2,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)
|
||||
|
||||
Reference in New Issue
Block a user