TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s
checks / unit (pull_request) Failing after 2s
`_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
This commit is contained in:
co-authored by
Claude Opus 5
parent
88c59a9918
commit
7f2c4e20e1
@@ -1944,6 +1944,363 @@ check("'off' is not refused, on any of the other configurations",
|
||||
{"price_csv_path": ""})))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("13. TARIFF-14 - the point whose OWN interval covers the instant")
|
||||
# ⚠️ THE RESOLUTION IS THE WHOLE SECTION, AND ITS ABSENCE IS WHY THE DEFECT
|
||||
# SHIPPED. Every price fixture in this repository before TARIFF-08 was the
|
||||
# invented HOURLY row shape, and on an hourly series `s <= u < s + 1 h` and
|
||||
# "until the next point's start" AGREE AT EVERY INSTANT - so the suite had
|
||||
# been exercising the one resolution that cannot expose the bug. Section 8
|
||||
# counts points on the 23- and 25-hour days and never asks `at()` for a
|
||||
# quarter-hour. Belgium moved to a 15-minute MTU on 2025-10-01 and the live
|
||||
# day-ahead feed returns 96 points at 900 s.
|
||||
|
||||
# ⚠️ BRUSSELS MIDNIGHT, NOT UTC MIDNIGHT, so that every wall-clock time in
|
||||
# a check name below is the time the series really renders - "00:15" and
|
||||
# "23:45" are what `canonical()` writes, not an offset away from it.
|
||||
Q0 = datetime(2026, 1, 15, 0, 0, tzinfo=timezone(timedelta(hours=1)))
|
||||
HOUR = timedelta(hours=1)
|
||||
|
||||
|
||||
def spaced(gaps, first=0.10, step=0.01, provisional=None, start=Q0):
|
||||
"""A series whose consecutive gaps are exactly `gaps`, in SECONDS.
|
||||
|
||||
`len(points) == len(gaps) + 1`. Prices ascend by `step`, so a point can be
|
||||
named by VALUE as well as by identity and a sweep that compared only
|
||||
prices could still tell two points apart.
|
||||
"""
|
||||
n = len(gaps) + 1
|
||||
flags = [False] * n if provisional is None else list(provisional)
|
||||
pts, t = [], start
|
||||
for i in range(n):
|
||||
pts.append(PricePoint(brussels(t), round(first + step * i, 6),
|
||||
bool(flags[i])))
|
||||
if i < len(gaps):
|
||||
t += timedelta(seconds=gaps[i])
|
||||
return PriceRead.series(pts, PriceProduct.DAY_AHEAD, "tariff14", start)
|
||||
|
||||
|
||||
def gaps_of(s):
|
||||
u = [p.start.astimezone(UTC) for p in s.points]
|
||||
return [int((b - a).total_seconds()) for a, b in zip(u, u[1:])]
|
||||
|
||||
|
||||
# The three shapes the acceptance criteria name, plus the two degenerate ones.
|
||||
Q = spaced([900] * 95) # the shipped 96-point Belgian day
|
||||
H = spaced([3600] * 23) # what every older fixture carried
|
||||
# ⚠️ FCST-04'S STEADY STATE, NOT AN EDGE CASE. Eight firm quarter-hours, then
|
||||
# the carried HOURLY points the fill adds for the hours nobody published. The
|
||||
# seam between them is a 900 s gap and the tail is 3600 s.
|
||||
MIX = spaced([900] * 8 + [3600] * 3,
|
||||
provisional=[False] * 9 + [True] * 3)
|
||||
# The same seam made the END of the series - the last point is the FIRST
|
||||
# carried hourly one, and the gap behind it is 900 s. This is the fixture the
|
||||
# tail decision is graded on.
|
||||
MIX_TAIL = spaced([900] * 8, provisional=[False] * 8 + [True])
|
||||
ONE = spaced([])
|
||||
|
||||
print("13a. the fixtures are not degenerate - asserted before anything else")
|
||||
check(f"the quarter-hourly fixture is the live shape - 96 points, spacing "
|
||||
f"{sorted(set(gaps_of(Q)))} s",
|
||||
len(Q.points) == 96 and set(gaps_of(Q)) == {900})
|
||||
check(f"the hourly fixture is 24 points at {sorted(set(gaps_of(H)))} s - and "
|
||||
f"it is a REGRESSION guard, not a witness: the shipped rule and this "
|
||||
f"one agree at every instant of it, which is exactly why nothing caught "
|
||||
f"this",
|
||||
len(H.points) == 24 and set(gaps_of(H)) == {3600})
|
||||
check(f"...while the MIXED fixture really is mixed, spacing "
|
||||
f"{sorted(set(gaps_of(MIX)))} s - the pair the FCST-04 soak measured in "
|
||||
f"70 % of samples, so a window derived from ONE global spacing would be "
|
||||
f"wrong in the NORMAL steady state",
|
||||
set(gaps_of(MIX)) == {900, 3600}
|
||||
and set(gaps_of(MIX_TAIL)) == {900}
|
||||
and gaps_of(MIX)[-1] == 3600 and gaps_of(MIX_TAIL)[-1] == 900)
|
||||
check("...and every point of every fixture carries a DISTINCT price, so 'the "
|
||||
"covering point' names one point and a wrong answer cannot coincide "
|
||||
"with the right one",
|
||||
all(len({p.eur_per_kwh for p in s.points}) == len(s.points)
|
||||
for s in (Q, H, MIX, MIX_TAIL)))
|
||||
check("...and the mixed fixtures carry BOTH firm and carried points, which is "
|
||||
"what makes them FCST-04's output rather than a spacing exercise",
|
||||
all(any(p.provisional for p in s.points)
|
||||
and any(not p.provisional for p in s.points)
|
||||
for s in (MIX, MIX_TAIL)))
|
||||
|
||||
print()
|
||||
print("13b. the reading the shipped scan got wrong, stated as literals")
|
||||
# ⚠️ ASSERTED AGAINST TYPED-OUT INDICES AND PRICES, not against a window this
|
||||
# file recomputed by price.py's own rule. The sweep below is exhaustive and
|
||||
# the sweep is derived; these four are the anchors that keep it honest.
|
||||
_q15 = derived(lambda: Q.at(Q0 + timedelta(minutes=15)))
|
||||
check(f"at(00:15) on the 900 s series is the 00:15 point at 0.11 EUR/kWh - the "
|
||||
f"shipped scan answered the 00:00 point at 0.10, because the :00 "
|
||||
f"point's HOUR had not run out yet (saw {_q15!r})",
|
||||
_q15 is Q.points[1] and derived(lambda: _q15.eur_per_kwh) == 0.11)
|
||||
_q195 = derived(lambda: Q.at(Q0 + timedelta(minutes=195)))
|
||||
check(f"at(03:15) is the 03:15 point at 0.23 - the shipped scan answered "
|
||||
f"02:30 at 0.20 here, and that is the SECOND failure mode: from the "
|
||||
f"second hour on the first point still inside its own hour wins, so the "
|
||||
f"error COMPOUNDS with depth into the series (saw {_q195!r})",
|
||||
_q195 is Q.points[13] and derived(lambda: _q195.eur_per_kwh) == 0.23)
|
||||
check("at(00:30) and at(00:45) are their own quarters too, so the answer is "
|
||||
"not merely 'one point later'",
|
||||
derived(lambda: Q.at(Q0 + timedelta(minutes=30))) is Q.points[2]
|
||||
and derived(lambda: Q.at(Q0 + timedelta(minutes=45))) is Q.points[3])
|
||||
check("on the MIXED series the quarter-hourly half answers per QUARTER and "
|
||||
"the carried hourly half answers per HOUR, in one series - at(00:15) is "
|
||||
"the 00:15 point and at(02:30) is the 02:00 one",
|
||||
derived(lambda: MIX.at(Q0 + timedelta(minutes=15))) is MIX.points[1]
|
||||
and derived(lambda: MIX.at(Q0 + timedelta(minutes=150))) is MIX.points[8]
|
||||
and derived(lambda: MIX.points[8].start.astimezone(UTC)) ==
|
||||
Q0 + timedelta(hours=2))
|
||||
|
||||
print()
|
||||
print("13c. the exhaustive sweep, at 900 s, 3600 s and mixed")
|
||||
|
||||
|
||||
def windows(s):
|
||||
"""`(start_utc, width)` per point, computed HERE from the gaps.
|
||||
|
||||
Deliberately NOT read off `_Series._cover`: a check built from the table it
|
||||
grades compares a thing against itself. The rule is restated - a point runs
|
||||
to the next start, capped at an hour; the last point copies the width in
|
||||
front of it; a lone point gets an hour.
|
||||
"""
|
||||
u = [p.start.astimezone(UTC) for p in s.points]
|
||||
out = []
|
||||
for i, t in enumerate(u):
|
||||
if i + 1 < len(u):
|
||||
g = u[i + 1] - t
|
||||
elif i:
|
||||
g = t - u[i - 1]
|
||||
else:
|
||||
g = HOUR
|
||||
out.append((t, g if g < HOUR else HOUR))
|
||||
return out
|
||||
|
||||
|
||||
def sweep(s):
|
||||
"""Every probe whose covering point is unambiguous, and what `at()` said."""
|
||||
wrong = []
|
||||
for i, (t, w) in enumerate(windows(s)):
|
||||
for off in (timedelta(0), w / 2, w - timedelta(seconds=1)):
|
||||
got = derived(lambda: s.at(t + off))
|
||||
if got is not s.points[i]:
|
||||
wrong.append(f"{(t + off).isoformat()} -> {got!r}, "
|
||||
f"wanted {s.points[i].eur_per_kwh}")
|
||||
return wrong
|
||||
|
||||
|
||||
for _name, _s in (("900 s", Q), ("3600 s", H), ("mixed 900/3600", MIX),
|
||||
("mixed, 900 s tail", MIX_TAIL)):
|
||||
_w = derived(lambda s=_s: sweep(s))
|
||||
check(f"{_name}: all {3 * len(_s.points)} probes inside the series return "
|
||||
f"the point whose OWN interval covers them "
|
||||
f"({_w if _w != [] else 'no misses'})", _w == [])
|
||||
|
||||
print()
|
||||
print("13d. the one-hour cap, which is what keeps FCST-04's holes holes")
|
||||
# ⚠️ NOT DECORATION. FCST-04 leaves an hour OUT when nothing published it and
|
||||
# yesterday has no value either, and price_forecast.py's module note names
|
||||
# `at()` raising Absence for it as the behaviour that sends the caller to the
|
||||
# static profile (§11.4 for that hour). Uncapped, "until the next point's
|
||||
# start" would stretch the point before a three-hour hole across the whole
|
||||
# hole - filling holes with a stale price, which is the defect this module
|
||||
# exists to refuse, introduced by the fix for a different one.
|
||||
HOLE = PriceRead.series(
|
||||
[PricePoint(brussels(Q0 + timedelta(hours=i)), 0.10 + i / 100.0, False)
|
||||
for i in (0, 1, 4, 5)],
|
||||
PriceProduct.DAY_AHEAD, "tariff14", Q0)
|
||||
_in_hole = [Q0 + timedelta(hours=2), Q0 + timedelta(hours=2, minutes=30),
|
||||
Q0 + timedelta(hours=3), Q0 + timedelta(hours=3, minutes=59)]
|
||||
check(f"a three-hour hole stays a hole: all {len(_in_hole)} instants inside it "
|
||||
f"raise Absence, while the point BEFORE it still covers its own hour and "
|
||||
f"the point after it answers from its own start",
|
||||
all(raises(lambda u=u: HOLE.at(u), Absence) for u in _in_hole)
|
||||
and derived(lambda: HOLE.at(Q0 + timedelta(hours=1, minutes=59)))
|
||||
is HOLE.points[1]
|
||||
and derived(lambda: HOLE.at(Q0 + timedelta(hours=4))) is HOLE.points[2])
|
||||
check(f"...and the cap is stated once, as MAX_POINT_SPAN = "
|
||||
f"{_price.MAX_POINT_SPAN}, rather than spelled into the lookup",
|
||||
_price.MAX_POINT_SPAN == HOUR)
|
||||
# ⚠️ AND THE OTHER HALF OF THE HOLE, WHICH IS THE ONE §8.3 READS. FCST-04
|
||||
# fills a whole hour nobody published with ONE carried point, so a
|
||||
# quarter-hourly day can carry an hourly carried point in the middle of it.
|
||||
# The instant inside that hour is covered by the CARRIED point - and the
|
||||
# shipped scan answered the FIRM quarter 52 minutes earlier, because that was
|
||||
# the first point still inside its own hour. `_act()`'s `here.provisional`
|
||||
# guard and `price_forecast._walk()`'s `series.at(u).provisional` both read the
|
||||
# flag off whatever that lookup returned, so the flag was FIRM on an hour
|
||||
# nobody published.
|
||||
SANDWICH = PriceRead.series(
|
||||
[PricePoint(brussels(Q0 + timedelta(minutes=15 * i)),
|
||||
round(0.10 + i / 1000.0, 6), False) for i in range(44)]
|
||||
+ [PricePoint(brussels(Q0 + timedelta(hours=11)), 0.500, True)]
|
||||
+ [PricePoint(brussels(Q0 + timedelta(hours=12, minutes=15 * i)),
|
||||
round(0.20 + i / 1000.0, 6), False) for i in range(4)],
|
||||
PriceProduct.DAY_AHEAD, "tariff14", Q0)
|
||||
_sand_t = Q0 + timedelta(hours=11, minutes=37)
|
||||
|
||||
|
||||
def shipped_at(s, u):
|
||||
"""The rule TARIFF-14 replaced, as the COUNTERFACTUAL only - nothing is
|
||||
graded against it. It is here so the fixture can be SHOWN to separate the
|
||||
two rules; a check whose data cannot reach the failure it names grades
|
||||
nothing, and on an hourly series these two agree at every instant."""
|
||||
uu = u.astimezone(UTC)
|
||||
for p in s.points:
|
||||
st = p.start.astimezone(UTC)
|
||||
if st <= uu < st + HOUR:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
check("the sandwich fixture is FCST-04's real output - 44 firm quarters, ONE "
|
||||
"carried hourly point filling the hour nobody published, then firm "
|
||||
"quarters again",
|
||||
len(SANDWICH.points) == 49
|
||||
and SANDWICH.points[44].provisional is True
|
||||
and sum(1 for p in SANDWICH.points if p.provisional) == 1)
|
||||
_sand_now = derived(lambda: SANDWICH.at(_sand_t))
|
||||
_sand_old = derived(lambda: shipped_at(SANDWICH, _sand_t))
|
||||
check(f"an instant inside the carried hour is covered by the CARRIED point, "
|
||||
f"so §8.3's flag is read off the hour it belongs to. The shipped scan "
|
||||
f"answered the FIRM quarter 52 minutes earlier "
|
||||
f"({derived(lambda: _sand_old.start.isoformat())}, provisional="
|
||||
f"{derived(lambda: _sand_old.provisional)}), which is a carried hour "
|
||||
f"wearing a published hour's flag",
|
||||
_sand_now is SANDWICH.points[44]
|
||||
and derived(lambda: _sand_now.provisional) is True
|
||||
and _sand_old is SANDWICH.points[43]
|
||||
and derived(lambda: _sand_old.provisional) is False)
|
||||
|
||||
print()
|
||||
print("13e. the LAST point's window - decided and documented, not inherited")
|
||||
# There is no next start and §9.3 says nothing about how wide the final
|
||||
# interval is, so THE LAST POINT IS GIVEN THE SAME SPAN AS THE POINT BEFORE IT
|
||||
# (one hour when it is the only point). The alternative - a flat one-hour tail
|
||||
# - is defensible for an hourly series and wrong for the live one: it answers
|
||||
# 00:30 TOMORROW with today's last price, which is reaching for a neighbour
|
||||
# across a day boundary.
|
||||
_ql = Q.points[-1].start.astimezone(UTC)
|
||||
_hl = H.points[-1].start.astimezone(UTC)
|
||||
_ml = MIX_TAIL.points[-1].start.astimezone(UTC)
|
||||
check("the 900 s day's last point covers its own QUARTER: at(+14:59) answers "
|
||||
"and at(+15:00) raises, so today's 23:45 price cannot be returned for "
|
||||
"00:30 tomorrow",
|
||||
derived(lambda: Q.at(_ql + timedelta(minutes=14, seconds=59)))
|
||||
is Q.points[-1]
|
||||
and raises(lambda: Q.at(_ql + timedelta(minutes=15)), Absence))
|
||||
check("the hourly series' last point still covers an HOUR, exactly as it "
|
||||
"shipped - no static or hourly site changes behaviour",
|
||||
derived(lambda: H.at(_hl + timedelta(minutes=59))) is H.points[-1]
|
||||
and raises(lambda: H.at(_hl + HOUR), Absence))
|
||||
check("the MIXED series ending at the firm/carried seam takes the 900 s in "
|
||||
"front of it, so its last 45 minutes raise Absence. That is the SAFE "
|
||||
"direction and it is CHOSEN: §11.4 sends the caller to the static "
|
||||
"profile rather than trading on a span the series never demonstrated, "
|
||||
"and the point is carried, so §8.3 forbids irreversible action on it "
|
||||
"anyway",
|
||||
derived(lambda: MIX_TAIL.at(_ml + timedelta(minutes=14, seconds=59)))
|
||||
is MIX_TAIL.points[-1]
|
||||
and raises(lambda: MIX_TAIL.at(_ml + timedelta(minutes=15)), Absence)
|
||||
and MIX_TAIL.points[-1].provisional)
|
||||
# ⚠️ HABIT 1. Without this the three checks above would all pass under a
|
||||
# single global tail rule of 15 minutes, or of an hour, depending which two
|
||||
# you read - so the claim that the tail FOLLOWS THE SERIES is asserted as its
|
||||
# own check, at one instant where the three fixtures must disagree.
|
||||
_t30 = [derived(lambda s=s, l=l: s.covers(l + timedelta(minutes=30)))
|
||||
for s, l in ((Q, _ql), (H, _hl), (MIX_TAIL, _ml))]
|
||||
check(f"...and those tails are genuinely three readings of one rule and not "
|
||||
f"one constant: 30 minutes past its own last point the hourly series "
|
||||
f"answers and both 900 s ones do not {_t30}",
|
||||
_t30 == [False, True, False])
|
||||
check("a series of ONE point has no gap in front of it to copy, so it gets an "
|
||||
"hour - the shipped behaviour, kept deliberately for the degenerate case",
|
||||
derived(lambda: ONE.at(Q0 + timedelta(minutes=59, seconds=59)))
|
||||
is ONE.points[0]
|
||||
and raises(lambda: ONE.at(Q0 + HOUR), Absence))
|
||||
|
||||
print()
|
||||
print("13f. order is established by the lookup, not assumed of the caller")
|
||||
# ⚠️ `at()` now reads a point's NEIGHBOURS, so it WOULD depend on sortedness.
|
||||
# The index is sorted at construction so that it cannot. `_validate_raw()`
|
||||
# refuses out-of-order and duplicate starts on the `load_prices()` path
|
||||
# (section 9 above), `price_history._parse_points()` re-sorts what it reads off
|
||||
# disk and FCST-04's fill emits ascending - but `PriceRead.series()` itself has
|
||||
# never enforced any of it, and an assumption that holds today and is unstated
|
||||
# is how this class of defect recurs.
|
||||
_REV = PriceRead.series(tuple(reversed(Q.points)), PriceProduct.DAY_AHEAD,
|
||||
"tariff14", Q0)
|
||||
check("the reversed fixture really is reversed, so the check below is not "
|
||||
"grading two identical series",
|
||||
_REV.points[0] is Q.points[-1] and _REV.points[-1] is Q.points[0]
|
||||
and _REV.points != Q.points)
|
||||
_rev_bad = derived(lambda: [p.start.isoformat() for p in Q.points
|
||||
if _REV.at(p.start) is not p])
|
||||
check(f"...and a series handed its 96 points in REVERSE order answers at() "
|
||||
f"identically at every one of them "
|
||||
f"({_rev_bad if _rev_bad != [] else 'no misses'})",
|
||||
_rev_bad == [])
|
||||
check("...while the load path refuses an unsorted feed outright, so the two "
|
||||
"defences are stated rather than one being quietly relied on",
|
||||
raises(lambda: _price._validate_raw(
|
||||
[RawPoint(T0 + timedelta(minutes=15), 0.1, False),
|
||||
RawPoint(T0, 0.2, False)], _caps), SourceUnavailable))
|
||||
check("a DUPLICATED start does not raise anywhere - not at construction and "
|
||||
"not on the 30 s control path. The later list entry takes the real span "
|
||||
"and the earlier one becomes unreachable through at(); load_prices() "
|
||||
"refuses the shape outright, and this says what happens to anyone who "
|
||||
"builds one by hand",
|
||||
derived(lambda: PriceRead.series(
|
||||
[PricePoint(brussels(Q0), 0.10, False),
|
||||
PricePoint(brussels(Q0), 0.20, False),
|
||||
PricePoint(brussels(Q0 + timedelta(minutes=15)), 0.30, False)],
|
||||
PriceProduct.DAY_AHEAD, "tariff14", Q0).at(Q0).eur_per_kwh) == 0.20)
|
||||
|
||||
print()
|
||||
print("13g. covers() shared it; window() did not")
|
||||
check("covers() shared the defect EXACTLY, because it has no window rule of "
|
||||
"its own - it asks at() - so the one change fixes both. On the 900 s "
|
||||
"day it is now True 14:59 past the last point and False 15:00 past it, "
|
||||
"where an hour-wide window said True for another 45 minutes",
|
||||
derived(lambda: Q.covers(_ql + timedelta(minutes=14, seconds=59))) is True
|
||||
and derived(lambda: Q.covers(_ql + timedelta(minutes=15))) is False
|
||||
and derived(lambda: Q.covers(_ql + timedelta(minutes=30))) is False
|
||||
and derived(lambda: Q.covers(Q0 + timedelta(minutes=15))) is True)
|
||||
check("window() did NOT share it, and the reason is worth a check rather than "
|
||||
"a rediscovery: it grades a point's START against a half-open range and "
|
||||
"never asks how long a point lasts, so it carries no resolution "
|
||||
"assumption at all. 00:07:30 .. 00:22:30 holds exactly the 00:15 point "
|
||||
"- while at(00:07:30) is the 00:00 one, which is what makes it a "
|
||||
"DIFFERENT question and not a coverage query",
|
||||
derived(lambda: Q.window(Q0 + timedelta(minutes=7, seconds=30),
|
||||
Q0 + timedelta(minutes=22, seconds=30)))
|
||||
== (Q.points[1],)
|
||||
and derived(lambda: Q.at(Q0 + timedelta(minutes=7, seconds=30)))
|
||||
is Q.points[0])
|
||||
|
||||
print()
|
||||
print("13h. the 'no data is not free' contract, unchanged")
|
||||
check("at() answers with a PricePoint or raises Absence - never None, at any "
|
||||
"resolution, so `series.at(t) or fallback` cannot become a price of zero",
|
||||
all(isinstance(derived(lambda s=s: s.at(s.points[0].start)), PricePoint)
|
||||
for s in (Q, H, MIX, MIX_TAIL, ONE))
|
||||
and raises(lambda: Q.at(Q0 - timedelta(seconds=1)), Absence)
|
||||
and raises(lambda: Q.at(Q0 + timedelta(days=2)), Absence))
|
||||
_absence_words = _reason(lambda: Q.at(Q0 + timedelta(days=2)))
|
||||
check(f"...and the refusal still names the range the series runs, so an "
|
||||
f"operator can tell 'outside the horizon' from 'into a hole'",
|
||||
Q.points[0].start.isoformat() in _absence_words
|
||||
and Q.points[-1].start.isoformat() in _absence_words)
|
||||
check("...and it never invents a neighbour: the instant one second before the "
|
||||
"series starts is refused, not answered with the first point",
|
||||
raises(lambda: Q.at(Q0 - timedelta(seconds=1)), Absence)
|
||||
and raises(lambda: MIX.at(Q0 - timedelta(seconds=1)), Absence)
|
||||
and raises(lambda: H.at(Q0 - timedelta(seconds=1)), Absence))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
if fails:
|
||||
|
||||
Reference in New Issue
Block a user