_Series.at() asked s <= u < s + 1 h of every point in list order and
returned the first match. On the hourly shape every price fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium moved to
a 15-minute MTU on 2025-10-01; the live day-ahead feed returns 96 points
at 900 s, where it is wrong twice over. Against the shipped code at 5059e28:
Inside the first hour the :00 point wins because its hour has not run out. From the second hour on the answer is not even the top of the hour — the
first point still inside its own hour wins — so the error compounds with depth
into the series.
What it cost is not "a slightly wrong price".arbitrage._act() tests here.start == v.cheapest.start / == v.dearest.start to decide whether this
interval is the buy one or the sell one. That identity almost never matched the
extremum, so the strategy idled through exactly the interval it exists to
trade, and read §8.3's here.provisional and §17's price < 0 off the same
wrong point. static_tariff.py emits hourly points, so static sites were
never affected; this is reachable only down the dynamic path TARIFF-08/09 made
work.
After, same probes, all three resolutions
ask
900 s
3600 s
mixed [900, 3600]
+0 min
02:00 0.10
02:00 0.10
02:00 0.10
+15 min
02:15 0.11
02:00 0.10
02:15 0.11
+45 min
02:45 0.13
02:00 0.10
02:45 0.13
+60 min
03:00 0.14
03:00 0.11
03:00 0.14
+75 min
03:15 0.15
03:00 0.11
03:00 0.14 (hourly point)
The hourly column is byte-for-byte what shipped. That is exactly why nothing
caught this: the suite had been exercising the one resolution on which the
broken rule and the right one agree.
The rule
A point covers from its own start until the next point's start, and never
more than one hour.
Each point takes its own gap, which is what the mixed case needs: FCST-04's
carry-forward emits hourly carried points beside quarter-hourly firm ones, and
the soak measured spacing [900, 3600] in 70 % of samples — the normal
steady state, not an edge case.
The one-hour cap is load-bearing. FCST-04 leaves an hour out of the
series when nothing published it and yesterday has no value either, and at()
raising Absence for that hour is what sends the caller to the static profile
(§11.4). Uncapped, "until the next point's start" would stretch the point
before a three-hour hole across the whole hole and answer every instant in it
with a stale price — filling holes, introduced by the fix for a different
defect.
The last point's window — decided, not inherited
There is no next start and §9.3 says nothing about the final interval's width. Chosen: the last point is given the same span as the point before it (one
hour when it is the only point).
flat one hour
the span in front of it (chosen)
96-point 900 s day, at(23:50)
answers 23:45
answers 23:45
the same day, at(00:30)tomorrow
answers with today's 23:45 price
Absence
hourly day, at(23:50)
answers 23:00
answers 23:00
mixed series ending at the firm/carried seam
answers for an hour
Absence after 15 min
The second row is why. The cost of the choice is stated rather than buried:
a mixed series whose last point is a carried hourly one behind quarter-hourly
firm ones loses its last 45 minutes to Absence. That direction is the safe
one — the caller falls back to the static profile, the point is carried so §8.3
forbids irreversible action on it anyway, and it sits at the far end of a 48 h
horizon. Under-covering costs a missed decision; over-covering prices a real
interval with a number that does not describe it. Argued in DECISIONS/TARIFF-14-the-window-a-point-covers.md §2.
Sortedness: at() now depends on it, and establishes it itself
The old rule read one point at a time and did not care about order. The new one
reads a point's neighbours, so it would depend on the series being
ascending. It does not: the lookup index is sorted by UTC start once, at
construction, so no caller's list order can reach at().
That matters because the three things that "guarantee" order today each
guarantee it somewhere else — _validate_raw() on the load_prices() path, price_history._parse_points() on the store read path, FCST-04's fill in that
one producer — while PriceRead.series() itself has never enforced anything.
Three producers agreeing today is a coincidence with good references, not an
invariant. A series handed its 96 points in reverse answers identically, and
that is a check (red the moment the sort is removed).
Duplicate starts (refused by _validate_raw(), constructible by hand) give
the earlier list entry a zero-width window and the later one the real span. One
of the pair becomes unreachable through at(); nothing raises, at
construction or on the 30 s control path. Said as a check, not left to be found.
covers() shared it; window() did not
covers() has no window rule of its own — it calls at() — so it shared
the defect exactly and the same change fixes it. Kept as a delegation: a
second copy of the rule is a second place for it to be wrong.
window(since, until) grades a point's start against a half-open
range and never asks how long a point lasts, so it carries no resolution
assumption. It is not a coverage query, and its docstring now says so. No
callers in app/.
The arbitrage-level check — the property the defect actually broke
test_arbitrage.pysection 10 drives the shipped decide() over a 96-point quarter-hourly day whose cheapest quarter is a :45 and whose
dearest is a :15 — neither on a whole hour, asserted as its own check,
without which the section would grade nothing an hourly fixture does not
already grade. It also states, as a check, that the shipped rule and this one
return different points at both probes, so the fixture can reach the failure
it names.
It grades: the verdict's extremes are the quarters; _point_at() matches them by identity, which is what makes _act()'s here.start == v.cheapest.start
true; decide() charges and discharges in those quarters at their own
prices, with those prices in the reason strings; a non-extremum quarter idles
for the right named reason and reports its own price; §17 refuses a negative
sell quarter and the same series with that one sign flipped discharges; and
the whole thing again on a mixed[900, 3600] plan, including an instant
inside a carried hour.
Mutations — eight, all red
Purged before every run (TEST-15), reverted with the tree fingerprint checked
afterwards (MUTATION.md habit 18), backup at a mktemp path per invocation.
Clean app/price.py = 90d8ca31f7c0109d, 85953 B.
mutation
Δ
red in
M1
at() back to the shipped fixed one-hour scan over self.points
14 checks, including the pre-existing section 6 one
M3 is the zero-byte one and it is the tail decision itself — the size
MUTATION.md names as the risky one, caught by the check written for it.
What the arbitrage file does NOT see, with sizes. M2, M4, M5, M6 and M8
leave test_arbitrage.py green. That is honest rather than a gap rounded off:
they are at()-level properties (holes, caller order, covers(), window(),
the start boundary) that no arbitrage fixture reaches, because its probes sit
seven minutes into a slot and its plans have no holes. The one that matters end
to end — M1, the shipped rule — is red in both files, on the named check THE PROPERTY THE DEFECT BROKE.
What this does NOT fix, with its shape
price_forecast._walk() — §8.3's irreversible-action gate — iterates committed_hours(), which steps in whole hours, and asks series.at(u) at
each. On a quarter-hourly series that now reliably returns the hour's :00
point (it previously returned something earlier and arbitrary), so the gate
reads one point per hour and never looks at the :15/:30/:45 flags. A
partially-provisional hour would be gated on its first quarter alone. It is not
reachable from anything shipped — FCST-04 carries whole hours and generic_dayahead publishes whole days — but that is an assumption about the producers, not a property of the gate, so it is written down in the decision
note §5 rather than left for the next sub-hourly feed to find. Fixing it means
teaching committed_hours() about resolution, a data-model question this card
deliberately does not open.
Scope
app/price.py only, plus its two suites, the changelog and the decision note. No change to a point's start semantics or to the canonical §9.3 format — PricePoint still has three fields and the span is derived from the neighbours,
never stored. at() is a linear scan over the index (192 entries on the shipped
48 h horizon) on the 30 s control path; a bisect would be correct and is not
worth the second way to be wrong at that size.
All 29 test files green.⚠️ Not deployed: no ha addons update, no writes to /addons/, no contact with the Supervisor or the inverter.
Rebased twice while open — what moved, and what was re-graded
Base moved 88c59a9 → 559f0eb (TARIFF-10) → a6e64fb (TARIFF-12).
The earlier CI failure on head 7f2c4e2 was NOT a test failure. The
workflow's grade the merge, not the branch step refused the PR because it did
not apply, and said so itself: REFUSED: refs/pull/80/head does not merge into release/1.0. NOTHING HAS BEEN TESTED. This is not a test failure - no test ran.
Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged.
The job died at the merge step and no suite executed.
Conflicts resolved.CHANGELOG.md both times: every entry under the single ## Unreleased in merge order, TARIFF-10 then TARIFF-12 then this card, no
version number picked. test_price.py once: both TARIFF-10 and this card had
appended a section "13" — nothing is dropped, TARIFF-10's stays 13, this
card's is renumbered 14, and TARIFF-10's temp-directory teardown stays at
the end of the file.
Neither base change touches this fix. TARIFF-10 added provider_id() / provider_configured() beside price_option() — the option-reading layer,
nothing in _Series, at(), covers(), window() or _cover_table().
TARIFF-12 does not touch app/price.py at all. The merged at() is
byte-identical to the one every mutation below was driven against.
TARIFF-10 widens who has a series, not who looks one up. run_tariff_refresh() now runs with arbitrage off, so a provider-configured
site has a standing plan where it had none — but it adds no at() consumer to
the 30 s tick: static_status() builds a static plan (hourly, immune), main.py calls only plan.words on the fetched plan, and price_forecast.price_permits()/begin_phase() have no caller in app/main.py. Decision note §7.
TARIFF-12 makes the tail decision reachable for the first time — and picks the
same answer. A served plan may now be up to 3 × TARIFF_REFRESH_S = 5400 s
old. Spans and the one-hour cap are unaffected structurally: every window comes
from the neighbours' absolutestart instants, frozen in a tuple, computed
once at construction, so the index cannot go stale. The tail is ~46½ h away at
the shipped 48 h horizon — but price_horizon_h is int(1,72), so a 1 h
horizon is supported, and a 90-minute serve-stale window against a 1 h series
puts now past the end. Under the chosen rule at() raises Absence and
§11.4 sends the site to the static profile; under the rejected flat
one-hour tail the same stale plan would have traded against the :45 print for
up to 45 minutes after that quarter had ended. Decision note §8.
Re-graded on the final base a6e64fb, not carried over: all 29 files
green; M1 (the shipped rule, +68 B) red on the same 24 named checks
across both files; M3 (the tail rule, zero bytes) red on the same 4;
each purged before the run and reverted with the fingerprint re-checked, from a
card-specific harness at /home/homelab/.cache/t14-mut/ with a mktemp backup
inside it. This branch contributes zero diff to app/main.py and app/static_tariff.py.
## The defect, reproduced rather than theorised
`_Series.at()` asked `s <= u < s + 1 h` of every point **in list order** and
returned the **first** match. On the hourly shape every price fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium moved to
a **15-minute MTU on 2025-10-01**; the live day-ahead feed returns **96 points
at 900 s**, where it is wrong twice over. Against the shipped code at `5059e28`:
```
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
ask + 0min -> 02:00 0.10
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
ask +30min -> 02:00 0.10 <-- WRONG
ask +45min -> 02:00 0.10 <-- WRONG
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
```
Inside the first hour the `:00` point wins because its hour has not run out.
**From the second hour on the answer is not even the top of the hour** — the
first point still inside its own hour wins — so the error compounds with depth
into the series.
**What it cost is not "a slightly wrong price".** `arbitrage._act()` tests
`here.start == v.cheapest.start` / `== v.dearest.start` to decide whether this
interval is the buy one or the sell one. That identity almost never matched the
extremum, so **the strategy idled through exactly the interval it exists to
trade**, and read §8.3's `here.provisional` and §17's `price < 0` off the same
wrong point. `static_tariff.py` emits hourly points, so **static sites were
never affected**; this is reachable only down the dynamic path TARIFF-08/09 made
work.
## After, same probes, all three resolutions
| ask | 900 s | 3600 s | mixed `[900, 3600]` |
|---|---|---|---|
| +0 min | 02:00 `0.10` | 02:00 `0.10` | 02:00 `0.10` |
| +15 min | **02:15 `0.11`** | 02:00 `0.10` | **02:15 `0.11`** |
| +45 min | **02:45 `0.13`** | 02:00 `0.10` | **02:45 `0.13`** |
| +60 min | **03:00 `0.14`** | 03:00 `0.11` | **03:00 `0.14`** |
| +75 min | **03:15 `0.15`** | 03:00 `0.11` | 03:00 `0.14` (hourly point) |
The hourly column is byte-for-byte what shipped. That is exactly why nothing
caught this: **the suite had been exercising the one resolution on which the
broken rule and the right one agree.**
## The rule
> **A point covers from its own start until the next point's start, and never
> more than one hour.**
Each point takes **its own** gap, which is what the mixed case needs: FCST-04's
carry-forward emits hourly carried points beside quarter-hourly firm ones, and
the soak measured spacing `[900, 3600]` in **70 % of samples** — the normal
steady state, not an edge case.
**The one-hour cap is load-bearing.** FCST-04 leaves an hour *out* of the
series when nothing published it and yesterday has no value either, and `at()`
raising `Absence` for that hour is what sends the caller to the static profile
(§11.4). Uncapped, "until the next point's start" would stretch the point
before a three-hour hole across the whole hole and answer every instant in it
with a stale price — **filling holes, introduced by the fix for a different
defect.**
## The last point's window — decided, not inherited
There is no next start and §9.3 says nothing about the final interval's width.
**Chosen: the last point is given the same span as the point before it** (one
hour when it is the only point).
| | flat one hour | the span in front of it (**chosen**) |
|---|---|---|
| 96-point 900 s day, `at(23:50)` | answers 23:45 | answers 23:45 |
| the same day, `at(00:30)` **tomorrow** | **answers with today's 23:45 price** | `Absence` |
| hourly day, `at(23:50)` | answers 23:00 | answers 23:00 |
| mixed series ending at the firm/carried seam | answers for an hour | `Absence` after 15 min |
The second row is why. **The cost of the choice is stated rather than buried**:
a mixed series whose last point is a carried hourly one behind quarter-hourly
firm ones loses its last 45 minutes to `Absence`. That direction is the safe
one — the caller falls back to the static profile, the point is carried so §8.3
forbids irreversible action on it anyway, and it sits at the far end of a 48 h
horizon. Under-covering costs a missed decision; over-covering prices a real
interval with a number that does not describe it. Argued in
`DECISIONS/TARIFF-14-the-window-a-point-covers.md` §2.
## Sortedness: `at()` now depends on it, and establishes it itself
The old rule read one point at a time and did not care about order. The new one
reads a point's **neighbours**, so it *would* depend on the series being
ascending. It does not: **the lookup index is sorted by UTC start once, at
construction**, so no caller's list order can reach `at()`.
That matters because the three things that "guarantee" order today each
guarantee it somewhere else — `_validate_raw()` on the `load_prices()` path,
`price_history._parse_points()` on the store read path, FCST-04's fill in that
one producer — while **`PriceRead.series()` itself has never enforced anything**.
Three producers agreeing today is a coincidence with good references, not an
invariant. A series handed its 96 points in **reverse** answers identically, and
that is a check (red the moment the sort is removed).
**Duplicate starts** (refused by `_validate_raw()`, constructible by hand) give
the earlier list entry a zero-width window and the later one the real span. One
of the pair becomes unreachable through `at()`; **nothing raises**, at
construction or on the 30 s control path. Said as a check, not left to be found.
## `covers()` shared it; `window()` did not
* **`covers()`** has no window rule of its own — it calls `at()` — so it shared
the defect exactly and the same change fixes it. Kept as a delegation: a
second copy of the rule is a second place for it to be wrong.
* **`window(since, until)`** grades a point's **start** against a half-open
range and never asks how long a point lasts, so it carries no resolution
assumption. It is **not** a coverage query, and its docstring now says so. No
callers in `app/`.
## The arbitrage-level check — the property the defect actually broke
`test_arbitrage.py` **section 10** drives the shipped `decide()` over a
**96-point quarter-hourly day** whose cheapest quarter is a `:45` and whose
dearest is a `:15` — **neither on a whole hour**, asserted as its own check,
without which the section would grade nothing an hourly fixture does not
already grade. It also states, as a check, that the shipped rule and this one
return **different points** at both probes, so the fixture can reach the failure
it names.
It grades: the verdict's extremes are the quarters; `_point_at()` matches them
**by identity**, which is what makes `_act()`'s `here.start == v.cheapest.start`
true; `decide()` charges and discharges in those quarters **at their own
prices**, with those prices in the reason strings; a non-extremum quarter idles
for the right named reason and reports **its own** price; §17 refuses a negative
sell **quarter** and the same series with that one sign flipped discharges; and
the whole thing again on a **mixed** `[900, 3600]` plan, including an instant
inside a carried hour.
## Mutations — eight, all red
Purged before every run (TEST-15), reverted with the tree fingerprint checked
afterwards (MUTATION.md habit 18), backup at a `mktemp` path per invocation.
Clean `app/price.py` = `90d8ca31f7c0109d`, 85953 B.
| | mutation | Δ | red in |
|---|---|---|---|
| M1 | `at()` back to the shipped fixed one-hour scan over `self.points` | +68 B | **24 named checks** — `test_price.py` 14b ×4, 14c ×3, 14d, 14e ×3, 14f, 14g; `test_arbitrage.py` 10b ×6, 10c ×3, 10d ×2 |
| M2 | drop the one-hour cap | −68 B | `test_price.py` 14d |
| M3 | flat one-hour tail instead of the span in front | **+0 B** | `test_price.py` 14e ×3, 14g |
| M4 | trust the caller's list order — drop the sort | −61 B | `test_price.py` 14f |
| M5 | `covers()` grows its own hour-wide rule | +95 B | `test_price.py` 14e, 14g |
| M6 | `window()` becomes a coverage query | −37 B | `test_price.py` 14g |
| M7 | the window's end becomes inclusive | +1 B | 16 checks across 14b–14g |
| M8 | the window's start becomes exclusive | −1 B | 14 checks, including the pre-existing section 6 one |
**M3 is the zero-byte one and it is the tail decision itself** — the size
MUTATION.md names as the risky one, caught by the check written for it.
**What the arbitrage file does NOT see, with sizes.** M2, M4, M5, M6 and M8
leave `test_arbitrage.py` green. That is honest rather than a gap rounded off:
they are `at()`-level properties (holes, caller order, `covers()`, `window()`,
the start boundary) that no arbitrage fixture reaches, because its probes sit
seven minutes into a slot and its plans have no holes. The one that matters end
to end — **M1, the shipped rule** — is red in **both** files, on the named check
`THE PROPERTY THE DEFECT BROKE`.
## What this does NOT fix, with its shape
`price_forecast._walk()` — §8.3's irreversible-action gate — iterates
`committed_hours()`, which steps in **whole hours**, and asks `series.at(u)` at
each. On a quarter-hourly series that now reliably returns the hour's `:00`
point (it previously returned something earlier and arbitrary), so the gate
reads **one point per hour** and never looks at the `:15`/`:30`/`:45` flags. A
partially-provisional hour would be gated on its first quarter alone. It is not
reachable from anything shipped — FCST-04 carries **whole** hours and
`generic_dayahead` publishes whole days — but that is an assumption about the
*producers*, not a property of the *gate*, so it is written down in the decision
note §5 rather than left for the next sub-hourly feed to find. Fixing it means
teaching `committed_hours()` about resolution, a data-model question this card
deliberately does not open.
## Scope
`app/price.py` only, plus its two suites, the changelog and the decision note.
**No change to a point's `start` semantics or to the canonical §9.3 format** —
`PricePoint` still has three fields and the span is derived from the neighbours,
never stored. `at()` is a linear scan over the index (192 entries on the shipped
48 h horizon) on the 30 s control path; a bisect would be correct and is not
worth the second way to be wrong at that size.
**All 29 test files green.** ⚠️ Not deployed: no `ha addons update`, no writes to
`/addons/`, no contact with the Supervisor or the inverter.
---
## Rebased twice while open — what moved, and what was re-graded
Base moved `88c59a9` → `559f0eb` (**TARIFF-10**) → `a6e64fb` (**TARIFF-12**).
**The earlier CI `failure` on head `7f2c4e2` was NOT a test failure.** The
workflow's *grade the merge, not the branch* step refused the PR because it did
not apply, and said so itself: `REFUSED: refs/pull/80/head does not merge into
release/1.0. NOTHING HAS BEEN TESTED. This is not a test failure - no test ran.`
Conflicts were `CHANGELOG.md` and `test_price.py`; `app/price.py` auto-merged.
The job died at the merge step and no suite executed.
**Conflicts resolved.** `CHANGELOG.md` both times: every entry under the single
`## Unreleased` in merge order, TARIFF-10 then TARIFF-12 then this card, **no
version number picked**. `test_price.py` once: both TARIFF-10 and this card had
appended a section "13" — nothing is dropped, TARIFF-10's stays **13**, this
card's is renumbered **14**, and TARIFF-10's temp-directory teardown stays at
the end of the file.
**Neither base change touches this fix.** TARIFF-10 added `provider_id()` /
`provider_configured()` beside `price_option()` — the option-reading layer,
nothing in `_Series`, `at()`, `covers()`, `window()` or `_cover_table()`.
TARIFF-12 does not touch `app/price.py` at all. The merged `at()` is
byte-identical to the one every mutation below was driven against.
**TARIFF-10 widens who has a series, not who looks one up.**
`run_tariff_refresh()` now runs with arbitrage off, so a provider-configured
site has a standing plan where it had none — but it adds no `at()` consumer to
the 30 s tick: `static_status()` builds a **static** plan (hourly, immune),
`main.py` calls only `plan.words` on the fetched plan, and
`price_forecast.price_permits()`/`begin_phase()` have **no caller in
`app/main.py`**. Decision note §7.
**TARIFF-12 makes the tail decision reachable for the first time — and picks the
same answer.** A served plan may now be up to `3 × TARIFF_REFRESH_S = 5400 s`
old. Spans and the one-hour cap are unaffected structurally: every window comes
from the neighbours' **absolute** `start` instants, frozen in a tuple, computed
once at construction, so the index cannot go stale. The tail is ~46½ h away at
the shipped 48 h horizon — but `price_horizon_h` is `int(1,72)`, so a **1 h**
horizon is supported, and a 90-minute serve-stale window against a 1 h series
puts `now` past the end. Under the chosen rule `at()` raises `Absence` and
§11.4 sends the site to the static profile; under the **rejected** flat
one-hour tail the same stale plan would have traded against the `:45` print for
up to 45 minutes after that quarter had ended. Decision note §8.
**Re-graded on the final base `a6e64fb`, not carried over:** all **29 files
green**; **M1** (the shipped rule, +68 B) red on the same **24 named checks**
across both files; **M3** (the tail rule, **zero bytes**) red on the same 4;
each purged before the run and reverted with the fingerprint re-checked, from a
card-specific harness at `/home/homelab/.cache/t14-mut/` with a `mktemp` backup
inside it. This branch contributes **zero diff** to `app/main.py` and
`app/static_tariff.py`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
`_Series.at()` asked `s <= u < s + 1 h` of every point IN LIST ORDER and
returned the FIRST match. On the hourly shape every fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium
moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns 96 points at 900 s, where it is wrong twice over - measured
against the shipped code at 5059e28:
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
Inside the first hour the :00 point wins because its hour has not run
out. From the second hour on the answer is not even the top of the hour
- the first point still inside its own hour wins - so the error
compounds with depth into the series.
`arbitrage._act()` tests `here.start == v.cheapest.start` /
`== v.dearest.start` to decide whether this interval is the buy one or
the sell one. That identity almost never held, so the strategy idled
through exactly the interval it exists to trade, and read §8.3's
`here.provisional` and §17's `price < 0` off the same wrong point.
`static_tariff.py` emits hourly points, so static sites were never
affected; this is reachable only down the dynamic path TARIFF-08/09
made work.
THE RULE: a point covers from its own start until the next point's
start, and never more than one hour. Quarter-hourly takes 900 s,
hourly is unchanged, and a MIXED series - FCST-04's carry-forward puts
hourly carried points beside quarter-hourly firm ones, spacing
[900, 3600] in 70 % of soak samples - takes each point's OWN gap.
The one-hour cap is load-bearing: FCST-04 leaves an hour OUT when
nothing published it and yesterday has no value, and `at()` raising
Absence for it is what sends the caller to the static profile (§11.4).
Uncapped, "until the next point's start" would stretch the point before
a three-hour hole across the whole hole and answer every instant in it
with a stale price.
THE LAST POINT is given the same span as the point before it (one hour
when it is the only point). A flat one-hour tail would answer 00:30
TOMORROW with today's 23:45 price. The cost - a mixed series ending at
the firm/carried seam loses its last 45 minutes to Absence - is argued
in DECISIONS/TARIFF-14-the-window-a-point-covers.md §2 rather than
buried; the direction is the safe one.
`at()` now reads a point's NEIGHBOURS, so it would depend on
sortedness. It establishes that itself: the lookup index is sorted by
UTC start once, at construction, so no caller's list order reaches it.
A series handed its 96 points in reverse answers identically, and that
is a check. `covers()` shared the defect exactly - it has no rule of
its own, it asks `at()` - and is fixed by the same change; `window()`
did not, and its docstring now says why.
Nothing about §9.3 or a point's `start` changed. PricePoint still has
three fields; the span is derived from the neighbours, never stored.
Graded by test_price.py section 13 (900 s, 3600 s, mixed, holes, the
tail, caller order, duplicate starts, covers()/window(), the no-data
contract) and end to end by test_arbitrage.py section 10, which drives
decide() over a 96-point quarter-hourly day whose cheapest and dearest
quarters are deliberately NOT on a whole hour, plus §17, §8.3 and the
mixed series. Eight mutations, all red, including a ZERO-BYTE one on
the tail rule; byte deltas and what the arbitrage file could not see
are in the decision note §6. All 29 test files green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
CI on PR #80 was NOT a test failure. The workflow's "grade the merge, not
the branch" step refused the PR because it does not apply, and said so
itself: "REFUSED: refs/pull/80/head does not merge into release/1.0.
NOTHING HAS BEEN TESTED. This is not a test failure - no test ran."
Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged.
CONFLICTS RESOLVED
* CHANGELOG.md - both entries belong under ## Unreleased. TARIFF-10's
table row and section come first because it landed first. No version
number is picked here; PKG-06/07's note stands untouched.
* test_price.py - BOTH cards appended a section "13". Nothing is
dropped: TARIFF-10's stays 13, this card's is renumbered 14, and
TARIFF-10's temp-directory teardown stays at the end of the file. The
two share no fixture, option dict or helper name.
WHAT TARIFF-10 CHANGES FOR THIS CARD - nothing in the reasoning, and the
check is recorded rather than asserted (decision note §7).
In app/price.py the two do not meet: TARIFF-10 added provider_id() and
provider_configured() beside price_option() and routed load_prices()
through them - the option-reading layer, touching nothing in _Series,
at(), covers(), window() or _cover_table(). The merged at() is
byte-identical to the one the mutations were driven against.
Behaviourally TARIFF-10 makes run_tariff_refresh() run with arbitrage
OFF, so a provider-configured site now has a standing plan - and
therefore a _Series - where before it had none. It does NOT add an at()
consumer to the 30 s tick: static_status() builds a STATIC plan (hourly,
immune), main.py calls only plan.words on the fetched plan, and
price_forecast.price_permits()/begin_phase() have no caller in main.py
at all. So TARIFF-10 neither creates nor closes any exposure this card
is about - it enlarges the defect's reach the moment such a site arms
trading, which strengthens the fix without changing it.
RE-GRADED ON THE MERGED TREE, not carried over
* all 29 test files green;
* all EIGHT mutations re-driven, purged before each run, each reverted
with the fingerprint re-checked. Same verdicts and same red-check
counts as before the merge: M1 (the shipped one-hour scan, +68 B) red
on 24 named checks across both files; M3 (the ZERO-BYTE tail rule) red
on 4; M2/M4/M6 on 1 each, M5 on 2, M7 on 16, M8 on 14.
* Clean app/price.py on the merged tree: 90d8ca31f7c0109d, 85953 B.
* None of TARIFF-10's 650 new lines of checks moves under any of the
eight - they grade resolve_tariff()'s reachability from a
configuration, not the lookup.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Second base move while this card was open: 559f0eb -> a6e64fb. Only
CHANGELOG.md conflicted; test_arbitrage.py auto-merged (TARIFF-12 used
section 8b, this card uses 10, so the two do not collide).
CHANGELOG: both entries under the single ## Unreleased in MERGE ORDER -
TARIFF-12's row and section precede this branch's, because it landed
first. No version number is picked; PKG-06/07's note stands untouched.
WHAT TARIFF-12 CHANGES FOR THIS CARD - written out in decision note §8
rather than left silent, because the question is a fair one.
A served plan can now be up to TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S
= 5400 s old, where before it was at most one refresh interval, and
at() is what answers "what does it cost right now" out of that older
plan.
* PER-POINT SPAN: unaffected, structurally. Every window is derived
from the neighbours' ABSOLUTE start instants - datetimes in a frozen
dataclass in a tuple, which cannot move - and _cover_table() runs
once at construction over data that cannot change. A plan's AGE is a
property of fetched_at/tariff_at, never of a window. The index cannot
go stale, which is a small positive under TARIFF-12: a kept plan now
lives longer and there is nothing in it that needed recomputing.
* ONE-HOUR CAP: unaffected. It bites only at a hole; walking further
into a series crosses more windows, each resolved on its own
neighbours.
* THE TAIL: reachable for the first time, and §2's choice survives it.
At the shipped 48 h horizon the tail is ~46.5 h beyond anything a
90-minute-stale plan can be asked about. But price_horizon_h is
int(1,72), so a 1 h horizon is a SUPPORTED configuration, and a
90-minute serve-stale window against a 1 h series puts `now` past the
end. Under the chosen rule a quarter-hourly 1 h series ends at :45,
its window closes on the hour, at() raises Absence, _act() idles and
§11.4 sends the site to the static profile. Under the REJECTED flat
one-hour tail the same stale plan would have traded against the :45
print for up to 45 minutes after the interval it describes had ended.
So TARIFF-12 does not weaken the decision - it is the first thing
that makes it reachable, and it selects the same answer. The rule
should be re-read if TARIFF_MAX_AGE_S is ever widened again.
* TariffPlan.from_feed is a pure predicate on `origin` and TARIFF-12
keeps or replaces a WHOLE PLAN OBJECT. It never splices two reads
into one series, so it cannot hand at() an unsorted or duplicated
point list.
RE-GRADED ON THIS BASE, not carried over: all 29 test files green;
app/price.py byte-identical to the TARIFF-10 merge (90d8ca31f7c0109d,
85953 B) because TARIFF-12 does not touch it; M1 re-driven at +68 B,
red on the same 24 named checks across both files; M3 re-driven at
ZERO BYTES, red on the same 4. Each purged before the run and reverted
with the fingerprint re-checked, from a card-specific harness at
/home/homelab/.cache/t14-mut/ with a mktemp backup inside it.
This branch contributes zero diff to app/main.py and app/static_tariff.py.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin
merged commit 8d09b30f16 into release/1.02026-09-13 01:56:05 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The defect, reproduced rather than theorised
_Series.at()askeds <= u < s + 1 hof every point in list order andreturned the first match. On the hourly shape every price fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium moved to
a 15-minute MTU on 2025-10-01; the live day-ahead feed returns 96 points
at 900 s, where it is wrong twice over. Against the shipped code at
5059e28:Inside the first hour the
:00point wins because its hour has not run out.From the second hour on the answer is not even the top of the hour — the
first point still inside its own hour wins — so the error compounds with depth
into the series.
What it cost is not "a slightly wrong price".
arbitrage._act()testshere.start == v.cheapest.start/== v.dearest.startto decide whether thisinterval is the buy one or the sell one. That identity almost never matched the
extremum, so the strategy idled through exactly the interval it exists to
trade, and read §8.3's
here.provisionaland §17'sprice < 0off the samewrong point.
static_tariff.pyemits hourly points, so static sites werenever affected; this is reachable only down the dynamic path TARIFF-08/09 made
work.
After, same probes, all three resolutions
[900, 3600]0.100.100.100.110.100.110.130.100.130.140.110.140.150.110.14(hourly point)The hourly column is byte-for-byte what shipped. That is exactly why nothing
caught this: the suite had been exercising the one resolution on which the
broken rule and the right one agree.
The rule
Each point takes its own gap, which is what the mixed case needs: FCST-04's
carry-forward emits hourly carried points beside quarter-hourly firm ones, and
the soak measured spacing
[900, 3600]in 70 % of samples — the normalsteady state, not an edge case.
The one-hour cap is load-bearing. FCST-04 leaves an hour out of the
series when nothing published it and yesterday has no value either, and
at()raising
Absencefor that hour is what sends the caller to the static profile(§11.4). Uncapped, "until the next point's start" would stretch the point
before a three-hour hole across the whole hole and answer every instant in it
with a stale price — filling holes, introduced by the fix for a different
defect.
The last point's window — decided, not inherited
There is no next start and §9.3 says nothing about the final interval's width.
Chosen: the last point is given the same span as the point before it (one
hour when it is the only point).
at(23:50)at(00:30)tomorrowAbsenceat(23:50)Absenceafter 15 minThe second row is why. The cost of the choice is stated rather than buried:
a mixed series whose last point is a carried hourly one behind quarter-hourly
firm ones loses its last 45 minutes to
Absence. That direction is the safeone — the caller falls back to the static profile, the point is carried so §8.3
forbids irreversible action on it anyway, and it sits at the far end of a 48 h
horizon. Under-covering costs a missed decision; over-covering prices a real
interval with a number that does not describe it. Argued in
DECISIONS/TARIFF-14-the-window-a-point-covers.md§2.Sortedness:
at()now depends on it, and establishes it itselfThe old rule read one point at a time and did not care about order. The new one
reads a point's neighbours, so it would depend on the series being
ascending. It does not: the lookup index is sorted by UTC start once, at
construction, so no caller's list order can reach
at().That matters because the three things that "guarantee" order today each
guarantee it somewhere else —
_validate_raw()on theload_prices()path,price_history._parse_points()on the store read path, FCST-04's fill in thatone producer — while
PriceRead.series()itself has never enforced anything.Three producers agreeing today is a coincidence with good references, not an
invariant. A series handed its 96 points in reverse answers identically, and
that is a check (red the moment the sort is removed).
Duplicate starts (refused by
_validate_raw(), constructible by hand) givethe earlier list entry a zero-width window and the later one the real span. One
of the pair becomes unreachable through
at(); nothing raises, atconstruction or on the 30 s control path. Said as a check, not left to be found.
covers()shared it;window()did notcovers()has no window rule of its own — it callsat()— so it sharedthe defect exactly and the same change fixes it. Kept as a delegation: a
second copy of the rule is a second place for it to be wrong.
window(since, until)grades a point's start against a half-openrange and never asks how long a point lasts, so it carries no resolution
assumption. It is not a coverage query, and its docstring now says so. No
callers in
app/.The arbitrage-level check — the property the defect actually broke
test_arbitrage.pysection 10 drives the shippeddecide()over a96-point quarter-hourly day whose cheapest quarter is a
:45and whosedearest is a
:15— neither on a whole hour, asserted as its own check,without which the section would grade nothing an hourly fixture does not
already grade. It also states, as a check, that the shipped rule and this one
return different points at both probes, so the fixture can reach the failure
it names.
It grades: the verdict's extremes are the quarters;
_point_at()matches themby identity, which is what makes
_act()'shere.start == v.cheapest.starttrue;
decide()charges and discharges in those quarters at their ownprices, with those prices in the reason strings; a non-extremum quarter idles
for the right named reason and reports its own price; §17 refuses a negative
sell quarter and the same series with that one sign flipped discharges; and
the whole thing again on a mixed
[900, 3600]plan, including an instantinside a carried hour.
Mutations — eight, all red
Purged before every run (TEST-15), reverted with the tree fingerprint checked
afterwards (MUTATION.md habit 18), backup at a
mktemppath per invocation.Clean
app/price.py=90d8ca31f7c0109d, 85953 B.at()back to the shipped fixed one-hour scan overself.pointstest_price.py14b ×4, 14c ×3, 14d, 14e ×3, 14f, 14g;test_arbitrage.py10b ×6, 10c ×3, 10d ×2test_price.py14dtest_price.py14e ×3, 14gtest_price.py14fcovers()grows its own hour-wide ruletest_price.py14e, 14gwindow()becomes a coverage querytest_price.py14gM3 is the zero-byte one and it is the tail decision itself — the size
MUTATION.md names as the risky one, caught by the check written for it.
What the arbitrage file does NOT see, with sizes. M2, M4, M5, M6 and M8
leave
test_arbitrage.pygreen. That is honest rather than a gap rounded off:they are
at()-level properties (holes, caller order,covers(),window(),the start boundary) that no arbitrage fixture reaches, because its probes sit
seven minutes into a slot and its plans have no holes. The one that matters end
to end — M1, the shipped rule — is red in both files, on the named check
THE PROPERTY THE DEFECT BROKE.What this does NOT fix, with its shape
price_forecast._walk()— §8.3's irreversible-action gate — iteratescommitted_hours(), which steps in whole hours, and asksseries.at(u)ateach. On a quarter-hourly series that now reliably returns the hour's
:00point (it previously returned something earlier and arbitrary), so the gate
reads one point per hour and never looks at the
:15/:30/:45flags. Apartially-provisional hour would be gated on its first quarter alone. It is not
reachable from anything shipped — FCST-04 carries whole hours and
generic_dayaheadpublishes whole days — but that is an assumption about theproducers, not a property of the gate, so it is written down in the decision
note §5 rather than left for the next sub-hourly feed to find. Fixing it means
teaching
committed_hours()about resolution, a data-model question this carddeliberately does not open.
Scope
app/price.pyonly, plus its two suites, the changelog and the decision note.No change to a point's
startsemantics or to the canonical §9.3 format —PricePointstill has three fields and the span is derived from the neighbours,never stored.
at()is a linear scan over the index (192 entries on the shipped48 h horizon) on the 30 s control path; a bisect would be correct and is not
worth the second way to be wrong at that size.
All 29 test files green. ⚠️ Not deployed: no
ha addons update, no writes to/addons/, no contact with the Supervisor or the inverter.Rebased twice while open — what moved, and what was re-graded
Base moved
88c59a9→559f0eb(TARIFF-10) →a6e64fb(TARIFF-12).The earlier CI
failureon head7f2c4e2was NOT a test failure. Theworkflow's grade the merge, not the branch step refused the PR because it did
not apply, and said so itself:
REFUSED: refs/pull/80/head does not merge into release/1.0. NOTHING HAS BEEN TESTED. This is not a test failure - no test ran.Conflicts were
CHANGELOG.mdandtest_price.py;app/price.pyauto-merged.The job died at the merge step and no suite executed.
Conflicts resolved.
CHANGELOG.mdboth times: every entry under the single## Unreleasedin merge order, TARIFF-10 then TARIFF-12 then this card, noversion number picked.
test_price.pyonce: both TARIFF-10 and this card hadappended a section "13" — nothing is dropped, TARIFF-10's stays 13, this
card's is renumbered 14, and TARIFF-10's temp-directory teardown stays at
the end of the file.
Neither base change touches this fix. TARIFF-10 added
provider_id()/provider_configured()besideprice_option()— the option-reading layer,nothing in
_Series,at(),covers(),window()or_cover_table().TARIFF-12 does not touch
app/price.pyat all. The mergedat()isbyte-identical to the one every mutation below was driven against.
TARIFF-10 widens who has a series, not who looks one up.
run_tariff_refresh()now runs with arbitrage off, so a provider-configuredsite has a standing plan where it had none — but it adds no
at()consumer tothe 30 s tick:
static_status()builds a static plan (hourly, immune),main.pycalls onlyplan.wordson the fetched plan, andprice_forecast.price_permits()/begin_phase()have no caller inapp/main.py. Decision note §7.TARIFF-12 makes the tail decision reachable for the first time — and picks the
same answer. A served plan may now be up to
3 × TARIFF_REFRESH_S = 5400 sold. Spans and the one-hour cap are unaffected structurally: every window comes
from the neighbours' absolute
startinstants, frozen in a tuple, computedonce at construction, so the index cannot go stale. The tail is ~46½ h away at
the shipped 48 h horizon — but
price_horizon_hisint(1,72), so a 1 hhorizon is supported, and a 90-minute serve-stale window against a 1 h series
puts
nowpast the end. Under the chosen ruleat()raisesAbsenceand§11.4 sends the site to the static profile; under the rejected flat
one-hour tail the same stale plan would have traded against the
:45print forup to 45 minutes after that quarter had ended. Decision note §8.
Re-graded on the final base
a6e64fb, not carried over: all 29 filesgreen; M1 (the shipped rule, +68 B) red on the same 24 named checks
across both files; M3 (the tail rule, zero bytes) red on the same 4;
each purged before the run and reverted with the fingerprint re-checked, from a
card-specific harness at
/home/homelab/.cache/t14-mut/with amktempbackupinside it. This branch contributes zero diff to
app/main.pyandapp/static_tariff.py.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU